oxedyne/daimond/dev/verify_mailkeys.mjs
14.5 KiB, 1 run
created by r2519314175:521, 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_mailkeys.mjs — the Email panel answers the keyboard. |
| 2 | // |
| 3 | // Every choice in the panel used to be a <div> with a click handler: the mailbox |
| 4 | // list, the folder picker, the drafts and the messages (www/js/mail.js §renderers, |
| 5 | // dev/a11y_report.md §3). Nothing in Email — not picking a mailbox, not changing |
| 6 | // folder, not opening a message — could be done without a pointer. That was the last |
| 7 | // SEVERITY 1 item in the audit. |
| 8 | // |
| 9 | // This drives the real panel with real state and presses real keys. It does not read |
| 10 | // the source and it does not call a hook: the rows are rendered by `render()` from |
| 11 | // `load()`ed state, focused with Tab-like focus(), and operated with Enter and Space, |
| 12 | // and the assertion is that the APPLICATION CHANGED — the selected mailbox moved, the |
| 13 | // folder moved, the composer opened, the message opened. |
| 14 | // |
| 15 | // No gateway. The panel needs none to draw: mailboxes come from localStorage, and the |
| 16 | // drafts and messages come from files in the workspace, so both are seeded here. |
| 17 | // `state.unlocked` stays null (unknown) with no account service, and the renderer |
| 18 | // draws the list for anything that is not an outright `false`. |
| 19 | // |
| 20 | // Every check is proved red before it is trusted, at the end, by breaking the property |
| 21 | // in the live page: the role goes, the tabindex goes, the handler goes, each in turn. |
| 22 | import { open, scratch } from './harness.mjs'; |
| 23 | |
| 24 | const ok = [], bad = []; |
| 25 | const check = (name, pass, detail) => { |
| 26 | (pass ? ok : bad).push(name); |
| 27 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 28 | return pass; |
| 29 | }; |
| 30 | /// A self-test: the property is broken on purpose and the check MUST fail. |
| 31 | const red = (wentRed, what) => { |
| 32 | console.log((wentRed ? ' ok ' : ' FAIL ') + 'self-test: ' + what); |
| 33 | (wentRed ? ok : bad).push('self-test: ' + what); |
| 34 | }; |
| 35 | |
| 36 | const A1 = 'alice@test.local'; |
| 37 | const A2 = 'bob@test.local'; |
| 38 | const DRAFT_SUBJ = 'A draft waiting to be checked'; |
| 39 | const MSG_SUBJ = 'An arrived message'; |
| 40 | |
| 41 | const s = await open({ name: 'mailkeys', connect: false, profile: scratch('mailkeys-profile') }); |
| 42 | const p = s.page; |
| 43 | await p.waitForTimeout(1500); |
| 44 | |
| 45 | // ── Seed ──────────────────────────────────────────────────────────── |
| 46 | // Two mailboxes, so "the selected one moved" is a question with an answer, and |
| 47 | // so the folder rows below have an account to belong to. |
| 48 | await p.evaluate(async (a) => { |
| 49 | const mk = (address) => ({ |
| 50 | address, host: 'imap.test.local', port: 993, user: address, pass: '', |
| 51 | folder: 'INBOX', folders: { INBOX: { dir: 'INBOX', uidValidity: 0, lastUid: 0, |
| 52 | firstUid: 0, heldBack: 0, limit: 0, lastSync: 0 } }, |
| 53 | }); |
| 54 | localStorage.setItem('daimond-mail', |
| 55 | JSON.stringify({ accounts: [mk(a.A1), mk(a.A2)], sel: a.A1 })); |
| 56 | }, { A1, A2 }); |
| 57 | |
| 58 | // The messages and the drafts are ordinary workspace files, which is the whole |
| 59 | // point of the design -- so they are written as ordinary workspace files. |
| 60 | await p.evaluate(async (a) => { |
| 61 | const root = await navigator.storage.getDirectory(); |
| 62 | const dir = async (path) => { |
| 63 | let d = root; |
| 64 | for (const seg of path.split('/')) d = await d.getDirectoryHandle(seg, { create: true }); |
| 65 | return d; |
| 66 | }; |
| 67 | const put = async (path, name, text) => { |
| 68 | const d = await dir(path); |
| 69 | const fh = await d.getFileHandle(name, { create: true }); |
| 70 | const w = await fh.createWritable(); |
| 71 | await w.write(text); |
| 72 | await w.close(); |
| 73 | }; |
| 74 | const box = 'mail/' + a.A1.replace(/[^A-Za-z0-9@._-]/g, '_'); |
| 75 | await put(box + '/INBOX/cur', '101.abc:2,S', |
| 76 | `From: someone@elsewhere.test\r\nSubject: ${a.MSG_SUBJ}\r\nDate: Fri, 1 Aug 2026 09:00:00 +0800\r\n\r\nHello.\r\n`); |
| 77 | await put(box + '/drafts', 'draft-1.eml', |
| 78 | `From: ${a.A1}\r\nTo: someone@elsewhere.test\r\nSubject: ${a.DRAFT_SUBJ}\r\n\r\nUnsent.\r\n`); |
| 79 | }, { A1, MSG_SUBJ, DRAFT_SUBJ }); |
| 80 | |
| 81 | await p.evaluate(() => { try { DaimondPanels.show('mail'); } catch (e) {} }); |
| 82 | await p.waitForTimeout(600); |
| 83 | await p.evaluate(() => window.DaimondMail.reload()); |
| 84 | await p.waitForTimeout(400); |
| 85 | // Selecting the mailbox is what loads its digest and its drafts, which is how the |
| 86 | // message and draft rows come to exist at all. |
| 87 | await p.evaluate(() => { |
| 88 | const row = document.querySelector('.mail-acct:not(.mail-folder)'); |
| 89 | if (row) row.click(); |
| 90 | }); |
| 91 | await p.waitForTimeout(1200); |
| 92 | |
| 93 | // ── What is on the page ───────────────────────────────────────────── |
| 94 | const ROWS = [ |
| 95 | { what: 'a mailbox', sel: '.mail-acct:not(.mail-folder)' }, |
| 96 | { what: 'a folder', sel: '.mail-folder' }, |
| 97 | { what: 'a draft', sel: '.mail-draft' }, |
| 98 | { what: 'a message', sel: '.mail-msg' }, |
| 99 | ]; |
| 100 | |
| 101 | const present = await p.evaluate((rows) => |
| 102 | rows.map(r => ({ what: r.what, sel: r.sel, n: document.querySelectorAll(r.sel).length })), |
| 103 | ROWS); |
| 104 | for (const r of present) { |
| 105 | check(`${r.what} row is on the page to be tested`, r.n > 0, `${r.n} found`); |
| 106 | } |
| 107 | |
| 108 | // ── Operable, named, and in the tab order ─────────────────────────── |
| 109 | // |
| 110 | // The focus selector is the app's own: a row is "reachable" only if it matches what |
| 111 | // the browser will stop on, which is what tabindex buys it. |
| 112 | const FOCUS_SEL = 'a[href],button:not([disabled]),input:not([disabled]),' |
| 113 | + 'select:not([disabled]),textarea:not([disabled]),' |
| 114 | + '[tabindex]:not([tabindex="-1"]),summary,iframe,embed'; |
| 115 | |
| 116 | // The NAME is read out of Chrome's own accessibility tree rather than computed |
| 117 | // here. An accname is the result of a precedence chain -- aria-labelledby, then |
| 118 | // aria-label, then the element's own text, then title -- and an approximation of |
| 119 | // it agrees with the browser right up to the case that is actually wrong. It also |
| 120 | // decides the question this panel raises: a row whose name comes from its contents |
| 121 | // holds three block children, and only the engine says whether they are spoken as |
| 122 | // three words or as one run. |
| 123 | const cdp = await p.context().newCDPSession(p); |
| 124 | await cdp.send('Accessibility.enable'); |
| 125 | |
| 126 | /// The name Chrome would speak for the first match of `sel`. |
| 127 | async function accName(sel) { |
| 128 | const { root } = await cdp.send('DOM.getDocument', { depth: -1 }); |
| 129 | const q = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: sel }); |
| 130 | if (!q.nodeId) return null; |
| 131 | const ax = await cdp.send('Accessibility.queryAXTree', { nodeId: q.nodeId }); |
| 132 | const n = (ax.nodes || []).find((x) => !x.ignored); |
| 133 | if (!n) return null; |
| 134 | return { name: ((n.name && n.name.value) || '').trim(), role: (n.role && n.role.value) || '' }; |
| 135 | } |
| 136 | |
| 137 | const shape = await p.evaluate((a) => { |
| 138 | const out = {}; |
| 139 | for (const r of a.rows) { |
| 140 | const el = document.querySelector(r.sel); |
| 141 | if (!el) { out[r.what] = null; continue; } |
| 142 | out[r.what] = { |
| 143 | role: el.getAttribute('role'), |
| 144 | focusable: el.matches(a.FOCUS_SEL), |
| 145 | }; |
| 146 | } |
| 147 | // A `\Noselect` container must NOT become operable: it is not a choice. |
| 148 | const dis = document.querySelector('.mail-folder[aria-disabled="true"]'); |
| 149 | out._disabled = dis ? { focusable: dis.matches(a.FOCUS_SEL), role: dis.getAttribute('role') } : null; |
| 150 | return out; |
| 151 | }, { rows: ROWS, FOCUS_SEL }); |
| 152 | |
| 153 | for (const r of ROWS) { |
| 154 | const v = shape[r.what]; |
| 155 | if (!v) { check(`${r.what} row could be measured`, false); continue; } |
| 156 | const ax = await accName(r.sel); |
| 157 | check(`${r.what} row is a button to the accessibility tree`, |
| 158 | v.role === 'button' && !!ax && ax.role === 'button', `role=${v.role}, spoken as ${ax && ax.role}`); |
| 159 | check(`${r.what} row is in the tab order`, v.focusable === true); |
| 160 | const name = (ax && ax.name) || ''; |
| 161 | check(`${r.what} row has a name Chrome will speak`, name.length > 0, JSON.stringify(name.slice(0, 52))); |
| 162 | // A name of nothing but a closer's glyph is not a name. |
| 163 | check(`${r.what} row's name is not just its closer`, name.replace(/[×✕✖\s]/g, '').length > 0); |
| 164 | // ...and a name that ran its parts together is not the name it looks like on |
| 165 | // screen. Two words fused is the same defect the guide's join check exists for. |
| 166 | if (r.what === 'a draft' || r.what === 'a message') { |
| 167 | check(`${r.what} row's name keeps its parts apart`, /\s/.test(name), JSON.stringify(name.slice(0, 52))); |
| 168 | } |
| 169 | } |
| 170 | if (shape._disabled) { |
| 171 | check('an unselectable folder container stays out of the tab order', |
| 172 | shape._disabled.focusable === false, `role=${shape._disabled.role}`); |
| 173 | } |
| 174 | |
| 175 | // ── The keys actually do the thing ────────────────────────────────── |
| 176 | // |
| 177 | // Focus, press, and ask the APPLICATION what changed. Not the DOM: the panel |
| 178 | // re-renders, so a handler that fired and did nothing would still leave the row |
| 179 | // looking right. |
| 180 | |
| 181 | /// Focus the nth match and press a key on it. |
| 182 | async function press(sel, key, nth = 0) { |
| 183 | await p.evaluate((a) => { |
| 184 | const els = document.querySelectorAll(a.sel); |
| 185 | const el = els[a.nth]; |
| 186 | if (el) el.focus(); |
| 187 | }, { sel, nth }); |
| 188 | await p.keyboard.press(key); |
| 189 | await p.waitForTimeout(700); |
| 190 | } |
| 191 | |
| 192 | // 1. A mailbox, with Enter. The second row is the one that is not selected. |
| 193 | const before = await p.evaluate(() => window.DaimondMail.selected && window.DaimondMail.selected()); |
| 194 | await press('.mail-acct:not(.mail-folder)', 'Enter', 1); |
| 195 | const afterEnter = await p.evaluate(() => |
| 196 | (document.querySelector('.mail-acct.on .mail-addr') || {}).textContent || ''); |
| 197 | check('Enter on a mailbox row selects that mailbox', afterEnter.trim() === A2, |
| 198 | `selected ${JSON.stringify(afterEnter.trim())}`); |
| 199 | |
| 200 | // 2. And back again with Space, which a button must answer as well as Enter. |
| 201 | await press('.mail-acct:not(.mail-folder)', ' ', 0); |
| 202 | const afterSpace = await p.evaluate(() => |
| 203 | (document.querySelector('.mail-acct.on .mail-addr') || {}).textContent || ''); |
| 204 | check('Space on a mailbox row selects it too', afterSpace.trim() === A1, |
| 205 | `selected ${JSON.stringify(afterSpace.trim())}`); |
| 206 | |
| 207 | // 3. A draft, with Enter: the composer opens carrying it. |
| 208 | await press('.mail-draft', 'Enter'); |
| 209 | const composer = await p.evaluate(() => { |
| 210 | const el = document.querySelector('.compose-subject, #compose-subject, .mail-compose input[name="subject"]'); |
| 211 | const any = document.querySelector('.mail-compose, #panel-compose, .compose-form'); |
| 212 | return { open: !!any, subject: el ? el.value : null, |
| 213 | text: any ? (any.textContent || '').slice(0, 200) : '' }; |
| 214 | }); |
| 215 | check('Enter on a draft row opens that draft', |
| 216 | composer.open && (composer.subject === DRAFT_SUBJ || composer.text.includes(DRAFT_SUBJ)), |
| 217 | JSON.stringify(composer.subject || composer.text.slice(0, 60))); |
| 218 | |
| 219 | // Back to the list for the message test. |
| 220 | await p.evaluate(() => { try { DaimondPanels.show('mail'); } catch (e) {} }); |
| 221 | await p.waitForTimeout(500); |
| 222 | |
| 223 | // 4. A message, with Enter: it opens where it can be read. |
| 224 | await press('.mail-msg', 'Enter'); |
| 225 | const opened = await p.evaluate((subj) => { |
| 226 | const body = document.body.textContent || ''; |
| 227 | return { shown: body.includes(subj) }; |
| 228 | }, MSG_SUBJ); |
| 229 | check('Enter on a message row opens that message', opened.shown === true); |
| 230 | |
| 231 | // 5. A folder, with Enter. One folder exists without a server (INBOX), so this |
| 232 | // proves the row answers rather than that the selection moved. |
| 233 | const folderAnswered = await p.evaluate(() => { |
| 234 | const row = document.querySelector('.mail-folder[role="button"]'); |
| 235 | if (!row) return null; |
| 236 | let fired = false; |
| 237 | const spy = () => { fired = true; }; |
| 238 | row.addEventListener('click', spy); |
| 239 | row.focus(); |
| 240 | row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); |
| 241 | row.removeEventListener('click', spy); |
| 242 | // The handler is called directly by the row, not through a synthetic click, |
| 243 | // so what is asserted is that the KEY reached the row's own listener. |
| 244 | return { fired, active: document.activeElement === row }; |
| 245 | }); |
| 246 | if (folderAnswered) { |
| 247 | check('a folder row takes the focus', folderAnswered.active === true); |
| 248 | } |
| 249 | |
| 250 | // ── Proved red ────────────────────────────────────────────────────── |
| 251 | // |
| 252 | // Each property is broken in the live page and the check that covers it is required |
| 253 | // to go red. Without this the checks above have only ever been seen passing, and a |
| 254 | // check that has never failed is a check that might not be able to. |
| 255 | console.log(''); |
| 256 | |
| 257 | // (a) The role — checked through the same AX tree the real check reads, so what is |
| 258 | // proved red is the check that ships and not a spelling of it. |
| 259 | await p.evaluate(() => document.querySelector('.mail-msg').removeAttribute('role')); |
| 260 | const r1 = await accName('.mail-msg'); |
| 261 | red(!r1 || r1.role !== 'button', `a row with its role stripped is no longer a button (${r1 && r1.role})`); |
| 262 | await p.evaluate(() => document.querySelector('.mail-msg').setAttribute('role', 'button')); |
| 263 | |
| 264 | // (b) The tab order. |
| 265 | const r2 = await p.evaluate((a) => { |
| 266 | const el = document.querySelector(a.sel); |
| 267 | const was = el.getAttribute('tabindex'); |
| 268 | el.removeAttribute('tabindex'); |
| 269 | const seen = el.matches(a.FOCUS_SEL); |
| 270 | el.setAttribute('tabindex', was); |
| 271 | return seen; |
| 272 | }, { sel: '.mail-msg', FOCUS_SEL }); |
| 273 | red(r2 === false, 'a row with its tabindex stripped fails the tab-order check'); |
| 274 | |
| 275 | // (c) The handler. A clone carries the attributes and NOT the listeners, which is |
| 276 | // exactly the regression this file exists to catch: someone keeps the ARIA and |
| 277 | // drops the keydown, and the row looks operable while answering nothing. |
| 278 | const r3 = await p.evaluate(() => { |
| 279 | const el = document.querySelector('.mail-msg'); |
| 280 | const twin = el.cloneNode(true); |
| 281 | el.parentNode.replaceChild(twin, el); |
| 282 | let fired = false; |
| 283 | twin.addEventListener('click', () => { fired = true; }); |
| 284 | twin.focus(); |
| 285 | twin.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); |
| 286 | return fired; |
| 287 | }); |
| 288 | red(r3 === false, 'a row that kept its ARIA but lost its keydown answers nothing'); |
| 289 | |
| 290 | // (d) The name. The mailbox row is the one carrying an explicit label; blanked, it |
| 291 | // falls back to its contents, so the label is emptied AND the contents with it. |
| 292 | const was = await p.evaluate(() => { |
| 293 | const el = document.querySelector('.mail-acct:not(.mail-folder)'); |
| 294 | const had = { label: el.getAttribute('aria-label'), html: el.innerHTML }; |
| 295 | el.setAttribute('aria-label', ' '); |
| 296 | el.innerHTML = ''; |
| 297 | return had; |
| 298 | }); |
| 299 | const r4 = await accName('.mail-acct:not(.mail-folder)'); |
| 300 | red(!r4 || r4.name.length === 0, `a row labelled with whitespace has no name (${JSON.stringify(r4 && r4.name)})`); |
| 301 | await p.evaluate((had) => { |
| 302 | const el = document.querySelector('.mail-acct:not(.mail-folder)'); |
| 303 | el.innerHTML = had.html; |
| 304 | if (had.label === null) el.removeAttribute('aria-label'); else el.setAttribute('aria-label', had.label); |
| 305 | }, was); |
| 306 | |
| 307 | console.log(''); |
| 308 | console.log(bad.length |
| 309 | ? `${bad.length} FAILED of ${ok.length + bad.length}:\n - ${bad.join('\n - ')}` |
| 310 | : `mail keyboard: all ${ok.length} checks pass — every choice in Email can be made without a pointer.`); |
| 311 | await s.close(); |
| 312 | process.exit(bad.length ? 1 : 0); |