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 | |
| 48 | import fs from 'node:fs'; |
| 49 | import { open, newChat, scratch } from './harness.mjs'; |
| 50 | |
| 51 | const out = []; |
| 52 | let bad = 0; |
| 53 | const check = (ok, what, detail) => { |
| 54 | out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`); |
| 55 | if (!ok) bad++; |
| 56 | return ok; |
| 57 | }; |
| 58 | const known = []; |
| 59 | const 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. |
| 67 | const 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. |
| 95 | const 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. |
| 102 | const 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. |
| 116 | const 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. |
| 134 | const 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 | |
| 153 | const 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. |
| 158 | async 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. |
| 185 | const 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. |
| 192 | const profile = scratch('pw', 'a11ya-' + process.pid); |
| 193 | const s = await open({ name: 'a11ya', profile }); |
| 194 | const closeBrowser = s.close; |
| 195 | s.close = async () => { |
| 196 | await closeBrowser(); |
| 197 | try { fs.rmSync(profile, { recursive: true, force: true }); } catch (e) { /* gone */ } |
| 198 | }; |
| 199 | const { page } = s; |
| 200 | await page.waitForTimeout(700); |
| 201 | |
| 202 | const cdp = await page.context().newCDPSession(page); |
| 203 | await cdp.send('Accessibility.enable'); |
| 204 | await cdp.send('DOM.enable'); |
| 205 | |
| 206 | async 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 | } |
| 213 | await newDiamond('Alpha'); |
| 214 | await newDiamond('Beta'); |
| 215 | await newChat(s); |
| 216 | await page.waitForTimeout(500); |
| 217 | |
| 218 | // ── 1. aria-expanded follows the popover ──────────────────────────── |
| 219 | const expOf = (sel) => page.getAttribute(sel, 'aria-expanded'); |
| 220 | check(await expOf('#settings-menu-btn') === 'false', |
| 221 | 'the appearance menu button starts collapsed', `aria-expanded=${await expOf('#settings-menu-btn')}`); |
| 222 | await page.click('#settings-menu-btn', { force: true }); |
| 223 | await page.waitForTimeout(400); |
| 224 | check(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'); |
| 227 | await page.keyboard.press('Escape'); |
| 228 | await page.waitForTimeout(350); |
| 229 | check(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 ───────────── |
| 234 | let galReached = false; |
| 235 | for (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 | } |
| 240 | if (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 | } |
| 275 | await page.setViewportSize({ width: 1500, height: 950 }); |
| 276 | await page.waitForTimeout(450); |
| 277 | |
| 278 | // ── 2b. aria-pressed on a panel chip tracks the panel ─────────────── |
| 279 | const 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 | }); |
| 287 | const c0 = await chipState(); |
| 288 | check(!!c0 && String(c0.open) === c0.pressed, |
| 289 | 'a panel chip\'s aria-pressed agrees with whether the panel is on screen', JSON.stringify(c0)); |
| 290 | await page.click(`#panel-tags .ptag[data-panel="${c0.panel}"]`, { force: true }); |
| 291 | await page.waitForTimeout(700); |
| 292 | const c1 = await chipState(); |
| 293 | check(!!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)}`); |
| 295 | await page.click(`#panel-tags .ptag[data-panel="${c0.panel}"]`, { force: true }); |
| 296 | await 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. |
| 301 | await page.$$eval('.diamond-box', (els) => els[0].click()); |
| 302 | await page.waitForTimeout(600); |
| 303 | for (const b of await page.$$('.crystal-act')) { |
| 304 | if (((await b.textContent()) || '').includes('Tags')) { await b.click({ force: true }); break; } |
| 305 | } |
| 306 | const editorUp = await page.waitForSelector('.tag-editor', { timeout: 8000 }).catch(() => null); |
| 307 | if (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 | } |
| 311 | const 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 | }); |
| 315 | if (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 ────────────────────── |
| 332 | await page.keyboard.press('Escape'); |
| 333 | await page.waitForTimeout(250); |
| 334 | await page.evaluate(() => document.getElementById('guide-btn').focus()); |
| 335 | await page.keyboard.press('Control+k'); |
| 336 | await page.waitForSelector('#palette', { state: 'visible', timeout: 8000 }); |
| 337 | await page.waitForTimeout(400); |
| 338 | const 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 | }); |
| 351 | check(pal.role === 'dialog' && pal.modal === 'true' && !!pal.label, |
| 352 | 'the palette is role="dialog" aria-modal="true" with a name', JSON.stringify(pal.label)); |
| 353 | check(pal.inputRole === 'combobox' && pal.listRole === 'listbox' && pal.controls === 'pal-list', |
| 354 | 'its box is a combobox wired to its listbox', JSON.stringify(pal)); |
| 355 | check(pal.pointsAtSomething, |
| 356 | 'aria-activedescendant points at a list item that exists', pal.activedescendant); |
| 357 | |
| 358 | // ── 5. The theme pulldown announces its bands ─────────────────────── |
| 359 | await page.keyboard.press('Escape'); |
| 360 | await page.waitForTimeout(300); |
| 361 | await page.click('#settings-menu-btn', { force: true }); |
| 362 | await page.waitForTimeout(450); |
| 363 | const 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 | }); |
| 370 | check(!!theme && !!theme.label, 'the theme pulldown has an accessible name', JSON.stringify(theme && theme.label)); |
| 371 | check(!!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)); |
| 373 | check(!!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. |
| 377 | const 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 | })(); |
| 385 | check(!!themeAx && themeAx.length === 3 && themeAx.every(Boolean), |
| 386 | 'and Chrome exposes all three as named groups', JSON.stringify(themeAx)); |
| 387 | await page.keyboard.press('Escape'); |
| 388 | await page.waitForTimeout(350); |
| 389 | |
| 390 | // ── 6. Nothing NEW announces as a glyph ───────────────────────────── |
| 391 | const rows = await accNames(cdp); |
| 392 | const mute = speechless(rows); |
| 393 | const freshMute = mute.filter((r) => !GLYPHS.has(r.sig + '|' + r.name.trim())); |
| 394 | check(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); |
| 398 | note(`${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 ───────────────────────────────────────────── |
| 407 | const loud = await page.evaluate(LOUD_ICONS); |
| 408 | check(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 ─────────────────────────────────────────────────────── |
| 413 | const badImg = await page.evaluate(BAD_IMAGES); |
| 414 | const freshImg = badImg.filter((x) => !IMGS.has(x)); |
| 415 | check(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); |
| 418 | if (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 ─────────────────────────── |
| 425 | const st = await page.evaluate(STRUCTURE); |
| 426 | out.push(`---- structure: ${st.main} <main>, ${st.h1} <h1>, ${st.headings} visible headings, ` |
| 427 | + `${st.unnamedRegions.length} unnamed regions, ${st.live.length} live regions`); |
| 428 | if (!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 | } |
| 434 | if (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 | } |
| 439 | note('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. |
| 445 | await page.click('#settings-menu-btn', { force: true }); |
| 446 | await page.waitForTimeout(450); |
| 447 | check(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'); |
| 451 | await page.keyboard.press('Escape'); |
| 452 | await page.waitForTimeout(300); |
| 453 | |
| 454 | // ── 10. The modal surfaces that carry no semantics ────────────────── |
| 455 | await page.click('#new-diamond-btn', { force: true }); |
| 456 | await page.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 457 | await page.waitForTimeout(300); |
| 458 | const 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 | }); |
| 464 | if (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 | } |
| 474 | await page.keyboard.press('Escape'); |
| 475 | await page.waitForTimeout(300); |
| 476 | if (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 | } |
| 481 | if (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 ──────────────────── |
| 489 | out.push(''); |
| 490 | out.push('--- self-test: breaking each property and requiring the check to notice'); |
| 491 | const 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. |
| 494 | const before = speechless(await accNames(cdp)).length; |
| 495 | await 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 | }); |
| 502 | let mute2 = speechless(await accNames(cdp)); |
| 503 | red(mute2.some((r) => r.sig.includes('guide-btn')), |
| 504 | 'a control stripped of its label is caught by the accessibility tree'); |
| 505 | await 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 | }); |
| 511 | mute2 = speechless(await accNames(cdp)); |
| 512 | red(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. |
| 516 | await 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 | }); |
| 521 | await page.click('#settings-menu-btn', { force: true }); |
| 522 | await page.waitForTimeout(450); |
| 523 | const lying = await page.evaluate(() => ({ |
| 524 | says: document.getElementById('settings-menu-btn').getAttribute('aria-expanded'), |
| 525 | open: document.getElementById('settings-menu').hidden === false, |
| 526 | })); |
| 527 | red(lying.open && lying.says === 'false', |
| 528 | 'an aria-expanded that stops tracking is caught (menu open, attribute says false)'); |
| 529 | await page.evaluate(() => { |
| 530 | document.getElementById('settings-menu-btn').removeEventListener('click', window.__a11yFreeze); |
| 531 | delete window.__a11yFreeze; |
| 532 | }); |
| 533 | await page.keyboard.press('Escape'); |
| 534 | await page.waitForTimeout(350); |
| 535 | await page.click('#settings-menu-btn', { force: true }); |
| 536 | await page.waitForTimeout(450); |
| 537 | red(await page.getAttribute('#settings-menu-btn', 'aria-expanded') === 'true', |
| 538 | 'and it tracks again once the interference is removed'); |
| 539 | await page.keyboard.press('Escape'); |
| 540 | await page.waitForTimeout(350); |
| 541 | |
| 542 | // (c) Unhide one icon svg. |
| 543 | await page.evaluate(() => { |
| 544 | const g = document.querySelector('#new-diamond-btn svg'); |
| 545 | g.removeAttribute('aria-hidden'); |
| 546 | g.id = 'a11y-selftest-svg'; |
| 547 | }); |
| 548 | let loud2 = await page.evaluate(LOUD_ICONS); |
| 549 | red(loud2.some((x) => x.includes('new-diamond-btn')), 'an icon that is no longer hidden is caught'); |
| 550 | await page.evaluate(() => { |
| 551 | const g = document.getElementById('a11y-selftest-svg'); |
| 552 | g.setAttribute('aria-hidden', 'true'); g.removeAttribute('id'); |
| 553 | }); |
| 554 | loud2 = await page.evaluate(LOUD_ICONS); |
| 555 | red(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. |
| 560 | const clean = (list) => list.filter((x) => !IMGS.has(x)); |
| 561 | await page.evaluate(() => { |
| 562 | const i = document.querySelector('.brand-wordmark.wm-on-dark'); |
| 563 | i.dataset.a11ySave = i.getAttribute('alt'); |
| 564 | i.removeAttribute('alt'); |
| 565 | }); |
| 566 | let img2 = await page.evaluate(BAD_IMAGES); |
| 567 | red(img2.some((x) => x.includes('no alt attribute')), 'an <img> with no alt is caught'); |
| 568 | await page.evaluate(() => { |
| 569 | const i = document.querySelector('.brand-wordmark.wm-on-dark'); |
| 570 | i.setAttribute('alt', i.dataset.a11ySave); delete i.dataset.a11ySave; |
| 571 | }); |
| 572 | img2 = await page.evaluate(BAD_IMAGES); |
| 573 | red(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. |
| 577 | await page.evaluate(() => { |
| 578 | const i = document.querySelector('#chead-mark'); |
| 579 | i.dataset.a11ySaveAh = i.getAttribute('aria-hidden') || ''; |
| 580 | i.removeAttribute('aria-hidden'); |
| 581 | }); |
| 582 | img2 = await page.evaluate(BAD_IMAGES); |
| 583 | red(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'); |
| 585 | await page.evaluate(() => { |
| 586 | const i = document.querySelector('#chead-mark'); |
| 587 | i.setAttribute('aria-hidden', i.dataset.a11ySaveAh || 'true'); delete i.dataset.a11ySaveAh; |
| 588 | }); |
| 589 | img2 = await page.evaluate(BAD_IMAGES); |
| 590 | red(clean(img2).length === 0, 'and it passes again once both routes are back'); |
| 591 | |
| 592 | // (e) Take aria-modal off the palette. |
| 593 | await page.evaluate(() => document.getElementById('guide-btn').focus()); |
| 594 | await page.keyboard.press('Control+k'); |
| 595 | await page.waitForSelector('#palette', { state: 'visible', timeout: 8000 }); |
| 596 | await page.waitForTimeout(300); |
| 597 | await page.evaluate(() => document.querySelector('#palette .pal-box').removeAttribute('aria-modal')); |
| 598 | let 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 | }); |
| 602 | red(!(pal2.role === 'dialog' && pal2.modal === 'true' && !!pal2.label), |
| 603 | 'a modal that loses aria-modal is caught'); |
| 604 | await page.evaluate(() => document.querySelector('#palette .pal-box').setAttribute('aria-modal', 'true')); |
| 605 | pal2 = 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 | }); |
| 609 | red(pal2.role === 'dialog' && pal2.modal === 'true' && !!pal2.label, |
| 610 | 'and it is a labelled modal again once it is restored'); |
| 611 | await page.keyboard.press('Escape'); |
| 612 | await page.waitForTimeout(250); |
| 613 | |
| 614 | await s.close(); |
| 615 | |
| 616 | console.log(out.join('\n')); |
| 617 | if (known.length) { |
| 618 | console.log(`\nKNOWN DEFECTS — reported, not failed (see dev/a11y_report.md):\n - ${known.join('\n - ')}`); |
| 619 | } |
| 620 | console.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`); |
| 623 | process.exit(bad === 0 ? 0 : 1); |