Oregami
Repositories/oxedyne/daimond

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
43import fs from 'node:fs';
44import { open, newChat, scratch } from './harness.mjs';
45
46const out = [];
47let bad = 0;
48const check = (ok, what, detail) => {
49 out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`);
50 if (!ok) bad++;
51 return ok;
52};
53const known = [];
54const 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.
61const 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
91const 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.
96const 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.
120const 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?
146const 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.
158const 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`.
163const 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.
177const 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.
184const 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.
196const 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};
208async 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.
220const profile = scratch('pw', 'a11yk-' + process.pid);
221const s = await open({ name: 'a11yk', profile });
222const closeBrowser = s.close;
223s.close = async () => {
224 await closeBrowser();
225 try { fs.rmSync(profile, { recursive: true, force: true }); } catch (e) { /* gone */ }
226};
227const { page } = s;
228await 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.
232async 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}
239await newDiamond('Alpha');
240await newDiamond('Beta');
241await newChat(s);
242await 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.
246await page.keyboard.press('Tab');
247await page.waitForTimeout(120);
248
249// ── 1. Tab order is document order ──────────────────────────────────
250const positives = await page.evaluate(POSITIVE_TABINDEX);
251check(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.
263const N = 18;
264await page.evaluate(() => window.scrollTo(0, 0));
265const wantOrder = await page.evaluate(DOM_ORDER, { sel: FOCUS_SEL, n: N });
266const 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};
272await 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);
277await page.waitForTimeout(80);
278const gotOrder = [await page.evaluate(NOW)];
279for (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}
284const firstDiff = wantOrder.findIndex((w, i) => w !== gotOrder[i]);
285check(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 ────────────────────────────
290const ghosts = await page.evaluate(GHOSTS_ON_PAGE, FOCUS_SEL);
291const fresh = [...new Set(ghosts)].filter((g) => !GHOSTS.has(g));
292check(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.
296const goneNow = [...GHOSTS].filter((g) => !ghosts.includes(g));
297if (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 ───────────────────────
313const surro = await page.evaluate(SURROGATES);
314const unreachable = surro.filter((x) => !x.ok);
315check(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.
326async 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}
344if (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 ───────────────────────
359const noRing = await page.evaluate(NO_FOCUS_RING, FOCUS_SEL);
360check(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 ──
365await page.evaluate(() => window.scrollTo(0, 0));
366await press(page, '#new-diamond-btn');
367await page.waitForSelector('.dlg-card', { timeout: 8000 });
368await page.waitForTimeout(300);
369let w = await page.evaluate(WHERE, '.dlg-card');
370check(w.inside, 'a dialog puts the focus inside itself when it opens', w.inside ? w.name : `focus is on ${w.name}`);
371
372const stops = await page.evaluate(COUNT_IN, { sel: '.dlg-card', focusSel: FOCUS_SEL });
373let escapedAt = -1, escapedTo = '';
374for (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}
380check(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
383await page.keyboard.press('Escape');
384await page.waitForTimeout(400);
385check(!(await page.$('.dlg-card')), 'Escape closes the dialog');
386w = await page.evaluate(WHERE, null);
387check(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 ──────────────────────────────────────────
391await press(page, '#settings-menu-btn');
392await page.waitForTimeout(350);
393check(await page.evaluate(() => document.getElementById('settings-menu').hidden === false),
394 'the appearance menu opens');
395w = await page.evaluate(WHERE, '#settings-menu');
396check(w.inside, 'the appearance menu takes the focus when it opens', w.inside ? w.name : `focus is on ${w.name}`);
397await page.keyboard.press('Escape');
398await page.waitForTimeout(350);
399check(await page.evaluate(() => document.getElementById('settings-menu').hidden === true),
400 'Escape closes the appearance menu');
401w = await page.evaluate(WHERE, null);
402check(w.id === 'settings-menu-btn', 'and the focus goes back to the button that opened it', `focus is on ${w.name}`);
403note('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.
410let galReached = false;
411for (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}
416if (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}
430await page.setViewportSize({ width: 1500, height: 950 });
431await page.waitForTimeout(450);
432
433// ── 8. The command palette ──────────────────────────────────────────
434await press(page, '#guide-btn');
435await page.waitForTimeout(300);
436await page.keyboard.press('Escape');
437await page.waitForTimeout(250);
438await page.evaluate(() => document.getElementById('guide-btn').focus());
439await page.keyboard.press('Control+k');
440await page.waitForSelector('#palette', { state: 'visible', timeout: 8000 });
441await page.waitForTimeout(300);
442w = await page.evaluate(WHERE, '#palette');
443check(w.id === 'pal-input', 'the palette opens with the caret in its box', `focus is on ${w.name}`);
444await page.keyboard.press('Tab');
445await page.waitForTimeout(120);
446w = await page.evaluate(WHERE, '#palette');
447check(w.id === 'pal-input', 'Tab is swallowed, so the keyboard cannot type behind the scrim',
448 `focus is on ${w.name}`);
449await page.keyboard.press('Escape');
450await page.waitForTimeout(300);
451check(await page.evaluate(() => document.getElementById('palette').hidden === true),
452 'Escape closes the palette');
453w = await page.evaluate(WHERE, null);
454if (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.
468out.push('');
469out.push('--- self-test: breaking each property and requiring the check to notice');
470
471const 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.
474await 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});
481let g2 = await page.evaluate(GHOSTS_ON_PAGE, FOCUS_SEL);
482red(g2.includes('span#a11y-selftest-ghost'), 'a new unfocusable clickable is caught by the census');
483await page.evaluate(() => document.getElementById('a11y-selftest-ghost').remove());
484g2 = await page.evaluate(GHOSTS_ON_PAGE, FOCUS_SEL);
485red(!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.
488await 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});
494await page.keyboard.press('Tab');
495let r2 = await page.evaluate(NO_FOCUS_RING, FOCUS_SEL);
496red(r2.some((x) => x.includes('addbtn')), 'a control stripped of its focus ring is caught');
497await page.evaluate(() => document.getElementById('a11y-selftest-ring').remove());
498await page.keyboard.press('Tab');
499r2 = await page.evaluate(NO_FOCUS_RING, FOCUS_SEL);
500red(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.
503await page.evaluate(() => document.getElementById('guide-btn').setAttribute('tabindex', '5'));
504let p2 = await page.evaluate(POSITIVE_TABINDEX);
505red(p2.length === 1 && p2[0].includes('guide-btn'), 'a positive tabindex is caught');
506await page.evaluate(() => document.getElementById('guide-btn').removeAttribute('tabindex'));
507p2 = await page.evaluate(POSITIVE_TABINDEX);
508red(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.
511await page.evaluate(() => {
512 const c = document.querySelector('.files-mode-chip.act');
513 if (c) { c.dataset.a11ySaveTi = c.getAttribute('tabindex') || ''; c.removeAttribute('tabindex'); }
514});
515let s2 = await page.evaluate(SURROGATES);
516red(s2.some((x) => !x.ok && x.sig.includes('files-mode-chip')),
517 'a role="button" that lost its tabindex is caught');
518await 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});
522s2 = await page.evaluate(SURROGATES);
523red(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.
527await 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});
537const clickOnly = await keyConsumed('#a11y-selftest-click-only', 'Enter');
538red(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.
541const realChip = await keyConsumed('.files-mode-chip.act', 'Enter');
542red(realChip === true, 'and the same probe still says yes to a chip that does answer it');
543await page.evaluate(() => document.getElementById('a11y-selftest-click-only').remove());
544await page.keyboard.press('Escape');
545await page.waitForTimeout(300);
546if (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.
554await press(page, '#settings-menu-btn');
555await page.waitForTimeout(350);
556const menuStops = await page.evaluate(COUNT_IN, { sel: '#settings-menu', focusSel: FOCUS_SEL });
557let leftMenu = false;
558for (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}
563red(leftMenu, `the same trap-walk reports an escape on an untrapped popover (${menuStops} stops)`);
564await page.keyboard.press('Escape');
565await page.waitForTimeout(350);
566
567// And once more on the dialog, so both answers come from one run of one walk.
568await press(page, '#new-diamond-btn');
569await page.waitForSelector('.dlg-card', { timeout: 8000 });
570await page.waitForTimeout(300);
571const stops2 = await page.evaluate(COUNT_IN, { sel: '.dlg-card', focusSel: FOCUS_SEL });
572let leftDlg = false;
573for (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}
578red(!leftDlg, 'and reports no escape on the dialog, in the same run');
579await page.keyboard.press('Escape');
580await page.waitForTimeout(300);
581
582await s.close();
583
584console.log(out.join('\n'));
585if (known.length) {
586 console.log(`\nKNOWN DEFECTS — reported, not failed (see dev/a11y_report.md):\n - ${known.join('\n - ')}`);
587}
588console.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`);
591process.exit(bad === 0 ? 0 : 1);