Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_escapable.mjs

14.9 KiB, 1 run

created by r2519314175:401, 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_escapable.mjs — nothing the app puts over the top of itself can trap you.
2//
3// A sibling of verify_reversible: same method, different property. That one asks
4// whether a control can be un-pressed; this one asks whether a thing that COVERS
5// the app can be got rid of, by the two means every user already knows —
6//
7// 1. Escape.
8// 2. The control the dialog itself offers for the purpose.
9//
10// Both, not either. A dialog with a Cancel button but no Escape stops a keyboard
11// user dead; a dialog that only answers to Escape leaves a pointer user hunting
12// for an X that is not there. And the Escape has to work from wherever the
13// pointer left the focus: a handler that only fires while focus is still inside
14// the dialog is a handler that stops working the moment the user clicks the
15// message text, which is a thing people do while reading it.
16//
17// Deliberate exceptions are declared, not assumed. The identity gate does NOT
18// answer Escape, on purpose: its dismiss control is "Skip for now" when creating
19// an account but "Forget this identity…" when unlocking one, and a stray Escape
20// must never reach that. So the gate is checked to STAY OPEN — an exception that
21// is asserted cannot rot into an accident.
22//
23// node dev/verify_escapable.mjs
24// node dev/verify_escapable.mjs 'Change passphrase' # one dialog
25//
26// Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway: every dialog here is
27// reachable without one, and any that is not is SKIPPED out loud rather than passed.
28
29import fs from 'node:fs';
30import { open, scratch, shot } from './harness.mjs';
31
32let failures = 0, skips = 0;
33const skipped = [];
34const check = (cond, msg, detail) => {
35 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : ''));
36 if (!cond) failures++;
37};
38const skip = (name, why) => {
39 console.log(' SKIP ' + name + ' — ' + why);
40 skipped.push(name + ': ' + why);
41 skips++;
42};
43
44/// Press a control through the DOM. The app's fades keep failing Playwright's
45/// actionability check, so a normal click can hang on a perfectly clickable
46/// button.
47async function press(page, sel) {
48 await page.waitForSelector(sel, { timeout: 10000 });
49 await page.evaluate((s) => { const e = document.querySelector(s); if (e) e.click(); }, sel);
50 await page.waitForTimeout(350);
51}
52
53/// Press the control inside `rootSel` whose text is exactly `text`. Exact, not
54/// Playwright's `:has-text`, which is a case-insensitive SUBSTRING and would
55/// press "Change name…" when asked for "Change passphrase…".
56async function pressLabel(page, rootSel, text) {
57 await page.waitForSelector(rootSel, { timeout: 10000 });
58 const hit = await page.evaluate(({ rootSel, text }) => {
59 const root = document.querySelector(rootSel);
60 if (!root) return false;
61 const b = [...root.querySelectorAll('button')].find((x) => (x.textContent || '').trim() === text);
62 if (!b) return false;
63 b.click();
64 return true;
65 }, { rootSel, text });
66 if (!hit) throw new Error(`no control labelled "${text}" in ${rootSel}`);
67 await page.waitForTimeout(400);
68}
69
70/// Is anything matching `sel` actually on screen? Removed, `display:none`,
71/// `hidden` and zero-sized all count as gone; a dialog that merely lost its
72/// backdrop has not closed.
73const IS_OPEN = (sel) => [...document.querySelectorAll(sel)]
74 .some((el) => el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden');
75
76// ── The things that cover the app ───────────────────────────────────────
77//
78// `reach` opens it, `sel` is what proves it is open, `close` is the control it
79// offers for getting rid of it (a selector, or `{ label, in }` for one found by
80// its words). `escape: false` declares a deliberate exception and must say why.
81const DIALOGS = [
82 {
83 name: 'Identity gate (create)',
84 open: { connect: false, signIn: false },
85 reach: async () => {},
86 sel: '#identity-modal',
87 close: '#id-skip',
88 // The gate's dismiss control is "Skip for now" while creating an account
89 // and "Forget this identity…" while unlocking one. Wiring Escape to it
90 // would put an irreversible erase one keystroke from a person who pressed
91 // Escape out of habit.
92 escape: false,
93 why: 'its dismiss control is destructive on the unlock screen',
94 },
95 {
96 name: 'Prompt (Change name)',
97 open: { connect: false },
98 reach: async (page) => {
99 await press(page, '#user-row');
100 await pressLabel(page, '#admin-home', 'Change name…');
101 },
102 sel: '.modal.dlg',
103 close: '.modal.dlg .dlg-cancel',
104 escape: true,
105 },
106 {
107 name: 'Confirm (Forget this identity)',
108 open: { connect: false },
109 reach: async (page) => {
110 await press(page, '#user-row');
111 await pressLabel(page, '#admin-home', 'Forget this identity…');
112 },
113 sel: '.modal.dlg',
114 close: '.modal.dlg .dlg-cancel',
115 escape: true,
116 },
117 {
118 name: 'Change passphrase',
119 open: { connect: false },
120 reach: async (page) => {
121 await press(page, '#user-row');
122 await pressLabel(page, '#admin-home', 'Change passphrase…');
123 await page.waitForSelector('.dlg-card', { timeout: 8000 });
124 await page.evaluate(() => { const i = document.querySelector('.dlg .dlg-input'); if (i) i.focus(); });
125 await page.keyboard.type('testpass1234');
126 await page.evaluate(() => { const b = [...document.querySelectorAll('.dlg .dlg-ok')].pop(); if (b) b.click(); });
127 await page.waitForSelector('#cp-modal', { timeout: 8000 });
128 },
129 sel: '#cp-modal',
130 close: { label: 'Cancel', in: '#cp-modal' },
131 escape: true,
132 },
133 {
134 // Not a modal, but it covers the rail and holds a form, and the same two
135 // ways out are the ones a user reaches for.
136 name: 'Admin form (Add a mailbox)',
137 open: { connect: false },
138 reach: async (page) => {
139 await page.evaluate(() => window.DaimondPanels && DaimondPanels.show('mail'));
140 await page.waitForTimeout(300);
141 await press(page, '#panel-mail [data-act="mail-add"]');
142 await page.waitForSelector('#admin-form .dlg-input', { timeout: 8000 });
143 },
144 sel: '#admin-form .dlg-actions',
145 close: { label: 'Cancel', in: '#admin-form' },
146 escape: true,
147 },
148 {
149 name: 'Pairing (Link another device)',
150 open: { connect: false },
151 reach: async (page) => {
152 await page.evaluate(() => window.DaimondPairing && DaimondPairing.showLink());
153 await page.waitForSelector('.pair-scrim', { timeout: 8000 });
154 },
155 sel: '.pair-scrim',
156 close: '.pair-box .ui-close',
157 escape: true,
158 },
159 {
160 name: 'Command palette',
161 open: { connect: false },
162 reach: async (page) => {
163 await page.keyboard.press('Control+k');
164 await page.waitForSelector('#palette', { state: 'visible', timeout: 8000 });
165 },
166 sel: '#palette',
167 close: '#pal-close',
168 escape: true,
169 },
170 {
171 name: 'Panel gallery',
172 open: { connect: false },
173 // The ⋯ tag, which is only in the row when a panel did not fit in it — and
174 // the row only fills up once the panels that wait for something to hold
175 // (Doc, Message, Compose) have joined it. So they are marked used first,
176 // which is what using them would do, and then the row is narrowed until it
177 // overflows. Without this the gallery is unreachable and the check would
178 // have nothing to say.
179 reach: async (page) => {
180 await page.evaluate(() => {
181 ['doc', 'msg', 'compose'].forEach((p) => {
182 try { DaimondPanels.markUsed(p); } catch (e) { /* not built yet */ }
183 });
184 try { DaimondPanels.reflow(); } catch (e) { /* nothing to reflow */ }
185 });
186 await page.waitForTimeout(500);
187 await press(page, '#panel-more');
188 await page.waitForSelector('#panel-gallery', { state: 'visible', timeout: 8000 });
189 },
190 sel: '#panel-gallery',
191 close: '#panel-gallery .ui-close',
192 escape: true,
193 viewport: { width: 900, height: 820 },
194 },
195 {
196 // Phase C's per-tile dialog. It carries Delete at its foot, so being able
197 // to get out of it without pressing anything is not a nicety: a user who
198 // opened it to look must be able to leave it the two ways they know.
199 name: 'Tile dialog (Diamond cog)',
200 // A model IS connected here: making a Diamond is how a tile comes to
201 // exist, and a rail with no tiles has no cog to press.
202 open: {},
203 reach: async (page) => {
204 await page.evaluate(() => { const b = document.getElementById('admin-close'); if (b) b.click(); });
205 await page.waitForTimeout(250);
206 await press(page, '#new-diamond-btn');
207 await page.waitForSelector('.dlg-card', { timeout: 8000 });
208 await page.evaluate(() => {
209 const card = [...document.querySelectorAll('.dlg-card')].find((c) => c.getClientRects().length);
210 const inp = card.querySelector('input.dlg-input');
211 inp.value = 'Escapable';
212 inp.dispatchEvent(new Event('input', { bubbles: true }));
213 card.querySelector('.dlg-ok').click();
214 });
215 await page.waitForSelector('#diamond-list .tile-cog', { timeout: 10000 });
216 await press(page, '#diamond-list .tile-cog');
217 await page.waitForSelector('.tile-dlg-card', { timeout: 8000 });
218 },
219 sel: '.tile-dlg-card',
220 close: '.tile-dlg-done',
221 escape: true,
222 },
223 {
224 // About: the splash, the wordmark, the build and the maker's badge. It is
225 // `openBodyDialog` with a hero above the heading, so it inherits this
226 // file's subject matter rather than reimplementing it — which is the
227 // reason to check it here rather than trusting that it does.
228 name: 'About',
229 open: { connect: false },
230 reach: async (page) => {
231 await press(page, '#about-btn');
232 await page.waitForSelector('.about-card', { timeout: 8000 });
233 },
234 sel: '.about-card',
235 close: '.about-card .tile-dlg-done',
236 escape: true,
237 },
238 {
239 name: 'Appearance menu',
240 open: { connect: false },
241 reach: async (page) => {
242 await press(page, '#settings-menu-btn');
243 await page.waitForSelector('#settings-menu', { state: 'visible', timeout: 8000 });
244 },
245 sel: '#settings-menu',
246 close: '#settings-menu .ui-close',
247 escape: true,
248 },
249];
250
251const only = process.argv[2] || '';
252
253for (const d of DIALOGS) {
254 if (only && d.name !== only) continue;
255 console.log(`\n── ${d.name}`);
256
257 // One session per probe: a dialog that was dismissed in an earlier check must
258 // not be able to explain a later one.
259 const start = async () => {
260 const dir = scratch('pw', 'esc-' + Math.random().toString(36).slice(2, 10));
261 const s = await open({ ...d.open, name: 'escapable', profile: dir });
262 // Some surfaces only exist at a size that forces them. The panel gallery is
263 // the overflow of the tag row, so a window wide enough to show every tag
264 // has no gallery at all.
265 if (d.viewport) { await s.page.setViewportSize(d.viewport); await s.page.waitForTimeout(400); }
266 const inner = s.close;
267 s.close = async () => {
268 await inner();
269 try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* gone */ }
270 };
271 await d.reach(s.page);
272 await s.page.waitForTimeout(300);
273 const up = await s.page.evaluate(IS_OPEN, d.sel);
274 if (!up) throw new Error(`${d.sel} did not open`);
275 return s;
276 };
277
278 // ── 1. Escape ──
279 try {
280 const s = await start();
281 await s.page.keyboard.press('Escape');
282 await s.page.waitForTimeout(500);
283 const stillUp = await s.page.evaluate(IS_OPEN, d.sel);
284 if (d.escape) {
285 check(!stillUp, 'Escape closes it', stillUp ? 'still on screen after Escape' : null);
286 } else {
287 check(stillUp, 'Escape deliberately does NOT close it', d.why);
288 }
289 if (stillUp && d.escape) await shot(s, 'escapable-' + d.name.replace(/\W+/g, '-').toLowerCase());
290 await s.close();
291 } catch (e) {
292 skip(d.name + ' / Escape', String(e && e.message ? e.message : e).split('\n')[0]);
293 }
294
295 // ── 2. Escape after the pointer has moved the focus ──
296 //
297 // Reading a dialog means clicking about in it. A key handler scoped to "focus
298 // is still inside the form" quietly stops working the moment that happens,
299 // and the dialog that answered Escape a second ago no longer does.
300 if (d.escape) {
301 try {
302 const s = await start();
303 // A REAL pointer click on the dialog's own body text. Not a synthetic
304 // focus() -- that would land focus on the element clicked and prove
305 // nothing. Clicking prose that cannot take focus leaves `activeElement`
306 // on the document body, which is exactly the state a handler scoped to
307 // "focus is still inside my form" stops firing in.
308 const box = await s.page.evaluate((sel) => {
309 const root = document.querySelector(sel);
310 const host = (root && root.closest('.modal, .pair-scrim, .admin-view')) || root;
311 const txt = host && (host.querySelector('.dlg-msg, p, h2, h3, .admin-title') || host);
312 if (!txt) return null;
313 const r = txt.getBoundingClientRect();
314 if (!r.width || !r.height) return null;
315 return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
316 }, d.sel);
317 if (!box) throw new Error('no text to click inside ' + d.sel);
318 await s.page.mouse.click(box.x, box.y);
319 await s.page.waitForTimeout(200);
320 const where = await s.page.evaluate(() => {
321 const a = document.activeElement;
322 return a ? (a.tagName + (a.id ? '#' + a.id : '')) : '(none)';
323 });
324 await s.page.keyboard.press('Escape');
325 await s.page.waitForTimeout(500);
326 const stillUp = await s.page.evaluate(IS_OPEN, d.sel);
327 check(!stillUp, 'Escape still closes it after clicking its text',
328 stillUp ? `focus was on ${where}, and the key handler only listens while the dialog holds it` : null);
329 await s.close();
330 } catch (e) {
331 skip(d.name + ' / Escape after click', String(e && e.message ? e.message : e).split('\n')[0]);
332 }
333 }
334
335 // ── 3. A way out for the pointer ──
336 //
337 // Either a control that says so, or dismissal by clicking away from it.
338 // Every surface here now carries a cross (see verify_closers, which is about
339 // nothing else), but the fallback stays: what this file asks is whether a
340 // user with no keyboard is stuck, and "the X went missing but a tap outside
341 // still works" is a different failure from "there is no way out at all".
342 try {
343 const s = await start();
344 let by = null;
345 if (d.close) {
346 if (typeof d.close === 'string') await press(s.page, d.close);
347 else await pressLabel(s.page, d.close.in, d.close.label);
348 await s.page.waitForTimeout(500);
349 if (!(await s.page.evaluate(IS_OPEN, d.sel))) by = 'its own close control';
350 }
351 if (!by) {
352 // Away from it, low and to the left, where a dialog never is.
353 await s.page.mouse.click(20, 900);
354 await s.page.waitForTimeout(500);
355 if (!(await s.page.evaluate(IS_OPEN, d.sel))) by = 'clicking away from it';
356 }
357 check(by !== null, 'a pointer can get rid of it', by
358 || 'no close control, and clicking away leaves it on screen');
359 if (!by) await shot(s, 'escapable-stuck-' + d.name.replace(/\W+/g, '-').toLowerCase());
360 await s.close();
361 } catch (e) {
362 skip(d.name + ' / pointer', String(e && e.message ? e.message : e).split('\n')[0]);
363 }
364}
365
366if (skipped.length) console.log('\nskipped: ' + skipped.join('; '));
367console.log(failures === 0
368 ? `\nescapable: everything that covers the app can be got rid of${skips ? ` (${skips} SKIPPED)` : ''}.`
369 : `\nescapable: ${failures} failure(s)${skips ? `, ${skips} SKIPPED` : ''}.`);
370process.exit(failures === 0 && skips === 0 ? 0 : 1);