oxedyne/daimond/dev/verify_announce.mjs
6.2 KiB, 1 run
created by r2519314175:231, 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_announce.mjs — a screen reader is told when an answer arrives, once. |
| 2 | // |
| 3 | // dev/a11y_report.md §8: nothing that changes on its own is announced. The chat |
| 4 | // was the worst of it -- press Send and a screen-reader user hears nothing, ever, |
| 5 | // neither the arrival of an answer nor its end. |
| 6 | // |
| 7 | // The obvious fix is the wrong one, which is why this file exists to pin the |
| 8 | // right one. `aria-live` on the thread reads EVERY change, and an answer arrives |
| 9 | // a few characters at a time, so the reader would hear it re-read at them in |
| 10 | // fragments for as long as the model kept typing. What is checked here is |
| 11 | // therefore two things at once: |
| 12 | // |
| 13 | // 1. The answer IS announced, once per turn, with the word count. |
| 14 | // 2. The thread itself is NOT live -- because a future change that "improves" |
| 15 | // this by marking the thread live would pass a naive test and make the app |
| 16 | // unusable for exactly the person it was meant to help. |
| 17 | // |
| 18 | // And the two popovers, from the same round: both are role="dialog", both cover |
| 19 | // the app, and Tab used to walk out of them into the page behind. |
| 20 | import { open, newChat, chat as sendChat } from './harness.mjs'; |
| 21 | |
| 22 | const ok = [], bad = []; |
| 23 | const check = (name, pass, detail) => { |
| 24 | (pass ? ok : bad).push(name); |
| 25 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 26 | return pass; |
| 27 | }; |
| 28 | |
| 29 | const s = await open({ name: 'announce' }); |
| 30 | const p = s.page; |
| 31 | await p.waitForTimeout(1500); |
| 32 | await newChat(s); |
| 33 | |
| 34 | // ── 1. The thread is silent ───────────────────────────────────────── |
| 35 | const thread = await p.evaluate(() => { |
| 36 | const el = document.getElementById('chat-output'); |
| 37 | return el ? { |
| 38 | live: el.getAttribute('aria-live'), |
| 39 | role: el.getAttribute('role'), |
| 40 | } : null; |
| 41 | }); |
| 42 | check('the streaming thread is NOT a live region', !!thread && !thread.live && thread.role !== 'log', |
| 43 | JSON.stringify(thread)); |
| 44 | |
| 45 | // ── 2. There is a status line, and it starts empty ────────────────── |
| 46 | const before = await p.evaluate(() => { |
| 47 | const el = document.getElementById('chat-say'); |
| 48 | return el ? { live: el.getAttribute('aria-live'), role: el.getAttribute('role'), |
| 49 | text: (el.textContent || '').trim(), |
| 50 | painted: el.getClientRects().length > 0 && el.clientWidth > 1 } : null; |
| 51 | }); |
| 52 | check('there is a polite status line for the chat', !!before && before.live === 'polite', |
| 53 | JSON.stringify(before && before.live)); |
| 54 | check('it is a status role', !!before && before.role === 'status'); |
| 55 | check('it says nothing before a turn', !!before && before.text === '', |
| 56 | JSON.stringify(before && before.text)); |
| 57 | check('and it is not drawn on screen', !!before && before.painted === false); |
| 58 | |
| 59 | // ── 3. One turn, one announcement, with the count ─────────────────── |
| 60 | await sendChat(s, '@text One two three four five'); |
| 61 | await p.waitForTimeout(1200); |
| 62 | |
| 63 | const said = await p.evaluate(() => |
| 64 | (document.getElementById('chat-say').textContent || '').trim()); |
| 65 | check('the answer is announced when it lands', /answered/i.test(said), JSON.stringify(said)); |
| 66 | const n = (said.match(/(\d+)/) || [])[1]; |
| 67 | check('the announcement carries a word count', !!n && Number(n) > 0, String(n)); |
| 68 | |
| 69 | // It must match what was actually said, not a guess. |
| 70 | const words = await p.evaluate(() => { |
| 71 | const msgs = [...document.querySelectorAll('.chat-msg-content')]; |
| 72 | const last = msgs[msgs.length - 1]; |
| 73 | return last ? (last.textContent || '').trim().split(/\s+/).filter(Boolean).length : 0; |
| 74 | }); |
| 75 | check('the count is the answer\'s own length', Math.abs(Number(n) - words) <= 1, |
| 76 | `announced ${n}, on screen ${words}`); |
| 77 | |
| 78 | // ── 4. A second turn of the same length still announces ───────────── |
| 79 | // |
| 80 | // The trap in a status line: writing the identical string is not a change, so the |
| 81 | // second announcement would be silent. This is the case that catches it. |
| 82 | await sendChat(s, '@text One two three four five'); |
| 83 | await p.waitForTimeout(1200); |
| 84 | const second = await p.evaluate(() => { |
| 85 | const el = document.getElementById('chat-say'); |
| 86 | return { text: (el.textContent || '').trim() }; |
| 87 | }); |
| 88 | check('a second answer of the same length is still announced', |
| 89 | /answered/i.test(second.text), JSON.stringify(second.text)); |
| 90 | |
| 91 | // ── 5. The two popovers hold Tab ──────────────────────────────────── |
| 92 | // |
| 93 | // Walked, not read: open the popover, press Tab past its last stop, and ask |
| 94 | // whether the focus is still inside it. |
| 95 | async function trapped(openSel, popSel, label) { |
| 96 | await p.evaluate((sel) => { |
| 97 | const b = document.querySelector(sel); |
| 98 | if (b) b.click(); |
| 99 | }, openSel); |
| 100 | await p.waitForTimeout(500); |
| 101 | const stops = await p.evaluate((sel) => { |
| 102 | const pop = document.querySelector(sel); |
| 103 | if (!pop || pop.hidden) return 0; |
| 104 | return pop.querySelectorAll('input,button,select,textarea,a[href],[tabindex]:not([tabindex="-1"])').length; |
| 105 | }, popSel); |
| 106 | if (!stops) { check(`${label} opened so its Tab can be walked`, false, '0 stops'); return; } |
| 107 | for (let i = 0; i < stops + 3; i++) await p.keyboard.press('Tab'); |
| 108 | const inside = await p.evaluate((sel) => { |
| 109 | const pop = document.querySelector(sel); |
| 110 | return !!pop && pop.contains(document.activeElement); |
| 111 | }, popSel); |
| 112 | check(`${label} keeps Tab inside it (${stops} stops, walked ${stops + 3})`, inside === true); |
| 113 | await p.keyboard.press('Escape'); |
| 114 | await p.waitForTimeout(300); |
| 115 | } |
| 116 | |
| 117 | await trapped('#settings-menu-btn', '#settings-menu', 'the appearance menu'); |
| 118 | // The ⋯ that opens the gallery exists only when the chip row has had to hide |
| 119 | // something, so the window is narrowed until it does. Opening the gallery by |
| 120 | // reaching into the module would test a door nobody walks through. |
| 121 | await p.setViewportSize({ width: 900, height: 800 }); |
| 122 | await p.waitForTimeout(600); |
| 123 | const haveMore = await p.evaluate(() => !!document.getElementById('panel-more')); |
| 124 | check('the chip row hid something, so the gallery has an opener', haveMore === true); |
| 125 | if (haveMore) await trapped('#panel-more', '#panel-gallery', 'the panel gallery'); |
| 126 | |
| 127 | console.log(''); |
| 128 | console.log(bad.length |
| 129 | ? `${bad.length} FAILED of ${ok.length + bad.length}:\n - ${bad.join('\n - ')}` |
| 130 | : `announce: all ${ok.length} checks pass — one announcement per answer, and the popovers hold Tab.`); |
| 131 | await s.close(); |
| 132 | process.exit(bad.length ? 1 : 0); |