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
3package gallery
4
5import (
6 "context"
7 "encoding/json"
8 "net/http"
9 "testing"
10 "time"
11
12 "github.com/chromedp/cdproto/runtime"
13 "github.com/chromedp/chromedp"
14 "github.com/chromedp/chromedp/kb"
15
16 "amadan.net/rastrillo/fiesta"
17 "amadan.net/rastrillo/fiesta/example"
18 "amadan.net/rastrillo/fiesta/internal/harness"
19)
20
21// focusRingExempt is the keyboard walk's exemption table: the tab
22// stops where "nothing visible changed" is the browser's doing and not
23// the page's, keyed by the focused element's tag name, with the reason.
24// The gate says out loud what it is not checking.
25//
26// Empty, and that is the point of keeping the table: rastrillo's walk
27// exempted the iframe, which Chromium makes a tab stop of its own and
28// paints nothing on, because it walked the gallery page with its
29// frames. These walks open each state's document on its own, so
30// there is no frame to stop on; an element of any kind with no ring
31// is a failure, and adding a kind here is a decision somebody has to
32// write down.
33var focusRingExempt = map[string]string{
34 // (none)
35}
36
37// focusablesJS is the selector the keyboard tests agree on: what a
38// browser puts in the tab order on these documents. No [tabindex] hunt
39// beyond the explicit ones, because the examples write none.
40const focusablesJS = `'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), summary, iframe, [tabindex]:not([tabindex="-1"])'`
41
42// walkJS reports where focus actually is, and whether it shows.
43//
44// Two things it has to get right that a first attempt does not.
45//
46// Focus inside a frame: once focus enters one, the top document's
47// activeElement is the frame and stays the frame for every element
48// inside it, which read naively looks like focus refusing to move,
49// which is exactly what a keyboard trap looks like. So the probe
50// descends: through the frame, into the document, to the element a
51// person is actually on. These walks open documents with no frames in
52// them, and the descent is kept so the probe reads the same whatever
53// it is pointed at.
54//
55// Whether the ring shows: a focus indicator is a style that only exists
56// while the element is focused, so it can only be measured against the
57// same element unfocused. Blur it, measure, focus it again. The refocus
58// is checked to have restored the same style: :focus-visible is a
59// heuristic about how focus arrived, and the walk arrived by Tab, so it
60// holds; if it ever stopped holding, the measurement would be lying and
61// the test says so rather than passing quietly.
62const walkJS = `(() => {
63 let el = document.activeElement, doc = document, depth = 0;
64 while (el && el.tagName === "IFRAME" && depth++ < 4) {
65 let d = null;
66 try { d = el.contentDocument; } catch (e) { d = null; }
67 const inner = d && d.activeElement;
68 if (!inner || inner === d.body || inner === d.documentElement) break;
69 doc = d; el = inner;
70 }
71 if (!el || el === document.body || el === document.documentElement) return JSON.stringify({tag: "body"});
72 const uaFrame = el.tagName === "IFRAME";
73 // Is there a ring, really. A first version of this compared raw
74 // computed values and was fooled twice, both times passing a page
75 // with no visible focus at all:
76 //
77 // - outline-offset counts for nothing on its own. Chromium reports
78 // an offset on an element whose outline is "none", and the value
79 // changes with focus, so "outline: none" showed a ring.
80 // - a ring you cannot see is not a ring. "outline-color:
81 // transparent" is a different colour string from the unfocused
82 // one, so a page where every indicator was invisible scored 29
83 // of 30.
84 //
85 // So every property is normalised to present-and-visible or absent
86 // before anything is compared. Absent means: outline with style
87 // none, zero width, or a fully transparent colour; box-shadow of
88 // none, or one drawn entirely in transparent colours; a border with
89 // no width or no visible colour; a transparent background or text
90 // colour. Offset rides along inside the outline, where it is a real
91 // difference, and contributes nothing when there is no outline.
92 const clear = v => {
93 v = (v || "").trim();
94 if (v === "transparent") return true;
95 const m = /^rgba?\(([^)]*)\)$/.exec(v);
96 if (!m) return false;
97 const parts = m[1].split(/[\s,\/]+/).filter(Boolean);
98 return parts.length > 3 && parseFloat(parts[3]) === 0;
99 };
100 const anyVisible = v => (String(v).match(/rgba?\([^)]*\)/g) || []).some(c => !clear(c));
101 const widest = v => Math.max(...String(v).split(/\s+/).map(parseFloat).map(n => isNaN(n) ? 0 : n), 0);
102 const one = e => {
103 if (!e) return "";
104 const s = (e.ownerDocument.defaultView || window).getComputedStyle(e);
105 const bits = [];
106 if (s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0 && !clear(s.outlineColor)) {
107 bits.push("outline:" + s.outlineStyle + " " + s.outlineWidth + " " + s.outlineColor + " " + s.outlineOffset);
108 }
109 if (s.boxShadow && s.boxShadow !== "none" && anyVisible(s.boxShadow)) {
110 bits.push("shadow:" + s.boxShadow);
111 }
112 if (widest(s.borderWidth) > 0 && anyVisible(s.borderColor)) {
113 bits.push("border:" + s.borderWidth + " " + s.borderColor);
114 }
115 if (!clear(s.backgroundColor)) bits.push("bg:" + s.backgroundColor);
116 if (!clear(s.color)) bits.push("fg:" + s.color);
117 if (s.textDecorationLine && s.textDecorationLine !== "none") bits.push("dec:" + s.textDecorationLine);
118 return bits.join(" ");
119 };
120 // The element and two ancestors, because a ring is not always painted
121 // on the thing that has focus: a wrapper with :focus-within or :has()
122 // is a normal way to do it, and tokens.css does it, on a switch's
123 // track and a choice card's label.
124 const snap = e => [one(e), one(e.parentElement), one(e.parentElement && e.parentElement.parentElement)].join(" || ");
125 const focused = snap(el);
126 el.blur();
127 const blurred = snap(el);
128 el.focus();
129 const cls = (el.className && el.className.toString) ? el.className.toString().trim().split(/\s+/)[0] : "";
130 const label = el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (cls ? "." + cls : "") +
131 (doc !== document ? " (in " + JSON.stringify(doc.title) + ")" : "");
132 const same = window.__prev === el;
133 window.__prev = el;
134 return JSON.stringify({
135 tag: label,
136 kind: el.tagName.toLowerCase(),
137 focused: focused,
138 blurred: blurred,
139 ring: focused !== blurred,
140 restored: snap(el) === focused,
141 same: same,
142 uaFrame: uaFrame,
143 stillOn: doc.activeElement === el,
144 });
145})()`
146
147// stop is one reading of walkJS.
148type stop struct {
149 Tag, Kind, Focused, Blurred string
150 Ring, Restored, Same, StillOn, UAFrame bool
151}
152
153// tab presses Tab and reads where focus landed.
154func tab(t *testing.T, ctx context.Context, where string) stop {
155 t.Helper()
156 var raw string
157 if err := chromedp.Run(ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(walkJS, &raw)); err != nil {
158 t.Fatalf("%s: %v", where, err)
159 }
160 var s stop
161 if err := json.Unmarshal([]byte(raw), &s); err != nil {
162 t.Fatalf("%s: decoding: %v", where, err)
163 }
164 return s
165}
166
167// TestA11yWalksTheKeyboard is 2.4.7 Focus Visible and 2.1.2 No Keyboard
168// Trap, neither of which axe can see: a focus indicator is a computed
169// style that only exists while an element is focused, and a trap is a
170// property of a sequence rather than of a node.
171//
172// The walk: thirty Tabs with real key events. Real, because
173// :focus-visible is a heuristic about how the focus arrived, and a
174// script calling .focus() out of nowhere does not always earn the ring.
175// Every stop must show something (2.4.7) and must be a different
176// element from the last (2.1.2, locally). Then the modal is walked all
177// the way round, which is the trap question answered globally: a
178// reader can always Tab their way back out.
179//
180// WHERE the stops are, and how many: rastrillo's walk Tabbed past its
181// gallery's rail and bar and spent thirty stops on the form page's
182// content, which was the form family's frames and the widget chrome
183// around each: the view tabs, the source blocks, the frames themselves
184// and the fields inside them. Here the walk is over the form family's
185// state documents themselves, opened one after another as top-level
186// pages in manifest order, each walked from its first stop until focus
187// leaves it, so every stop is a control the examples ship and none is
188// the gallery's own. That family holds fewer than thirty: 22 documents
189// (field 7, field-text 5, field-url 3, field-money 3, field-textarea 4)
190// with one control each, 22 stops, measured on 2026-10-27. So the
191// count is adapted: the walk covers every stop in the family, asserts
192// the number equals the documents' own count of focusable elements,
193// and holds it at formStops or more, so a family that lost a control,
194// or a Tab that skipped one, fails by number.
195func TestA11yWalksTheKeyboard(t *testing.T) {
196 rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) })
197 ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second)
198 defer cancel()
199 m := manifest(t)
200 theme := fiesta.ThemeNames()[0]
201
202 docs := statesOf(m, "form")
203 if len(docs) == 0 {
204 t.Fatal("the manifest has no form family")
205 }
206 // formStops is the family's count of stops when this was written:
207 // 7 + 5 + 3 + 3 + 4 documents, one control each.
208 const formStops = 22
209 stops, rings, focusables := 0, 0, 0
210 for _, d := range docs {
211 where := d[0] + "/" + d[1]
212 var count int
213 mustRun(t, ctx,
214 chromedp.EmulateViewport(1280, 900),
215 chromedp.Navigate(rig.Origin+href(frameFile(theme, "en", d[0], d[1]))),
216 chromedp.WaitReady("body"),
217 chromedp.Evaluate(`document.querySelectorAll(`+focusablesJS+`).length`, &count),
218 )
219 focusables += count
220 walked := 0
221 // Bounded by the document's own count plus the browser's chrome
222 // stop, so a document that cycles never walks forever.
223 for walked <= count {
224 s := tab(t, ctx, where)
225 if s.Tag == "body" {
226 // Focus left the document: the browser's own chrome,
227 // between the last stop and the first. On to the next
228 // document.
229 break
230 }
231 stops++
232 walked++
233 if s.Same {
234 t.Errorf("stop %d (%s): focus did not move off %s: keyboard trap (WCAG 2.1.2)", stops, where, s.Tag)
235 }
236 if !s.UAFrame && (!s.StillOn || !s.Restored) {
237 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",
238 stops, where, s.Tag, s.StillOn, s.Restored)
239 }
240 if !s.Ring {
241 if reason, ok := focusRingExempt[s.Kind]; ok {
242 t.Logf("EXEMPT stop %d (%s): %s: %s", stops, where, s.Tag, reason)
243 continue
244 }
245 t.Errorf("stop %d (%s): %s has no visible focus indicator (WCAG 2.4.7)\n focused: %s\n blurred: %s",
246 stops, where, s.Tag, s.Focused, s.Blurred)
247 continue
248 }
249 rings++
250 }
251 if walked != count {
252 t.Errorf("%s: Tab reached %d stops of the %d focusable elements in the document", where, walked, count)
253 }
254 }
255 if stops != focusables {
256 t.Errorf("the form family's %d documents hold %d focusable elements and the walk made %d stops", len(docs), focusables, stops)
257 }
258 if stops < formStops {
259 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)
260 }
261 t.Logf("form: %d of %d stops showed a focus indicator, over %d documents", rings, stops, len(docs))
262
263 // The full circuit, on the document small enough to walk all of.
264 var modalCount int
265 modal := frameFile(theme, "en", "modal", mustDefault(t, "modal"))
266 mustRun(t, ctx,
267 chromedp.Navigate(rig.Origin+href(modal)),
268 chromedp.WaitReady("body"),
269 chromedp.Evaluate(`document.querySelectorAll(`+focusablesJS+`).length`, &modalCount),
270 )
271 if modalCount == 0 {
272 t.Fatal("the modal has nothing focusable; Close is a link, so this is a rendering failure")
273 }
274 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"; })()`
275 var first string
276 mustRun(t, ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(firstOf, &first))
277 if first == "body" {
278 t.Fatalf("modal: the first Tab landed nowhere; the backdrop is inert and the panel has %d focusables", modalCount)
279 }
280 // One extra tab past the count is the browser's own chrome, which
281 // shows up as body; one more comes back to the first element. Both
282 // are ways out, and either proves there is no trap.
283 escaped := false
284 for i := 0; i < modalCount+3 && !escaped; i++ {
285 var here string
286 mustRun(t, ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(firstOf, &here))
287 if here == "body" || here == first {
288 escaped = true
289 }
290 }
291 if !escaped {
292 t.Errorf("modal: %d tabs never returned to %q: focus is trapped (WCAG 2.1.2)", modalCount+3, first)
293 }
294 rig.Screen("body", "the modal")
295}
296
297// stageDocument is the page every visitor meets first: the stage
298// layout's default state with the signin example's default state in
299// its slot, through example.Slot, so the walk is over that document
300// whatever the shipped layout happens to carry. It is served beside
301// the tree as a frame document of its own.
302func stageDocument(t *testing.T, m *example.Manifest) (name string, doc []byte) {
303 t.Helper()
304 layout, err := example.Document(fiesta.FS(), "layout-stage", mustDefault(t, "layout-stage"))
305 if err != nil {
306 t.Fatal(err)
307 }
308 signin, err := example.Markup(fiesta.FS(), "signin", mustDefault(t, "signin"))
309 if err != nil {
310 t.Fatal(err)
311 }
312 slotted, err := example.Slot(layout, signin)
313 if err != nil {
314 t.Fatal(err)
315 }
316 if slotted == layout {
317 t.Logf("the stage layout's default state already carries the signin default in its slot")
318 }
319 theme := fiesta.ThemeNames()[0]
320 stage, _ := m.Entry("layout-stage")
321 framed, err := newTestSite(t, fiesta.FS(), mountPath).frame(slotted, theme, "en", stage, "with-signin")
322 if err != nil {
323 t.Fatal(err)
324 }
325 return theme + "/en/frames/layout-stage/with-signin.html", []byte(framed)
326}
327
328// TestA11yWalksTheStageShell is the keyboard walk on the page every
329// visitor meets first: the stage shell with the sign-in card in it,
330// walked all the way round from where autofocus puts the reader. Every
331// stop must show a focus indicator (2.4.7), none may hold focus (2.1.2),
332// and the circuit must reach every focusable element on the page: a
333// card control that Tab never reaches is a control a keyboard user does
334// not have, and the form family's walk above never meets this card.
335func TestA11yWalksTheStageShell(t *testing.T) {
336 m := manifest(t)
337 name, doc := stageDocument(t, m)
338 rig := harness.New(t, func(string) http.Handler { return newTree(t, map[string][]byte{name: doc}) })
339 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
340 defer cancel()
341
342 var count int
343 var start string
344 mustRun(t, ctx,
345 chromedp.EmulateViewport(1280, 900),
346 chromedp.Navigate(rig.Origin+href(name)),
347 chromedp.WaitReady("body"),
348 chromedp.Evaluate(`document.querySelectorAll(`+focusablesJS+`).length`, &count),
349 // Autofocus lands at a rendering update, not at load.
350 chromedp.Evaluate(`(async () => {
351 for (let i = 0; i < 40 && document.activeElement === document.body; i++) await new Promise(r => setTimeout(r, 50));
352 return document.activeElement.id;
353 })()`, &start, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }),
354 )
355 if start != "rst-signin-email" {
356 t.Fatalf("the stage shell's card starts focus on %q, want the email field", start)
357 }
358 visited := map[int]string{}
359 back := false
360 for i := 0; i < count+3 && !back; i++ {
361 // at is the stop's place among the focusables: walkJS's label is
362 // tag, id and class, and the skip link and the foot's link are
363 // both a bare <a>, so a set of labels would count them as one.
364 s := tab(t, ctx, "stage shell")
365 var at int
366 mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll(`+focusablesJS+`)].indexOf(document.activeElement)`, &at))
367 if s.Tag == "body" {
368 // The browser's own chrome, between the last stop and the
369 // first: a way out, not a stop to measure.
370 continue
371 }
372 if s.Same {
373 t.Errorf("stage shell: tab %d: focus did not move off %s: keyboard trap (WCAG 2.1.2)", i+1, s.Tag)
374 }
375 if !s.Ring {
376 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)
377 }
378 if at < 0 {
379 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)
380 }
381 visited[at] = s.Tag
382 back = s.Tag == "input#"+start
383 }
384 if !back {
385 t.Errorf("stage shell: %d tabs never came back round to the email field: focus is trapped (WCAG 2.1.2)", count+3)
386 }
387 if len(visited) != count {
388 t.Errorf("stage shell: the walk reached %d of the page's %d focusable elements: %v", len(visited), count, visited)
389 }
390 t.Logf("stage shell: %d stops walked round from %s", len(visited), start)
391 rig.Screen("body", "the stage shell")
392}
393