//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 = `
`
// 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["/"], "