//go:build browser // The drive helpers every browser test in this package shares: the // fixture server, the depth page, the pseudo-state inspector, the // probes and the readers. Ported from rastrillo's ui/ test files at // the seed commit, each with every dependency it reaches, so the drives that // follow can be ported without them. package check import ( "context" "encoding/json" "fmt" "io" "net/http" "regexp" "strings" "testing" "time" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/css" "github.com/chromedp/cdproto/dom" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/harness" ) // ── The fixture server (ui/touch_browser_test.go) ──────────────────── // sizingDoc is a page carrying the stylesheet, a theme, fiesta.js and // busy.js (light dismiss and the busy spinner, which the row-menu legs // drive), anchor.js (a menu's flip without anchor positioning), and the // scripts that enhance controls (select.js and datetime.js change what // a field renders, calendar.js draws the grid the calendar legs // measure). No CSP: this is a test page. func sizingDoc(title, body string) string { return `` + `` + title + `` + `` + `` + `` + `` + body + `` } // sizingMux serves pages by path ("/" is the root only) and the assets // sizingDoc links, with the scripts from the embedded tree. func sizingMux(t *testing.T, pages map[string]string) *http.ServeMux { t.Helper() return sizingMuxWith(t, pages, fiesta.Script) } // sizingMuxWith is sizingMux with the scripts looked up through // scripts, so a drive can serve a release build's files in place of // the tree's. It serves /tokens.css and /theme.css (stylesheets), // /shell.css, /images/ (fixtureImages), /icons/.svg, and every // /.js and /i18n/.js through scripts, which answers false // for a file it does not have (a 404 the harness reports). func sizingMuxWith(t *testing.T, pages map[string]string, scripts func(name string) ([]byte, bool)) *http.ServeMux { t.Helper() mux := http.NewServeMux() stylesheets(t, mux) fixtureImages(mux) mux.HandleFunc("GET /shell.css", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/css") w.Write(fiesta.ShellCSS()) }) mux.HandleFunc("GET /icons/{file}", func(w http.ResponseWriter, r *http.Request) { svg, ok := fiesta.Icon(strings.TrimSuffix(r.PathValue("file"), ".svg")) if !ok || !strings.HasSuffix(r.PathValue("file"), ".svg") { http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/svg+xml") w.Write(svg) }) serveScript := func(w http.ResponseWriter, r *http.Request, name string) { if !strings.HasSuffix(name, ".js") { http.NotFound(w, r) return } body, ok := scripts(name) if !ok { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/javascript") w.Write(body) } mux.HandleFunc("GET /i18n/{file}", func(w http.ResponseWriter, r *http.Request) { serveScript(w, r, "i18n/"+r.PathValue("file")) }) // One segment, after every literal page path: a page a drive // registers at /x wins over this, so it only ever answers for a // script, or 404s for anything else, as rastrillo's mux did. mux.HandleFunc("GET /{file}", func(w http.ResponseWriter, r *http.Request) { serveScript(w, r, r.PathValue("file")) }) for path, html := range pages { html, pattern := html, "GET "+path if path == "/" { pattern = "GET /{$}" } mux.HandleFunc(pattern, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, html) }) } return mux } // sizingRig boots one browser, touch or mouse. Two rigs rather than one // switched: the pointer is a launch flag (harness.WithCoarsePointer). func sizingRig(t *testing.T, coarse bool, pages map[string]string, opts ...harness.Option) *harness.Rig { t.Helper() if coarse { opts = append(opts, harness.WithCoarsePointer()) } return harness.New(t, func(string) http.Handler { return sizingMux(t, pages) }, opts...) } // requirePointer is every touch leg's control: a touch leg that is // really running on a fine pointer would pass on desktop sizes, so it // fails first. func requirePointer(t *testing.T, ctx context.Context, coarse bool) { t.Helper() var got bool if err := chromedp.Run(ctx, chromedp.Evaluate(`matchMedia("(pointer: coarse)").matches`, &got)); err != nil { t.Fatalf("reading the pointer: %v", err) } if got != coarse { t.Fatalf("(pointer: coarse) is %v, this leg needs %v; the rig is not the device this leg claims to measure", got, coarse) } } // tokensJS reads the four type tokens as pixels, the body size, and the // two halves of the query, so a leg can say which half it exercised. const tokensJS = `(() => { const probe = v => { const s = document.createElement("span"); s.style.fontSize = "var(" + v + ")"; document.body.appendChild(s); const px = parseFloat(getComputedStyle(s).fontSize); s.remove(); return px; }; return JSON.stringify({Lg: probe("--rst-fs-lg"), Base: probe("--rst-fs-base"), Sm: probe("--rst-fs-sm"), Xs: probe("--rst-fs-xs"), Body: parseFloat(getComputedStyle(document.body).fontSize), Coarse: matchMedia("(pointer: coarse)").matches, Width: innerWidth}); })()` type typeReading struct { Lg, Base, Sm, Xs, Body float64 Coarse bool Width int } // fontsJS reads every text-entry control's computed size, visible or // not — a size is computed for a field inside a closed menu too, and a // field that zooms when its menu opens is the bug. const fontsJS = `(() => { const TEXTY = 'input:not([type=checkbox]):not([type=radio]):not([type=hidden]):not([type=range]):not([type=submit]):not([type=button]):not([type=color]):not([type=file]), select, textarea'; const out = []; document.querySelectorAll(TEXTY).forEach(el => out.push({ Name: el.id || el.name || el.getAttribute("aria-label") || el.outerHTML.slice(0, 80), Px: parseFloat(getComputedStyle(el).fontSize)})); return JSON.stringify(out); })()` type fontReading struct { Name string Px float64 } // ── The routes the fixtures share (ui/shell_browser_test.go, ui/mediagrid_browser_test.go) ── // stylesheets wires the two routes every page needs: the library // stylesheet under test, and a theme to fill in its colour and radius // tokens. Without the theme --rst-radius is undefined and the corner // assertions would read 0px for the wrong reason. func stylesheets(t *testing.T, mux *http.ServeMux) { t.Helper() mux.HandleFunc("GET /tokens.css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") w.Write(fiesta.TokensCSS()) }) mux.HandleFunc("GET /theme.css", func(w http.ResponseWriter, r *http.Request) { css, ok := fiesta.ThemeCSS(fiesta.ThemeNames()[0]) if !ok { http.Error(w, "no theme", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/css") w.Write(css) }) } // fixtureImages serves the pictures the shared fixtures name under // /images/, as one small SVG, for every drive that renders a picture: // the harness reports a 404 as a problem on the page, and a media-grid // fixture with no picture to load would fail a drive about spelling or // target size for a reason that is neither. func fixtureImages(mux *http.ServeMux) { mux.HandleFunc("GET /images/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "image/svg+xml") fmt.Fprint(w, ``) }) } // ── The depth fixture and its readers (ui/depth_browser_test.go) ───── // depthBody is the page every depth drive reads: both spellings of // every component the depth language touches, with stable ids. It // links no light.js; lightDoc adds the tag. // markup-spelling: old-spelling begin — the depth fixture carries both spellings of each component it touches, so every rule is read in each. const depthBody = `

Go

A box.

Open12
Name
Gammathree
Filter
AllOpen
More
Alpha
Beta

Body

` // markup-spelling: old-spelling end func depthDoc(t *testing.T) map[string]string { t.Helper() return map[string]string{"/": sizingDoc("depth", depthBody)} } // still freezes the page (C14): no transition, no animation, and the // light held straight above if light.js is on the page. Every drive // that reads a state snapshot calls it first, because a hover read in // the first 250ms of a 0.25s transition is a number the test did not // choose. The injected sheet is test-only; nothing shipped carries an // !important. It also enables the inspector for forceState (inspect). func still(t *testing.T, ctx context.Context) { t.Helper() inspect(t, ctx) mustRun(t, ctx, chromedp.Evaluate(`(() => { const s = document.createElement("style"); s.textContent = "*, *::before, *::after { transition: none !important; animation: none !important; }"; document.head.append(s); document.dispatchEvent(new CustomEvent("rst:light", {detail: {x: 0, y: -1, hold: true}})); return true; })()`, nil)) } // inspect enables the DOM and CSS domains once for the page, which // forceState needs: enabling them again per call cleared every // pseudo-state forced so far, so a drive that pinned hover on two // buttons read the first one at rest. On its own, for a drive that // forces a state and then watches a transition run (a row's lift // easing away under an open menu), which still() would freeze. func inspect(t *testing.T, ctx context.Context) { t.Helper() mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { if err := dom.Enable().Do(c); err != nil { return err } if err := css.Enable().Do(c); err != nil { return err } // One DOM.getDocument per page: every call invalidates the node // ids handed out before it, and a forced pseudo-state is keyed // by node id, so a second call dropped the first button's hover. root, err := dom.GetDocument().Do(c) if err != nil { return err } forcedRoot = root.NodeID return nil })) } // forcedRoot is the page's document node, set by inspect() for forceState. var forcedRoot cdp.NodeID // forceState pins pseudo-classes on one element through the inspector // (what DevTools' :hov panel uses). The list REPLACES the element's // forced set, so a drive that wants hover and focus together passes // both in one call; states on other elements stay pinned. still() or // inspect() must have run on the page first. func forceState(t *testing.T, ctx context.Context, selector string, states ...string) { t.Helper() if states == nil { states = []string{} } if forcedRoot == cdp.EmptyNodeID { t.Fatal("forceState before still() or inspect(): the document node is not known") } mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { id, err := dom.QuerySelector(forcedRoot, selector).Do(c) if err != nil { return err } if id == cdp.EmptyNodeID { return fmt.Errorf("forceState: no element matches %q", selector) } return css.ForcePseudoState(id, states).Do(c) })) } // computed reads one computed property of one element, with any // color(srgb …) or oklch(…) in it rewritten as rgb()/rgba(): a derived // day token serialises as one of those, and every literal in the depth // drives is an eight-bit triple (canonColours, colourhelp_test.go). func computed(t *testing.T, ctx context.Context, selector, prop string) string { t.Helper() var out string mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector(`+jsStr(selector)+`)).getPropertyValue(`+jsStr(prop)+`)`, &out)) return canonColours(strings.TrimSpace(out)) } func jsStr(s string) string { b, _ := json.Marshal(s) return string(b) } func rect(t *testing.T, ctx context.Context, selector string) (x, y, w, h float64) { t.Helper() var r struct{ X, Y, W, H float64 } at(t, ctx, `(() => { const r = document.querySelector(`+jsStr(selector)+`).getBoundingClientRect(); return JSON.stringify({X: r.left, Y: r.top, W: r.width, H: r.height}); })()`, &r) return r.X, r.Y, r.W, r.H } func open(t *testing.T, ctx context.Context, rig *harness.Rig, path string, reduce bool) { t.Helper() if reduce { mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}})) } mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#field", chromedp.ByQuery)) } // pressFallbackRig serves the depth fixture with tokens.css altered by // edit, over the sizing mux's copy. func pressFallbackRig(t *testing.T, edit func(string) string, opts ...harness.Option) *harness.Rig { t.Helper() tokens := []byte(edit(string(fiesta.TokensCSS()))) return harness.New(t, func(string) http.Handler { mux := sizingMux(t, depthDoc(t)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/tokens.css" { w.Header().Set("Content-Type", "text/css") w.Write(tokens) return } mux.ServeHTTP(w, r) }) }, opts...) } // ── Running and reading (ui/rowmenu_browser_test.go, ui/rows_browser_test.go, ui/select_browser_test.go) ── // mustRun is chromedp.Run that fails the test on a browser error: a // swallowed error would leave the leg reading a page that never got // where the leg thinks it is, and passing on it. func mustRun(t *testing.T, ctx context.Context, actions ...chromedp.Action) { t.Helper() if err := chromedp.Run(ctx, actions...); err != nil { t.Fatal(err) } } // at evaluates a JS expression returning JSON into v. func at(t *testing.T, ctx context.Context, js string, v any, opts ...chromedp.EvaluateOption) { t.Helper() var raw string if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw, opts...)); err != nil { t.Fatalf("evaluating: %v\n%s", err, js) } if err := json.Unmarshal([]byte(raw), v); err != nil { t.Fatalf("decoding %q: %v", raw, err) } } // point is a place on screen and what elementFromPoint finds there. type point struct { X, Y float64 Hit string } // probe returns the point at (x, y) in the box of sel, where fx/fy are // fractions of the width and height and dx/dy pixel offsets from that. // // It reads the page as it will be painted, two frames either side of // its scroll. A script may answer the last action, or this scroll, in // its next animation frame: select.js places an opened list and // scrolls its active row into view there. Read at once, the target // moves whenever that frame falls between the reading and the click, // which a loaded machine makes likely: a probe of Option 7 in a just // opened combobox clicked Option 3. The frames before the scroll let // queued work land so it cannot undo the scroll; the frames after let // the page answer the scroll itself. func probe(t *testing.T, ctx context.Context, sel string, fx, fy, dx, dy float64) point { t.Helper() return probeScripted(t, ctx, true, sel, fx, fy, dx, dy) } // probeScripted is probe for a leg that may run with script execution // disabled. There no frame callback ever runs, so a wait for one never // ends (the evaluation fails with "Promise was collected"); nor can a // script have queued work for a frame, so there is nothing to wait for. func probeScripted(t *testing.T, ctx context.Context, scripts bool, sel string, fx, fy, dx, dy float64) point { t.Helper() var p point at(t, ctx, fmt.Sprintf(`(async () => { const frames = () => %v ? new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))) : null; await frames(); const el = document.querySelector(%q); el.scrollIntoView({block: "center", inline: "center"}); await frames(); const r = el.getBoundingClientRect(); const x = r.left + r.width * %v + %v, y = r.top + r.height * %v + %v; const h = document.elementFromPoint(x, y); return JSON.stringify({X: x, Y: y, Hit: h ? (h.id || h.tagName) : "nothing"}); })()`, scripts, sel, fx, dx, fy, dy), &p, awaitPromise) return p } func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) } // ── Target readings and settling (ui/sizing_browser_test.go) ───────── // targetReading is one control, measured as its activation area. type targetReading struct { Name, Hit string W, H float64 Fits, Owns, Inline bool } func readTargets(t *testing.T, ctx context.Context, js string) []targetReading { t.Helper() var raw string if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw)); err != nil { t.Fatalf("measuring targets: %v", err) } var got []targetReading if err := json.Unmarshal([]byte(raw), &got); err != nil { t.Fatalf("reading targets (%q): %v", raw, err) } return got } // settleUntil polls a page expression until it is true or 10s pass. func settleUntil(t *testing.T, ctx context.Context, expr string) { t.Helper() deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { var yes bool // Errors are tolerated: an evaluation that lands while a document // is being swapped in fails, and the next one reads the new page. if err := chromedp.Run(ctx, chromedp.Evaluate(expr, &yes)); err == nil && yes { return } time.Sleep(100 * time.Millisecond) } t.Fatalf("never became true within 10s: %s", expr) } // ── Anchor positioning (internal/designsystem/galleryrig/rig.go) ───── // WithoutAnchorPositioning replaces the page's tokens.css with a copy // whose anchor-positioning @supports condition can never hold, which is // what an engine without the feature gets: the menu panel positioned // absolutely under its summary instead of fixed against it. const WithoutAnchorPositioning = `(async () => { const link = document.querySelector('link[href*="/tokens."][href$=".css"]'); const css = await (await fetch(link.href)).text(); const style = document.createElement("style"); style.textContent = css.replaceAll("@supports (position-area: block-end) and (position-try-fallbacks: flip-block)", "@supports (position-area: rastrillo-no-such-value)"); link.replaceWith(style); return true; })()` // RequireAnchorPositioning is the control for every leg that runs with // and without the feature: the named menu panel computes position: // fixed when anchor positioning applies and absolute when it does not. // A leg that asked for "without" and still got fixed would pass on the // path it claims to have left. selector picks one panel rather than // "the first [rst-dropdown-menu] on the page", because a page can carry // more than one (the bar's and the rail foot's), and the first found is // not necessarily the one the leg is driving. func RequireAnchorPositioning(t *testing.T, ctx context.Context, where, selector string, want bool) { t.Helper() var pos string if err := chromedp.Run(ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector(`+jsStr(selector)+`)).position`, &pos)); err != nil { t.Fatalf("%s: reading the menu panel's position: %v", where, err) } if (pos == "fixed") != want { t.Fatalf("%s: the menu panel is position: %s; this leg needs anchor positioning %v", where, pos, want) } } // ── The other spelling (ui/markup_v3_browser_test.go) ──────────────── // classSpelling translates markup the other way — rst- attributes back // into a class list — with tokens.css's own class list as the key. That // key is unambiguous because TestNoTwoClassesWantTheSameAttribute // holds: no two classes want one attribute, so no attribute has two // classes to choose between. // // It exists because of the flip. The partials and the styleguide hand // this drive attribute markup now, and a fixture half in each spelling // would compare identical halves and prove nothing about them. func classSpelling(t *testing.T, html string) string { t.Helper() byAttr := map[string]string{} byVariant := map[string]string{} for _, class := range sortedKeys(styledClasses(t, string(fiesta.TokensCSS()))) { name, variant, ok := attributeFor(class) if !ok { continue } if variant == "" { byAttr[name] = class continue } byVariant[name+"~="+variant] = class } out := openTagInFixture.ReplaceAllStringFunc(html, func(tag string) string { // The class attribute comes out of the tag before the scan: its // tokens follow whitespace exactly as an attribute does, and the // seven utilities that live there must survive the round trip. kept := "" if m := classInMarkup.FindStringSubmatch(tag); m != nil { kept = m[1] } stripped := classInMarkup.ReplaceAllString(tag, "") var classes []string rewritten := rstAttrInFixture.ReplaceAllStringFunc(stripped, func(m string) string { g := rstAttrInFixture.FindStringSubmatch(m) name, value := g[1], g[2] if name == "rst-tone" { return ` data-tone="` + value + `"` } base, known := byAttr[name] if !known { t.Errorf("the fixture writes [%s], which tokens.css has no class for — the round trip cannot be checked", name) return m } classes = append(classes, base) for _, variant := range strings.Fields(value) { v, ok := byVariant[name+"~="+variant] if !ok { t.Errorf("the fixture writes %s=%q, which tokens.css has no class for", name, variant) continue } classes = append(classes, v) } return "" }) if kept != "" { classes = append(classes, strings.Fields(kept)...) } if len(classes) == 0 { return tag } // After the tag name, so the class lands where an author would // have written it rather than at the end of the attribute list. i := strings.IndexAny(rewritten, " \t\n>") return rewritten[:i] + ` class="` + strings.Join(classes, " ") + `"` + rewritten[i:] }) return out } // ── The fixture server's own test ──────────────────────────────────── // scriptTags matches the script elements sizingDoc links, for a page // that must load without them. var scriptTags = regexp.MustCompile(`]*>`) // TestTheDepthFixtureServesItsStylesheets is the fixture server's own // check, ahead of the drives that read it: the depth page, served with // no script tags, loads /tokens.css and /theme.css with a 200 each, the // theme's tokens resolve on the page, and the harness reports no // console error and no failed request. The scripts are left off so // this reads the stylesheets alone; TestTheFixturePageLoadsEveryAsset // below is the one that loads them. func TestTheDepthFixtureServesItsStylesheets(t *testing.T) { pages := depthDoc(t) pages["/"] = scriptTags.ReplaceAllString(pages["/"], "") if strings.Contains(pages["/"], "x

")}) }) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("p", chromedp.ByQuery)) var assets []string at(t, ctx, `JSON.stringify([...document.querySelectorAll("link[rel=stylesheet], script[src]")].map(e => e.href || e.src))`, &assets) if len(assets) != 8 { t.Fatalf("the page links %d assets, want 8 (two stylesheets, six scripts): %q", len(assets), assets) } client := &http.Client{Timeout: 10 * time.Second} for _, url := range assets { res, err := client.Get(url) if err != nil { t.Fatalf("GET %s: %v", url, err) } body, err := io.ReadAll(res.Body) res.Body.Close() if err != nil { t.Fatalf("reading GET %s: %v", url, err) } if res.StatusCode != http.StatusOK { t.Errorf("GET %s answered %d, want 200", url, res.StatusCode) } if len(body) == 0 { t.Errorf("GET %s answered an empty body", url) } } var ran bool mustRun(t, ctx, chromedp.Evaluate(`window.fiestaShim === true`, &ran)) if !ran { t.Error("fiesta.js did not run on the page: window.fiestaShim is not set") } rig.Screen("body", "the smoke page with every asset") }