//go:build browser package gallery import ( "context" "encoding/json" "net/http" "testing" "time" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/harness" ) // focusRingExempt is the keyboard walk's exemption table: the tab // stops where "nothing visible changed" is the browser's doing and not // the page's, keyed by the focused element's tag name, with the reason. // The gate says out loud what it is not checking. // // Empty, and that is the point of keeping the table: rastrillo's walk // exempted the iframe, which Chromium makes a tab stop of its own and // paints nothing on, because it walked the gallery page with its // frames. These walks open each state's document on its own, so // there is no frame to stop on; an element of any kind with no ring // is a failure, and adding a kind here is a decision somebody has to // write down. var focusRingExempt = map[string]string{ // (none) } // focusablesJS is the selector the keyboard tests agree on: what a // browser puts in the tab order on these documents. No [tabindex] hunt // beyond the explicit ones, because the examples write none. const focusablesJS = `'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), summary, iframe, [tabindex]:not([tabindex="-1"])'` // walkJS reports where focus actually is, and whether it shows. // // Two things it has to get right that a first attempt does not. // // Focus inside a frame: once focus enters one, the top document's // activeElement is the frame and stays the frame for every element // inside it, which read naively looks like focus refusing to move, // which is exactly what a keyboard trap looks like. So the probe // descends: through the frame, into the document, to the element a // person is actually on. These walks open documents with no frames in // them, and the descent is kept so the probe reads the same whatever // it is pointed at. // // Whether the ring shows: a focus indicator is a style that only exists // while the element is focused, so it can only be measured against the // same element unfocused. Blur it, measure, focus it again. The refocus // is checked to have restored the same style: :focus-visible is a // heuristic about how focus arrived, and the walk arrived by Tab, so it // holds; if it ever stopped holding, the measurement would be lying and // the test says so rather than passing quietly. const walkJS = `(() => { let el = document.activeElement, doc = document, depth = 0; while (el && el.tagName === "IFRAME" && depth++ < 4) { let d = null; try { d = el.contentDocument; } catch (e) { d = null; } const inner = d && d.activeElement; if (!inner || inner === d.body || inner === d.documentElement) break; doc = d; el = inner; } if (!el || el === document.body || el === document.documentElement) return JSON.stringify({tag: "body"}); const uaFrame = el.tagName === "IFRAME"; // Is there a ring, really. A first version of this compared raw // computed values and was fooled twice, both times passing a page // with no visible focus at all: // // - outline-offset counts for nothing on its own. Chromium reports // an offset on an element whose outline is "none", and the value // changes with focus, so "outline: none" showed a ring. // - a ring you cannot see is not a ring. "outline-color: // transparent" is a different colour string from the unfocused // one, so a page where every indicator was invisible scored 29 // of 30. // // So every property is normalised to present-and-visible or absent // before anything is compared. Absent means: outline with style // none, zero width, or a fully transparent colour; box-shadow of // none, or one drawn entirely in transparent colours; a border with // no width or no visible colour; a transparent background or text // colour. Offset rides along inside the outline, where it is a real // difference, and contributes nothing when there is no outline. const clear = v => { v = (v || "").trim(); if (v === "transparent") return true; const m = /^rgba?\(([^)]*)\)$/.exec(v); if (!m) return false; const parts = m[1].split(/[\s,\/]+/).filter(Boolean); return parts.length > 3 && parseFloat(parts[3]) === 0; }; const anyVisible = v => (String(v).match(/rgba?\([^)]*\)/g) || []).some(c => !clear(c)); const widest = v => Math.max(...String(v).split(/\s+/).map(parseFloat).map(n => isNaN(n) ? 0 : n), 0); const one = e => { if (!e) return ""; const s = (e.ownerDocument.defaultView || window).getComputedStyle(e); const bits = []; if (s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0 && !clear(s.outlineColor)) { bits.push("outline:" + s.outlineStyle + " " + s.outlineWidth + " " + s.outlineColor + " " + s.outlineOffset); } if (s.boxShadow && s.boxShadow !== "none" && anyVisible(s.boxShadow)) { bits.push("shadow:" + s.boxShadow); } if (widest(s.borderWidth) > 0 && anyVisible(s.borderColor)) { bits.push("border:" + s.borderWidth + " " + s.borderColor); } if (!clear(s.backgroundColor)) bits.push("bg:" + s.backgroundColor); if (!clear(s.color)) bits.push("fg:" + s.color); if (s.textDecorationLine && s.textDecorationLine !== "none") bits.push("dec:" + s.textDecorationLine); return bits.join(" "); }; // The element and two ancestors, because a ring is not always painted // on the thing that has focus: a wrapper with :focus-within or :has() // is a normal way to do it, and tokens.css does it, on a switch's // track and a choice card's label. const snap = e => [one(e), one(e.parentElement), one(e.parentElement && e.parentElement.parentElement)].join(" || "); const focused = snap(el); el.blur(); const blurred = snap(el); el.focus(); const cls = (el.className && el.className.toString) ? el.className.toString().trim().split(/\s+/)[0] : ""; const label = el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (cls ? "." + cls : "") + (doc !== document ? " (in " + JSON.stringify(doc.title) + ")" : ""); const same = window.__prev === el; window.__prev = el; return JSON.stringify({ tag: label, kind: el.tagName.toLowerCase(), focused: focused, blurred: blurred, ring: focused !== blurred, restored: snap(el) === focused, same: same, uaFrame: uaFrame, stillOn: doc.activeElement === el, }); })()` // stop is one reading of walkJS. type stop struct { Tag, Kind, Focused, Blurred string Ring, Restored, Same, StillOn, UAFrame bool } // tab presses Tab and reads where focus landed. func tab(t *testing.T, ctx context.Context, where string) stop { t.Helper() var raw string if err := chromedp.Run(ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(walkJS, &raw)); err != nil { t.Fatalf("%s: %v", where, err) } var s stop if err := json.Unmarshal([]byte(raw), &s); err != nil { t.Fatalf("%s: decoding: %v", where, err) } return s } // TestA11yWalksTheKeyboard is 2.4.7 Focus Visible and 2.1.2 No Keyboard // Trap, neither of which axe can see: a focus indicator is a computed // style that only exists while an element is focused, and a trap is a // property of a sequence rather than of a node. // // The walk: thirty Tabs with real key events. Real, because // :focus-visible is a heuristic about how the focus arrived, and a // script calling .focus() out of nowhere does not always earn the ring. // Every stop must show something (2.4.7) and must be a different // element from the last (2.1.2, locally). Then the modal is walked all // the way round, which is the trap question answered globally: a // reader can always Tab their way back out. // // WHERE the stops are, and how many: rastrillo's walk Tabbed past its // gallery's rail and bar and spent thirty stops on the form page's // content, which was the form family's frames and the widget chrome // around each: the view tabs, the source blocks, the frames themselves // and the fields inside them. Here the walk is over the form family's // state documents themselves, opened one after another as top-level // pages in manifest order, each walked from its first stop until focus // leaves it, so every stop is a control the examples ship and none is // the gallery's own. That family holds fewer than thirty: 22 documents // (field 7, field-text 5, field-url 3, field-money 3, field-textarea 4) // with one control each, 22 stops, measured on 2026-10-27. So the // count is adapted: the walk covers every stop in the family, asserts // the number equals the documents' own count of focusable elements, // and holds it at formStops or more, so a family that lost a control, // or a Tab that skipped one, fails by number. func TestA11yWalksTheKeyboard(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) defer cancel() m := manifest(t) theme := fiesta.ThemeNames()[0] docs := statesOf(m, "form") if len(docs) == 0 { t.Fatal("the manifest has no form family") } // formStops is the family's count of stops when this was written: // 7 + 5 + 3 + 3 + 4 documents, one control each. const formStops = 22 stops, rings, focusables := 0, 0, 0 for _, d := range docs { where := d[0] + "/" + d[1] var count int mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(frameFile(theme, "en", d[0], d[1]))), chromedp.WaitReady("body"), chromedp.Evaluate(`document.querySelectorAll(`+focusablesJS+`).length`, &count), ) focusables += count walked := 0 // Bounded by the document's own count plus the browser's chrome // stop, so a document that cycles never walks forever. for walked <= count { s := tab(t, ctx, where) if s.Tag == "body" { // Focus left the document: the browser's own chrome, // between the last stop and the first. On to the next // document. break } stops++ walked++ if s.Same { t.Errorf("stop %d (%s): focus did not move off %s: keyboard trap (WCAG 2.1.2)", stops, where, s.Tag) } if !s.UAFrame && (!s.StillOn || !s.Restored) { t.Errorf("stop %d (%s): %s: measuring the focus ring did not leave focus as it found it (stillOn=%v restored=%v); the reading below cannot be trusted", stops, where, s.Tag, s.StillOn, s.Restored) } if !s.Ring { if reason, ok := focusRingExempt[s.Kind]; ok { t.Logf("EXEMPT stop %d (%s): %s: %s", stops, where, s.Tag, reason) continue } t.Errorf("stop %d (%s): %s has no visible focus indicator (WCAG 2.4.7)\n focused: %s\n blurred: %s", stops, where, s.Tag, s.Focused, s.Blurred) continue } rings++ } if walked != count { t.Errorf("%s: Tab reached %d stops of the %d focusable elements in the document", where, walked, count) } } if stops != focusables { t.Errorf("the form family's %d documents hold %d focusable elements and the walk made %d stops", len(docs), focusables, stops) } if stops < formStops { t.Errorf("the form family's %d documents hold only %d stops between them, under the %d this walk was written for", len(docs), stops, formStops) } t.Logf("form: %d of %d stops showed a focus indicator, over %d documents", rings, stops, len(docs)) // The full circuit, on the document small enough to walk all of. var modalCount int modal := frameFile(theme, "en", "modal", mustDefault(t, "modal")) mustRun(t, ctx, chromedp.Navigate(rig.Origin+href(modal)), chromedp.WaitReady("body"), chromedp.Evaluate(`document.querySelectorAll(`+focusablesJS+`).length`, &modalCount), ) if modalCount == 0 { t.Fatal("the modal has nothing focusable; Close is a link, so this is a rendering failure") } const firstOf = `(() => { const e = document.activeElement; return e && e !== document.body ? (e.tagName.toLowerCase() + (e.id ? "#" + e.id : "") + "|" + (e.textContent || "").trim().slice(0, 40)) : "body"; })()` var first string mustRun(t, ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(firstOf, &first)) if first == "body" { t.Fatalf("modal: the first Tab landed nowhere; the backdrop is inert and the panel has %d focusables", modalCount) } // One extra tab past the count is the browser's own chrome, which // shows up as body; one more comes back to the first element. Both // are ways out, and either proves there is no trap. escaped := false for i := 0; i < modalCount+3 && !escaped; i++ { var here string mustRun(t, ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(firstOf, &here)) if here == "body" || here == first { escaped = true } } if !escaped { t.Errorf("modal: %d tabs never returned to %q: focus is trapped (WCAG 2.1.2)", modalCount+3, first) } rig.Screen("body", "the modal") } // stageDocument is the page every visitor meets first: the stage // layout's default state with the signin example's default state in // its slot, through example.Slot, so the walk is over that document // whatever the shipped layout happens to carry. It is served beside // the tree as a frame document of its own. func stageDocument(t *testing.T, m *example.Manifest) (name string, doc []byte) { t.Helper() layout, err := example.Document(fiesta.FS(), "layout-stage", mustDefault(t, "layout-stage")) if err != nil { t.Fatal(err) } signin, err := example.Markup(fiesta.FS(), "signin", mustDefault(t, "signin")) if err != nil { t.Fatal(err) } slotted, err := example.Slot(layout, signin) if err != nil { t.Fatal(err) } if slotted == layout { t.Logf("the stage layout's default state already carries the signin default in its slot") } theme := fiesta.ThemeNames()[0] stage, _ := m.Entry("layout-stage") framed, err := newTestSite(t, fiesta.FS(), mountPath).frame(slotted, theme, "en", stage, "with-signin") if err != nil { t.Fatal(err) } return theme + "/en/frames/layout-stage/with-signin.html", []byte(framed) } // TestA11yWalksTheStageShell is the keyboard walk on the page every // visitor meets first: the stage shell with the sign-in card in it, // walked all the way round from where autofocus puts the reader. Every // stop must show a focus indicator (2.4.7), none may hold focus (2.1.2), // and the circuit must reach every focusable element on the page: a // card control that Tab never reaches is a control a keyboard user does // not have, and the form family's walk above never meets this card. func TestA11yWalksTheStageShell(t *testing.T) { m := manifest(t) name, doc := stageDocument(t, m) rig := harness.New(t, func(string) http.Handler { return newTree(t, map[string][]byte{name: doc}) }) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() var count int var start string mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(name)), chromedp.WaitReady("body"), chromedp.Evaluate(`document.querySelectorAll(`+focusablesJS+`).length`, &count), // Autofocus lands at a rendering update, not at load. chromedp.Evaluate(`(async () => { for (let i = 0; i < 40 && document.activeElement === document.body; i++) await new Promise(r => setTimeout(r, 50)); return document.activeElement.id; })()`, &start, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }), ) if start != "rst-signin-email" { t.Fatalf("the stage shell's card starts focus on %q, want the email field", start) } visited := map[int]string{} back := false for i := 0; i < count+3 && !back; i++ { // at is the stop's place among the focusables: walkJS's label is // tag, id and class, and the skip link and the foot's link are // both a bare , so a set of labels would count them as one. s := tab(t, ctx, "stage shell") var at int mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll(`+focusablesJS+`)].indexOf(document.activeElement)`, &at)) if s.Tag == "body" { // The browser's own chrome, between the last stop and the // first: a way out, not a stop to measure. continue } if s.Same { t.Errorf("stage shell: tab %d: focus did not move off %s: keyboard trap (WCAG 2.1.2)", i+1, s.Tag) } if !s.Ring { t.Errorf("stage shell: tab %d: %s has no visible focus indicator (WCAG 2.4.7)\n focused: %s\n blurred: %s", i+1, s.Tag, s.Focused, s.Blurred) } if at < 0 { t.Errorf("stage shell: tab %d: focus is on %s, which the focusable selector does not count; the coverage check below would be comparing different things", i+1, s.Tag) } visited[at] = s.Tag back = s.Tag == "input#"+start } if !back { t.Errorf("stage shell: %d tabs never came back round to the email field: focus is trapped (WCAG 2.1.2)", count+3) } if len(visited) != count { t.Errorf("stage shell: the walk reached %d of the page's %d focusable elements: %v", len(visited), count, visited) } t.Logf("stage shell: %d stops walked round from %s", len(visited), start) rig.Screen("body", "the stage shell") }