Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_focus.mjs

10.8 KiB, 1 run

created by r2519314175:413, 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_focus.mjs — the keyboard can always find where it is, and cannot fall
2// out of a dialog that is covering the app.
3//
4// The third of the property searches, after verify_reversible (can a step be
5// taken back?) and verify_escapable (can a cover be got rid of?). This one asks
6// where the FOCUS is, which is the whole interface for anyone driving by
7// keyboard and half of it for everyone else:
8//
9// 1. When a dialog opens, focus lands inside it. Otherwise the first Tab goes
10// to the browser's own bar, and the second into the page BEHIND the modal.
11// 2. While it is open, Tab cannot leave it. A modal that lets Tab walk into
12// the app underneath is a modal only to the mouse: the caret ends up typing
13// into a chat box the user cannot see, behind a scrim they cannot click.
14// 3. When it closes, focus goes back to something. Left on <body>, the next
15// Tab starts again from the top of the document, and a keyboard user has to
16// walk the whole app to get back to the control they just used.
17//
18// Each is checked on its own, on a fresh session, so one failure does not
19// explain the next. A surface that cannot be reached is SKIPPED out loud.
20//
21// node dev/verify_focus.mjs
22// node dev/verify_focus.mjs 'Change passphrase'
23//
24// Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway.
25
26import fs from 'node:fs';
27import { open, scratch } from './harness.mjs';
28
29let failures = 0, skips = 0;
30const skipped = [];
31const check = (cond, msg, detail) => {
32 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : ''));
33 if (!cond) failures++;
34};
35const skip = (name, why) => {
36 console.log(' SKIP ' + name + ' — ' + why);
37 skipped.push(name + ': ' + why);
38 skips++;
39};
40
41/// Where a control is on screen, having scrolled it into view. Returns null
42/// when it is not there or has no box.
43const BOX_OF = ({ rootSel, text }) => {
44 const root = rootSel ? document.querySelector(rootSel) : document;
45 if (!root) return null;
46 const el = text
47 ? [...root.querySelectorAll('button')].find((x) => (x.textContent || '').trim() === text)
48 : root;
49 if (!el) return null;
50 el.scrollIntoView({ block: 'center', inline: 'center' });
51 const r = el.getBoundingClientRect();
52 if (!r.width || !r.height) return null;
53 return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
54};
55
56/// Press a control with the MOUSE, at its coordinates.
57///
58/// Not `element.click()`, which every other verifier here uses to get past the
59/// app's fades. A scripted `.click()` fires the handler but never moves the
60/// focus, so `document.activeElement` stays on the body -- and a dialog that
61/// dutifully restores the focus it found on opening then restores the body, and
62/// reads as broken. In a check ABOUT focus, only a real click will do.
63async function press(page, sel) {
64 await page.waitForSelector(sel, { timeout: 10000 });
65 const box = await page.evaluate(BOX_OF, { rootSel: sel, text: '' });
66 if (!box) throw new Error(`${sel} has no box to click`);
67 await page.mouse.click(box.x, box.y);
68 await page.waitForTimeout(400);
69}
70
71/// Press the control inside `rootSel` whose text is exactly `text`. Exact, not
72/// `:has-text`, which is a case-insensitive substring.
73async function pressLabel(page, rootSel, text) {
74 await page.waitForSelector(rootSel, { timeout: 10000 });
75 const box = await page.evaluate(BOX_OF, { rootSel, text });
76 if (!box) throw new Error(`no control labelled "${text}" in ${rootSel}`);
77 await page.mouse.click(box.x, box.y);
78 await page.waitForTimeout(450);
79}
80
81/// Where the focus is, as something readable, and whether it is inside `sel`.
82const WHERE = (sel) => {
83 const a = document.activeElement;
84 const root = document.querySelector(sel);
85 const name = !a ? '(none)'
86 : a.tagName + (a.id ? '#' + a.id : '')
87 + (a.className && typeof a.className === 'string' ? '.' + a.className.trim().split(/\s+/)[0] : '');
88 return {
89 name,
90 inside: !!(root && a && root.contains(a)),
91 onBody: !a || a === document.body || a === document.documentElement,
92 };
93};
94
95/// How many things inside the dialog can take focus. Tab is pressed a couple
96/// more times than that, so a trap that merely takes a long way round is still
97/// caught going past the end.
98const FOCUSABLE = (sel) => {
99 const root = document.querySelector(sel);
100 if (!root) return 0;
101 return root.querySelectorAll(
102 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]),'
103 + ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])').length;
104};
105
106// ── The dialogs, and what each is entitled to ───────────────────────────
107//
108// `trap: false` declares a surface that is deliberately NOT a focus trap, and
109// must say why. The Admin form is the one that earns it: the whole point of
110// putting Daimond's forms in the rail rather than in a modal is that the chat
111// stays live beside them, so a user can ask what an app password is and read the
112// answer while the box asking for one is on screen. Trapping focus in it would
113// undo exactly that.
114const DIALOGS = [
115 {
116 name: 'Identity gate (create)',
117 open: { connect: false, signIn: false },
118 reach: async () => {},
119 sel: '#identity-modal',
120 close: '#id-skip',
121 trap: true,
122 },
123 {
124 name: 'Prompt (Change name)',
125 open: { connect: false },
126 reach: async (page) => {
127 await press(page, '#user-row');
128 await pressLabel(page, '#admin-home', 'Change name…');
129 },
130 sel: '.modal.dlg .dlg-card',
131 close: '.modal.dlg .dlg-cancel',
132 trap: true,
133 // The control that opened it, which is where focus should come back to.
134 opener: 'Change name…',
135 },
136 {
137 name: 'Confirm (Forget this identity)',
138 open: { connect: false },
139 reach: async (page) => {
140 await press(page, '#user-row');
141 await pressLabel(page, '#admin-home', 'Forget this identity…');
142 },
143 sel: '.modal.dlg .dlg-card',
144 close: '.modal.dlg .dlg-cancel',
145 trap: true,
146 opener: 'Forget this identity…',
147 },
148 {
149 name: 'Change passphrase',
150 open: { connect: false },
151 reach: async (page) => {
152 await press(page, '#user-row');
153 await pressLabel(page, '#admin-home', 'Change passphrase…');
154 await page.waitForSelector('.dlg-card', { timeout: 8000 });
155 await press(page, '.dlg .dlg-input');
156 await page.keyboard.type('testpass1234');
157 await press(page, '.dlg .dlg-ok');
158 await page.waitForSelector('#cp-modal', { timeout: 8000 });
159 },
160 sel: '#cp-modal .dlg-card',
161 close: { label: 'Cancel', in: '#cp-modal' },
162 trap: true,
163 },
164 {
165 name: 'Pairing (Link another device)',
166 open: { connect: false },
167 reach: async (page) => {
168 await page.evaluate(() => window.DaimondPairing && DaimondPairing.showLink());
169 await page.waitForSelector('.pair-scrim', { timeout: 8000 });
170 await page.waitForTimeout(600);
171 },
172 sel: '.pair-box',
173 close: '.pair-box .ui-close',
174 trap: true,
175 },
176 {
177 name: 'Command palette',
178 open: { connect: false },
179 reach: async (page) => {
180 await page.keyboard.press('Control+k');
181 await page.waitForSelector('#palette', { state: 'visible', timeout: 8000 });
182 },
183 sel: '#palette',
184 close: null, // dismissed by clicking away; nothing to press
185 trap: true,
186 },
187 {
188 name: 'Admin form (Add a mailbox)',
189 open: { connect: false },
190 reach: async (page) => {
191 await page.evaluate(() => window.DaimondPanels && DaimondPanels.show('mail'));
192 await page.waitForTimeout(300);
193 await press(page, '#panel-mail [data-act="mail-add"]');
194 await page.waitForSelector('#admin-form .dlg-input', { timeout: 8000 });
195 },
196 sel: '#admin-form',
197 close: { label: 'Cancel', in: '#admin-form' },
198 trap: false,
199 why: 'the chat stays reachable beside it on purpose — that is why forms open in the rail',
200 },
201];
202
203const only = process.argv[2] || '';
204
205for (const d of DIALOGS) {
206 if (only && d.name !== only) continue;
207 console.log(`\n── ${d.name}`);
208
209 const start = async () => {
210 const dir = scratch('pw', 'foc-' + Math.random().toString(36).slice(2, 10));
211 const s = await open({ ...d.open, name: 'focus', profile: dir });
212 const inner = s.close;
213 s.close = async () => {
214 await inner();
215 try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* gone */ }
216 };
217 await d.reach(s.page);
218 await s.page.waitForSelector(d.sel, { timeout: 10000 });
219 await s.page.waitForTimeout(400);
220 return s;
221 };
222
223 // ── 1. Focus lands inside it ──
224 try {
225 const s = await start();
226 const w = await s.page.evaluate(WHERE, d.sel);
227 check(w.inside, 'focus lands inside it when it opens',
228 w.inside ? w.name : `focus is on ${w.name}, outside the dialog`);
229 await s.close();
230 } catch (e) {
231 skip(d.name + ' / opens focused', String(e && e.message ? e.message : e).split('\n')[0]);
232 }
233
234 // ── 2. Tab cannot walk out of it ──
235 try {
236 const s = await start();
237 const n = await s.page.evaluate(FOCUSABLE, d.sel);
238 let escapedAt = -1, escapedTo = '';
239 for (let i = 0; i < n + 3; i++) {
240 await s.page.keyboard.press('Tab');
241 await s.page.waitForTimeout(60);
242 const w = await s.page.evaluate(WHERE, d.sel);
243 if (!w.inside) { escapedAt = i + 1; escapedTo = w.name; break; }
244 }
245 if (d.trap) {
246 check(escapedAt === -1, `Tab stays inside it (${n} focusable, ${n + 3} presses)`,
247 escapedAt === -1 ? null : `Tab ${escapedAt} landed on ${escapedTo}, behind the dialog`);
248 } else {
249 check(escapedAt !== -1, 'Tab deliberately reaches the app behind it', d.why);
250 }
251 await s.close();
252 } catch (e) {
253 skip(d.name + ' / tab trap', String(e && e.message ? e.message : e).split('\n')[0]);
254 }
255
256 // ── 3. Closing gives the focus back ──
257 if (!d.close) {
258 console.log(' ---- focus on close: not checked, it has no close control');
259 } else {
260 try {
261 const s = await start();
262 if (typeof d.close === 'string') await press(s.page, d.close);
263 else await pressLabel(s.page, d.close.in, d.close.label);
264 await s.page.waitForTimeout(500);
265 const w = await s.page.evaluate(WHERE, d.sel);
266 check(!w.onBody, 'closing puts the focus back on something',
267 w.onBody ? 'focus was left on the document body, so the next Tab starts from the top of the app'
268 : `focus returned to ${w.name}`);
269 // Where it went is worth saying even when it went somewhere: back to the
270 // control that opened it is right, anywhere else is merely not wrong.
271 if (!w.onBody && d.opener) {
272 const back = await s.page.evaluate(() =>
273 ((document.activeElement || {}).textContent || '').trim());
274 if (back !== d.opener) {
275 console.log(` ---- note: focus returned to "${back}", not to "${d.opener}" which opened it`);
276 }
277 }
278 await s.close();
279 } catch (e) {
280 skip(d.name + ' / focus on close', String(e && e.message ? e.message : e).split('\n')[0]);
281 }
282 }
283}
284
285if (skipped.length) console.log('\nskipped: ' + skipped.join('; '));
286console.log(failures === 0
287 ? `\nfocus: nothing strands the keyboard${skips ? ` (${skips} SKIPPED)` : ''}.`
288 : `\nfocus: ${failures} failure(s)${skips ? `, ${skips} SKIPPED` : ''}.`);
289process.exit(failures === 0 && skips === 0 ? 0 : 1);