oxedyne/daimond/dev/verify_a11y_keyboard.mjs
28.4 KiB, 1 run
created by r2519314175:219, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_a11y_keyboard.mjs — the app can be driven, and left, without a mouse. |
| 2 | // |
| 3 | // WHAT THIS FILE LOCKS DOWN. Each of these is a property that holds RIGHT NOW |
| 4 | // and must keep holding; a change that breaks one fails the run. |
| 5 | // |
| 6 | // 1. Tab order is document order. Nothing carries a positive tabindex, and a |
| 7 | // walk of the first stops lands on the visible controls in the order they |
| 8 | // are written. A positive tabindex is the one way to make Tab jump about, |
| 9 | // and it is never worth it. |
| 10 | // 2. Nothing NEW becomes keyboard-invisible. Every element the CSS declares |
| 11 | // clickable (`cursor: pointer`) that cannot take focus is counted against a |
| 12 | // frozen census, GHOSTS below. The census may only shrink. A new div with a |
| 13 | // click handler on it fails this run, loudly, naming the element. |
| 14 | // 3. A surrogate control is a whole control. Anything carrying `role="button"` |
| 15 | // that is not a <button> must be focusable AND must act on Enter and on |
| 16 | // Space -- checked by pressing them, not by reading the markup. |
| 17 | // 4. Every focusable control shows a visible focus indicator: its computed |
| 18 | // style under :focus-visible differs from its resting style in at least one |
| 19 | // of outline / box-shadow / background / border / colour. `outline: none` |
| 20 | // with nothing put back fails here. |
| 21 | // 5. A dialog opens focused, keeps Tab inside it, closes on Escape, and gives |
| 22 | // the focus back to the control that opened it. |
| 23 | // 6. The appearance menu and the panel gallery each move focus into |
| 24 | // themselves, close on Escape, and return focus to their opener. |
| 25 | // 7. The command palette opens with the caret in its box and swallows Tab, so |
| 26 | // the keyboard cannot end up typing behind the scrim. |
| 27 | // |
| 28 | // KNOWN DEFECTS are reported at the end under "KNOWN" and do NOT fail the run. |
| 29 | // They are written up with a file:line and a fix in dev/a11y_report.md. They are |
| 30 | // asserted as known rather than asserted as correct, deliberately: freezing a |
| 31 | // bug into a test makes the fix look like a regression. |
| 32 | // |
| 33 | // SELF-TEST. The last section breaks five of the properties above in the live |
| 34 | // page and requires each check to go red, then restores them and requires green |
| 35 | // again. A check never seen red is not evidence, so the evidence is produced on |
| 36 | // every run. |
| 37 | // |
| 38 | // node dev/verify_a11y_keyboard.mjs |
| 39 | // |
| 40 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs |
| 41 | // (DAIMOND_MOCK_PORT, default 9099). No gateway. |
| 42 | |
| 43 | import fs from 'node:fs'; |
| 44 | import { open, newChat, scratch } from './harness.mjs'; |
| 45 | |
| 46 | const out = []; |
| 47 | let bad = 0; |
| 48 | const check = (ok, what, detail) => { |
| 49 | out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`); |
| 50 | if (!ok) bad++; |
| 51 | return ok; |
| 52 | }; |
| 53 | const known = []; |
| 54 | const note = (what, why) => known.push(`${what}\n ${why}`); |
| 55 | |
| 56 | // ── The frozen census of keyboard-invisible clickables ────────────── |
| 57 | // |
| 58 | // Every entry is a real defect, written up in dev/a11y_report.md. The list is |
| 59 | // here so that the NEXT one fails this run rather than joining them quietly. |
| 60 | // Matched on the element's tag+id+class signature. |
| 61 | const GHOSTS = new Set([ |
| 62 | 'div#astat-store.astat-row', |
| 63 | 'div#astat-store-native.astat-row', |
| 64 | 'span.astat-dot.off', |
| 65 | 'span.astat-dot.ok', |
| 66 | 'span.astat-dot.warn', |
| 67 | 'span.astat-val', |
| 68 | 'span.astat-aside', |
| 69 | 'div.session-box-header', |
| 70 | 'span.session-box-name', |
| 71 | 'div.session-box-meta', |
| 72 | 'span.session-box-ctx', |
| 73 | 'span.session-box-time', |
| 74 | 'div.session-box.chat-box.active.active', |
| 75 | 'div.session-box.chat-box.active', |
| 76 | 'div.session-box.chat-box.pending', |
| 77 | 'div.tile-active', |
| 78 | 'div.tile-active-top', |
| 79 | 'span.tile-model-chip', |
| 80 | 'div.tile-meter', |
| 81 | 'span.tile-tok', |
| 82 | 'div.tile-pending', |
| 83 | ]); |
| 84 | |
| 85 | // ── Page-side predicates ──────────────────────────────────────────── |
| 86 | // |
| 87 | // Written as strings so the same source can be handed to page.evaluate both for |
| 88 | // the real check and for the self-test, and so the self-test cannot accidentally |
| 89 | // exercise a different implementation from the one that ships. |
| 90 | |
| 91 | const FOCUS_SEL = 'a[href],button:not([disabled]),input:not([disabled]),' |
| 92 | + 'select:not([disabled]),textarea:not([disabled]),' |
| 93 | + '[tabindex]:not([tabindex="-1"]),summary,iframe,embed'; |
| 94 | |
| 95 | /// Every element the CSS says is clickable but the keyboard cannot reach. |
| 96 | const GHOSTS_ON_PAGE = (sel) => { |
| 97 | const sig = (e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') |
| 98 | + (typeof e.className === 'string' && e.className.trim() |
| 99 | ? '.' + e.className.trim().split(/\s+/).join('.') : ''); |
| 100 | const vis = (e) => { |
| 101 | const r = e.getBoundingClientRect(); |
| 102 | if (!r.width || !r.height) return false; |
| 103 | const cs = getComputedStyle(e); |
| 104 | return cs.visibility !== 'hidden' && cs.opacity !== '0'; |
| 105 | }; |
| 106 | return [...document.querySelectorAll('*')].filter((e) => { |
| 107 | if (!vis(e) || e.matches(sel)) return false; |
| 108 | if (getComputedStyle(e).cursor !== 'pointer') return false; |
| 109 | // A control nested inside a focusable one is reached with its parent. |
| 110 | let p = e.parentElement; |
| 111 | while (p) { if (p.matches(sel)) return false; p = p.parentElement; } |
| 112 | return true; |
| 113 | }).map(sig); |
| 114 | }; |
| 115 | |
| 116 | /// Controls whose focus ring is indistinguishable from their resting state. |
| 117 | /// |
| 118 | /// One representative per tag+class, because the app draws hundreds of the same |
| 119 | /// button and the property belongs to the RULE, not to the instance. |
| 120 | const NO_FOCUS_RING = (sel) => { |
| 121 | const P = ['outlineStyle', 'outlineWidth', 'outlineColor', 'boxShadow', |
| 122 | 'backgroundColor', 'borderColor', 'borderWidth', 'borderStyle', 'color', |
| 123 | 'textDecorationLine', 'filter']; |
| 124 | const snap = (e) => { const cs = getComputedStyle(e); return P.map((p) => cs[p]).join('|'); }; |
| 125 | const vis = (e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; |
| 126 | const sig = (e) => e.tagName.toLowerCase() |
| 127 | + (typeof e.className === 'string' && e.className.trim() |
| 128 | ? '.' + e.className.trim().split(/\s+/).join('.') : '') + (e.id ? '#' + e.id : ''); |
| 129 | const prev = document.activeElement; |
| 130 | const seen = new Set(), bad = []; |
| 131 | [...document.querySelectorAll(sel)].filter(vis).forEach((e) => { |
| 132 | // An iframe's ring is the embedding browser's business, not the page's. |
| 133 | if (e.tagName === 'IFRAME' || e.tagName === 'EMBED') return; |
| 134 | const key = e.tagName + '|' + (typeof e.className === 'string' ? e.className : ''); |
| 135 | if (seen.has(key)) return; |
| 136 | seen.add(key); |
| 137 | const rest = snap(e); |
| 138 | e.focus(); |
| 139 | if (snap(e) === rest) bad.push(sig(e)); |
| 140 | }); |
| 141 | if (prev && prev.focus) prev.focus(); |
| 142 | return bad; |
| 143 | }; |
| 144 | |
| 145 | /// Anything wearing role="button" that is not one: is it reachable? |
| 146 | const SURROGATES = () => { |
| 147 | const sig = (e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') |
| 148 | + (typeof e.className === 'string' && e.className.trim() |
| 149 | ? '.' + e.className.trim().split(/\s+/).join('.') : ''); |
| 150 | return [...document.querySelectorAll('[role="button"],[role="link"],[role="checkbox"],[role="switch"],[role="tab"]')] |
| 151 | .filter((e) => !['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA'].includes(e.tagName)) |
| 152 | .filter((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }) |
| 153 | .map((e) => ({ sig: sig(e), ti: e.getAttribute('tabindex'), |
| 154 | ok: e.matches('[tabindex]:not([tabindex="-1"])') })); |
| 155 | }; |
| 156 | |
| 157 | /// Elements pulled out of document order by a positive tabindex. |
| 158 | const POSITIVE_TABINDEX = () => [...document.querySelectorAll('[tabindex]')] |
| 159 | .filter((e) => Number(e.getAttribute('tabindex')) > 0) |
| 160 | .map((e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + '@' + e.getAttribute('tabindex')); |
| 161 | |
| 162 | /// Where the focus is, and whether it is inside `sel`. |
| 163 | const WHERE = (sel) => { |
| 164 | const a = document.activeElement; |
| 165 | const root = sel ? document.querySelector(sel) : null; |
| 166 | return { |
| 167 | name: !a ? '(none)' : a.tagName + (a.id ? '#' + a.id : '') |
| 168 | + (typeof a.className === 'string' && a.className.trim() |
| 169 | ? '.' + a.className.trim().split(/\s+/)[0] : ''), |
| 170 | id: a ? a.id : '', |
| 171 | inside: !!(root && a && root.contains(a)), |
| 172 | onBody: !a || a === document.body || a === document.documentElement, |
| 173 | }; |
| 174 | }; |
| 175 | |
| 176 | /// How many stops a surface holds, so a trap can be walked past the end of it. |
| 177 | const COUNT_IN = ({ sel, focusSel }) => { |
| 178 | const root = document.querySelector(sel); |
| 179 | if (!root) return 0; |
| 180 | return [...root.querySelectorAll(focusSel)].filter((e) => !e.disabled && e.getClientRects().length).length; |
| 181 | }; |
| 182 | |
| 183 | /// The first `n` visible focusables, in document order. |
| 184 | const DOM_ORDER = ({ sel, n }) => [...document.querySelectorAll(sel)] |
| 185 | .filter((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }) |
| 186 | .slice(0, n) |
| 187 | .map((e) => e.tagName + (e.id ? '#' + e.id : '') |
| 188 | + (typeof e.className === 'string' && e.className.trim() |
| 189 | ? '.' + e.className.trim().split(/\s+/)[0] : '')); |
| 190 | |
| 191 | // ── Real clicks, because focus is what is being measured ──────────── |
| 192 | // |
| 193 | // A scripted `element.click()` fires the handler without moving the focus, so a |
| 194 | // dialog that faithfully restores the focus it found on opening restores the |
| 195 | // BODY and reads as broken. verify_focus.mjs learned this; the same applies here. |
| 196 | const BOX_OF = ({ rootSel, text }) => { |
| 197 | const root = rootSel ? document.querySelector(rootSel) : document; |
| 198 | if (!root) return null; |
| 199 | const el = text |
| 200 | ? [...root.querySelectorAll('button')].find((x) => (x.textContent || '').trim() === text) |
| 201 | : root; |
| 202 | if (!el) return null; |
| 203 | el.scrollIntoView({ block: 'center', inline: 'center' }); |
| 204 | const r = el.getBoundingClientRect(); |
| 205 | if (!r.width || !r.height) return null; |
| 206 | return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; |
| 207 | }; |
| 208 | async function press(page, sel) { |
| 209 | await page.waitForSelector(sel, { timeout: 10000 }); |
| 210 | const b = await page.evaluate(BOX_OF, { rootSel: sel, text: '' }); |
| 211 | if (!b) throw new Error(`${sel} has no box to click`); |
| 212 | await page.mouse.click(b.x, b.y); |
| 213 | await page.waitForTimeout(400); |
| 214 | } |
| 215 | |
| 216 | // ── The run ───────────────────────────────────────────────────────── |
| 217 | |
| 218 | // The profile is taken away with the browser: a run that leaves one behind |
| 219 | // leaves ~350 MB behind, and the pile has reached gigabytes before now. |
| 220 | const profile = scratch('pw', 'a11yk-' + process.pid); |
| 221 | const s = await open({ name: 'a11yk', profile }); |
| 222 | const closeBrowser = s.close; |
| 223 | s.close = async () => { |
| 224 | await closeBrowser(); |
| 225 | try { fs.rmSync(profile, { recursive: true, force: true }); } catch (e) { /* gone */ } |
| 226 | }; |
| 227 | const { page } = s; |
| 228 | await page.waitForTimeout(700); |
| 229 | |
| 230 | // Two Diamonds and a chat, so the rail holds the repeating rows the audit is |
| 231 | // really about. An empty rail passes every keyboard test by having nothing in it. |
| 232 | async function newDiamond(name) { |
| 233 | await page.click('#new-diamond-btn', { force: true }); |
| 234 | await page.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 235 | await page.fill('.dlg-input', name); |
| 236 | await page.click('.dlg-ok', { force: true }); |
| 237 | await page.waitForTimeout(700); |
| 238 | } |
| 239 | await newDiamond('Alpha'); |
| 240 | await newDiamond('Beta'); |
| 241 | await newChat(s); |
| 242 | await page.waitForTimeout(500); |
| 243 | |
| 244 | // Establish keyboard modality once: Chrome only matches :focus-visible when the |
| 245 | // last interaction was a key, and every focus check below depends on it. |
| 246 | await page.keyboard.press('Tab'); |
| 247 | await page.waitForTimeout(120); |
| 248 | |
| 249 | // ── 1. Tab order is document order ────────────────────────────────── |
| 250 | const positives = await page.evaluate(POSITIVE_TABINDEX); |
| 251 | check(positives.length === 0, |
| 252 | 'nothing carries a positive tabindex, so Tab follows the document', |
| 253 | positives.length ? JSON.stringify(positives) : null); |
| 254 | |
| 255 | // Walked from the very top, stopping short of the stage: the Web panel holds an |
| 256 | // iframe, and a Tab into an iframe leaves document.activeElement on the frame, |
| 257 | // which desynchronises any walk that continues past it. |
| 258 | // |
| 259 | // The walk STARTS by focusing the first control rather than by blurring back to |
| 260 | // the body. Blur leaves Chrome's sequential-navigation starting point where it |
| 261 | // was, so the next Tab carries on from the middle of the document and the walk |
| 262 | // measures nothing. |
| 263 | const N = 18; |
| 264 | await page.evaluate(() => window.scrollTo(0, 0)); |
| 265 | const wantOrder = await page.evaluate(DOM_ORDER, { sel: FOCUS_SEL, n: N }); |
| 266 | const NOW = () => { |
| 267 | const a = document.activeElement; |
| 268 | return a.tagName + (a.id ? '#' + a.id : '') |
| 269 | + (typeof a.className === 'string' && a.className.trim() |
| 270 | ? '.' + a.className.trim().split(/\s+/)[0] : ''); |
| 271 | }; |
| 272 | await page.evaluate((sel) => { |
| 273 | const first = [...document.querySelectorAll(sel)] |
| 274 | .find((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }); |
| 275 | if (first) first.focus(); |
| 276 | }, FOCUS_SEL); |
| 277 | await page.waitForTimeout(80); |
| 278 | const gotOrder = [await page.evaluate(NOW)]; |
| 279 | for (let i = 1; i < N; i++) { |
| 280 | await page.keyboard.press('Tab'); |
| 281 | await page.waitForTimeout(40); |
| 282 | gotOrder.push(await page.evaluate(NOW)); |
| 283 | } |
| 284 | const firstDiff = wantOrder.findIndex((w, i) => w !== gotOrder[i]); |
| 285 | check(firstDiff === -1, `a ${N}-stop Tab walk visits the controls in the order they are written`, |
| 286 | firstDiff === -1 ? null |
| 287 | : `stop ${firstDiff + 1}: document says ${wantOrder[firstDiff]}, Tab went to ${gotOrder[firstDiff]}`); |
| 288 | |
| 289 | // ── 2. Nothing new is keyboard-invisible ──────────────────────────── |
| 290 | const ghosts = await page.evaluate(GHOSTS_ON_PAGE, FOCUS_SEL); |
| 291 | const fresh = [...new Set(ghosts)].filter((g) => !GHOSTS.has(g)); |
| 292 | check(fresh.length === 0, |
| 293 | `no NEW clickable-but-unfocusable element (${new Set(ghosts).size} known, census of ${GHOSTS.size})`, |
| 294 | fresh.length ? `not in the census: ${JSON.stringify(fresh)}` : null); |
| 295 | // The census may shrink: say so, so it gets tightened rather than left slack. |
| 296 | const goneNow = [...GHOSTS].filter((g) => !ghosts.includes(g)); |
| 297 | if (goneNow.length && goneNow.length < GHOSTS.size) { |
| 298 | out.push(`---- ${goneNow.length} census entries were not on the page this run ` |
| 299 | + `(either fixed, or that surface was not open): ${JSON.stringify(goneNow.slice(0, 6))}`); |
| 300 | } |
| 301 | // The Diamonds list and the chat tiles were both here, and both are fixed: |
| 302 | // each row carries the role, the tab stop and the Enter/Space handler. |
| 303 | // `dev/verify_railkeys.mjs` makes a Diamond and two chats and presses the keys |
| 304 | // against them -- Enter and Space each open a chat, Enter acts on a Diamond row, |
| 305 | // and the delete button says WHICH Diamond it would delete. |
| 306 | // The Email panel was here, and is not any more: all four row types now carry the |
| 307 | // role, the tab stop and the Enter/Space handler, and `dev/verify_mailkeys.mjs` |
| 308 | // presses the keys against a seeded panel and asserts the application changed. A |
| 309 | // note left behind after its fix makes the fix look like a regression, which is |
| 310 | // exactly what the header of this file says not to do. |
| 311 | |
| 312 | // ── 3. A surrogate control is a whole control ─────────────────────── |
| 313 | const surro = await page.evaluate(SURROGATES); |
| 314 | const unreachable = surro.filter((x) => !x.ok); |
| 315 | check(unreachable.length === 0, |
| 316 | `every role="button" surrogate can take focus (${surro.length} on screen)`, |
| 317 | unreachable.length ? JSON.stringify(unreachable) : null); |
| 318 | |
| 319 | // And that it actually acts on the two keys a button acts on. |
| 320 | // |
| 321 | // Measured by whether the key was CONSUMED, not by hunting for a side effect: a |
| 322 | // surrogate that handles Enter calls preventDefault before letting the event go, |
| 323 | // so a probe listener attached after the app's own sees defaultPrevented. That |
| 324 | // is the same question for every surrogate whatever it does, which a side-effect |
| 325 | // test is not. |
| 326 | async function keyConsumed(sel, key) { |
| 327 | await page.evaluate((s) => { |
| 328 | window.__a11yDP = null; |
| 329 | const el = document.querySelector(s); |
| 330 | if (!el) return; |
| 331 | window.__a11yProbe = (e) => { window.__a11yDP = e.defaultPrevented; }; |
| 332 | el.addEventListener('keydown', window.__a11yProbe); // registered last, so it runs last |
| 333 | el.focus(); |
| 334 | }, sel); |
| 335 | await page.keyboard.press(key); |
| 336 | await page.waitForTimeout(200); |
| 337 | const dp = await page.evaluate(() => window.__a11yDP); |
| 338 | await page.evaluate((s) => { |
| 339 | const el = document.querySelector(s); |
| 340 | if (el && window.__a11yProbe) el.removeEventListener('keydown', window.__a11yProbe); |
| 341 | }, sel); |
| 342 | return dp; |
| 343 | } |
| 344 | if (await page.$('.files-mode-chip.act')) { |
| 345 | for (const key of ['Enter', 'Space']) { |
| 346 | const took = await keyConsumed('.files-mode-chip.act', key); |
| 347 | check(took === true, `a role="button" chip acts on ${key}`, |
| 348 | took === true ? null : 'the key passed straight through — no keydown handler'); |
| 349 | await page.keyboard.press('Escape'); |
| 350 | await page.waitForTimeout(300); |
| 351 | // Whatever the chip opened is put away, so the next check starts clean. |
| 352 | if (await page.$('.dlg-card')) { await page.keyboard.press('Escape'); await page.waitForTimeout(250); } |
| 353 | } |
| 354 | } else { |
| 355 | check(false, 'a role="button" surrogate was on screen to press', 'none found'); |
| 356 | } |
| 357 | |
| 358 | // ── 4. Every control shows where the focus is ─────────────────────── |
| 359 | const noRing = await page.evaluate(NO_FOCUS_RING, FOCUS_SEL); |
| 360 | check(noRing.length === 0, |
| 361 | 'every focusable control changes visibly when it takes keyboard focus', |
| 362 | noRing.length ? `no change on: ${JSON.stringify(noRing)}` : null); |
| 363 | |
| 364 | // ── 5. A dialog: focused, trapped, escapable, and gives focus back ── |
| 365 | await page.evaluate(() => window.scrollTo(0, 0)); |
| 366 | await press(page, '#new-diamond-btn'); |
| 367 | await page.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 368 | await page.waitForTimeout(300); |
| 369 | let w = await page.evaluate(WHERE, '.dlg-card'); |
| 370 | check(w.inside, 'a dialog puts the focus inside itself when it opens', w.inside ? w.name : `focus is on ${w.name}`); |
| 371 | |
| 372 | const stops = await page.evaluate(COUNT_IN, { sel: '.dlg-card', focusSel: FOCUS_SEL }); |
| 373 | let escapedAt = -1, escapedTo = ''; |
| 374 | for (let i = 0; i < stops + 3; i++) { |
| 375 | await page.keyboard.press('Tab'); |
| 376 | await page.waitForTimeout(50); |
| 377 | const x = await page.evaluate(WHERE, '.dlg-card'); |
| 378 | if (!x.inside) { escapedAt = i + 1; escapedTo = x.name; break; } |
| 379 | } |
| 380 | check(escapedAt === -1, `Tab cannot walk out of a dialog (${stops} stops, ${stops + 3} presses)`, |
| 381 | escapedAt === -1 ? null : `Tab ${escapedAt} landed on ${escapedTo}, behind the scrim`); |
| 382 | |
| 383 | await page.keyboard.press('Escape'); |
| 384 | await page.waitForTimeout(400); |
| 385 | check(!(await page.$('.dlg-card')), 'Escape closes the dialog'); |
| 386 | w = await page.evaluate(WHERE, null); |
| 387 | check(w.id === 'new-diamond-btn', 'closing the dialog gives the focus back to the control that opened it', |
| 388 | `focus is on ${w.name}`); |
| 389 | |
| 390 | // ── 6. The appearance menu ────────────────────────────────────────── |
| 391 | await press(page, '#settings-menu-btn'); |
| 392 | await page.waitForTimeout(350); |
| 393 | check(await page.evaluate(() => document.getElementById('settings-menu').hidden === false), |
| 394 | 'the appearance menu opens'); |
| 395 | w = await page.evaluate(WHERE, '#settings-menu'); |
| 396 | check(w.inside, 'the appearance menu takes the focus when it opens', w.inside ? w.name : `focus is on ${w.name}`); |
| 397 | await page.keyboard.press('Escape'); |
| 398 | await page.waitForTimeout(350); |
| 399 | check(await page.evaluate(() => document.getElementById('settings-menu').hidden === true), |
| 400 | 'Escape closes the appearance menu'); |
| 401 | w = await page.evaluate(WHERE, null); |
| 402 | check(w.id === 'settings-menu-btn', 'and the focus goes back to the button that opened it', `focus is on ${w.name}`); |
| 403 | note('The appearance menu and the gallery do not hold Tab', |
| 404 | 'Both are role="dialog" popovers over the app (www/index.html:98, :101), but Tab walks ' |
| 405 | + 'straight out of them into the page behind and they stay open. See a11y_report.md §9.'); |
| 406 | |
| 407 | // ── 7. The panel gallery ──────────────────────────────────────────── |
| 408 | // The ⋯ button only exists once the chip row has overflowed, so the window is |
| 409 | // narrowed until it does — which is the state a real user meets it in. |
| 410 | let galReached = false; |
| 411 | for (const width of [1000, 900, 850, 820, 790]) { |
| 412 | await page.setViewportSize({ width, height: 900 }); |
| 413 | await page.waitForTimeout(450); |
| 414 | if (await page.$('#panel-more')) { galReached = true; break; } |
| 415 | } |
| 416 | if (galReached) { |
| 417 | await press(page, '#panel-more'); |
| 418 | await page.waitForTimeout(350); |
| 419 | w = await page.evaluate(WHERE, '#panel-gallery'); |
| 420 | check(w.inside, 'the panel gallery takes the focus when it opens', w.inside ? w.name : `focus is on ${w.name}`); |
| 421 | await page.keyboard.press('Escape'); |
| 422 | await page.waitForTimeout(350); |
| 423 | check(await page.evaluate(() => document.getElementById('panel-gallery').hidden === true), |
| 424 | 'Escape closes the panel gallery'); |
| 425 | w = await page.evaluate(WHERE, null); |
| 426 | check(w.id === 'panel-more', 'and the focus goes back to the ⋯ that opened it', `focus is on ${w.name}`); |
| 427 | } else { |
| 428 | check(false, 'the ⋯ gallery button could be reached by narrowing the window', 'it never appeared'); |
| 429 | } |
| 430 | await page.setViewportSize({ width: 1500, height: 950 }); |
| 431 | await page.waitForTimeout(450); |
| 432 | |
| 433 | // ── 8. The command palette ────────────────────────────────────────── |
| 434 | await press(page, '#guide-btn'); |
| 435 | await page.waitForTimeout(300); |
| 436 | await page.keyboard.press('Escape'); |
| 437 | await page.waitForTimeout(250); |
| 438 | await page.evaluate(() => document.getElementById('guide-btn').focus()); |
| 439 | await page.keyboard.press('Control+k'); |
| 440 | await page.waitForSelector('#palette', { state: 'visible', timeout: 8000 }); |
| 441 | await page.waitForTimeout(300); |
| 442 | w = await page.evaluate(WHERE, '#palette'); |
| 443 | check(w.id === 'pal-input', 'the palette opens with the caret in its box', `focus is on ${w.name}`); |
| 444 | await page.keyboard.press('Tab'); |
| 445 | await page.waitForTimeout(120); |
| 446 | w = await page.evaluate(WHERE, '#palette'); |
| 447 | check(w.id === 'pal-input', 'Tab is swallowed, so the keyboard cannot type behind the scrim', |
| 448 | `focus is on ${w.name}`); |
| 449 | await page.keyboard.press('Escape'); |
| 450 | await page.waitForTimeout(300); |
| 451 | check(await page.evaluate(() => document.getElementById('palette').hidden === true), |
| 452 | 'Escape closes the palette'); |
| 453 | w = await page.evaluate(WHERE, null); |
| 454 | if (w.onBody) { |
| 455 | note('Closing the command palette drops the focus on <body>', |
| 456 | 'www/js/workspace.js:737 closePalette() hides the scrim without restoring the focus ' |
| 457 | + 'it took. The next Tab starts again from the top of the app. See a11y_report.md §6.'); |
| 458 | } else { |
| 459 | check(true, 'closing the palette leaves the focus on something', `focus is on ${w.name}`); |
| 460 | } |
| 461 | |
| 462 | // ── 9. SELF-TEST: each check is shown going red ───────────────────── |
| 463 | // |
| 464 | // A check that has never failed is an assertion about the test, not about the |
| 465 | // app. Five properties are broken in the live page, the SAME predicate is run, |
| 466 | // and it must report the breakage; then the page is put back and it must go |
| 467 | // quiet again. Restoration is verified, not assumed. |
| 468 | out.push(''); |
| 469 | out.push('--- self-test: breaking each property and requiring the check to notice'); |
| 470 | |
| 471 | const red = (ok, what) => check(ok, `[self-test] ${what}`); |
| 472 | |
| 473 | // (a) The census: give a plain <span> a pointer cursor and no way to focus it. |
| 474 | await page.evaluate(() => { |
| 475 | const d = document.createElement('span'); |
| 476 | d.id = 'a11y-selftest-ghost'; |
| 477 | d.textContent = 'x'; |
| 478 | d.style.cssText = 'cursor:pointer;display:inline-block;width:20px;height:20px'; |
| 479 | document.querySelector('.top-actions').appendChild(d); |
| 480 | }); |
| 481 | let g2 = await page.evaluate(GHOSTS_ON_PAGE, FOCUS_SEL); |
| 482 | red(g2.includes('span#a11y-selftest-ghost'), 'a new unfocusable clickable is caught by the census'); |
| 483 | await page.evaluate(() => document.getElementById('a11y-selftest-ghost').remove()); |
| 484 | g2 = await page.evaluate(GHOSTS_ON_PAGE, FOCUS_SEL); |
| 485 | red(!g2.includes('span#a11y-selftest-ghost'), 'and the census is quiet again once it is removed'); |
| 486 | |
| 487 | // (b) The focus ring: strip it from one class of button. |
| 488 | await page.evaluate(() => { |
| 489 | const st = document.createElement('style'); |
| 490 | st.id = 'a11y-selftest-ring'; |
| 491 | st.textContent = '.addbtn:focus, .addbtn:focus-visible { outline: none !important; box-shadow: none !important; }'; |
| 492 | document.head.appendChild(st); |
| 493 | }); |
| 494 | await page.keyboard.press('Tab'); |
| 495 | let r2 = await page.evaluate(NO_FOCUS_RING, FOCUS_SEL); |
| 496 | red(r2.some((x) => x.includes('addbtn')), 'a control stripped of its focus ring is caught'); |
| 497 | await page.evaluate(() => document.getElementById('a11y-selftest-ring').remove()); |
| 498 | await page.keyboard.press('Tab'); |
| 499 | r2 = await page.evaluate(NO_FOCUS_RING, FOCUS_SEL); |
| 500 | red(r2.length === 0, 'and every control passes again once the ring is restored'); |
| 501 | |
| 502 | // (c) A positive tabindex, which is the one way to scramble Tab order. |
| 503 | await page.evaluate(() => document.getElementById('guide-btn').setAttribute('tabindex', '5')); |
| 504 | let p2 = await page.evaluate(POSITIVE_TABINDEX); |
| 505 | red(p2.length === 1 && p2[0].includes('guide-btn'), 'a positive tabindex is caught'); |
| 506 | await page.evaluate(() => document.getElementById('guide-btn').removeAttribute('tabindex')); |
| 507 | p2 = await page.evaluate(POSITIVE_TABINDEX); |
| 508 | red(p2.length === 0, 'and the page is clean again once it is removed'); |
| 509 | |
| 510 | // (d) A role="button" surrogate with its tabindex taken away. |
| 511 | await page.evaluate(() => { |
| 512 | const c = document.querySelector('.files-mode-chip.act'); |
| 513 | if (c) { c.dataset.a11ySaveTi = c.getAttribute('tabindex') || ''; c.removeAttribute('tabindex'); } |
| 514 | }); |
| 515 | let s2 = await page.evaluate(SURROGATES); |
| 516 | red(s2.some((x) => !x.ok && x.sig.includes('files-mode-chip')), |
| 517 | 'a role="button" that lost its tabindex is caught'); |
| 518 | await page.evaluate(() => { |
| 519 | const c = document.querySelector('.files-mode-chip.act'); |
| 520 | if (c && c.dataset.a11ySaveTi) { c.setAttribute('tabindex', c.dataset.a11ySaveTi); delete c.dataset.a11ySaveTi; } |
| 521 | }); |
| 522 | s2 = await page.evaluate(SURROGATES); |
| 523 | red(s2.every((x) => x.ok), 'and every surrogate is reachable again once it is restored'); |
| 524 | |
| 525 | // (e) The key-consumed probe, against the exact shape of the defect it exists to |
| 526 | // catch: a span wearing role="button" and a tabindex, wired to click only. |
| 527 | await page.evaluate(() => { |
| 528 | const b = document.createElement('span'); |
| 529 | b.id = 'a11y-selftest-click-only'; |
| 530 | b.setAttribute('role', 'button'); |
| 531 | b.setAttribute('tabindex', '0'); |
| 532 | b.textContent = 'press me'; |
| 533 | b.style.cssText = 'position:fixed;left:2px;bottom:2px;z-index:99999'; |
| 534 | b.addEventListener('click', () => { window.__a11ySelftestClicked = true; }); |
| 535 | document.body.appendChild(b); |
| 536 | }); |
| 537 | const clickOnly = await keyConsumed('#a11y-selftest-click-only', 'Enter'); |
| 538 | red(clickOnly !== true, 'a click-only role="button" is seen NOT to answer Enter'); |
| 539 | // The same probe on the real chip, which does answer, so the probe is not simply |
| 540 | // reporting "no" to everything. |
| 541 | const realChip = await keyConsumed('.files-mode-chip.act', 'Enter'); |
| 542 | red(realChip === true, 'and the same probe still says yes to a chip that does answer it'); |
| 543 | await page.evaluate(() => document.getElementById('a11y-selftest-click-only').remove()); |
| 544 | await page.keyboard.press('Escape'); |
| 545 | await page.waitForTimeout(300); |
| 546 | if (await page.$('.dlg-card')) { await page.keyboard.press('Escape'); await page.waitForTimeout(250); } |
| 547 | |
| 548 | // (f) The focus-trap walk, run against a surface that genuinely does NOT trap. |
| 549 | // |
| 550 | // Not a broken copy of the dialog: the appearance menu is a real popover that |
| 551 | // covers the app and lets Tab walk out of it (defect §9 in a11y_report.md), so |
| 552 | // the identical walk that says "trapped" for the dialog must say "escaped" here. |
| 553 | // If it does not, the walk is measuring nothing and the dialog's pass is empty. |
| 554 | await press(page, '#settings-menu-btn'); |
| 555 | await page.waitForTimeout(350); |
| 556 | const menuStops = await page.evaluate(COUNT_IN, { sel: '#settings-menu', focusSel: FOCUS_SEL }); |
| 557 | let leftMenu = false; |
| 558 | for (let i = 0; i < menuStops + 3; i++) { |
| 559 | await page.keyboard.press('Tab'); |
| 560 | await page.waitForTimeout(50); |
| 561 | if (!(await page.evaluate(WHERE, '#settings-menu')).inside) { leftMenu = true; break; } |
| 562 | } |
| 563 | red(leftMenu, `the same trap-walk reports an escape on an untrapped popover (${menuStops} stops)`); |
| 564 | await page.keyboard.press('Escape'); |
| 565 | await page.waitForTimeout(350); |
| 566 | |
| 567 | // And once more on the dialog, so both answers come from one run of one walk. |
| 568 | await press(page, '#new-diamond-btn'); |
| 569 | await page.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 570 | await page.waitForTimeout(300); |
| 571 | const stops2 = await page.evaluate(COUNT_IN, { sel: '.dlg-card', focusSel: FOCUS_SEL }); |
| 572 | let leftDlg = false; |
| 573 | for (let i = 0; i < stops2 + 3; i++) { |
| 574 | await page.keyboard.press('Tab'); |
| 575 | await page.waitForTimeout(50); |
| 576 | if (!(await page.evaluate(WHERE, '.dlg-card')).inside) { leftDlg = true; break; } |
| 577 | } |
| 578 | red(!leftDlg, 'and reports no escape on the dialog, in the same run'); |
| 579 | await page.keyboard.press('Escape'); |
| 580 | await page.waitForTimeout(300); |
| 581 | |
| 582 | await s.close(); |
| 583 | |
| 584 | console.log(out.join('\n')); |
| 585 | if (known.length) { |
| 586 | console.log(`\nKNOWN DEFECTS — reported, not failed (see dev/a11y_report.md):\n - ${known.join('\n - ')}`); |
| 587 | } |
| 588 | console.log(bad === 0 |
| 589 | ? `\nALL ${out.filter((l) => l.startsWith('PASS')).length} CHECKS PASSED` |
| 590 | : `\n${bad} of ${out.filter((l) => /^(PASS|FAIL)/.test(l)).length} FAILED`); |
| 591 | process.exit(bad === 0 ? 0 : 1); |