oxedyne/daimond/dev/verify_railkeys.mjs
10.7 KiB, 1 run
created by r2519314175:625, 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_railkeys.mjs — a Diamond and a chat can be opened from the keyboard. |
| 2 | // |
| 3 | // Both were severity-1 findings (dev/a11y_report.md §1 and §2) and both were |
| 4 | // answered in the code afterwards, but nothing pressed the keys to say so: the |
| 5 | // KNOWN DEFECT notes in verify_a11y_keyboard went on reporting them as live for |
| 6 | // as long as the fixes had been shipped. A note left standing after its fix makes |
| 7 | // the fix look like a regression the next time anyone reads it, and a fix nobody |
| 8 | // tested is a fix nobody can rely on. This is the test. |
| 9 | // |
| 10 | // §1. A Diamond row was a <div> with a click handler whose only focusable child |
| 11 | // was the x that deletes it, so tabbing the rail reached "delete this |
| 12 | // Diamond" and never "open this Diamond": the destructive act had a keyboard |
| 13 | // route and the central one did not. |
| 14 | // §2. A chat's .tile-label is a readonly <input>, so Tab reached it -- but only a |
| 15 | // mouse click selected the chat, and Enter was mapped to blur(), which is the |
| 16 | // one thing that looks like it worked. |
| 17 | // |
| 18 | // Both are asserted by pressing the key and asking the APPLICATION what changed. |
| 19 | // Each is then proved red by breaking the property in the live page. |
| 20 | import { open, newChat } 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 | const red = (wentRed, what) => { |
| 29 | console.log((wentRed ? ' ok ' : ' FAIL ') + 'self-test: ' + what); |
| 30 | (wentRed ? ok : bad).push('self-test: ' + what); |
| 31 | }; |
| 32 | |
| 33 | // Connected to the mock: without a provider the composer never appears, so there |
| 34 | // is no second chat to switch to and nothing to press a key on. |
| 35 | const s = await open({ name: 'railkeys' }); |
| 36 | const p = s.page; |
| 37 | await p.waitForTimeout(1500); |
| 38 | |
| 39 | // ── §2. Two chats, so "the chat changed" has an answer ────────────── |
| 40 | await newChat(s); |
| 41 | // The second one goes through the buttons directly. `newChat` returns early when |
| 42 | // a composer is already visible, and driving it twice fights its own guard. |
| 43 | await p.click('#new-session-btn', { force: true }); |
| 44 | await p.waitForTimeout(500); |
| 45 | const start = p.locator('.tile-start').first(); |
| 46 | if (await start.count()) await start.click({ force: true }); |
| 47 | await p.waitForTimeout(900); |
| 48 | |
| 49 | const tiles = await p.evaluate(() => document.querySelectorAll('.tile-label').length); |
| 50 | check('two chats are on the page to switch between', tiles >= 2, `${tiles} tiles`); |
| 51 | |
| 52 | /// WHICH chat is currently open, by id. |
| 53 | /// |
| 54 | /// It read the centre header's TEXT, which worked only while every chat carried |
| 55 | /// a distinct auto-generated name. Chats have no names now — the rail derives a |
| 56 | /// relative time — so two chats made a second apart both read "the chat from |
| 57 | /// just now" and "the open chat changed" had no answer. The id is what the |
| 58 | /// question was always about, and the rail marks it with `.active`. |
| 59 | const current = () => p.evaluate(() => |
| 60 | ((document.querySelector('.session-box.chat-box.active') || {}).dataset || {}).id || ''); |
| 61 | |
| 62 | const before = await current(); |
| 63 | // Focus the tile of the chat that is NOT open, and press Enter on it. |
| 64 | const focused = await p.evaluate(() => { |
| 65 | const box = [...document.querySelectorAll('.session-box.chat-box')] |
| 66 | .find((e) => !e.classList.contains('active')); |
| 67 | const lab = box && box.querySelector('.tile-label'); |
| 68 | if (!lab) return null; |
| 69 | lab.focus(); |
| 70 | return document.activeElement === lab; |
| 71 | }); |
| 72 | check('the other chat\'s tile takes the focus', focused === true); |
| 73 | await p.keyboard.press('Enter'); |
| 74 | await p.waitForTimeout(800); |
| 75 | const afterEnter = await current(); |
| 76 | check('Enter on a chat tile opens that chat', afterEnter !== before && !!afterEnter, |
| 77 | `${JSON.stringify(before)} -> ${JSON.stringify(afterEnter)}`); |
| 78 | |
| 79 | // And Space, which a control that answers Enter must also answer. |
| 80 | await p.evaluate(() => { |
| 81 | const box = [...document.querySelectorAll('.session-box.chat-box')] |
| 82 | .find((e) => !e.classList.contains('active')); |
| 83 | const lab = box && box.querySelector('.tile-label'); |
| 84 | if (lab) lab.focus(); |
| 85 | }); |
| 86 | await p.keyboard.press(' '); |
| 87 | await p.waitForTimeout(800); |
| 88 | const afterSpace = await current(); |
| 89 | check('Space on a chat tile opens that chat too', afterSpace === before, |
| 90 | `back to ${JSON.stringify(afterSpace)}`); |
| 91 | |
| 92 | // ── §1. A Diamond row is the control ──────────────────────────────── |
| 93 | // Through the dialog a person uses: the + in the rail, a name, Create. |
| 94 | await p.evaluate(() => document.getElementById('new-diamond-btn').click()); |
| 95 | await p.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 96 | const made = await p.evaluate(() => { |
| 97 | const card = [...document.querySelectorAll('.dlg-card')].find((c) => c.getClientRects().length); |
| 98 | const inp = card && card.querySelector('input.dlg-input'); |
| 99 | if (!inp) return 'no name field'; |
| 100 | inp.value = 'Keyboard'; |
| 101 | inp.dispatchEvent(new Event('input', { bubbles: true })); |
| 102 | card.querySelector('.dlg-ok').click(); |
| 103 | return 'ok'; |
| 104 | }); |
| 105 | check('a Diamond could be made to test with', made === 'ok', made); |
| 106 | await p.waitForTimeout(1500); |
| 107 | |
| 108 | const rows = await p.evaluate(() => document.querySelectorAll('.diamond-box').length); |
| 109 | check('a Diamond row is on the page to be tested', rows > 0, `${rows} rows`); |
| 110 | |
| 111 | const shape = await p.evaluate(() => { |
| 112 | const row = document.querySelector('.diamond-box'); |
| 113 | if (!row) return null; |
| 114 | const FOCUS_SEL = 'a[href],button:not([disabled]),input:not([disabled]),' |
| 115 | + 'select:not([disabled]),textarea:not([disabled]),' |
| 116 | + '[tabindex]:not([tabindex="-1"]),summary,iframe,embed'; |
| 117 | return { |
| 118 | role: row.getAttribute('role'), |
| 119 | focusable: row.matches(FOCUS_SEL), |
| 120 | label: row.getAttribute('aria-label'), |
| 121 | // The cog that replaced the closer cross, and whether it says WHICH row |
| 122 | // it is on: five tiles must not announce as five identical "Settings". |
| 123 | delLabel: (row.querySelector('.tile-cog') || {}).ariaLabel || null, |
| 124 | }; |
| 125 | }); |
| 126 | if (shape) { |
| 127 | check('a Diamond row is a button to the accessibility tree', shape.role === 'button', `role=${shape.role}`); |
| 128 | check('a Diamond row is in the tab order', shape.focusable === true); |
| 129 | check('a Diamond row says its own name', !!shape.label && shape.label.trim().length > 0, |
| 130 | JSON.stringify(shape.label)); |
| 131 | // §5: two Diamonds must not present two identical controls. |
| 132 | check('the cog names the Diamond it would open the settings of', |
| 133 | !!shape.delLabel && shape.delLabel !== '×' && shape.delLabel.trim().length > 1, |
| 134 | JSON.stringify(shape.delLabel)); |
| 135 | } |
| 136 | |
| 137 | // Enter on the row selects it. With one Diamond the selection cannot move, so |
| 138 | // what is asserted is that the KEY reached the row's own click path. |
| 139 | const answered = await p.evaluate(() => { |
| 140 | const row = document.querySelector('.diamond-box'); |
| 141 | if (!row) return null; |
| 142 | let fired = false; |
| 143 | const spy = () => { fired = true; }; |
| 144 | row.addEventListener('click', spy); |
| 145 | row.focus(); |
| 146 | row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); |
| 147 | row.removeEventListener('click', spy); |
| 148 | return { fired, focused: document.activeElement === row }; |
| 149 | }); |
| 150 | if (answered) { |
| 151 | check('a Diamond row takes the focus', answered.focused === true); |
| 152 | check('Enter on a Diamond row acts on the row', answered.fired === true); |
| 153 | } |
| 154 | |
| 155 | // ── Proved red ────────────────────────────────────────────────────── |
| 156 | console.log(''); |
| 157 | |
| 158 | const r1 = rows === 0 ? null : await p.evaluate(() => { |
| 159 | const row = document.querySelector('.diamond-box'); |
| 160 | const was = row.getAttribute('tabindex'); |
| 161 | row.removeAttribute('tabindex'); |
| 162 | const seen = row.matches('[tabindex]:not([tabindex="-1"])'); |
| 163 | row.setAttribute('tabindex', was); |
| 164 | return seen; |
| 165 | }); |
| 166 | red(r1 === false, 'a Diamond row with its tabindex stripped fails the tab-order check'); |
| 167 | |
| 168 | // A clone keeps the attributes and loses the listeners: the row still LOOKS |
| 169 | // operable and answers nothing, which is the regression this file is for. |
| 170 | const r2 = await p.evaluate(() => { |
| 171 | const row = document.querySelector('.diamond-box'); |
| 172 | const twin = row.cloneNode(true); |
| 173 | row.parentNode.replaceChild(twin, row); |
| 174 | let fired = false; |
| 175 | twin.addEventListener('click', () => { fired = true; }); |
| 176 | twin.focus(); |
| 177 | twin.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); |
| 178 | return fired; |
| 179 | }); |
| 180 | red(r2 === false, 'a Diamond row that kept its ARIA but lost its keydown answers nothing'); |
| 181 | |
| 182 | // And the chat tile: THE LABEL IS A CONTROL, NOT A FIELD. |
| 183 | // |
| 184 | // This check replaces one that no longer means anything. The label used to be a |
| 185 | // readonly `<input>` that a double-click made editable, so Enter had two jobs — |
| 186 | // open the chat, or commit a rename — and the old self-test proved the second |
| 187 | // branch existed. The rename gesture has left the tile entirely (renaming a chat |
| 188 | // is now "Keep as a Diamond", with a plain rename two clicks in behind the cog), |
| 189 | // so the branch is gone and the old test could only ever pass: `readOnly` on a |
| 190 | // `<button>` sets a property nothing reads. |
| 191 | // |
| 192 | // What CAN regress is somebody putting the field back, at which point Tab |
| 193 | // through the rail lands in a text box again and the a11y §2 finding returns. So |
| 194 | // that is what is asserted: no tile carries an editable field, and the label is |
| 195 | // a real button. |
| 196 | const r3 = await p.evaluate(() => { |
| 197 | const box = [...document.querySelectorAll('.session-box.chat-box')].find(Boolean); |
| 198 | if (!box) return null; |
| 199 | const lab = box.querySelector('.tile-label'); |
| 200 | return { |
| 201 | isButton: !!lab && lab.tagName === 'BUTTON', |
| 202 | fields: box.querySelectorAll('input[type=text], input:not([type]), textarea').length, |
| 203 | }; |
| 204 | }); |
| 205 | check('a chat tile\'s label is a button, not a field to type in', |
| 206 | !!r3 && r3.isButton === true, JSON.stringify(r3)); |
| 207 | check('and the tile carries no editable field for Tab to land in', |
| 208 | !!r3 && r3.fields === 0, JSON.stringify(r3)); |
| 209 | // The same check, run against a tile that HAS the old field put back, so it is |
| 210 | // proved to notice rather than merely to pass. |
| 211 | const r3red = await p.evaluate(() => { |
| 212 | const box = [...document.querySelectorAll('.session-box.chat-box')].find(Boolean); |
| 213 | if (!box) return null; |
| 214 | const twin = box.cloneNode(true); |
| 215 | const lab = twin.querySelector('.tile-label'); |
| 216 | const inp = document.createElement('input'); |
| 217 | inp.className = 'tile-label'; |
| 218 | inp.readOnly = true; |
| 219 | if (lab) lab.replaceWith(inp); |
| 220 | return { |
| 221 | isButton: inp.tagName === 'BUTTON', |
| 222 | fields: twin.querySelectorAll('input[type=text], input:not([type]), textarea').length, |
| 223 | }; |
| 224 | }); |
| 225 | red(!!r3red && (r3red.isButton === false || r3red.fields > 0), |
| 226 | 'a tile with the old rename field put back fails both label checks'); |
| 227 | |
| 228 | console.log(''); |
| 229 | console.log(bad.length |
| 230 | ? `${bad.length} FAILED of ${ok.length + bad.length}:\n - ${bad.join('\n - ')}` |
| 231 | : `rail keyboard: all ${ok.length} checks pass — a Diamond and a chat both open from the keyboard.`); |
| 232 | await s.close(); |
| 233 | process.exit(bad.length ? 1 : 0); |