//go:build browser // Under reduced motion nothing on a Fiesta page moves and nothing // fades: every transition and animation the stylesheets declare has a // disable under @media (prefers-reduced-motion: reduce). This drive is // the authority on that promise. It asks the browser, which applies // the cascade, rather than reading the stylesheets: a disable that // loses, to a later rule or to an !important one, reads as a disable // to any reader that does not order and weigh every declaration, and // the browser is the only reader that cannot get that wrong. The // static gate in css_test.go stays as a fast early warning. package check import ( "context" "fmt" "net/http" "sort" "strings" "sync" "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/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/harness" ) // motionPass is one state the drive puts every element of a page in // before it reads what moves. The states are forced on every element // at once, through the inspector, since a pointer over one element // hovers each of its ancestors too and a row's hover rule names the // row, not the link under the pointer. Every
is opened in // the last two, since an open menu is where the drop-down animation // and the rules under an open menu live. type motionPass struct { name string states []string open bool } var motionPasses = []motionPass{ {name: "at rest"}, {name: "hovered", states: []string{"hover"}}, {name: "pressed", states: []string{"hover", "active"}}, {name: "tapped", states: []string{"active"}}, {name: "focused", states: []string{"focus", "focus-visible", "focus-within"}}, {name: "targeted", states: []string{"target"}}, {name: "opened", open: true}, {name: "opened and hovered", states: []string{"hover"}, open: true}, } // motionWidths are the two widths the stylesheets switch at: the // shell's phone rules (the menu that drops in, the index rows) apply // only under 800px. var motionWidths = []int64{1280, 390} // motionDoc is one example state as the drive serves it. type motionDoc struct{ name, path string } // motionSite serves every state of every example, layouts included, or // of the one example called only if it is not "", at the depth the // tree keeps it, examples//.html, so its // relative links resolve. The theme each document links is rewritten // to theme, and /css/tokens.css answers with tokens, so a control can // serve a stylesheet with a planted fault. Everything else under css/, // js/ and examples/assets/ is the tree's. func motionSite(t *testing.T, theme string, tokens []byte, only string) ([]motionDoc, func(string) http.Handler) { t.Helper() m, err := example.Load(fiesta.FS()) if err != nil { t.Fatal(err) } const day = "../../css/themes/day.css" var docs []motionDoc pages := map[string]string{} for _, e := range m.Examples { if only != "" && only != e.Name { continue } for _, s := range e.States { doc, err := example.Document(fiesta.FS(), e.Name, s.ID) if err != nil { t.Fatal(err) } if strings.Count(doc, day) != 1 { t.Fatalf("%s/%s links %s %d times, want once: the drive cannot put the theme under test in its place", e.Name, s.ID, day, strings.Count(doc, day)) } d := motionDoc{name: e.Name + "/" + s.ID, path: "/examples/" + e.Name + "/" + s.ID + ".html"} pages[d.path] = strings.Replace(doc, day, "../../css/themes/"+theme+".css", 1) docs = append(docs, d) } } return docs, func(string) http.Handler { mux := http.NewServeMux() tree := http.FileServerFS(fiesta.FS()) for _, prefix := range []string{"/css/", "/js/", "/examples/assets/"} { mux.Handle("GET "+prefix, tree) } mux.HandleFunc("GET /css/tokens.css", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/css") w.Write(tokens) }) for path, doc := range pages { doc := doc mux.HandleFunc("GET "+path, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, doc) }) } return mux } } // motionReading is what one page in one pass says about itself. type motionReading struct { Reduce, Hover bool Theme int Elements int Moving []string } // readMotion lists what still moves on the page, in words: each // element, or its ::before, ::after, ::marker, ::placeholder or // ::backdrop, whose computed transition has a duration on any property // or whose computed animation names one, and each animation the // document is running, which also catches one a script started. Its // own reading says whether reduced motion and hover are on, how many // elements it read and how many rules the theme under test holds, so // a page that was not what the drive thinks it is cannot pass. const readMotion = `(theme => { const secs = v => v.split(",").map(s => { s = s.trim(); return s.endsWith("ms") ? parseFloat(s) / 1000 : parseFloat(s); }); const label = el => { let s = el.localName; if (el.id) s += "#" + el.id; for (const a of el.attributes) if (a.name.startsWith("rst-")) s += "[" + a.name + (a.value ? '="' + a.value + '"' : "") + "]"; for (const c of el.classList) if (c.startsWith("rst-")) s += "." + c; return s; }; const name = el => { const parts = []; for (let e = el, i = 0; e && i < 3; e = e.parentElement, i++) parts.unshift(label(e)); return parts.join(" > "); }; const moving = []; const read = (el, pseudo) => { const cs = getComputedStyle(el, pseudo || null); if ((pseudo === "::before" || pseudo === "::after") && (cs.content === "none" || cs.content === "normal")) return; const props = cs.transitionProperty.split(",").map(s => s.trim()), durs = secs(cs.transitionDuration); props.forEach((p, i) => { const d = durs[i % durs.length]; if (p !== "none" && d > 0) moving.push(name(el) + pseudo + " transitions " + p + " over " + d + "s"); }); for (const n of cs.animationName.split(",").map(s => s.trim())) if (n !== "none") moving.push(name(el) + pseudo + " runs the animation " + n); }; const all = document.querySelectorAll("*"); for (const el of all) for (const p of ["", "::before", "::after", "::marker", "::placeholder", "::backdrop"]) read(el, p); for (const a of document.getAnimations()) { const e = a.effect; moving.push((e && e.target ? name(e.target) + (e.pseudoElement || "") : "the document") + " is running " + a.constructor.name + " " + (a.animationName || a.transitionProperty || a.id || "")); } const sheet = [...document.styleSheets].find(s => s.href && s.href.endsWith("/css/themes/" + theme + ".css")); return JSON.stringify({Reduce: matchMedia("(prefers-reduced-motion: reduce)").matches, Hover: matchMedia("(hover: hover)").matches, Theme: sheet ? sheet.cssRules.length : 0, Elements: all.length, Moving: moving}); })` // forceEveryElement pins states on every element of the page, which // replaces whatever was pinned before: an empty list releases them. // The calls go out together, not one round trip at a time, since a // layout has hundreds of elements and each pass pins all of them. func forceEveryElement(states []string) chromedp.ActionFunc { return func(c context.Context) error { root, err := dom.GetDocument().Do(c) if err != nil { return err } ids, err := dom.QuerySelectorAll(root.NodeID, "*").Do(c) if err != nil { return err } if states == nil { states = []string{} } var wg sync.WaitGroup errs := make(chan error, len(ids)) gate := make(chan struct{}, 32) for _, id := range ids { wg.Add(1) gate <- struct{}{} go func(id cdp.NodeID) { defer wg.Done() defer func() { <-gate }() if err := css.ForcePseudoState(id, states).Do(c); err != nil { errs <- err } }(id) } wg.Wait() close(errs) return <-errs } } // motionFinding is one thing that moves: where the drive first saw // it, how many times it saw it in all, and the passes it saw it in, // each as " at ". type motionFinding struct { where string times int in map[string]bool } // sweepMotion loads each of docs at each width in one browser, with // reduced motion emulated or not and a mouse that hovers, puts every // page through every pass, and returns what moved, keyed by what the // page said. It fails the test if a page's reduced motion is not what // the caller asked for, has no hover, did not load the theme under // test or has no elements. func sweepMotion(t *testing.T, theme string, docs []motionDoc, site func(string) http.Handler, widths []int64, reduce bool) map[string]*motionFinding { t.Helper() rig := harness.New(t, site, harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) defer cancel() preference := "no-preference" if reduce { preference = "reduce" } mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: preference}})) found := map[string]*motionFinding{} for _, width := range widths { for _, d := range docs { mustRun(t, ctx, chromedp.EmulateViewport(width, 900), chromedp.Navigate(rig.Origin+d.path), chromedp.WaitReady("body", chromedp.ByQuery), chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, awaitPromise), chromedp.ActionFunc(func(c context.Context) error { if err := dom.Enable().Do(c); err != nil { return err } return css.Enable().Do(c) })) for _, pass := range motionPasses { where := fmt.Sprintf("%s at %d, %s, in %s", d.name, width, pass.name, theme) if pass.open { mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll("details").forEach(d => d.open = true), true`, nil)) } mustRun(t, ctx, forceEveryElement(pass.states)) var r motionReading at(t, ctx, readMotion+`(`+jsStr(theme)+`)`, &r) if r.Reduce != reduce || !r.Hover { t.Fatalf("%s: reduced motion %v, hover %v; the drive wants reduced motion %v and hover", where, r.Reduce, r.Hover, reduce) } if r.Theme == 0 { t.Fatalf("%s: the page has not loaded themes/%s.css, so it is not the theme under test", where, theme) } if r.Elements == 0 { t.Fatalf("%s: the page has no elements to read", where) } for _, m := range r.Moving { f, ok := found[m] if !ok { f = &motionFinding{where: where, in: map[string]bool{}} found[m] = f } f.times++ f.in[fmt.Sprintf("%s at %d", pass.name, width)] = true } } } } return found } // reportMotion fails the test once for each thing that moved. func reportMotion(t *testing.T, found map[string]*motionFinding) { t.Helper() keys := make([]string, 0, len(found)) for k := range found { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { t.Errorf("under reduced motion, %s (first in %s; %d readings in all)", k, found[k].where, found[k].times) } } // nothingMoves is the k-th of the six parts of // TestNothingMovesUnderReducedMotion: every state of every example, // layouts and the column shell among them, in one theme at one width. func nothingMoves(t *testing.T, k int) { themes := fiesta.ThemeNames() if len(themes)*len(motionWidths) != 6 { t.Fatalf("fiesta ships %d themes and the drive reads %d widths; it is dealt into six parts, one a theme and width", len(themes), len(motionWidths)) } theme, width := themes[k/2], motionWidths[k%2] docs, site := motionSite(t, theme, fiesta.TokensCSS(), "") if len(docs) < 150 { t.Fatalf("the manifest lists %d states, want at least 150; a set this small is a broken manifest", len(docs)) } reportMotion(t, sweepMotion(t, theme, docs, site, []int64{width}, true)) } func TestNothingMovesUnderReducedMotion1of6(t *testing.T) { nothingMoves(t, 0) } func TestNothingMovesUnderReducedMotion2of6(t *testing.T) { nothingMoves(t, 1) } func TestNothingMovesUnderReducedMotion3of6(t *testing.T) { nothingMoves(t, 2) } func TestNothingMovesUnderReducedMotion4of6(t *testing.T) { nothingMoves(t, 3) } func TestNothingMovesUnderReducedMotion5of6(t *testing.T) { nothingMoves(t, 4) } func TestNothingMovesUnderReducedMotion6of6(t *testing.T) { nothingMoves(t, 5) } // The drive's own controls: a copy of tokens.css with a disable that // loses, served under the snippet's example, must be named as moving. // The first two pass the static gate, which would have to order and // weigh the cascade to see them. The last two it does see; here they // show the drive reaches a hovered element and a pseudo-element. var losingDisables = []struct{ name, css, moving string }{ // A later rule for the same selector wins over the disable, which // came first (Astra's example). {"a later transition", ` @media (prefers-reduced-motion: reduce) { .rst-snippet, [rst-snippet] { transition: none; } } .rst-snippet, [rst-snippet] { transition: transform 0.2s; } .rst-snippet:hover, [rst-snippet]:hover { transform: translateY(-2px); } `, "[rst-snippet] transitions transform over 0.2s"}, // !important wins over a disable that is not, wherever it stands. {"an !important transition", ` .rst-snippet, [rst-snippet] { transition: transform 0.2s !important; } @media (prefers-reduced-motion: reduce) { .rst-snippet, [rst-snippet] { transition: none; } } `, "[rst-snippet] transitions transform over 0.2s"}, // Only a hovered snippet moves, so only the hovered pass sees it. {"a transition on hover only", ` @media (prefers-reduced-motion: reduce) { .rst-snippet, [rst-snippet] { transition: none; } } .rst-snippet:hover, [rst-snippet]:hover { transition: translate 0.2s; } `, "[rst-snippet] transitions translate over 0.2s"}, // A pseudo-element is styled apart from its element. {"an animation on ::before", ` .rst-snippet::before, [rst-snippet]::before { animation: rst-spin 1s infinite; content: ""; } @media (prefers-reduced-motion: reduce) { .rst-snippet, [rst-snippet] { animation: none; } } `, "[rst-snippet]::before runs the animation rst-spin"}, } // TestTheMotionDriveSeesADisableThatLoses runs the drive on the // snippet's example, in day at 1280, once for each losing disable // appended to tokens.css, and wants it to name the snippet as moving. // The shipped tokens.css is the control: on it the same page moves // nowhere. func TestTheMotionDriveSeesADisableThatLoses(t *testing.T) { const theme = "day" shipped := fiesta.TokensCSS() docs, site := motionSite(t, theme, shipped, "snippet") if len(docs) == 0 { t.Fatal("the manifest has no snippet example to plant a fault under") } if found := sweepMotion(t, theme, docs, site, []int64{1280}, true); len(found) != 0 { reportMotion(t, found) t.Fatal("the snippet moves under the shipped tokens.css; the controls below would prove nothing") } for _, c := range losingDisables { t.Run(c.name, func(t *testing.T) { tokens := append(append([]byte(nil), shipped...), c.css...) if problems, _, err := motionProblems(string(tokens)); err == nil { t.Logf("the static gate on this tokens.css: %d problems %v", len(problems), problems) } docs, site := motionSite(t, theme, tokens, "snippet") found := sweepMotion(t, theme, docs, site, []int64{1280}, true) named := false for what := range found { named = named || strings.Contains(what, c.moving) } if !named { t.Errorf("the drive did not see %q; it saw %d other things move:", c.moving, len(found)) reportMotion(t, found) } }) } } // shippedMotions are motions the stylesheets ship, each with a pass // that must see it without reduced motion, or must not. Together they // show that each way the drive reaches a state takes: the hover, the // press, the target and the open menu, the pseudo-element, the phone // width and the animation a document runs. A pass that silently // forced nothing would leave the drive under reduced motion reading // every page at rest and passing. var shippedMotions = []struct { what, in string seen bool }{ {"[rst-btn] transitions translate over 0.3s", "at rest at 1280", true}, {"[rst-caret] transitions transform over 0.15s", "at rest at 1280", true}, {"[rst-tip]::after transitions opacity over 0.12s", "at rest at 1280", true}, {"[rst-switch-track] transitions --rst-switch-p over 0.24s", "at rest at 1280", true}, {"[rst-spin] runs the animation rst-spin", "at rest at 1280", true}, {"[rst-spin] is running CSSAnimation rst-spin", "at rest at 1280", true}, // A row fades in only once it is hovered, and lands at once when // its link is pressed. {"[rst-row] transitions --rst-hover over 0.25s", "hovered at 1280", true}, {"[rst-row] transitions --rst-hover over 0.25s", "at rest at 1280", false}, {"[rst-row] transitions --rst-hover over 0.25s", "pressed at 1280", false}, {"[rst-row] is running CSSTransition --rst-hover", "hovered at 1280", true}, // A pressed button lands at once. Not every button: a disabled or // busy one is never pressed, so it keeps its transition. {"[rst-snippet-copy] transitions translate over 0.3s", "at rest at 1280", true}, {"[rst-snippet-copy] transitions translate over 0.3s", "pressed at 1280", false}, {"[rst-snippet-copy] transitions translate over 0.3s", "tapped at 1280", false}, // The shell's menu drops in only when it is open, at a phone's // width; the index row flashes only when it is the target. {"[rst-shell-tail] runs the animation rst-shell-drop", "opened at 390", true}, {"[rst-shell-tail] runs the animation rst-shell-drop", "at rest at 390", false}, {"[rst-shell-tail] runs the animation rst-shell-drop", "opened at 1280", false}, {"runs the animation rst-shell-flash", "targeted at 390", true}, {"runs the animation rst-shell-flash", "at rest at 390", false}, } // TestTheMotionDriveSeesWhatTheStylesheetsShip runs the drive over // every state in day at both widths without reduced motion, and holds // it to shippedMotions. func TestTheMotionDriveSeesWhatTheStylesheetsShip(t *testing.T) { docs, site := motionSite(t, "day", fiesta.TokensCSS(), "") found := sweepMotion(t, "day", docs, site, motionWidths, false) for _, want := range shippedMotions { seen := false for what, f := range found { seen = seen || strings.Contains(what, want.what) && f.in[want.in] } if seen != want.seen { t.Errorf("without reduced motion, %q seen %s: %v, want %v", want.what, want.in, seen, want.seen) } } }