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 | |
| 26 | import fs from 'node:fs'; |
| 27 | import { open, scratch } from './harness.mjs'; |
| 28 | |
| 29 | let failures = 0, skips = 0; |
| 30 | const skipped = []; |
| 31 | const check = (cond, msg, detail) => { |
| 32 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 33 | if (!cond) failures++; |
| 34 | }; |
| 35 | const 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. |
| 43 | const 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. |
| 63 | async 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. |
| 73 | async 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`. |
| 82 | const 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. |
| 98 | const 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. |
| 114 | const 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 | |
| 203 | const only = process.argv[2] || ''; |
| 204 | |
| 205 | for (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 | |
| 285 | if (skipped.length) console.log('\nskipped: ' + skipped.join('; ')); |
| 286 | console.log(failures === 0 |
| 287 | ? `\nfocus: nothing strands the keyboard${skips ? ` (${skips} SKIPPED)` : ''}.` |
| 288 | : `\nfocus: ${failures} failure(s)${skips ? `, ${skips} SKIPPED` : ''}.`); |
| 289 | process.exit(failures === 0 && skips === 0 ? 0 : 1); |