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// The accessibility gate.
4//
5// "And everything is WCAG 2.2 AA, right?" This file is the half of
6// that answer a machine can give. It serves what Render produces in a
7// real Chromium, the exact bytes fiesta gallery would write, injects
8// the vendored axe-core, and runs it over every example state in every
9// theme and both schemes with the WCAG 2.2 AA tag set. Any violation
10// fails the build, printing rule id, impact and the offending
11// selector, so a regression names itself.
12//
13// Run it with:
14//
15// make browser.internal.gallery~1of2 browser.internal.gallery~2of2
16//
17// Three honest limits, written down here rather than implied:
18//
19// - Automated scanning catches roughly half of the WCAG success
20// criteria, the machine-checkable half. The rest (meaningful
21// alternative text, sensible reading order, whether a label says
22// something true) is a person's job. A green run here is a floor,
23// not a certificate.
24// - axe checks what is on the screen. Every state is scanned as its
25// own document, which is where its markup lives; the gallery page
26// around it is measured by the reflow drive and not scanned by
27// axe, and the keyboard walks open the state documents on their
28// own too.
29// - Two criteria axe cannot see at all get their own drives:
30// 1.4.10 Reflow (a 320px viewport that does not scroll sideways)
31// and 2.4.7/2.1.2 (keyboard_browser_test.go's walks, which prove
32// focus is visible at every stop and can always move on).
33package gallery
34
35import (
36 "context"
37 "encoding/json"
38 "fmt"
39 "net/http"
40 "path"
41 "sort"
42 "strings"
43 "sync"
44 "testing"
45 "time"
46
47 "github.com/chromedp/cdproto/cdp"
48 "github.com/chromedp/cdproto/emulation"
49 "github.com/chromedp/cdproto/runtime"
50 "github.com/chromedp/chromedp"
51
52 "amadan.net/rastrillo/fiesta"
53 "amadan.net/rastrillo/fiesta/example"
54 "amadan.net/rastrillo/fiesta/internal/axe"
55 "amadan.net/rastrillo/fiesta/internal/harness"
56)
57
58// mountPath is where the drives serve the tree: under a directory
59// rather than at the root, so every absolute URL the render writes is
60// exercised against a mount a developer's machine would not need.
61const mountPath = "/gallery"
62
63// tree serves the rendered tree under mountPath, with extra files
64// beside it, and reports every path it was asked for and did not have
65// when the test ends: a frame whose stylesheet 404s is a frame the
66// drive measured unstyled. requests records every request, for the
67// drive that proves a frame requests nothing.
68type tree struct {
69 files map[string][]byte
70 mu sync.Mutex
71 missing []string
72 requests []string
73}
74
75func newTree(t *testing.T, extra map[string][]byte) *tree {
76 t.Helper()
77 files := render(t, mountPath)
78 for name, body := range extra {
79 files[name] = body
80 }
81 tr := &tree{files: files}
82 t.Cleanup(func() {
83 tr.mu.Lock()
84 defer tr.mu.Unlock()
85 if len(tr.missing) > 0 {
86 t.Errorf("the tree was asked for %d file(s) it does not have: %v", len(tr.missing), tr.missing)
87 }
88 })
89 return tr
90}
91
92var contentTypes = map[string]string{
93 ".html": "text/html; charset=utf-8",
94 ".css": "text/css; charset=utf-8",
95 ".js": "text/javascript; charset=utf-8",
96 ".svg": "image/svg+xml",
97}
98
99func (tr *tree) ServeHTTP(w http.ResponseWriter, r *http.Request) {
100 tr.mu.Lock()
101 tr.requests = append(tr.requests, r.Method+" "+r.URL.RequestURI())
102 tr.mu.Unlock()
103 if !strings.HasPrefix(r.URL.Path, mountPath+"/") || r.Method != http.MethodGet {
104 http.NotFound(w, r)
105 return
106 }
107 name := strings.TrimPrefix(r.URL.Path, mountPath+"/")
108 body, ok := tr.files[name]
109 if !ok {
110 tr.mu.Lock()
111 tr.missing = append(tr.missing, r.URL.Path)
112 tr.mu.Unlock()
113 http.NotFound(w, r)
114 return
115 }
116 if ct, ok := contentTypes[path.Ext(name)]; ok {
117 w.Header().Set("Content-Type", ct)
118 }
119 w.Write(body)
120}
121
122// seen returns the requests so far.
123func (tr *tree) seen() []string {
124 tr.mu.Lock()
125 defer tr.mu.Unlock()
126 return append([]string(nil), tr.requests...)
127}
128
129// href is the URL of one file of the tree at the mount.
130func href(rel string) string { return mountPath + "/" + rel }
131
132// frameFile is the path of one state's frame document within the tree.
133func frameFile(theme, locale, name, state string) string {
134 return theme + "/" + locale + "/frames/" + name + "/" + state + ".html"
135}
136
137func mustRun(t *testing.T, ctx context.Context, actions ...chromedp.Action) {
138 t.Helper()
139 if err := chromedp.Run(ctx, actions...); err != nil {
140 t.Fatal(err)
141 }
142}
143
144// at evaluates a JS expression returning a JSON string into v.
145func at(t *testing.T, ctx context.Context, js string, v any, opts ...chromedp.EvaluateOption) {
146 t.Helper()
147 var raw string
148 if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw, opts...)); err != nil {
149 t.Fatalf("evaluating: %v\n%s", err, js)
150 }
151 if err := json.Unmarshal([]byte(raw), v); err != nil {
152 t.Fatalf("decoding %q: %v", raw, err)
153 }
154}
155
156func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }
157
158// settleJS takes every frame off lazy loading and reports how many
159// there are and how many gallery.js has painted.
160const settleJS = `(() => {
161 const f = [...document.querySelectorAll(".ds-view__frame")];
162 f.forEach(x => { if (x.loading !== "eager") x.loading = "eager"; });
163 return JSON.stringify({total: f.length, painted: f.filter(x => x.hasAttribute("data-gallery-painted")).length});
164})()`
165
166// painted loads every frame on the page and waits until gallery.js
167// has painted them all, so a reading that follows is of documents
168// that are there. It is Fiesta's own wait, on the painted mark; the
169// ported drives' eagerly, in rig_browser_test.go, waits on each
170// frame's document settling instead.
171func painted(t *testing.T, ctx context.Context, where string) int {
172 t.Helper()
173 deadline := time.Now().Add(60 * time.Second)
174 for {
175 var got struct{ Total, Painted int }
176 at(t, ctx, settleJS, &got)
177 if got.Total == 0 {
178 t.Fatalf("%s: no frames on the page", where)
179 }
180 if got.Painted == got.Total {
181 t.Logf("%s: %d frames loaded and painted", where, got.Total)
182 return got.Total
183 }
184 if !time.Now().Before(deadline) {
185 t.Fatalf("%s: %d of %d frames painted after 60s", where, got.Painted, got.Total)
186 }
187 time.Sleep(50 * time.Millisecond)
188 }
189}
190
191// report prints one scan's violations, one block each, and returns how
192// many there were.
193func report(t *testing.T, where string, vs []axe.Violation) int {
194 t.Helper()
195 for _, v := range vs {
196 nodes := v.Nodes
197 if len(nodes) > 5 {
198 nodes = append(nodes[:5], fmt.Sprintf("… and %d more", len(v.Nodes)-5))
199 }
200 t.Errorf("%s: %s (%s): %s\n %s", where, v.ID, v.Impact, v.Help, strings.Join(nodes, "\n "))
201 }
202 return len(vs)
203}
204
205// axeLegs are TestEveryExamplePassesAxe's legs: the themes in English,
206// and day in Arabic. Every theme is scanned by one of them.
207var axeLegs = []struct{ theme, locale string }{{"day", "en"}, {"plain", "en"}, {"signal", "en"}, {"day", "ar"}}
208
209// TestEveryExamplePassesAxe is the gate: every frame document frameFiles
210// names, scanned in day, plain and signal with data-theme light and
211// dark, and in Arabic day, with the WCAG 2.2 AA tag set. Expected: no
212// violations. The rules axe could not decide are logged per leg, never
213// silently dropped: they are the seam between what this scan holds and
214// what a person reviews. Preservation: part 3's gate, iterating
215// frameFiles so a document a later task adds is scanned the day it
216// lands.
217//
218// It is one test per leg, so the suite's slices can run the legs on
219// shards of their own: TestEveryExamplePassesAxeDayEn and the three
220// after it. Each checks that every theme is scanned by some leg, and
221// that its own leg scanned something, which the one test checked of
222// all four together.
223func TestEveryExamplePassesAxeDayEn(t *testing.T) { examplesPassAxe(t, 0) }
224func TestEveryExamplePassesAxePlainEn(t *testing.T) { examplesPassAxe(t, 1) }
225func TestEveryExamplePassesAxeSignalEn(t *testing.T) { examplesPassAxe(t, 2) }
226func TestEveryExamplePassesAxeDayAr(t *testing.T) { examplesPassAxe(t, 3) }
227
228// examplesPassAxe is TestEveryExamplePassesAxe's leg i.
229func examplesPassAxe(t *testing.T, i int) {
230 if len(axeLegs) != 4 {
231 t.Fatalf("axeLegs has %d legs and there are four tests, one per leg; a leg needs a test of its own", len(axeLegs))
232 }
233 for _, theme := range fiesta.ThemeNames() {
234 found := false
235 for _, l := range axeLegs {
236 found = found || l.theme == theme
237 }
238 if !found {
239 t.Fatalf("theme %s is scanned by no leg", theme)
240 }
241 }
242 rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) })
243 ctx, cancel := context.WithTimeout(rig.Context(), 18*time.Minute)
244 defer cancel()
245 s := newTestSite(t, fiesta.FS(), mountPath)
246 m := s.m
247
248 scans, failed := 0, 0
249 for _, leg := range axeLegs[i : i+1] {
250 name := leg.theme + " " + leg.locale
251 incomplete := map[string]int{}
252 start := time.Now()
253 for _, e := range m.Examples {
254 for _, f := range s.frameFiles(e) {
255 where := name + " " + strings.TrimSuffix(strings.TrimPrefix(f, "frames/"), ".html")
256 mustRun(t, ctx,
257 chromedp.EmulateViewport(1280, 900),
258 chromedp.Navigate(rig.Origin+href(leg.theme+"/"+leg.locale+"/"+f)),
259 chromedp.WaitReady("body"),
260 )
261 // The body's ground is read back after each flip, and the
262 // two must differ: a dark scan of a page still painted
263 // light would be the light scan twice.
264 ground := map[string]string{}
265 for _, scheme := range []string{"light", "dark"} {
266 var bg string
267 mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg))
268 ground[scheme] = bg
269 res, err := axe.Scan(ctx, "", axe.DefaultTags())
270 if err != nil {
271 t.Fatalf("%s %s: %v", where, scheme, err)
272 }
273 scans++
274 for _, id := range res.Incomplete {
275 incomplete[id]++
276 }
277 failed += report(t, where+" "+scheme, res.Violations)
278 }
279 if ground["light"] == ground["dark"] {
280 t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", where, ground["light"])
281 }
282 }
283 }
284 var ids []string
285 for id, n := range incomplete {
286 ids = append(ids, fmt.Sprintf("%s x%d", id, n))
287 }
288 sort.Strings(ids)
289 t.Logf("%s: scanned in %s; needs review, axe could not decide: %v", name, time.Since(start).Round(time.Second), ids)
290 }
291 if scans == 0 {
292 t.Fatal("no document was scanned")
293 }
294 t.Logf("%d scans, %d violations", scans, failed)
295 rig.Screen("body", "the last frame document")
296}
297
298// measureJS reports whether the document scrolls sideways, and the
299// elements that reach past either edge. Both edges, because an LTR
300// page spills past the right and an RTL page past the left. The edge
301// is the root's own laid-out box, not clientWidth, which over a stable
302// gutter differs by the scrollbar's width wherever none is drawn.
303const measureJS = `(() => {
304 const de = document.documentElement;
305 const w = Math.round(de.getBoundingClientRect().width);
306 const over = [];
307 document.querySelectorAll("body *").forEach(el => {
308 const r = el.getBoundingClientRect();
309 if (r.width === 0) return;
310 if (r.right > w + 1 || r.left < -1) {
311 const cls = (el.className || "").toString().trim().split(/\s+/)[0];
312 over.push(el.tagName.toLowerCase() + (cls ? "." + cls : "") + " [" + Math.round(r.left) + "…" + Math.round(r.right) + "]");
313 }
314 });
315 return JSON.stringify({doc: de.scrollWidth, client: w, body: document.body.scrollWidth, over: over.slice(0, 8)});
316})()`
317
318// TestTheGalleryHasNoHorizontalScrollAt320 is WCAG 2.2 AA 1.4.10
319// Reflow on the gallery's own pages: content at a 320 CSS-pixel
320// viewport must not need horizontal scrolling. 320x640 is the
321// criterion's own number, 1280 CSS px at 400% zoom, so a desktop
322// browser rather than mobile emulation. A frame is a scroll container
323// by design and may scroll inside its box; the page may not.
324func TestTheGalleryHasNoHorizontalScrollAt320(t *testing.T) {
325 rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) })
326 ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute)
327 defer cancel()
328 m := manifest(t)
329 theme := fiesta.ThemeNames()[0]
330
331 pages := []string{theme + "/en/index.html", theme + "/en/tokens.html", theme + "/en/icons.html", theme + "/ar/index.html"}
332 for _, e := range m.Examples {
333 pages = append(pages, theme+"/en/"+e.Name+".html")
334 }
335 pages = append(pages, theme+"/ar/field.html")
336 for _, p := range pages {
337 mustRun(t, ctx,
338 chromedp.ActionFunc(func(c context.Context) error {
339 return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c)
340 }),
341 chromedp.Navigate(rig.Origin+href(p)),
342 chromedp.WaitReady("body"),
343 )
344 var got struct {
345 Doc, Client, Body int
346 Over []string
347 }
348 at(t, ctx, measureJS, &got)
349 if got.Doc > got.Client || got.Body > got.Client {
350 t.Errorf("%s scrolls sideways at 320px (WCAG 1.4.10): document %dpx, body %dpx in a %dpx page\n widest: %s",
351 p, got.Doc, got.Body, got.Client, strings.Join(got.Over, "\n "))
352 }
353 }
354 t.Logf("%d pages measured", len(pages))
355 rig.Screen("main", "the last page")
356}
357
358// submitJS submits every form in every frame on the page, by its
359// submit button where it has one, and reports how many submit events
360// the frames saw and whether every frame kept its window: a mark set
361// on a window before the submits is still there after only if no
362// navigation replaced it.
363const submitJS = `(async () => {
364 const frames = [...document.querySelectorAll(".ds-view__frame")];
365 let events = 0, forms = 0;
366 for (const f of frames) {
367 const w = f.contentWindow, d = f.contentDocument;
368 w.__mark = 1;
369 w.addEventListener("submit", () => { events++; }, true);
370 for (const form of d.querySelectorAll("form")) {
371 forms++;
372 const b = form.querySelector('button[type="submit"], input[type="submit"], button:not([type])');
373 if (b) b.click(); else form.requestSubmit();
374 }
375 }
376 await new Promise(r => setTimeout(r, 300));
377 const kept = frames.filter(f => f.contentWindow.__mark === 1 && f.contentDocument.readyState === "complete").length;
378 return JSON.stringify({frames: frames.length, forms, events, kept});
379})()`
380
381// TestAFrameSubmitsNothing: submitting each form in a few frames makes
382// no request beyond the frame's own assets, and the frame keeps its
383// document. Three example pages whose states hold forms of different
384// kinds: a confirmation, a select inside a field, and a search bar.
385func TestAFrameSubmitsNothing(t *testing.T) {
386 var tr *tree
387 rig := harness.New(t, func(string) http.Handler { tr = newTree(t, nil); return tr })
388 ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute)
389 defer cancel()
390 theme := fiesta.ThemeNames()[0]
391
392 total := 0
393 for _, name := range []string{"confirm-form", "field-select", "list-bar-search"} {
394 mustRun(t, ctx,
395 chromedp.EmulateViewport(1280, 900),
396 chromedp.Navigate(rig.Origin+href(theme+"/en/"+name+".html")),
397 chromedp.WaitReady("body"),
398 )
399 painted(t, ctx, name)
400 before := len(tr.seen())
401 var got struct{ Frames, Forms, Events, Kept int }
402 at(t, ctx, submitJS, &got, awaitPromise)
403 if got.Forms == 0 || got.Events == 0 {
404 t.Errorf("%s: %d forms submitted and %d submit events seen; the drive submitted nothing", name, got.Forms, got.Events)
405 }
406 if got.Kept != got.Frames {
407 t.Errorf("%s: %d of %d frames kept their document through the submits", name, got.Kept, got.Frames)
408 }
409 after := tr.seen()
410 if len(after) > before {
411 t.Errorf("%s: the submits made %d request(s):\n %s", name, len(after)-before, strings.Join(after[before:], "\n "))
412 }
413 total += got.Events
414 t.Logf("%s: %d forms in %d frames, %d submit events, no request", name, got.Forms, got.Frames, got.Events)
415 }
416 if total == 0 {
417 t.Fatal("no submit event was seen on any page")
418 }
419 rig.Screen("main", "the last page")
420}
421
422// schemeJS reports the parent's scheme and, for every frame, its
423// scheme attribute and the colour painted behind its content: the body
424// where it paints one, else the root.
425const schemeJS = `(() => {
426 const paint = d => {
427 const b = getComputedStyle(d.body).backgroundColor, r = getComputedStyle(d.documentElement).backgroundColor;
428 return b === "rgba(0, 0, 0, 0)" ? r : b;
429 };
430 const frames = [...document.querySelectorAll(".ds-view__frame")].map(f => ({
431 theme: f.contentDocument.documentElement.getAttribute("data-theme") || "",
432 bg: paint(f.contentDocument),
433 }));
434 let stored = "";
435 try { stored = localStorage.getItem("fiesta-scheme") || ""; } catch (e) {}
436 return JSON.stringify({theme: document.documentElement.getAttribute("data-theme") || "", stored, frames});
437})()`
438
439// luminance is the relative luminance of an rgb() string, for telling
440// a dark canvas from a light one.
441func luminance(t *testing.T, rgb string) float64 {
442 t.Helper()
443 var r, g, b float64
444 if _, err := fmt.Sscanf(rgb, "rgb(%g, %g, %g)", &r, &g, &b); err != nil {
445 t.Fatalf("%q is not an rgb() colour: %v", rgb, err)
446 }
447 lin := func(c float64) float64 {
448 c /= 255
449 if c <= 0.03928 {
450 return c / 12.92
451 }
452 return ((c + 0.055) / 1.055) * ((c + 0.055) / 1.055) * ((c + 0.055) / 1.055)
453 }
454 return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b)
455}
456
457// chooseScheme picks one scheme from the header's menu, as a reader
458// would: open the menu, press the button. A page carries two copies of
459// the menu, the pinned bar's and the phone's display settings', and
460// one of them is hidden at any width, so the drive finds the visible
461// copy and keeps every query inside it; chromedp.Click on the bare
462// selector waits on the first copy in the document, the hidden one at
463// a desktop width.
464func chooseScheme(t *testing.T, ctx context.Context, scheme string) {
465 t.Helper()
466 var menus []*cdp.Node
467 var at int
468 mustRun(t, ctx,
469 chromedp.Nodes(`[rst-scheme]`, &menus, chromedp.ByQueryAll),
470 chromedp.Evaluate(`[...document.querySelectorAll("[rst-scheme]")].findIndex(m => m.checkVisibility())`, &at),
471 )
472 if at < 0 || at >= len(menus) {
473 t.Fatalf("none of the %d copies of the scheme menu is visible", len(menus))
474 }
475 menu := chromedp.FromNode(menus[at])
476 btn := fmt.Sprintf(`input[value=%q] ~ button`, scheme)
477 mustRun(t, ctx,
478 chromedp.Click(`summary`, chromedp.ByQuery, menu),
479 chromedp.WaitVisible(btn, chromedp.ByQuery, menu),
480 chromedp.Click(btn, chromedp.ByQuery, menu),
481 )
482}
483
484// TestTheSchemeMenuRepaintsTheFrames: choosing Dark in the parent
485// gives every loaded frame data-theme="dark" and a dark computed
486// background, and a frame loaded afterwards starts dark. The later
487// frame is added outside gallery.js's reach, so only frame.js can have
488// painted it.
489func TestTheSchemeMenuRepaintsTheFrames(t *testing.T) {
490 rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) })
491 ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute)
492 defer cancel()
493 theme := fiesta.ThemeNames()[0]
494
495 mustRun(t, ctx,
496 chromedp.EmulateViewport(1280, 900),
497 chromedp.Navigate(rig.Origin+href(theme+"/en/field.html")),
498 chromedp.WaitReady("body"),
499 )
500 n := painted(t, ctx, "field")
501
502 type reading struct {
503 Theme, Stored string
504 Frames []struct{ Theme, Bg string }
505 }
506 var before reading
507 at(t, ctx, schemeJS, &before)
508 if before.Theme != "" || before.Stored != "" {
509 t.Fatalf("before any choice the page is on %q with %q stored; the drive would be testing a remembered choice", before.Theme, before.Stored)
510 }
511 for i, f := range before.Frames {
512 if f.Theme != "" {
513 t.Errorf("frame %d starts on %q, want the OS's scheme", i, f.Theme)
514 }
515 if l := luminance(t, f.Bg); l < 0.5 {
516 t.Errorf("frame %d starts dark (%s); the reading after Dark would prove nothing", i, f.Bg)
517 }
518 }
519
520 chooseScheme(t, ctx, "dark")
521 deadline := time.Now().Add(10 * time.Second)
522 var after reading
523 for {
524 at(t, ctx, schemeJS, &after)
525 dark := 0
526 for _, f := range after.Frames {
527 if f.Theme == "dark" {
528 dark++
529 }
530 }
531 if dark == len(after.Frames) || !time.Now().Before(deadline) {
532 break
533 }
534 time.Sleep(50 * time.Millisecond)
535 }
536 if after.Theme != "dark" {
537 t.Errorf("after choosing Dark the page is on %q", after.Theme)
538 }
539 if after.Stored != "dark" {
540 t.Errorf("after choosing Dark the page stored %q; the header's menu is the standalone state, which remembers", after.Stored)
541 }
542 if len(after.Frames) != n {
543 t.Fatalf("%d frames read, %d loaded", len(after.Frames), n)
544 }
545 for i, f := range after.Frames {
546 if f.Theme != "dark" {
547 t.Errorf("frame %d is on %q after Dark was chosen", i, f.Theme)
548 }
549 if l := luminance(t, f.Bg); l > 0.2 {
550 t.Errorf("frame %d paints %s after Dark was chosen, which is not dark", i, f.Bg)
551 }
552 }
553
554 // A frame loaded afterwards, outside gallery.js's class, so the
555 // attribute it starts with is frame.js's doing alone.
556 later := fmt.Sprintf(`(async () => {
557 const f = document.createElement("iframe");
558 f.src = %q;
559 const loaded = new Promise(r => f.addEventListener("load", r));
560 document.querySelector("main").append(f);
561 await loaded;
562 const d = f.contentDocument;
563 const b = getComputedStyle(d.body).backgroundColor, r = getComputedStyle(d.documentElement).backgroundColor;
564 return JSON.stringify({theme: d.documentElement.getAttribute("data-theme") || "", bg: b === "rgba(0, 0, 0, 0)" ? r : b});
565 })()`, href(frameFile(theme, "en", "badge", mustDefault(t, "badge"))))
566 var fresh struct{ Theme, Bg string }
567 at(t, ctx, later, &fresh, awaitPromise)
568 if fresh.Theme != "dark" {
569 t.Errorf("a frame loaded after the choice starts on %q, want dark", fresh.Theme)
570 }
571 if l := luminance(t, fresh.Bg); l > 0.2 {
572 t.Errorf("a frame loaded after the choice paints %s, which is not dark", fresh.Bg)
573 }
574 rig.Screen("main", "the field page in Dark")
575}
576
577// placeJS reports where the reader's focus and scroll are on the
578// gallery page, and how many of its frames hold a field that asked for
579// autofocus.
580const placeJS = `(() => {
581 const asked = [...document.querySelectorAll(".ds-view__frame")].filter(f => f.contentDocument.querySelector("[autofocus]")).length;
582 const a = document.activeElement;
583 return JSON.stringify({active: a ? a.tagName.toLowerCase() : "", scrollY: Math.round(scrollY), asked});
584})()`
585
586// TestFramesKeepTheReadersPlace: on the signin example's page, whose
587// states ask for autofocus, loading every frame leaves focus on the
588// parent and the page where it was. A browser grants a same-origin
589// frame's autofocus, so without frame.js's hold the first frame to
590// load would take the reader's focus and scroll the page to itself.
591func TestFramesKeepTheReadersPlace(t *testing.T) {
592 rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) })
593 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
594 defer cancel()
595 theme := fiesta.ThemeNames()[0]
596
597 mustRun(t, ctx,
598 chromedp.EmulateViewport(1280, 900),
599 chromedp.Navigate(rig.Origin+href(theme+"/en/signin.html")),
600 chromedp.WaitReady("body"),
601 )
602 painted(t, ctx, "signin")
603 // Autofocus lands at a rendering update after load, so the reading
604 // waits two frames.
605 mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise))
606 var got struct {
607 Active string
608 ScrollY, Asked int
609 }
610 at(t, ctx, placeJS, &got)
611 if got.Asked == 0 {
612 t.Fatal("no frame on the signin page asks for autofocus; the check below would prove nothing")
613 }
614 if got.Active != "body" {
615 t.Errorf("after the frames loaded, focus is on the parent's %s, not its body: a frame took it", got.Active)
616 }
617 if got.ScrollY != 0 {
618 t.Errorf("after the frames loaded, the page is scrolled to %dpx: a frame's autofocus moved it", got.ScrollY)
619 }
620 t.Logf("signin: %d frames asked for autofocus; focus is on the parent's %s at %dpx", got.Asked, got.Active, got.ScrollY)
621 rig.Screen("main", "the signin page")
622}
623
624// mustDefault is the manifest's default state for name.
625func mustDefault(t *testing.T, name string) string {
626 t.Helper()
627 state, ok := manifest(t).Default(name)
628 if !ok || state == "" {
629 t.Fatalf("the manifest has no default state for %s", name)
630 }
631 return state
632}
633
634// statesOf lists one family's states in manifest order, as
635// name/state pairs.
636func statesOf(m *example.Manifest, family string) [][2]string {
637 var out [][2]string
638 for _, e := range m.Examples {
639 if e.Family != family {
640 continue
641 }
642 for _, s := range e.States {
643 out = append(out, [2]string{e.Name, s.ID})
644 }
645 }
646 return out
647}
648