//go:build browser // The accessibility gate. // // "And everything is WCAG 2.2 AA, right?" This file is the half of // that answer a machine can give. It serves what Render produces in a // real Chromium, the exact bytes fiesta gallery would write, injects // the vendored axe-core, and runs it over every example state in every // theme and both schemes with the WCAG 2.2 AA tag set. Any violation // fails the build, printing rule id, impact and the offending // selector, so a regression names itself. // // Run it with: // // make browser.internal.gallery~1of2 browser.internal.gallery~2of2 // // Three honest limits, written down here rather than implied: // // - Automated scanning catches roughly half of the WCAG success // criteria, the machine-checkable half. The rest (meaningful // alternative text, sensible reading order, whether a label says // something true) is a person's job. A green run here is a floor, // not a certificate. // - axe checks what is on the screen. Every state is scanned as its // own document, which is where its markup lives; the gallery page // around it is measured by the reflow drive and not scanned by // axe, and the keyboard walks open the state documents on their // own too. // - Two criteria axe cannot see at all get their own drives: // 1.4.10 Reflow (a 320px viewport that does not scroll sideways) // and 2.4.7/2.1.2 (keyboard_browser_test.go's walks, which prove // focus is visible at every stop and can always move on). package gallery import ( "context" "encoding/json" "fmt" "net/http" "path" "sort" "strings" "sync" "testing" "time" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/axe" "amadan.net/rastrillo/fiesta/internal/harness" ) // mountPath is where the drives serve the tree: under a directory // rather than at the root, so every absolute URL the render writes is // exercised against a mount a developer's machine would not need. const mountPath = "/gallery" // tree serves the rendered tree under mountPath, with extra files // beside it, and reports every path it was asked for and did not have // when the test ends: a frame whose stylesheet 404s is a frame the // drive measured unstyled. requests records every request, for the // drive that proves a frame requests nothing. type tree struct { files map[string][]byte mu sync.Mutex missing []string requests []string } func newTree(t *testing.T, extra map[string][]byte) *tree { t.Helper() files := render(t, mountPath) for name, body := range extra { files[name] = body } tr := &tree{files: files} t.Cleanup(func() { tr.mu.Lock() defer tr.mu.Unlock() if len(tr.missing) > 0 { t.Errorf("the tree was asked for %d file(s) it does not have: %v", len(tr.missing), tr.missing) } }) return tr } var contentTypes = map[string]string{ ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", } func (tr *tree) ServeHTTP(w http.ResponseWriter, r *http.Request) { tr.mu.Lock() tr.requests = append(tr.requests, r.Method+" "+r.URL.RequestURI()) tr.mu.Unlock() if !strings.HasPrefix(r.URL.Path, mountPath+"/") || r.Method != http.MethodGet { http.NotFound(w, r) return } name := strings.TrimPrefix(r.URL.Path, mountPath+"/") body, ok := tr.files[name] if !ok { tr.mu.Lock() tr.missing = append(tr.missing, r.URL.Path) tr.mu.Unlock() http.NotFound(w, r) return } if ct, ok := contentTypes[path.Ext(name)]; ok { w.Header().Set("Content-Type", ct) } w.Write(body) } // seen returns the requests so far. func (tr *tree) seen() []string { tr.mu.Lock() defer tr.mu.Unlock() return append([]string(nil), tr.requests...) } // href is the URL of one file of the tree at the mount. func href(rel string) string { return mountPath + "/" + rel } // frameFile is the path of one state's frame document within the tree. func frameFile(theme, locale, name, state string) string { return theme + "/" + locale + "/frames/" + name + "/" + state + ".html" } 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 a JSON string 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) } } func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) } // settleJS takes every frame off lazy loading and reports how many // there are and how many gallery.js has painted. const settleJS = `(() => { const f = [...document.querySelectorAll(".ds-view__frame")]; f.forEach(x => { if (x.loading !== "eager") x.loading = "eager"; }); return JSON.stringify({total: f.length, painted: f.filter(x => x.hasAttribute("data-gallery-painted")).length}); })()` // painted loads every frame on the page and waits until gallery.js // has painted them all, so a reading that follows is of documents // that are there. It is Fiesta's own wait, on the painted mark; the // ported drives' eagerly, in rig_browser_test.go, waits on each // frame's document settling instead. func painted(t *testing.T, ctx context.Context, where string) int { t.Helper() deadline := time.Now().Add(60 * time.Second) for { var got struct{ Total, Painted int } at(t, ctx, settleJS, &got) if got.Total == 0 { t.Fatalf("%s: no frames on the page", where) } if got.Painted == got.Total { t.Logf("%s: %d frames loaded and painted", where, got.Total) return got.Total } if !time.Now().Before(deadline) { t.Fatalf("%s: %d of %d frames painted after 60s", where, got.Painted, got.Total) } time.Sleep(50 * time.Millisecond) } } // report prints one scan's violations, one block each, and returns how // many there were. func report(t *testing.T, where string, vs []axe.Violation) int { t.Helper() for _, v := range vs { nodes := v.Nodes if len(nodes) > 5 { nodes = append(nodes[:5], fmt.Sprintf("… and %d more", len(v.Nodes)-5)) } t.Errorf("%s: %s (%s): %s\n %s", where, v.ID, v.Impact, v.Help, strings.Join(nodes, "\n ")) } return len(vs) } // axeLegs are TestEveryExamplePassesAxe's legs: the themes in English, // and day in Arabic. Every theme is scanned by one of them. var axeLegs = []struct{ theme, locale string }{{"day", "en"}, {"plain", "en"}, {"signal", "en"}, {"day", "ar"}} // TestEveryExamplePassesAxe is the gate: every frame document frameFiles // names, scanned in day, plain and signal with data-theme light and // dark, and in Arabic day, with the WCAG 2.2 AA tag set. Expected: no // violations. The rules axe could not decide are logged per leg, never // silently dropped: they are the seam between what this scan holds and // what a person reviews. Preservation: part 3's gate, iterating // frameFiles so a document a later task adds is scanned the day it // lands. // // It is one test per leg, so the suite's slices can run the legs on // shards of their own: TestEveryExamplePassesAxeDayEn and the three // after it. Each checks that every theme is scanned by some leg, and // that its own leg scanned something, which the one test checked of // all four together. func TestEveryExamplePassesAxeDayEn(t *testing.T) { examplesPassAxe(t, 0) } func TestEveryExamplePassesAxePlainEn(t *testing.T) { examplesPassAxe(t, 1) } func TestEveryExamplePassesAxeSignalEn(t *testing.T) { examplesPassAxe(t, 2) } func TestEveryExamplePassesAxeDayAr(t *testing.T) { examplesPassAxe(t, 3) } // examplesPassAxe is TestEveryExamplePassesAxe's leg i. func examplesPassAxe(t *testing.T, i int) { if len(axeLegs) != 4 { t.Fatalf("axeLegs has %d legs and there are four tests, one per leg; a leg needs a test of its own", len(axeLegs)) } for _, theme := range fiesta.ThemeNames() { found := false for _, l := range axeLegs { found = found || l.theme == theme } if !found { t.Fatalf("theme %s is scanned by no leg", theme) } } rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) ctx, cancel := context.WithTimeout(rig.Context(), 18*time.Minute) defer cancel() s := newTestSite(t, fiesta.FS(), mountPath) m := s.m scans, failed := 0, 0 for _, leg := range axeLegs[i : i+1] { name := leg.theme + " " + leg.locale incomplete := map[string]int{} start := time.Now() for _, e := range m.Examples { for _, f := range s.frameFiles(e) { where := name + " " + strings.TrimSuffix(strings.TrimPrefix(f, "frames/"), ".html") mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(leg.theme+"/"+leg.locale+"/"+f)), chromedp.WaitReady("body"), ) // The body's ground is read back after each flip, and the // two must differ: a dark scan of a page still painted // light would be the light scan twice. ground := map[string]string{} for _, scheme := range []string{"light", "dark"} { var bg string mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) ground[scheme] = bg res, err := axe.Scan(ctx, "", axe.DefaultTags()) if err != nil { t.Fatalf("%s %s: %v", where, scheme, err) } scans++ for _, id := range res.Incomplete { incomplete[id]++ } failed += report(t, where+" "+scheme, res.Violations) } if ground["light"] == ground["dark"] { t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", where, ground["light"]) } } } var ids []string for id, n := range incomplete { ids = append(ids, fmt.Sprintf("%s x%d", id, n)) } sort.Strings(ids) t.Logf("%s: scanned in %s; needs review, axe could not decide: %v", name, time.Since(start).Round(time.Second), ids) } if scans == 0 { t.Fatal("no document was scanned") } t.Logf("%d scans, %d violations", scans, failed) rig.Screen("body", "the last frame document") } // measureJS reports whether the document scrolls sideways, and the // elements that reach past either edge. Both edges, because an LTR // page spills past the right and an RTL page past the left. The edge // is the root's own laid-out box, not clientWidth, which over a stable // gutter differs by the scrollbar's width wherever none is drawn. const measureJS = `(() => { const de = document.documentElement; const w = Math.round(de.getBoundingClientRect().width); const over = []; document.querySelectorAll("body *").forEach(el => { const r = el.getBoundingClientRect(); if (r.width === 0) return; if (r.right > w + 1 || r.left < -1) { const cls = (el.className || "").toString().trim().split(/\s+/)[0]; over.push(el.tagName.toLowerCase() + (cls ? "." + cls : "") + " [" + Math.round(r.left) + "…" + Math.round(r.right) + "]"); } }); return JSON.stringify({doc: de.scrollWidth, client: w, body: document.body.scrollWidth, over: over.slice(0, 8)}); })()` // TestTheGalleryHasNoHorizontalScrollAt320 is WCAG 2.2 AA 1.4.10 // Reflow on the gallery's own pages: content at a 320 CSS-pixel // viewport must not need horizontal scrolling. 320x640 is the // criterion's own number, 1280 CSS px at 400% zoom, so a desktop // browser rather than mobile emulation. A frame is a scroll container // by design and may scroll inside its box; the page may not. func TestTheGalleryHasNoHorizontalScrollAt320(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) defer cancel() m := manifest(t) theme := fiesta.ThemeNames()[0] pages := []string{theme + "/en/index.html", theme + "/en/tokens.html", theme + "/en/icons.html", theme + "/ar/index.html"} for _, e := range m.Examples { pages = append(pages, theme+"/en/"+e.Name+".html") } pages = append(pages, theme+"/ar/field.html") for _, p := range pages { mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c) }), chromedp.Navigate(rig.Origin+href(p)), chromedp.WaitReady("body"), ) var got struct { Doc, Client, Body int Over []string } at(t, ctx, measureJS, &got) if got.Doc > got.Client || got.Body > got.Client { t.Errorf("%s scrolls sideways at 320px (WCAG 1.4.10): document %dpx, body %dpx in a %dpx page\n widest: %s", p, got.Doc, got.Body, got.Client, strings.Join(got.Over, "\n ")) } } t.Logf("%d pages measured", len(pages)) rig.Screen("main", "the last page") } // submitJS submits every form in every frame on the page, by its // submit button where it has one, and reports how many submit events // the frames saw and whether every frame kept its window: a mark set // on a window before the submits is still there after only if no // navigation replaced it. const submitJS = `(async () => { const frames = [...document.querySelectorAll(".ds-view__frame")]; let events = 0, forms = 0; for (const f of frames) { const w = f.contentWindow, d = f.contentDocument; w.__mark = 1; w.addEventListener("submit", () => { events++; }, true); for (const form of d.querySelectorAll("form")) { forms++; const b = form.querySelector('button[type="submit"], input[type="submit"], button:not([type])'); if (b) b.click(); else form.requestSubmit(); } } await new Promise(r => setTimeout(r, 300)); const kept = frames.filter(f => f.contentWindow.__mark === 1 && f.contentDocument.readyState === "complete").length; return JSON.stringify({frames: frames.length, forms, events, kept}); })()` // TestAFrameSubmitsNothing: submitting each form in a few frames makes // no request beyond the frame's own assets, and the frame keeps its // document. Three example pages whose states hold forms of different // kinds: a confirmation, a select inside a field, and a search bar. func TestAFrameSubmitsNothing(t *testing.T) { var tr *tree rig := harness.New(t, func(string) http.Handler { tr = newTree(t, nil); return tr }) ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute) defer cancel() theme := fiesta.ThemeNames()[0] total := 0 for _, name := range []string{"confirm-form", "field-select", "list-bar-search"} { mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(theme+"/en/"+name+".html")), chromedp.WaitReady("body"), ) painted(t, ctx, name) before := len(tr.seen()) var got struct{ Frames, Forms, Events, Kept int } at(t, ctx, submitJS, &got, awaitPromise) if got.Forms == 0 || got.Events == 0 { t.Errorf("%s: %d forms submitted and %d submit events seen; the drive submitted nothing", name, got.Forms, got.Events) } if got.Kept != got.Frames { t.Errorf("%s: %d of %d frames kept their document through the submits", name, got.Kept, got.Frames) } after := tr.seen() if len(after) > before { t.Errorf("%s: the submits made %d request(s):\n %s", name, len(after)-before, strings.Join(after[before:], "\n ")) } total += got.Events t.Logf("%s: %d forms in %d frames, %d submit events, no request", name, got.Forms, got.Frames, got.Events) } if total == 0 { t.Fatal("no submit event was seen on any page") } rig.Screen("main", "the last page") } // schemeJS reports the parent's scheme and, for every frame, its // scheme attribute and the colour painted behind its content: the body // where it paints one, else the root. const schemeJS = `(() => { const paint = d => { const b = getComputedStyle(d.body).backgroundColor, r = getComputedStyle(d.documentElement).backgroundColor; return b === "rgba(0, 0, 0, 0)" ? r : b; }; const frames = [...document.querySelectorAll(".ds-view__frame")].map(f => ({ theme: f.contentDocument.documentElement.getAttribute("data-theme") || "", bg: paint(f.contentDocument), })); let stored = ""; try { stored = localStorage.getItem("fiesta-scheme") || ""; } catch (e) {} return JSON.stringify({theme: document.documentElement.getAttribute("data-theme") || "", stored, frames}); })()` // luminance is the relative luminance of an rgb() string, for telling // a dark canvas from a light one. func luminance(t *testing.T, rgb string) float64 { t.Helper() var r, g, b float64 if _, err := fmt.Sscanf(rgb, "rgb(%g, %g, %g)", &r, &g, &b); err != nil { t.Fatalf("%q is not an rgb() colour: %v", rgb, err) } lin := func(c float64) float64 { c /= 255 if c <= 0.03928 { return c / 12.92 } return ((c + 0.055) / 1.055) * ((c + 0.055) / 1.055) * ((c + 0.055) / 1.055) } return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b) } // chooseScheme picks one scheme from the header's menu, as a reader // would: open the menu, press the button. A page carries two copies of // the menu, the pinned bar's and the phone's display settings', and // one of them is hidden at any width, so the drive finds the visible // copy and keeps every query inside it; chromedp.Click on the bare // selector waits on the first copy in the document, the hidden one at // a desktop width. func chooseScheme(t *testing.T, ctx context.Context, scheme string) { t.Helper() var menus []*cdp.Node var at int mustRun(t, ctx, chromedp.Nodes(`[rst-scheme]`, &menus, chromedp.ByQueryAll), chromedp.Evaluate(`[...document.querySelectorAll("[rst-scheme]")].findIndex(m => m.checkVisibility())`, &at), ) if at < 0 || at >= len(menus) { t.Fatalf("none of the %d copies of the scheme menu is visible", len(menus)) } menu := chromedp.FromNode(menus[at]) btn := fmt.Sprintf(`input[value=%q] ~ button`, scheme) mustRun(t, ctx, chromedp.Click(`summary`, chromedp.ByQuery, menu), chromedp.WaitVisible(btn, chromedp.ByQuery, menu), chromedp.Click(btn, chromedp.ByQuery, menu), ) } // TestTheSchemeMenuRepaintsTheFrames: choosing Dark in the parent // gives every loaded frame data-theme="dark" and a dark computed // background, and a frame loaded afterwards starts dark. The later // frame is added outside gallery.js's reach, so only frame.js can have // painted it. func TestTheSchemeMenuRepaintsTheFrames(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute) defer cancel() theme := fiesta.ThemeNames()[0] mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(theme+"/en/field.html")), chromedp.WaitReady("body"), ) n := painted(t, ctx, "field") type reading struct { Theme, Stored string Frames []struct{ Theme, Bg string } } var before reading at(t, ctx, schemeJS, &before) if before.Theme != "" || before.Stored != "" { 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) } for i, f := range before.Frames { if f.Theme != "" { t.Errorf("frame %d starts on %q, want the OS's scheme", i, f.Theme) } if l := luminance(t, f.Bg); l < 0.5 { t.Errorf("frame %d starts dark (%s); the reading after Dark would prove nothing", i, f.Bg) } } chooseScheme(t, ctx, "dark") deadline := time.Now().Add(10 * time.Second) var after reading for { at(t, ctx, schemeJS, &after) dark := 0 for _, f := range after.Frames { if f.Theme == "dark" { dark++ } } if dark == len(after.Frames) || !time.Now().Before(deadline) { break } time.Sleep(50 * time.Millisecond) } if after.Theme != "dark" { t.Errorf("after choosing Dark the page is on %q", after.Theme) } if after.Stored != "dark" { t.Errorf("after choosing Dark the page stored %q; the header's menu is the standalone state, which remembers", after.Stored) } if len(after.Frames) != n { t.Fatalf("%d frames read, %d loaded", len(after.Frames), n) } for i, f := range after.Frames { if f.Theme != "dark" { t.Errorf("frame %d is on %q after Dark was chosen", i, f.Theme) } if l := luminance(t, f.Bg); l > 0.2 { t.Errorf("frame %d paints %s after Dark was chosen, which is not dark", i, f.Bg) } } // A frame loaded afterwards, outside gallery.js's class, so the // attribute it starts with is frame.js's doing alone. later := fmt.Sprintf(`(async () => { const f = document.createElement("iframe"); f.src = %q; const loaded = new Promise(r => f.addEventListener("load", r)); document.querySelector("main").append(f); await loaded; const d = f.contentDocument; const b = getComputedStyle(d.body).backgroundColor, r = getComputedStyle(d.documentElement).backgroundColor; return JSON.stringify({theme: d.documentElement.getAttribute("data-theme") || "", bg: b === "rgba(0, 0, 0, 0)" ? r : b}); })()`, href(frameFile(theme, "en", "badge", mustDefault(t, "badge")))) var fresh struct{ Theme, Bg string } at(t, ctx, later, &fresh, awaitPromise) if fresh.Theme != "dark" { t.Errorf("a frame loaded after the choice starts on %q, want dark", fresh.Theme) } if l := luminance(t, fresh.Bg); l > 0.2 { t.Errorf("a frame loaded after the choice paints %s, which is not dark", fresh.Bg) } rig.Screen("main", "the field page in Dark") } // placeJS reports where the reader's focus and scroll are on the // gallery page, and how many of its frames hold a field that asked for // autofocus. const placeJS = `(() => { const asked = [...document.querySelectorAll(".ds-view__frame")].filter(f => f.contentDocument.querySelector("[autofocus]")).length; const a = document.activeElement; return JSON.stringify({active: a ? a.tagName.toLowerCase() : "", scrollY: Math.round(scrollY), asked}); })()` // TestFramesKeepTheReadersPlace: on the signin example's page, whose // states ask for autofocus, loading every frame leaves focus on the // parent and the page where it was. A browser grants a same-origin // frame's autofocus, so without frame.js's hold the first frame to // load would take the reader's focus and scroll the page to itself. func TestFramesKeepTheReadersPlace(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() theme := fiesta.ThemeNames()[0] mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(theme+"/en/signin.html")), chromedp.WaitReady("body"), ) painted(t, ctx, "signin") // Autofocus lands at a rendering update after load, so the reading // waits two frames. mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) var got struct { Active string ScrollY, Asked int } at(t, ctx, placeJS, &got) if got.Asked == 0 { t.Fatal("no frame on the signin page asks for autofocus; the check below would prove nothing") } if got.Active != "body" { t.Errorf("after the frames loaded, focus is on the parent's %s, not its body: a frame took it", got.Active) } if got.ScrollY != 0 { t.Errorf("after the frames loaded, the page is scrolled to %dpx: a frame's autofocus moved it", got.ScrollY) } t.Logf("signin: %d frames asked for autofocus; focus is on the parent's %s at %dpx", got.Asked, got.Active, got.ScrollY) rig.Screen("main", "the signin page") } // mustDefault is the manifest's default state for name. func mustDefault(t *testing.T, name string) string { t.Helper() state, ok := manifest(t).Default(name) if !ok || state == "" { t.Fatalf("the manifest has no default state for %s", name) } return state } // statesOf lists one family's states in manifest order, as // name/state pairs. func statesOf(m *example.Manifest, family string) [][2]string { var out [][2]string for _, e := range m.Examples { if e.Family != family { continue } for _, s := range e.States { out = append(out, [2]string{e.Name, s.ID}) } } return out }