| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 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. |
| 33 | var 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. |
| 40 | 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"])'` |
| 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. |
| 62 | const 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. |
| 148 | type 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. |
| 154 | func 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. |
| 195 | func 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. |
| 302 | func 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. |
| 335 | func 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 | |