rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3// Under reduced motion nothing on a Fiesta page moves and nothing
4// fades: every transition and animation the stylesheets declare has a
5// disable under @media (prefers-reduced-motion: reduce). This drive is
6// the authority on that promise. It asks the browser, which applies
7// the cascade, rather than reading the stylesheets: a disable that
8// loses, to a later rule or to an !important one, reads as a disable
9// to any reader that does not order and weigh every declaration, and
10// the browser is the only reader that cannot get that wrong. The
11// static gate in css_test.go stays as a fast early warning.
12package check
13
14import (
15 "context"
16 "fmt"
17 "net/http"
18 "sort"
19 "strings"
20 "sync"
21 "testing"
22 "time"
23
24 "github.com/chromedp/cdproto/cdp"
25 "github.com/chromedp/cdproto/css"
26 "github.com/chromedp/cdproto/dom"
27 "github.com/chromedp/cdproto/emulation"
28 "github.com/chromedp/chromedp"
29
30 "amadan.net/rastrillo/fiesta"
31 "amadan.net/rastrillo/fiesta/example"
32 "amadan.net/rastrillo/fiesta/internal/harness"
33)
34
35// motionPass is one state the drive puts every element of a page in
36// before it reads what moves. The states are forced on every element
37// at once, through the inspector, since a pointer over one element
38// hovers each of its ancestors too and a row's hover rule names the
39// row, not the link under the pointer. Every <details> is opened in
40// the last two, since an open menu is where the drop-down animation
41// and the rules under an open menu live.
42type motionPass struct {
43 name string
44 states []string
45 open bool
46}
47
48var motionPasses = []motionPass{
49 {name: "at rest"},
50 {name: "hovered", states: []string{"hover"}},
51 {name: "pressed", states: []string{"hover", "active"}},
52 {name: "tapped", states: []string{"active"}},
53 {name: "focused", states: []string{"focus", "focus-visible", "focus-within"}},
54 {name: "targeted", states: []string{"target"}},
55 {name: "opened", open: true},
56 {name: "opened and hovered", states: []string{"hover"}, open: true},
57}
58
59// motionWidths are the two widths the stylesheets switch at: the
60// shell's phone rules (the menu that drops in, the index rows) apply
61// only under 800px.
62var motionWidths = []int64{1280, 390}
63
64// motionDoc is one example state as the drive serves it.
65type motionDoc struct{ name, path string }
66
67// motionSite serves every state of every example, layouts included, or
68// of the one example called only if it is not "", at the depth the
69// tree keeps it, examples/<name>/<state>.html, so its
70// relative links resolve. The theme each document links is rewritten
71// to theme, and /css/tokens.css answers with tokens, so a control can
72// serve a stylesheet with a planted fault. Everything else under css/,
73// js/ and examples/assets/ is the tree's.
74func motionSite(t *testing.T, theme string, tokens []byte, only string) ([]motionDoc, func(string) http.Handler) {
75 t.Helper()
76 m, err := example.Load(fiesta.FS())
77 if err != nil {
78 t.Fatal(err)
79 }
80 const day = "../../css/themes/day.css"
81 var docs []motionDoc
82 pages := map[string]string{}
83 for _, e := range m.Examples {
84 if only != "" && only != e.Name {
85 continue
86 }
87 for _, s := range e.States {
88 doc, err := example.Document(fiesta.FS(), e.Name, s.ID)
89 if err != nil {
90 t.Fatal(err)
91 }
92 if strings.Count(doc, day) != 1 {
93 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))
94 }
95 d := motionDoc{name: e.Name + "/" + s.ID, path: "/examples/" + e.Name + "/" + s.ID + ".html"}
96 pages[d.path] = strings.Replace(doc, day, "../../css/themes/"+theme+".css", 1)
97 docs = append(docs, d)
98 }
99 }
100 return docs, func(string) http.Handler {
101 mux := http.NewServeMux()
102 tree := http.FileServerFS(fiesta.FS())
103 for _, prefix := range []string{"/css/", "/js/", "/examples/assets/"} {
104 mux.Handle("GET "+prefix, tree)
105 }
106 mux.HandleFunc("GET /css/tokens.css", func(w http.ResponseWriter, _ *http.Request) {
107 w.Header().Set("Content-Type", "text/css")
108 w.Write(tokens)
109 })
110 for path, doc := range pages {
111 doc := doc
112 mux.HandleFunc("GET "+path, func(w http.ResponseWriter, _ *http.Request) {
113 w.Header().Set("Content-Type", "text/html; charset=utf-8")
114 fmt.Fprint(w, doc)
115 })
116 }
117 return mux
118 }
119}
120
121// motionReading is what one page in one pass says about itself.
122type motionReading struct {
123 Reduce, Hover bool
124 Theme int
125 Elements int
126 Moving []string
127}
128
129// readMotion lists what still moves on the page, in words: each
130// element, or its ::before, ::after, ::marker, ::placeholder or
131// ::backdrop, whose computed transition has a duration on any property
132// or whose computed animation names one, and each animation the
133// document is running, which also catches one a script started. Its
134// own reading says whether reduced motion and hover are on, how many
135// elements it read and how many rules the theme under test holds, so
136// a page that was not what the drive thinks it is cannot pass.
137const readMotion = `(theme => {
138 const secs = v => v.split(",").map(s => { s = s.trim(); return s.endsWith("ms") ? parseFloat(s) / 1000 : parseFloat(s); });
139 const label = el => { let s = el.localName; if (el.id) s += "#" + el.id;
140 for (const a of el.attributes) if (a.name.startsWith("rst-")) s += "[" + a.name + (a.value ? '="' + a.value + '"' : "") + "]";
141 for (const c of el.classList) if (c.startsWith("rst-")) s += "." + c; return s; };
142 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(" > "); };
143 const moving = [];
144 const read = (el, pseudo) => {
145 const cs = getComputedStyle(el, pseudo || null);
146 if ((pseudo === "::before" || pseudo === "::after") && (cs.content === "none" || cs.content === "normal")) return;
147 const props = cs.transitionProperty.split(",").map(s => s.trim()), durs = secs(cs.transitionDuration);
148 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"); });
149 for (const n of cs.animationName.split(",").map(s => s.trim())) if (n !== "none") moving.push(name(el) + pseudo + " runs the animation " + n);
150 };
151 const all = document.querySelectorAll("*");
152 for (const el of all) for (const p of ["", "::before", "::after", "::marker", "::placeholder", "::backdrop"]) read(el, p);
153 for (const a of document.getAnimations()) {
154 const e = a.effect;
155 moving.push((e && e.target ? name(e.target) + (e.pseudoElement || "") : "the document") + " is running " + a.constructor.name + " " + (a.animationName || a.transitionProperty || a.id || ""));
156 }
157 const sheet = [...document.styleSheets].find(s => s.href && s.href.endsWith("/css/themes/" + theme + ".css"));
158 return JSON.stringify({Reduce: matchMedia("(prefers-reduced-motion: reduce)").matches, Hover: matchMedia("(hover: hover)").matches,
159 Theme: sheet ? sheet.cssRules.length : 0, Elements: all.length, Moving: moving});
160})`
161
162// forceEveryElement pins states on every element of the page, which
163// replaces whatever was pinned before: an empty list releases them.
164// The calls go out together, not one round trip at a time, since a
165// layout has hundreds of elements and each pass pins all of them.
166func forceEveryElement(states []string) chromedp.ActionFunc {
167 return func(c context.Context) error {
168 root, err := dom.GetDocument().Do(c)
169 if err != nil {
170 return err
171 }
172 ids, err := dom.QuerySelectorAll(root.NodeID, "*").Do(c)
173 if err != nil {
174 return err
175 }
176 if states == nil {
177 states = []string{}
178 }
179 var wg sync.WaitGroup
180 errs := make(chan error, len(ids))
181 gate := make(chan struct{}, 32)
182 for _, id := range ids {
183 wg.Add(1)
184 gate <- struct{}{}
185 go func(id cdp.NodeID) {
186 defer wg.Done()
187 defer func() { <-gate }()
188 if err := css.ForcePseudoState(id, states).Do(c); err != nil {
189 errs <- err
190 }
191 }(id)
192 }
193 wg.Wait()
194 close(errs)
195 return <-errs
196 }
197}
198
199// motionFinding is one thing that moves: where the drive first saw
200// it, how many times it saw it in all, and the passes it saw it in,
201// each as "<pass> at <width>".
202type motionFinding struct {
203 where string
204 times int
205 in map[string]bool
206}
207
208// sweepMotion loads each of docs at each width in one browser, with
209// reduced motion emulated or not and a mouse that hovers, puts every
210// page through every pass, and returns what moved, keyed by what the
211// page said. It fails the test if a page's reduced motion is not what
212// the caller asked for, has no hover, did not load the theme under
213// test or has no elements.
214func sweepMotion(t *testing.T, theme string, docs []motionDoc, site func(string) http.Handler, widths []int64, reduce bool) map[string]*motionFinding {
215 t.Helper()
216 rig := harness.New(t, site, harness.WithHover())
217 ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second)
218 defer cancel()
219 preference := "no-preference"
220 if reduce {
221 preference = "reduce"
222 }
223 mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: preference}}))
224 found := map[string]*motionFinding{}
225 for _, width := range widths {
226 for _, d := range docs {
227 mustRun(t, ctx, chromedp.EmulateViewport(width, 900), chromedp.Navigate(rig.Origin+d.path), chromedp.WaitReady("body", chromedp.ByQuery),
228 chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, awaitPromise),
229 chromedp.ActionFunc(func(c context.Context) error {
230 if err := dom.Enable().Do(c); err != nil {
231 return err
232 }
233 return css.Enable().Do(c)
234 }))
235 for _, pass := range motionPasses {
236 where := fmt.Sprintf("%s at %d, %s, in %s", d.name, width, pass.name, theme)
237 if pass.open {
238 mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll("details").forEach(d => d.open = true), true`, nil))
239 }
240 mustRun(t, ctx, forceEveryElement(pass.states))
241 var r motionReading
242 at(t, ctx, readMotion+`(`+jsStr(theme)+`)`, &r)
243 if r.Reduce != reduce || !r.Hover {
244 t.Fatalf("%s: reduced motion %v, hover %v; the drive wants reduced motion %v and hover", where, r.Reduce, r.Hover, reduce)
245 }
246 if r.Theme == 0 {
247 t.Fatalf("%s: the page has not loaded themes/%s.css, so it is not the theme under test", where, theme)
248 }
249 if r.Elements == 0 {
250 t.Fatalf("%s: the page has no elements to read", where)
251 }
252 for _, m := range r.Moving {
253 f, ok := found[m]
254 if !ok {
255 f = &motionFinding{where: where, in: map[string]bool{}}
256 found[m] = f
257 }
258 f.times++
259 f.in[fmt.Sprintf("%s at %d", pass.name, width)] = true
260 }
261 }
262 }
263 }
264 return found
265}
266
267// reportMotion fails the test once for each thing that moved.
268func reportMotion(t *testing.T, found map[string]*motionFinding) {
269 t.Helper()
270 keys := make([]string, 0, len(found))
271 for k := range found {
272 keys = append(keys, k)
273 }
274 sort.Strings(keys)
275 for _, k := range keys {
276 t.Errorf("under reduced motion, %s (first in %s; %d readings in all)", k, found[k].where, found[k].times)
277 }
278}
279
280// nothingMoves is the k-th of the six parts of
281// TestNothingMovesUnderReducedMotion: every state of every example,
282// layouts and the column shell among them, in one theme at one width.
283func nothingMoves(t *testing.T, k int) {
284 themes := fiesta.ThemeNames()
285 if len(themes)*len(motionWidths) != 6 {
286 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))
287 }
288 theme, width := themes[k/2], motionWidths[k%2]
289 docs, site := motionSite(t, theme, fiesta.TokensCSS(), "")
290 if len(docs) < 150 {
291 t.Fatalf("the manifest lists %d states, want at least 150; a set this small is a broken manifest", len(docs))
292 }
293 reportMotion(t, sweepMotion(t, theme, docs, site, []int64{width}, true))
294}
295
296func TestNothingMovesUnderReducedMotion1of6(t *testing.T) { nothingMoves(t, 0) }
297func TestNothingMovesUnderReducedMotion2of6(t *testing.T) { nothingMoves(t, 1) }
298func TestNothingMovesUnderReducedMotion3of6(t *testing.T) { nothingMoves(t, 2) }
299func TestNothingMovesUnderReducedMotion4of6(t *testing.T) { nothingMoves(t, 3) }
300func TestNothingMovesUnderReducedMotion5of6(t *testing.T) { nothingMoves(t, 4) }
301func TestNothingMovesUnderReducedMotion6of6(t *testing.T) { nothingMoves(t, 5) }
302
303// The drive's own controls: a copy of tokens.css with a disable that
304// loses, served under the snippet's example, must be named as moving.
305// The first two pass the static gate, which would have to order and
306// weigh the cascade to see them. The last two it does see; here they
307// show the drive reaches a hovered element and a pseudo-element.
308var losingDisables = []struct{ name, css, moving string }{
309 // A later rule for the same selector wins over the disable, which
310 // came first (Astra's example).
311 {"a later transition", `
312@media (prefers-reduced-motion: reduce) {
313 .rst-snippet, [rst-snippet] { transition: none; }
314}
315.rst-snippet, [rst-snippet] { transition: transform 0.2s; }
316.rst-snippet:hover, [rst-snippet]:hover { transform: translateY(-2px); }
317`, "[rst-snippet] transitions transform over 0.2s"},
318 // !important wins over a disable that is not, wherever it stands.
319 {"an !important transition", `
320.rst-snippet, [rst-snippet] { transition: transform 0.2s !important; }
321@media (prefers-reduced-motion: reduce) {
322 .rst-snippet, [rst-snippet] { transition: none; }
323}
324`, "[rst-snippet] transitions transform over 0.2s"},
325 // Only a hovered snippet moves, so only the hovered pass sees it.
326 {"a transition on hover only", `
327@media (prefers-reduced-motion: reduce) {
328 .rst-snippet, [rst-snippet] { transition: none; }
329}
330.rst-snippet:hover, [rst-snippet]:hover { transition: translate 0.2s; }
331`, "[rst-snippet] transitions translate over 0.2s"},
332 // A pseudo-element is styled apart from its element.
333 {"an animation on ::before", `
334.rst-snippet::before, [rst-snippet]::before { animation: rst-spin 1s infinite; content: ""; }
335@media (prefers-reduced-motion: reduce) {
336 .rst-snippet, [rst-snippet] { animation: none; }
337}
338`, "[rst-snippet]::before runs the animation rst-spin"},
339}
340
341// TestTheMotionDriveSeesADisableThatLoses runs the drive on the
342// snippet's example, in day at 1280, once for each losing disable
343// appended to tokens.css, and wants it to name the snippet as moving.
344// The shipped tokens.css is the control: on it the same page moves
345// nowhere.
346func TestTheMotionDriveSeesADisableThatLoses(t *testing.T) {
347 const theme = "day"
348 shipped := fiesta.TokensCSS()
349 docs, site := motionSite(t, theme, shipped, "snippet")
350 if len(docs) == 0 {
351 t.Fatal("the manifest has no snippet example to plant a fault under")
352 }
353 if found := sweepMotion(t, theme, docs, site, []int64{1280}, true); len(found) != 0 {
354 reportMotion(t, found)
355 t.Fatal("the snippet moves under the shipped tokens.css; the controls below would prove nothing")
356 }
357 for _, c := range losingDisables {
358 t.Run(c.name, func(t *testing.T) {
359 tokens := append(append([]byte(nil), shipped...), c.css...)
360 if problems, _, err := motionProblems(string(tokens)); err == nil {
361 t.Logf("the static gate on this tokens.css: %d problems %v", len(problems), problems)
362 }
363 docs, site := motionSite(t, theme, tokens, "snippet")
364 found := sweepMotion(t, theme, docs, site, []int64{1280}, true)
365 named := false
366 for what := range found {
367 named = named || strings.Contains(what, c.moving)
368 }
369 if !named {
370 t.Errorf("the drive did not see %q; it saw %d other things move:", c.moving, len(found))
371 reportMotion(t, found)
372 }
373 })
374 }
375}
376
377// shippedMotions are motions the stylesheets ship, each with a pass
378// that must see it without reduced motion, or must not. Together they
379// show that each way the drive reaches a state takes: the hover, the
380// press, the target and the open menu, the pseudo-element, the phone
381// width and the animation a document runs. A pass that silently
382// forced nothing would leave the drive under reduced motion reading
383// every page at rest and passing.
384var shippedMotions = []struct {
385 what, in string
386 seen bool
387}{
388 {"[rst-btn] transitions translate over 0.3s", "at rest at 1280", true},
389 {"[rst-caret] transitions transform over 0.15s", "at rest at 1280", true},
390 {"[rst-tip]::after transitions opacity over 0.12s", "at rest at 1280", true},
391 {"[rst-switch-track] transitions --rst-switch-p over 0.24s", "at rest at 1280", true},
392 {"[rst-spin] runs the animation rst-spin", "at rest at 1280", true},
393 {"[rst-spin] is running CSSAnimation rst-spin", "at rest at 1280", true},
394 // A row fades in only once it is hovered, and lands at once when
395 // its link is pressed.
396 {"[rst-row] transitions --rst-hover over 0.25s", "hovered at 1280", true},
397 {"[rst-row] transitions --rst-hover over 0.25s", "at rest at 1280", false},
398 {"[rst-row] transitions --rst-hover over 0.25s", "pressed at 1280", false},
399 {"[rst-row] is running CSSTransition --rst-hover", "hovered at 1280", true},
400 // A pressed button lands at once. Not every button: a disabled or
401 // busy one is never pressed, so it keeps its transition.
402 {"[rst-snippet-copy] transitions translate over 0.3s", "at rest at 1280", true},
403 {"[rst-snippet-copy] transitions translate over 0.3s", "pressed at 1280", false},
404 {"[rst-snippet-copy] transitions translate over 0.3s", "tapped at 1280", false},
405 // The shell's menu drops in only when it is open, at a phone's
406 // width; the index row flashes only when it is the target.
407 {"[rst-shell-tail] runs the animation rst-shell-drop", "opened at 390", true},
408 {"[rst-shell-tail] runs the animation rst-shell-drop", "at rest at 390", false},
409 {"[rst-shell-tail] runs the animation rst-shell-drop", "opened at 1280", false},
410 {"runs the animation rst-shell-flash", "targeted at 390", true},
411 {"runs the animation rst-shell-flash", "at rest at 390", false},
412}
413
414// TestTheMotionDriveSeesWhatTheStylesheetsShip runs the drive over
415// every state in day at both widths without reduced motion, and holds
416// it to shippedMotions.
417func TestTheMotionDriveSeesWhatTheStylesheetsShip(t *testing.T) {
418 docs, site := motionSite(t, "day", fiesta.TokensCSS(), "")
419 found := sweepMotion(t, "day", docs, site, motionWidths, false)
420 for _, want := range shippedMotions {
421 seen := false
422 for what, f := range found {
423 seen = seen || strings.Contains(what, want.what) && f.in[want.in]
424 }
425 if seen != want.seen {
426 t.Errorf("without reduced motion, %q seen %s: %v, want %v", want.what, want.in, seen, want.seen)
427 }
428 }
429}
430