Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_a11y_aria.mjs

31.4 KiB, 1 run

created by r2519314175:217, 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_aria.mjs — what the app tells a screen reader is what is on screen.
2//
3// The accessible name of every control is read out of CHROME'S OWN accessibility
4// tree over CDP (Accessibility.queryAXTree), not computed here. That matters:
5// an accname is the result of a precedence chain — aria-labelledby, then
6// aria-label, then the element's own text, then title — and a hand-rolled
7// approximation of it would agree with the browser right up to the case that is
8// actually wrong. Six of Daimond's buttons carry a perfectly good `title` and
9// still announce as a punctuation mark, because their text content outranks it.
10// Only the real engine says so.
11//
12// WHAT THIS FILE LOCKS DOWN. Each holds right now and must keep holding.
13//
14// 1. Disclosure state is real: aria-expanded on the appearance menu button and
15// on the gallery's ⋯ FOLLOWS the popover, checked shut, open and shut again.
16// 2. Toggle state is real: aria-pressed on a panel chip and on a gallery pin
17// tracks the thing it describes, checked by toggling it.
18// 3. The command palette is a labelled modal: role="dialog", aria-modal="true",
19// an accessible name, and a combobox whose aria-activedescendant points at a
20// list item that exists.
21// 4. The two popovers carry role and an accessible name.
22// 5. The theme pulldown announces its three bands: an aria-label, three
23// <optgroup>s, and Chrome exposing each band as a named group.
24// 6. Icon-only controls: no control's accessible name may be empty or bare
25// punctuation, beyond a frozen census, GLYPHS below. The census may only
26// shrink; a new nameless button fails the run and is named.
27// 7. Every inline <svg> inside a control is aria-hidden="true", so an icon is
28// never read out beside the label it is illustrating.
29// 8. Every <img> carries an alt; a decorative one carries alt="" AND
30// aria-hidden="true", so it is out of the tree by both routes.
31// 9. The one live region there is (the reading-size sample) stays a live region.
32//
33// KNOWN DEFECTS are printed at the end and do NOT fail the run — they are
34// written up with a file:line and a fix in dev/a11y_report.md. Asserting a bug
35// as correct would make its fix look like a regression, so they are asserted as
36// known instead.
37//
38// SELF-TEST. The last section strips an aria-label, freezes an aria-expanded,
39// unhides an icon svg, removes an alt and removes an aria-modal — five real
40// breakages in the live page — and requires the matching check to go red each
41// time, then restores and requires green. A check never seen red is not evidence.
42//
43// node dev/verify_a11y_aria.mjs
44//
45// Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs
46// (DAIMOND_MOCK_PORT, default 9099). No gateway.
47
48import fs from 'node:fs';
49import { open, newChat, scratch } from './harness.mjs';
50
51const out = [];
52let bad = 0;
53const check = (ok, what, detail) => {
54 out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`);
55 if (!ok) bad++;
56 return ok;
57};
58const known = [];
59const note = (what, why) => known.push(`${what}\n ${why}`);
60
61// ── The frozen census of controls with no usable spoken name ────────
62//
63// Chrome's accname for each of these is the character printed on it, or nothing
64// at all. Most carry a `title` that says the right thing and is outranked by the
65// text content. All are written up in dev/a11y_report.md §4 and §10; the census
66// is here so the NEXT one fails this run instead of joining them.
67const GLYPHS = new Set([
68 'button#settings-btn.icon-btn|⚙',
69 'button#collapse-btn.chip-btn|−',
70 'button#chat-send|➤',
71 'button#chat-jump|↑',
72 'button#chat-end|↓',
73 'button#web-back.icon-btn|‹',
74 'button#web-reload.icon-btn|⟳',
75 'button#web-pop.icon-btn|↗',
76 'iframe#web-frame.web-frame|', // a Tab stop announced only as "frame"
77 // The tag editor's remove-a-tag ×. Its neighbour four rules down --
78 // poolCloser at www/js/daimond.js:10091 -- sets the aria-label this one wants.
79 'button.tag-x|×', // www/js/daimond.js:10043
80 // Not on screen in this run's state, but the same shape in the source:
81 // a bare × with a title and no aria-label.
82 //
83 // `button.mail-del` came OFF this census in phase G part two: the mailbox's
84 // closer cross is gone and Remove sits at the foot of its settings dialog,
85 // which is a labelled button with words in it. The fallback path in `mail.js`
86 // still draws one for a container that offers no dialog, and this app is not
87 // such a container.
88 'button.arte-drop|×', // www/js/daimond.js:10200
89]);
90
91// ── The frozen census of images with a half-declaration ─────────────
92//
93// The app's own pattern for a decorative image is alt="" AND aria-hidden="true";
94// these carry only the first half. Written up in dev/a11y_report.md §12.
95const IMGS = new Set([
96 'img#web-mirror.web-mirror: alt="" but not aria-hidden',
97]);
98
99// ── Page-side predicates ────────────────────────────────────────────
100
101/// Inline <svg> that a screen reader will read out beside its own button's label.
102const LOUD_ICONS = () => {
103 const sig = (e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : '')
104 + (typeof e.className === 'string' && e.className.trim()
105 ? '.' + e.className.trim().split(/\s+/).join('.')
106 : (e.className && e.className.baseVal ? '.' + e.className.baseVal.trim().split(/\s+/).join('.') : ''));
107 return [...document.querySelectorAll('svg')]
108 .filter((e) => e.getAttribute('aria-hidden') !== 'true')
109 // An svg that IS the control (it carries its own role and label) is fine.
110 .filter((e) => !e.getAttribute('role') && !e.getAttribute('aria-label'))
111 .filter((e) => e.closest('button,a,[role="button"],label'))
112 .map((e) => sig(e.closest('button,a,[role="button"],label')) + ' > svg');
113};
114
115/// Images with no text alternative, and decorative ones only half-hidden.
116const BAD_IMAGES = () => {
117 const sig = (e) => 'img' + (e.id ? '#' + e.id : '')
118 + (typeof e.className === 'string' && e.className.trim()
119 ? '.' + e.className.trim().split(/\s+/).join('.') : '');
120 return [...document.querySelectorAll('img')].map((e) => {
121 const alt = e.getAttribute('alt');
122 if (alt == null) return sig(e) + ': no alt attribute at all';
123 // alt="" declares it decorative; aria-hidden takes it out of the tree by
124 // the other route as well, which is what stops a stray title or filename
125 // being read in its place.
126 if (alt === '' && e.getAttribute('aria-hidden') !== 'true') {
127 return sig(e) + ': alt="" but not aria-hidden';
128 }
129 return null;
130 }).filter(Boolean);
131};
132
133/// Landmarks and headings, as they stand.
134const STRUCTURE = () => ({
135 main: document.querySelectorAll('main,[role="main"]').length,
136 h1: document.querySelectorAll('h1').length,
137 // role="heading" counts. The panel heads are <span>s carrying the role rather
138 // than <h2>s, because making them elements would have moved the layout, and a
139 // count that ignored them would report zero headings on a page that has
140 // thirteen -- a report line that is wrong is worse than no report line.
141 headings: [...document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]')]
142 .filter((e) => e.getClientRects().length).length,
143 unnamedRegions: [...document.querySelectorAll('aside,nav,header,footer,[role="region"],[role="complementary"]')]
144 .filter((e) => e.getClientRects().length)
145 .filter((e) => !e.getAttribute('aria-label') && !e.getAttribute('aria-labelledby'))
146 .map((e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : '')),
147 live: [...document.querySelectorAll('[aria-live],[role="status"],[role="alert"],[role="log"]')]
148 .map((e) => (e.id || (typeof e.className === 'string' ? e.className : '')) + '=' + (e.getAttribute('aria-live') || e.getAttribute('role'))),
149});
150
151// ── The oracle: Chrome's own accname for every control ──────────────
152
153const AX_SEL = 'a[href],button:not([disabled]),input:not([disabled]),'
154 + 'select:not([disabled]),textarea:not([disabled]),'
155 + '[tabindex]:not([tabindex="-1"]),[role="button"],iframe';
156
157/// Every visible control, with the name the browser would speak.
158async function accNames(cdp) {
159 const { root } = await cdp.send('DOM.getDocument', { depth: -1 });
160 const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: AX_SEL });
161 const rows = [];
162 for (const nodeId of nodeIds) {
163 let ax;
164 try { ax = await cdp.send('Accessibility.queryAXTree', { nodeId }); } catch (e) { continue; }
165 // An ignored node is one the tree does not carry -- a hidden panel's
166 // buttons, mostly. Judging those would be judging markup nobody meets.
167 const n = (ax.nodes || []).find((x) => !x.ignored);
168 if (!n) continue;
169 let d;
170 try { d = await cdp.send('DOM.describeNode', { nodeId }); } catch (e) { continue; }
171 const attrs = d.node.attributes || [];
172 const at = {};
173 for (let i = 0; i < attrs.length; i += 2) at[attrs[i]] = attrs[i + 1];
174 rows.push({
175 sig: d.node.nodeName.toLowerCase() + (at.id ? '#' + at.id : '')
176 + (at.class ? '.' + at.class.trim().split(/\s+/).join('.') : ''),
177 role: n.role ? n.role.value : '',
178 name: n.name ? n.name.value : '',
179 });
180 }
181 return rows;
182}
183
184/// Controls whose spoken name is nothing, or nothing but punctuation.
185const speechless = (rows) => rows.filter((r) =>
186 !r.name.trim() || !/[\p{L}\p{N}]/u.test(r.name));
187
188// ── The run ─────────────────────────────────────────────────────────
189
190// The profile is taken away with the browser: a run that leaves one behind
191// leaves ~350 MB behind, and the pile has reached gigabytes before now.
192const profile = scratch('pw', 'a11ya-' + process.pid);
193const s = await open({ name: 'a11ya', profile });
194const closeBrowser = s.close;
195s.close = async () => {
196 await closeBrowser();
197 try { fs.rmSync(profile, { recursive: true, force: true }); } catch (e) { /* gone */ }
198};
199const { page } = s;
200await page.waitForTimeout(700);
201
202const cdp = await page.context().newCDPSession(page);
203await cdp.send('Accessibility.enable');
204await cdp.send('DOM.enable');
205
206async function newDiamond(name) {
207 await page.click('#new-diamond-btn', { force: true });
208 await page.waitForSelector('.dlg-input', { timeout: 10000 });
209 await page.fill('.dlg-input', name);
210 await page.click('.dlg-ok', { force: true });
211 await page.waitForTimeout(700);
212}
213await newDiamond('Alpha');
214await newDiamond('Beta');
215await newChat(s);
216await page.waitForTimeout(500);
217
218// ── 1. aria-expanded follows the popover ────────────────────────────
219const expOf = (sel) => page.getAttribute(sel, 'aria-expanded');
220check(await expOf('#settings-menu-btn') === 'false',
221 'the appearance menu button starts collapsed', `aria-expanded=${await expOf('#settings-menu-btn')}`);
222await page.click('#settings-menu-btn', { force: true });
223await page.waitForTimeout(400);
224check(await expOf('#settings-menu-btn') === 'true'
225 && await page.evaluate(() => document.getElementById('settings-menu').hidden === false),
226 'opening it sets aria-expanded="true", and the menu really is open');
227await page.keyboard.press('Escape');
228await page.waitForTimeout(350);
229check(await expOf('#settings-menu-btn') === 'false'
230 && await page.evaluate(() => document.getElementById('settings-menu').hidden === true),
231 'and Escape puts both back');
232
233// ── 2 & 4. The gallery: state, role, name, and its pins ─────────────
234let galReached = false;
235for (const width of [1000, 900, 850, 820, 790]) {
236 await page.setViewportSize({ width, height: 900 });
237 await page.waitForTimeout(450);
238 if (await page.$('#panel-more')) { galReached = true; break; }
239}
240if (galReached) {
241 check(await expOf('#panel-more') === 'false', 'the gallery ⋯ starts collapsed');
242 check(await page.getAttribute('#panel-more', 'aria-haspopup') === 'dialog',
243 'and says what it opens (aria-haspopup="dialog")');
244 await page.click('#panel-more', { force: true });
245 await page.waitForTimeout(400);
246 check(await expOf('#panel-more') === 'true', 'opening the gallery sets aria-expanded="true"');
247 check(await page.getAttribute('#panel-gallery', 'role') === 'dialog'
248 && !!(await page.getAttribute('#panel-gallery', 'aria-label')),
249 'the gallery carries role="dialog" and an accessible name',
250 JSON.stringify(await page.getAttribute('#panel-gallery', 'aria-label')));
251
252 // A pin is a two-state control and must say which state it is in.
253 const pin0 = await page.evaluate(() => {
254 const p = document.querySelector('#panel-gallery .gal-pin');
255 return p ? { pressed: p.getAttribute('aria-pressed'), label: p.getAttribute('aria-label') } : null;
256 });
257 check(!!pin0 && (pin0.pressed === 'true' || pin0.pressed === 'false') && !!pin0.label,
258 'a gallery pin carries aria-pressed and its own label', JSON.stringify(pin0));
259 await page.click('#panel-gallery .gal-pin', { force: true });
260 await page.waitForTimeout(400);
261 const pin1 = await page.evaluate(() => {
262 const p = document.querySelector('#panel-gallery .gal-pin');
263 return p ? p.getAttribute('aria-pressed') : null;
264 });
265 check(pin1 !== null && pin1 !== pin0.pressed,
266 'and it flips when the pin is used', `${pin0.pressed} → ${pin1}`);
267 await page.click('#panel-gallery .gal-pin', { force: true }); // put it back
268 await page.waitForTimeout(400);
269 await page.keyboard.press('Escape');
270 await page.waitForTimeout(350);
271 check(await expOf('#panel-more') === 'false', 'and closing it puts aria-expanded back');
272} else {
273 check(false, 'the gallery ⋯ could be reached by narrowing the window', 'it never appeared');
274}
275await page.setViewportSize({ width: 1500, height: 950 });
276await page.waitForTimeout(450);
277
278// ── 2b. aria-pressed on a panel chip tracks the panel ───────────────
279const chipState = () => page.evaluate(() => {
280 const c = document.querySelector('#panel-tags .ptag[data-panel="spend"]')
281 || document.querySelector('#panel-tags .ptag:not([disabled])');
282 if (!c) return null;
283 const p = document.getElementById('panel-' + c.dataset.panel);
284 return { panel: c.dataset.panel, pressed: c.getAttribute('aria-pressed'),
285 open: !!(p && p.getClientRects().length) };
286});
287const c0 = await chipState();
288check(!!c0 && String(c0.open) === c0.pressed,
289 'a panel chip\'s aria-pressed agrees with whether the panel is on screen', JSON.stringify(c0));
290await page.click(`#panel-tags .ptag[data-panel="${c0.panel}"]`, { force: true });
291await page.waitForTimeout(700);
292const c1 = await chipState();
293check(!!c1 && c1.pressed !== c0.pressed && String(c1.open) === c1.pressed,
294 'and both flip together when the chip is used', `${JSON.stringify(c0)} → ${JSON.stringify(c1)}`);
295await page.click(`#panel-tags .ptag[data-panel="${c0.panel}"]`, { force: true });
296await page.waitForTimeout(700);
297
298// ── 1b. The tag filter's disclosure ─────────────────────────────────
299// The filter only exists once a tag does, so one is put on through the editor
300// the way a user does it.
301await page.$$eval('.diamond-box', (els) => els[0].click());
302await page.waitForTimeout(600);
303for (const b of await page.$$('.crystal-act')) {
304 if (((await b.textContent()) || '').includes('Tags')) { await b.click({ force: true }); break; }
305}
306const editorUp = await page.waitForSelector('.tag-editor', { timeout: 8000 }).catch(() => null);
307if (editorUp) {
308 const sug = await page.$$('.tag-sug .tag-chip');
309 if (sug.length) { await sug[0].click({ force: true }); await page.waitForTimeout(700); }
310}
311const tagf = await page.evaluate(() => {
312 const b = document.querySelector('#diamond-filter .tagf-toggle');
313 return b ? { exp: b.getAttribute('aria-expanded'), shown: !!b.getClientRects().length } : null;
314});
315if (tagf && tagf.shown) {
316 check(tagf.exp === 'true' || tagf.exp === 'false',
317 'the tag pool\'s disclosure declares its state', JSON.stringify(tagf));
318 await page.click('#diamond-filter .tagf-toggle', { force: true });
319 await page.waitForTimeout(500);
320 const tagf2 = await page.evaluate(() => {
321 const b = document.querySelector('#diamond-filter .tagf-toggle');
322 return { exp: b.getAttribute('aria-expanded'), pool: !!document.querySelector('#diamond-filter .tagf-pool') };
323 });
324 check(tagf2.exp !== tagf.exp && String(tagf2.pool) === tagf2.exp,
325 'and it flips with the pool it discloses', `${tagf.exp} → ${JSON.stringify(tagf2)}`);
326} else {
327 check(false, 'a tag could be added, so the pool disclosure could be checked',
328 'the tag editor was not reachable');
329}
330
331// ── 3. The command palette is a labelled modal ──────────────────────
332await page.keyboard.press('Escape');
333await page.waitForTimeout(250);
334await page.evaluate(() => document.getElementById('guide-btn').focus());
335await page.keyboard.press('Control+k');
336await page.waitForSelector('#palette', { state: 'visible', timeout: 8000 });
337await page.waitForTimeout(400);
338const pal = await page.evaluate(() => {
339 const b = document.querySelector('#palette .pal-box');
340 const i = document.getElementById('pal-input');
341 const ad = i.getAttribute('aria-activedescendant');
342 return {
343 role: b.getAttribute('role'), modal: b.getAttribute('aria-modal'),
344 label: b.getAttribute('aria-label'),
345 inputRole: i.getAttribute('role'), controls: i.getAttribute('aria-controls'),
346 activedescendant: ad, pointsAtSomething: !!(ad && document.getElementById(ad)),
347 listRole: (document.getElementById('pal-list') || {}).getAttribute
348 ? document.getElementById('pal-list').getAttribute('role') : null,
349 };
350});
351check(pal.role === 'dialog' && pal.modal === 'true' && !!pal.label,
352 'the palette is role="dialog" aria-modal="true" with a name', JSON.stringify(pal.label));
353check(pal.inputRole === 'combobox' && pal.listRole === 'listbox' && pal.controls === 'pal-list',
354 'its box is a combobox wired to its listbox', JSON.stringify(pal));
355check(pal.pointsAtSomething,
356 'aria-activedescendant points at a list item that exists', pal.activedescendant);
357
358// ── 5. The theme pulldown announces its bands ───────────────────────
359await page.keyboard.press('Escape');
360await page.waitForTimeout(300);
361await page.click('#settings-menu-btn', { force: true });
362await page.waitForTimeout(450);
363const theme = await page.evaluate(() => {
364 const sel = document.querySelector('#settings-menu .theme-pick');
365 if (!sel) return null;
366 return { label: sel.getAttribute('aria-label'),
367 groups: [...sel.querySelectorAll('optgroup')].map((g) => ({ label: g.label, n: g.children.length })),
368 loose: [...sel.children].filter((c) => c.tagName === 'OPTION').length };
369});
370check(!!theme && !!theme.label, 'the theme pulldown has an accessible name', JSON.stringify(theme && theme.label));
371check(!!theme && theme.groups.length === 3 && theme.groups.every((g) => g.label && g.n > 0),
372 'and its palettes are in three named bands', JSON.stringify(theme && theme.groups));
373check(!!theme && theme.loose === 0,
374 'with no palette left outside a band, where it would be announced without one',
375 `${theme && theme.loose} loose options`);
376// And the browser really exposes the bands, rather than the markup merely holding them.
377const themeAx = await (async () => {
378 const { root } = await cdp.send('DOM.getDocument', { depth: -1 });
379 const q = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '#settings-menu .theme-pick' });
380 if (!q.nodeIds.length) return null;
381 const ax = await cdp.send('Accessibility.queryAXTree', { nodeId: q.nodeIds[0] });
382 return (ax.nodes || []).filter((n) => !n.ignored && n.role && n.role.value === 'group')
383 .map((n) => (n.name && n.name.value) || '');
384})();
385check(!!themeAx && themeAx.length === 3 && themeAx.every(Boolean),
386 'and Chrome exposes all three as named groups', JSON.stringify(themeAx));
387await page.keyboard.press('Escape');
388await page.waitForTimeout(350);
389
390// ── 6. Nothing NEW announces as a glyph ─────────────────────────────
391const rows = await accNames(cdp);
392const mute = speechless(rows);
393const freshMute = mute.filter((r) => !GLYPHS.has(r.sig + '|' + r.name.trim()));
394check(freshMute.length === 0,
395 `no NEW control announces as nothing or as bare punctuation `
396 + `(${rows.length} controls in the tree, ${mute.length} known)`,
397 freshMute.length ? JSON.stringify(freshMute) : null);
398note(`${mute.length} controls on screen announce as a punctuation mark or as nothing`,
399 'Chrome\'s accname for each is the character printed on it: Send is "➤", Settings is '
400 + '"⚙". Each carries a correct `title`, which loses to text content. See '
401 + 'a11y_report.md §4.');
402// The pair of identical "×" delete buttons that used to be noted here is gone:
403// phase C replaced the tile\'s closer cross with a cog whose accessible name
404// carries the tile\'s own name, and moved Delete into the dialog it opens.
405
406// ── 7. Icons are silent ─────────────────────────────────────────────
407const loud = await page.evaluate(LOUD_ICONS);
408check(loud.length === 0,
409 'every inline icon inside a control is aria-hidden, so it is not read beside the label',
410 loud.length ? JSON.stringify(loud.slice(0, 8)) : null);
411
412// ── 8. Images ───────────────────────────────────────────────────────
413const badImg = await page.evaluate(BAD_IMAGES);
414const freshImg = badImg.filter((x) => !IMGS.has(x));
415check(freshImg.length === 0,
416 `every <img> has a text alternative, decorative ones hidden both ways (${badImg.length} known)`,
417 freshImg.length ? JSON.stringify(freshImg) : null);
418if (badImg.length) {
419 note('A decorative image is declared decorative only once',
420 'www/index.html:337 — #web-mirror carries alt="" but no aria-hidden, unlike the two '
421 + 'beside it which carry both. See a11y_report.md §12.');
422}
423
424// ── 9. Structure, and the one live region ───────────────────────────
425const st = await page.evaluate(STRUCTURE);
426out.push(`---- structure: ${st.main} <main>, ${st.h1} <h1>, ${st.headings} visible headings, `
427 + `${st.unnamedRegions.length} unnamed regions, ${st.live.length} live regions`);
428if (!st.main) {
429 note('There is no <main>, no <h1>, and not one visible heading',
430 'www/index.html:61 opens the app as <div id="app">; the panels are unnamed <aside> '
431 + 'and <section> elements. Region and heading navigation, which is how a screen-reader '
432 + 'user moves around a page this size, reaches nothing. See a11y_report.md §7.');
433}
434if (st.unnamedRegions.length) {
435 note(`${st.unnamedRegions.length} landmark regions have no name`,
436 `${JSON.stringify(st.unnamedRegions)} — each <aside> is announced as "complementary" `
437 + 'with nothing to tell it from the next one. See a11y_report.md §7.');
438}
439note('Nothing that changes on its own is announced',
440 'The one aria-live in the app is the reading-size sample (www/js/workspace.js:432). The '
441 + 'streaming answer (#chat-output), the sync chip (www/js/sync.js:245) and the spend meter '
442 + 'all change without a keypress and say nothing. See a11y_report.md §8.');
443
444// The one that exists must keep existing, or the count above means nothing.
445await page.click('#settings-menu-btn', { force: true });
446await page.waitForTimeout(450);
447check(await page.evaluate(() => {
448 const e = document.querySelector('#settings-menu .sample');
449 return !!e && e.getAttribute('aria-live') === 'polite';
450}), 'the reading-size sample is still a polite live region');
451await page.keyboard.press('Escape');
452await page.waitForTimeout(300);
453
454// ── 10. The modal surfaces that carry no semantics ──────────────────
455await page.click('#new-diamond-btn', { force: true });
456await page.waitForSelector('.dlg-card', { timeout: 8000 });
457await page.waitForTimeout(300);
458const dlg = await page.evaluate(() => {
459 const c = document.querySelector('.dlg-card');
460 return { role: c.getAttribute('role'), modal: c.getAttribute('aria-modal'),
461 label: c.getAttribute('aria-label'), labelledby: c.getAttribute('aria-labelledby'),
462 h2: (c.querySelector('h2') || {}).textContent || '' };
463});
464if (dlg.role !== 'dialog') {
465 note('Every dialog in the app is an unlabelled <div>',
466 'www/js/daimond.js:3361 builds the card as `modal-card dlg-card` with no role, no '
467 + 'aria-modal and no aria-labelledby, though it already writes an <h2> — '
468 + `"${dlg.h2}" — that would name it. Same at :12134, and in www/index.html:602 `
469 + '(#settings-modal) and :611 (#identity-modal). See a11y_report.md §5.');
470} else {
471 check(dlg.modal === 'true' && !!(dlg.label || dlg.labelledby),
472 'a dialog is a labelled modal', JSON.stringify(dlg));
473}
474await page.keyboard.press('Escape');
475await page.waitForTimeout(300);
476if (await page.getAttribute('#web-frame', 'title') === null) {
477 note('The Web panel\'s iframe has no title',
478 'www/index.html:335 — an untitled frame is announced only as "frame", and it is a '
479 + 'Tab stop. See a11y_report.md §10.');
480}
481if (await page.getAttribute('#settings-menu-btn', 'aria-haspopup') === 'menu'
482 && await page.getAttribute('#settings-menu', 'role') === 'dialog') {
483 note('The appearance button promises a menu and opens a dialog',
484 'www/index.html:88 says aria-haspopup="menu"; www/index.html:101 gives the target '
485 + 'role="dialog". See a11y_report.md §11.');
486}
487
488// ── 11. SELF-TEST: each check is shown going red ────────────────────
489out.push('');
490out.push('--- self-test: breaking each property and requiring the check to notice');
491const red = (ok, what) => check(ok, `[self-test] ${what}`);
492
493// (a) Take the label off a control that has one, and ask the browser again.
494const before = speechless(await accNames(cdp)).length;
495await page.evaluate(() => {
496 const b = document.getElementById('guide-btn');
497 b.dataset.a11ySave = b.getAttribute('aria-label') || '';
498 b.removeAttribute('aria-label');
499 b.dataset.a11ySaveTitle = b.getAttribute('title') || '';
500 b.removeAttribute('title'); // title is the fallback; both must go
501});
502let mute2 = speechless(await accNames(cdp));
503red(mute2.some((r) => r.sig.includes('guide-btn')),
504 'a control stripped of its label is caught by the accessibility tree');
505await page.evaluate(() => {
506 const b = document.getElementById('guide-btn');
507 b.setAttribute('aria-label', b.dataset.a11ySave);
508 b.setAttribute('title', b.dataset.a11ySaveTitle);
509 delete b.dataset.a11ySave; delete b.dataset.a11ySaveTitle;
510});
511mute2 = speechless(await accNames(cdp));
512red(mute2.length === before && !mute2.some((r) => r.sig.includes('guide-btn')),
513 'and the tree is back to the known census once the label is restored');
514
515// (b) Freeze aria-expanded, so it lies about a menu that really opened.
516await page.evaluate(() => {
517 const b = document.getElementById('settings-menu-btn');
518 window.__a11yFreeze = () => b.setAttribute('aria-expanded', 'false');
519 b.addEventListener('click', window.__a11yFreeze);
520});
521await page.click('#settings-menu-btn', { force: true });
522await page.waitForTimeout(450);
523const lying = await page.evaluate(() => ({
524 says: document.getElementById('settings-menu-btn').getAttribute('aria-expanded'),
525 open: document.getElementById('settings-menu').hidden === false,
526}));
527red(lying.open && lying.says === 'false',
528 'an aria-expanded that stops tracking is caught (menu open, attribute says false)');
529await page.evaluate(() => {
530 document.getElementById('settings-menu-btn').removeEventListener('click', window.__a11yFreeze);
531 delete window.__a11yFreeze;
532});
533await page.keyboard.press('Escape');
534await page.waitForTimeout(350);
535await page.click('#settings-menu-btn', { force: true });
536await page.waitForTimeout(450);
537red(await page.getAttribute('#settings-menu-btn', 'aria-expanded') === 'true',
538 'and it tracks again once the interference is removed');
539await page.keyboard.press('Escape');
540await page.waitForTimeout(350);
541
542// (c) Unhide one icon svg.
543await page.evaluate(() => {
544 const g = document.querySelector('#new-diamond-btn svg');
545 g.removeAttribute('aria-hidden');
546 g.id = 'a11y-selftest-svg';
547});
548let loud2 = await page.evaluate(LOUD_ICONS);
549red(loud2.some((x) => x.includes('new-diamond-btn')), 'an icon that is no longer hidden is caught');
550await page.evaluate(() => {
551 const g = document.getElementById('a11y-selftest-svg');
552 g.setAttribute('aria-hidden', 'true'); g.removeAttribute('id');
553});
554loud2 = await page.evaluate(LOUD_ICONS);
555red(loud2.length === 0, 'and every icon is silent again once it is restored');
556
557// (d) Take the alt off an image. Measured against the frozen census, not
558// against zero: one standing violation is already known, and a self-test that
559// demanded zero would fail on it rather than on the breakage it just made.
560const clean = (list) => list.filter((x) => !IMGS.has(x));
561await page.evaluate(() => {
562 const i = document.querySelector('.brand-wordmark.wm-on-dark');
563 i.dataset.a11ySave = i.getAttribute('alt');
564 i.removeAttribute('alt');
565});
566let img2 = await page.evaluate(BAD_IMAGES);
567red(img2.some((x) => x.includes('no alt attribute')), 'an <img> with no alt is caught');
568await page.evaluate(() => {
569 const i = document.querySelector('.brand-wordmark.wm-on-dark');
570 i.setAttribute('alt', i.dataset.a11ySave); delete i.dataset.a11ySave;
571});
572img2 = await page.evaluate(BAD_IMAGES);
573red(clean(img2).length === 0, 'and every image is back to the known census once the alt is back');
574
575// A decorative image half-hidden -- alt="" and nothing else -- is the subtler
576// half of the same check, and must be caught on its own.
577await page.evaluate(() => {
578 const i = document.querySelector('#chead-mark');
579 i.dataset.a11ySaveAh = i.getAttribute('aria-hidden') || '';
580 i.removeAttribute('aria-hidden');
581});
582img2 = await page.evaluate(BAD_IMAGES);
583red(img2.some((x) => x.includes('chead-mark') && x.includes('alt="" but not aria-hidden')),
584 'a decorative image hidden by only one of the two routes is caught');
585await page.evaluate(() => {
586 const i = document.querySelector('#chead-mark');
587 i.setAttribute('aria-hidden', i.dataset.a11ySaveAh || 'true'); delete i.dataset.a11ySaveAh;
588});
589img2 = await page.evaluate(BAD_IMAGES);
590red(clean(img2).length === 0, 'and it passes again once both routes are back');
591
592// (e) Take aria-modal off the palette.
593await page.evaluate(() => document.getElementById('guide-btn').focus());
594await page.keyboard.press('Control+k');
595await page.waitForSelector('#palette', { state: 'visible', timeout: 8000 });
596await page.waitForTimeout(300);
597await page.evaluate(() => document.querySelector('#palette .pal-box').removeAttribute('aria-modal'));
598let pal2 = await page.evaluate(() => {
599 const b = document.querySelector('#palette .pal-box');
600 return { role: b.getAttribute('role'), modal: b.getAttribute('aria-modal'), label: b.getAttribute('aria-label') };
601});
602red(!(pal2.role === 'dialog' && pal2.modal === 'true' && !!pal2.label),
603 'a modal that loses aria-modal is caught');
604await page.evaluate(() => document.querySelector('#palette .pal-box').setAttribute('aria-modal', 'true'));
605pal2 = await page.evaluate(() => {
606 const b = document.querySelector('#palette .pal-box');
607 return { role: b.getAttribute('role'), modal: b.getAttribute('aria-modal'), label: b.getAttribute('aria-label') };
608});
609red(pal2.role === 'dialog' && pal2.modal === 'true' && !!pal2.label,
610 'and it is a labelled modal again once it is restored');
611await page.keyboard.press('Escape');
612await page.waitForTimeout(250);
613
614await s.close();
615
616console.log(out.join('\n'));
617if (known.length) {
618 console.log(`\nKNOWN DEFECTS — reported, not failed (see dev/a11y_report.md):\n - ${known.join('\n - ')}`);
619}
620console.log(bad === 0
621 ? `\nALL ${out.filter((l) => l.startsWith('PASS')).length} CHECKS PASSED`
622 : `\n${bad} of ${out.filter((l) => /^(PASS|FAIL)/.test(l)).length} FAILED`);
623process.exit(bad === 0 ? 0 : 1);