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 | |
| 29 | import fs from 'node:fs'; |
| 30 | import { open, scratch, shot } from './harness.mjs'; |
| 31 | |
| 32 | let failures = 0, skips = 0; |
| 33 | const skipped = []; |
| 34 | const check = (cond, msg, detail) => { |
| 35 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 36 | if (!cond) failures++; |
| 37 | }; |
| 38 | const 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. |
| 47 | async 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…". |
| 56 | async 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. |
| 73 | const 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. |
| 81 | const 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 | |
| 251 | const only = process.argv[2] || ''; |
| 252 | |
| 253 | for (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 | |
| 366 | if (skipped.length) console.log('\nskipped: ' + skipped.join('; ')); |
| 367 | console.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` : ''}.`); |
| 370 | process.exit(failures === 0 && skips === 0 ? 0 : 1); |