Oregami
Repositories/oxedyne/daimond

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.
22import { open, scratch } from './harness.mjs';
23
24const ok = [], bad = [];
25const 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.
31const red = (wentRed, what) => {
32 console.log((wentRed ? ' ok ' : ' FAIL ') + 'self-test: ' + what);
33 (wentRed ? ok : bad).push('self-test: ' + what);
34};
35
36const A1 = 'alice@test.local';
37const A2 = 'bob@test.local';
38const DRAFT_SUBJ = 'A draft waiting to be checked';
39const MSG_SUBJ = 'An arrived message';
40
41const s = await open({ name: 'mailkeys', connect: false, profile: scratch('mailkeys-profile') });
42const p = s.page;
43await 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.
48await 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.
60await 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
81await p.evaluate(() => { try { DaimondPanels.show('mail'); } catch (e) {} });
82await p.waitForTimeout(600);
83await p.evaluate(() => window.DaimondMail.reload());
84await 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.
87await p.evaluate(() => {
88 const row = document.querySelector('.mail-acct:not(.mail-folder)');
89 if (row) row.click();
90});
91await p.waitForTimeout(1200);
92
93// ── What is on the page ─────────────────────────────────────────────
94const 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
101const present = await p.evaluate((rows) =>
102 rows.map(r => ({ what: r.what, sel: r.sel, n: document.querySelectorAll(r.sel).length })),
103 ROWS);
104for (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.
112const 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.
123const cdp = await p.context().newCDPSession(p);
124await cdp.send('Accessibility.enable');
125
126/// The name Chrome would speak for the first match of `sel`.
127async 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
137const 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
153for (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}
170if (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.
182async 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.
193const before = await p.evaluate(() => window.DaimondMail.selected && window.DaimondMail.selected());
194await press('.mail-acct:not(.mail-folder)', 'Enter', 1);
195const afterEnter = await p.evaluate(() =>
196 (document.querySelector('.mail-acct.on .mail-addr') || {}).textContent || '');
197check('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.
201await press('.mail-acct:not(.mail-folder)', ' ', 0);
202const afterSpace = await p.evaluate(() =>
203 (document.querySelector('.mail-acct.on .mail-addr') || {}).textContent || '');
204check('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.
208await press('.mail-draft', 'Enter');
209const 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});
215check('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.
220await p.evaluate(() => { try { DaimondPanels.show('mail'); } catch (e) {} });
221await p.waitForTimeout(500);
222
223// 4. A message, with Enter: it opens where it can be read.
224await press('.mail-msg', 'Enter');
225const opened = await p.evaluate((subj) => {
226 const body = document.body.textContent || '';
227 return { shown: body.includes(subj) };
228}, MSG_SUBJ);
229check('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.
233const 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});
246if (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.
255console.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.
259await p.evaluate(() => document.querySelector('.mail-msg').removeAttribute('role'));
260const r1 = await accName('.mail-msg');
261red(!r1 || r1.role !== 'button', `a row with its role stripped is no longer a button (${r1 && r1.role})`);
262await p.evaluate(() => document.querySelector('.mail-msg').setAttribute('role', 'button'));
263
264// (b) The tab order.
265const 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 });
273red(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.
278const 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});
288red(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.
292const 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});
299const r4 = await accName('.mail-acct:not(.mail-folder)');
300red(!r4 || r4.name.length === 0, `a row labelled with whitespace has no name (${JSON.stringify(r4 && r4.name)})`);
301await 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
307console.log('');
308console.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.`);
311await s.close();
312process.exit(bad.length ? 1 : 0);