oxedyne/daimond/dev/verify_reversible.mjs
22.5 KiB, 1 run
created by r2519314175:653, 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_reversible.mjs — every step a user can take, they can take back. |
| 2 | // |
| 3 | // This exists because of a defect no other verifier could have found. On the |
| 4 | // Create your account screen, "Choose my own passphrase instead" switched to a |
| 5 | // typed passphrase AND HID ITSELF, so there was no way back to the generated |
| 6 | // words short of abandoning account creation altogether. Anyone who clicked it |
| 7 | // to see what it did was stuck. Every scripted check passed throughout: they all |
| 8 | // assert that a named thing does a named thing, and nobody had thought to write |
| 9 | // "and you can change your mind", so nothing looked. |
| 10 | // |
| 11 | // The general shape of the bug is a ONE-WAY DOOR: a control that moves the |
| 12 | // interface to a new state and leaves no route back to the old one. That is a |
| 13 | // property of the interface rather than of any particular feature, so it can be |
| 14 | // searched for rather than enumerated by hand. |
| 15 | // |
| 16 | // How it searches. From a starting state it takes a DOM signature -- which |
| 17 | // containers are visible, which controls are on offer. Then, for each control in |
| 18 | // turn, on a FRESH page each time: click it, and if the signature changed, try |
| 19 | // every control in the new state to see whether any of them restores the |
| 20 | // original signature. If none does, the first control is a one-way door and is |
| 21 | // reported. Reloading between probes is what keeps each answer independent; |
| 22 | // clicking through one long session would let earlier clicks explain later ones. |
| 23 | // |
| 24 | // Controls that are MEANT to be one-way are named in `leaves` per surface -- |
| 25 | // submitting the form, skipping, cancelling, logging out, anything that hands |
| 26 | // off to the operating system. A door out is not a trap; a door that closes |
| 27 | // behind you inside the room is. |
| 28 | // |
| 29 | // node dev/verify_reversible.mjs |
| 30 | // node dev/verify_reversible.mjs 'Change passphrase' # one surface |
| 31 | // |
| 32 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs |
| 33 | // (DAIMOND_MOCK_PORT, default 9099). No gateway: these are gates, drawers, panels and |
| 34 | // dialogs only. |
| 35 | |
| 36 | import fs from 'node:fs'; |
| 37 | import { open, scratch } from './harness.mjs'; |
| 38 | |
| 39 | let failures = 0; |
| 40 | let skips = 0; |
| 41 | const check = (cond, msg, detail) => { |
| 42 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 43 | if (!cond) failures++; |
| 44 | }; |
| 45 | /// A surface that could not be reached is NOT a pass. It is announced, counted, |
| 46 | /// and printed again at the end, so a run that quietly stopped searching half |
| 47 | /// the app cannot read as a clean one. |
| 48 | const skipped = []; |
| 49 | /// `expected` marks a surface that declared what it needs and did not get it -- |
| 50 | /// the account service, say, which no gateway-free run has. It is still printed, |
| 51 | /// every time, in the body AND in the closing line; it just does not turn the run |
| 52 | /// red, because a permanently red run is one nobody reads. |
| 53 | const skip = (name, why, expected) => { |
| 54 | console.log(' SKIP ' + name + ' — ' + why); |
| 55 | skipped.push(name + ': ' + why); |
| 56 | if (!expected) skips++; |
| 57 | }; |
| 58 | |
| 59 | // ── The surfaces to search ────────────────────────────────────────────── |
| 60 | // |
| 61 | // `reach` drives the app the way a user does until the surface is on screen; |
| 62 | // `ready` is what proves it arrived; `root` is the region searched. `leaves` |
| 63 | // name the controls whose whole purpose is to leave, matched against a |
| 64 | // control's id OR its label, and `leavesRe` does the same for a family of |
| 65 | // labels that carry a name in them (an account row, say). |
| 66 | const SURFACES = [ |
| 67 | { |
| 68 | name: 'Create your account', |
| 69 | open: { connect: false, signIn: false }, |
| 70 | ready: '#id-primary', |
| 71 | root: '#identity-modal', |
| 72 | leaves: ['id-primary', 'id-skip'], |
| 73 | }, |
| 74 | { |
| 75 | name: 'Admin drawer', |
| 76 | open: { connect: false }, |
| 77 | // The identity row, not the cog: the cog TOGGLES, and with no model |
| 78 | // connected the app has already opened the drawer on Models by itself, so |
| 79 | // pressing it would shut the very drawer under test. The row always means |
| 80 | // "show me the account's controls". |
| 81 | reach: async (page) => { await click(page, '#user-row'); }, |
| 82 | ready: '#admin-home', |
| 83 | root: '#admin', |
| 84 | // Leaving the app (a download, an OS file picker, a new tab), leaving the |
| 85 | // account (log out, forget, switch), and the drawer's own ×. |
| 86 | leaves: [ |
| 87 | 'admin-close', |
| 88 | 'Log out', |
| 89 | 'Forget this identity…', |
| 90 | 'Export a backup', |
| 91 | 'Import a backup…', |
| 92 | '+ Add another account', |
| 93 | 'Daimond Dashboard ↗', |
| 94 | // The sync switch RELOADS THE APP, which is a hand-off in the same |
| 95 | // family as logging out. It is not a door that closes behind you: after |
| 96 | // the reload the same control in the same place reads the other way |
| 97 | // round, so the state is reversible even though the screen it was |
| 98 | // reversed from has gone. Both labels, because which one is shown |
| 99 | // depends on the state it is in. |
| 100 | 'Start without syncing', |
| 101 | 'Turn syncing back on', |
| 102 | ], |
| 103 | leavesRe: [/— switch$/], |
| 104 | }, |
| 105 | { |
| 106 | name: 'Models', |
| 107 | open: { connect: false }, |
| 108 | reach: async (page) => { await click(page, '#astat-model'); }, |
| 109 | ready: '#admin-models', |
| 110 | root: '#admin-models', |
| 111 | // The sync switch used to have a twin here (`cfg-sync-btn`); it was a |
| 112 | // duplicate of the account drawer's own switch and now lives there alone, |
| 113 | // so this surface has only the save-and-start hand-off to leave by. |
| 114 | leaves: ['byok-save'], |
| 115 | }, |
| 116 | { |
| 117 | name: 'Credits', |
| 118 | open: { connect: false }, |
| 119 | reach: async (page) => { await click(page, '#astat-account'); }, |
| 120 | ready: '#admin-credits', |
| 121 | root: '#admin-credits', |
| 122 | leaves: [], |
| 123 | // Balance, packs and the auto-reload switch are all drawn from what the |
| 124 | // account service answers. With no gateway the view correctly holds one |
| 125 | // sentence and no controls, so there is nothing to search. |
| 126 | needs: 'the account service', |
| 127 | }, |
| 128 | { |
| 129 | // The generated-passphrase dialog reached from Admin. It is the create |
| 130 | // screen's twin -- same words, same acknowledgement, same escape hatch -- |
| 131 | // so it is exactly where that screen's defect would be expected to have |
| 132 | // been copied. |
| 133 | name: 'Change passphrase', |
| 134 | open: { connect: false }, |
| 135 | reach: async (page) => { |
| 136 | await click(page, '#user-row'); |
| 137 | await clickLabel(page, '#admin-home', 'Change passphrase…'); |
| 138 | await page.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 139 | // The current passphrase is asked for first, and checked before the |
| 140 | // new one is offered, so the dialog under test is two steps in. |
| 141 | await page.evaluate(() => { |
| 142 | const i = document.querySelector('.dlg .dlg-input'); |
| 143 | if (i) { i.focus(); } |
| 144 | }); |
| 145 | await page.keyboard.type('testpass1234'); |
| 146 | await page.evaluate(() => { |
| 147 | const b = [...document.querySelectorAll('.dlg .dlg-ok')].pop(); |
| 148 | if (b) b.click(); |
| 149 | }); |
| 150 | }, |
| 151 | ready: '#cp-modal', |
| 152 | root: '#cp-modal', |
| 153 | leaves: ['Cancel', 'Change it'], |
| 154 | // The app's closer answers to "Close <the thing it closes>" now, not to a |
| 155 | // bare "Close": one cross per surface, each named after what it shuts, so |
| 156 | // a reader hears which of the app's crosses this is. A closer IS a one-way |
| 157 | // door and always was — that is what `leaves` exists to say — so it is |
| 158 | // named by the shape of its name rather than by a list that has to be |
| 159 | // edited every time a dialog is renamed. |
| 160 | leavesRe: [/^Close /], |
| 161 | }, |
| 162 | { |
| 163 | name: 'Email panel', |
| 164 | open: { connect: false }, |
| 165 | reach: async (page) => { |
| 166 | await page.evaluate(() => window.DaimondPanels && DaimondPanels.show('mail')); |
| 167 | }, |
| 168 | ready: '#panel-mail', |
| 169 | root: '#panel-mail', |
| 170 | leaves: ['Close panel'], |
| 171 | }, |
| 172 | { |
| 173 | // The Doc panel is empty until something is opened in it, so the search |
| 174 | // would otherwise find one control and learn nothing. A role prompt is |
| 175 | // the shortest real path to a document: Admin offers it, and the app |
| 176 | // seeds the file from the shipped default. |
| 177 | name: 'Doc panel', |
| 178 | open: { connect: false }, |
| 179 | reach: async (page) => { |
| 180 | await click(page, '#user-row'); |
| 181 | // The role is interpolated into `home.edit_prompt` with its own |
| 182 | // capitalisation ("Edit the Chat prompt…"), and clickLabel matches |
| 183 | // exactly -- so a lower-case spelling here quietly stopped opening the |
| 184 | // Doc panel, and this whole surface went unsearched while the run |
| 185 | // stayed green. The label is written as the app writes it. |
| 186 | await clickLabel(page, '#admin-home', 'Edit the Chat prompt…'); |
| 187 | await page.waitForSelector('#panel-doc .files-view-head', { timeout: 10000 }); |
| 188 | }, |
| 189 | ready: '#panel-doc .files-view-head', |
| 190 | root: '#panel-doc', |
| 191 | leaves: ['Download', '← Back', 'Close panel'], |
| 192 | commits: ['✔ Save', 'Saving…'], |
| 193 | }, |
| 194 | { |
| 195 | // Phase C's per-tile dialog. Simple and Max both have to be a door you |
| 196 | // can walk back through: a detail level you can raise and not lower is |
| 197 | // the one-way door this file exists to find, and this dialog is now the |
| 198 | // only place either is chosen. |
| 199 | name: 'Tile dialog (Diamond cog)', |
| 200 | open: {}, // a model is connected: a Diamond is what makes the tile |
| 201 | reach: async (page) => { |
| 202 | await page.evaluate(() => { const b = document.getElementById('admin-close'); if (b) b.click(); }); |
| 203 | await page.waitForTimeout(250); |
| 204 | await click(page, '#new-diamond-btn'); |
| 205 | await page.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 206 | await page.evaluate(() => { |
| 207 | const card = [...document.querySelectorAll('.dlg-card')].find((c) => c.getClientRects().length); |
| 208 | const inp = card.querySelector('input.dlg-input'); |
| 209 | inp.value = 'Reversible'; |
| 210 | inp.dispatchEvent(new Event('input', { bubbles: true })); |
| 211 | card.querySelector('.dlg-ok').click(); |
| 212 | }); |
| 213 | await page.waitForSelector('#diamond-list .tile-cog', { timeout: 10000 }); |
| 214 | await click(page, '#diamond-list .tile-cog'); |
| 215 | await page.waitForSelector('.tile-dlg-card', { timeout: 8000 }); |
| 216 | }, |
| 217 | ready: '.tile-dlg-card', |
| 218 | root: '.tile-dlg-card', |
| 219 | // Delete leaves by destroying the thing the dialog is about, and the closer |
| 220 | // leaves by shutting it. Neither is a door that closes behind you inside |
| 221 | // the room. The pause light is NOT here: it is a toggle, and it must be |
| 222 | // searched. |
| 223 | // |
| 224 | // `Done` is gone: seq 98 replaced the foot's Done button with a cross in |
| 225 | // the top right, so the way out is now `✕` and the accessible name `Close`. |
| 226 | // This list still said Done, so the closer was searched as though it were |
| 227 | // an ordinary control and reported as a one-way door -- which every closer |
| 228 | // is, and which is exactly what `leaves` exists to say. Both spellings are |
| 229 | // named because the control carries the glyph and the word. |
| 230 | leaves: ['Delete', 'Done', 'Close', '✕'], |
| 231 | // The app's closer answers to "Close <the thing it closes>" now, not to a |
| 232 | // bare "Close": one cross per surface, each named after what it shuts, so |
| 233 | // a reader hears which of the app's crosses this is. A closer IS a one-way |
| 234 | // door and always was — that is what `leaves` exists to say — so it is |
| 235 | // named by the shape of its name rather than by a list that has to be |
| 236 | // edited every time a dialog is renamed. |
| 237 | leavesRe: [/^Close /], |
| 238 | }, |
| 239 | ]; |
| 240 | |
| 241 | /// Every element a user could press, as one selector. Not just `button`: the |
| 242 | /// workspace and the tool rows offer chips that are spans wearing `.act`, and a |
| 243 | /// search that only knew about buttons would call those surfaces controlless. |
| 244 | const CTRL_SEL = 'button, [role="button"], .act'; |
| 245 | |
| 246 | /// What the interface is showing, reduced to something comparable: which |
| 247 | /// elements are on screen, and the names of the controls on offer. |
| 248 | /// |
| 249 | /// Deliberately NOT the field values: typing changes those without changing |
| 250 | /// where the user is, and a signature that moved every keystroke would call |
| 251 | /// every text box a new state. |
| 252 | /// |
| 253 | /// Visibility is `getClientRects()`, NOT `getComputedStyle(el).display`. |
| 254 | /// `display` is not inherited, so a button inside a `display:none` panel |
| 255 | /// computes as perfectly visible -- which is why the first run of the Admin |
| 256 | /// drawer offered the hidden provider form's buttons and then reported fifteen |
| 257 | /// one-way doors that were nothing of the kind. |
| 258 | const SIGNATURE = ({ rootSel, ctrlSel }) => { |
| 259 | const root = document.querySelector(rootSel); |
| 260 | if (!root) return { absent: true, vis: [], labels: [] }; |
| 261 | const shown = (el) => el.getClientRects().length > 0 |
| 262 | && getComputedStyle(el).visibility !== 'hidden'; |
| 263 | const vis = []; |
| 264 | root.querySelectorAll('[id]').forEach((el) => { if (shown(el)) vis.push(el.id); }); |
| 265 | // Every name a control answers to, joined -- NOT the first one that happens |
| 266 | // to be set. A button whose text goes "Edit" -> "✔ Save" keeps its title of |
| 267 | // "Edit" throughout, so a signature that stopped at the title could not see |
| 268 | // the panel change mode at all, and the search reported the surface clean. |
| 269 | const name = (el) => [ |
| 270 | el.id, |
| 271 | el.getAttribute('aria-label') || '', |
| 272 | el.getAttribute('title') || '', |
| 273 | el.getAttribute('data-act') || '', |
| 274 | (el.textContent || '').trim().replace(/\s+/g, ' '), |
| 275 | ].join('|'); |
| 276 | const labels = []; |
| 277 | root.querySelectorAll(ctrlSel).forEach((b) => { if (shown(b)) labels.push(name(b)); }); |
| 278 | return { absent: false, vis: vis.sort(), labels: labels.sort() }; |
| 279 | }; |
| 280 | |
| 281 | /// The controls a user could actually press right now. |
| 282 | /// |
| 283 | /// The label is what the user READS -- the button's own text first, and only |
| 284 | /// then `aria-label` or `title`, which is all an icon-only button has. Taking |
| 285 | /// the title first hid mode changes: the Doc panel's Edit button keeps |
| 286 | /// `title="Edit"` while its text becomes "Save", so the search saw one control |
| 287 | /// where a person sees two states. |
| 288 | const CONTROLS = ({ rootSel, ctrlSel }) => { |
| 289 | const root = document.querySelector(rootSel); |
| 290 | if (!root) return []; |
| 291 | const out = []; |
| 292 | root.querySelectorAll(ctrlSel).forEach((b, i) => { |
| 293 | if (b.disabled || !b.getClientRects().length) return; |
| 294 | if (getComputedStyle(b).visibility === 'hidden') return; |
| 295 | const label = (b.textContent || '').trim() || b.getAttribute('aria-label') |
| 296 | || b.getAttribute('title') || b.getAttribute('data-act') || ''; |
| 297 | out.push({ idx: i, id: b.id || '', label: label.replace(/\s+/g, ' ').slice(0, 60) }); |
| 298 | }); |
| 299 | return out; |
| 300 | }; |
| 301 | |
| 302 | /// The parts of a signature that move on their own. |
| 303 | /// |
| 304 | /// Some of what a panel shows is live: a credit balance, a byte count, whether |
| 305 | /// the account service answered this second. Those change with no click at all, |
| 306 | /// so a search that compared raw signatures would call every control a door. |
| 307 | /// Rather than hand-listing which rows are volatile -- a list that goes stale |
| 308 | /// the moment a row is added -- the noise floor is MEASURED: take the signature |
| 309 | /// twice with a pause between, and whatever moved on its own is excluded from |
| 310 | /// every later comparison, and said out loud. |
| 311 | function noiseBetween(a, b) { |
| 312 | const diff = (x, y) => { |
| 313 | const cx = new Map(), cy = new Map(); |
| 314 | x.forEach((v) => cx.set(v, (cx.get(v) || 0) + 1)); |
| 315 | y.forEach((v) => cy.set(v, (cy.get(v) || 0) + 1)); |
| 316 | const out = new Set(); |
| 317 | for (const k of new Set([...cx.keys(), ...cy.keys()])) { |
| 318 | if ((cx.get(k) || 0) !== (cy.get(k) || 0)) out.add(k); |
| 319 | } |
| 320 | return out; |
| 321 | }; |
| 322 | return { vis: diff(a.vis, b.vis), labels: diff(a.labels, b.labels) }; |
| 323 | } |
| 324 | |
| 325 | /// A signature as a comparable string, with the measured noise taken out. |
| 326 | function key(sig, noise) { |
| 327 | if (sig.absent) return 'ABSENT'; |
| 328 | return JSON.stringify({ |
| 329 | vis: sig.vis.filter((v) => !noise.vis.has(v)), |
| 330 | labels: sig.labels.filter((v) => !noise.labels.has(v)), |
| 331 | }); |
| 332 | } |
| 333 | |
| 334 | /// Click the nth control in the root. One argument only: page.evaluate takes a |
| 335 | /// single value, so the selector and the index travel together. |
| 336 | const CLICK_NTH = ({ rootSel, ctrlSel, idx }) => { |
| 337 | const root = document.querySelector(rootSel); |
| 338 | if (!root) return false; |
| 339 | const els = root.querySelectorAll(ctrlSel); |
| 340 | if (!els[idx]) return false; |
| 341 | els[idx].click(); |
| 342 | return true; |
| 343 | }; |
| 344 | |
| 345 | /// Press a control by selector, tolerating the app's fades (Playwright's |
| 346 | /// actionability check hangs on them, so this goes through the DOM). |
| 347 | async function click(page, sel) { |
| 348 | await page.waitForSelector(sel, { timeout: 10000 }); |
| 349 | await page.evaluate((s) => { const e = document.querySelector(s); if (e) e.click(); }, sel); |
| 350 | await page.waitForTimeout(350); |
| 351 | } |
| 352 | |
| 353 | /// Press the control inside `rootSel` whose text is exactly `text`. Exact, not |
| 354 | /// `:has-text`, which is a case-insensitive substring and would happily press |
| 355 | /// "Change name…" when asked for "Change passphrase…". |
| 356 | async function clickLabel(page, rootSel, text) { |
| 357 | await page.waitForSelector(rootSel, { timeout: 10000 }); |
| 358 | const hit = await page.evaluate(({ rootSel, text }) => { |
| 359 | const root = document.querySelector(rootSel); |
| 360 | if (!root) return false; |
| 361 | const b = [...root.querySelectorAll('button')] |
| 362 | .find((x) => (x.textContent || '').trim() === text); |
| 363 | if (!b) return false; |
| 364 | b.click(); |
| 365 | return true; |
| 366 | }, { rootSel, text }); |
| 367 | if (!hit) throw new Error(`no control labelled "${text}" in ${rootSel}`); |
| 368 | await page.waitForTimeout(400); |
| 369 | } |
| 370 | |
| 371 | /// Is this control declared as one that is meant to leave? |
| 372 | function isLeaf(surf, c) { |
| 373 | if ((surf.leaves || []).includes(c.id) || (surf.leaves || []).includes(c.label)) return true; |
| 374 | return (surf.leavesRe || []).some((re) => re.test(c.label)); |
| 375 | } |
| 376 | |
| 377 | /// Is this control one that COMMITS -- writes a file, spends money, changes the |
| 378 | /// account? Such a control may well put the screen back the way it was, but it |
| 379 | /// is not an undo, and letting one answer for the way home is how a search |
| 380 | /// declares "Edit, then Save" a round trip. They are barred from being the |
| 381 | /// route back; they are still probed as doors themselves. |
| 382 | function isCommit(surf, c) { |
| 383 | return (surf.commits || []).includes(c.id) || (surf.commits || []).includes(c.label); |
| 384 | } |
| 385 | |
| 386 | const only = process.argv[2] || ''; |
| 387 | |
| 388 | for (const surf of SURFACES) { |
| 389 | if (only && surf.name !== only) continue; |
| 390 | console.log(`\n── ${surf.name}`); |
| 391 | const arg = { rootSel: surf.root, ctrlSel: CTRL_SEL }; |
| 392 | |
| 393 | // A fresh session per probe: its own browser profile, so nothing a previous |
| 394 | // probe stored can explain this one's answer. |
| 395 | // |
| 396 | // The ACCOUNT NAME is deliberately constant across them. It was random per |
| 397 | // session, and the identity row prints it -- so every signature taken in one |
| 398 | // session differed from every signature taken in another by the name alone, |
| 399 | // and the Admin drawer reported all nineteen of its controls as one-way |
| 400 | // doors. A fresh profile is what independence needs; a fresh name was never |
| 401 | // part of it. |
| 402 | const start = async () => { |
| 403 | const dir = scratch('pw', 'rev-' + Math.random().toString(36).slice(2, 10)); |
| 404 | const s = await open({ ...surf.open, name: 'reversible', profile: dir }); |
| 405 | const inner = s.close; |
| 406 | // Profiles are megabytes each and a search opens hundreds of them; a run |
| 407 | // that left them behind would fill the scratch root by itself. |
| 408 | s.close = async () => { |
| 409 | await inner(); |
| 410 | try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* gone */ } |
| 411 | }; |
| 412 | if (surf.reach) await surf.reach(s.page); |
| 413 | await s.page.waitForSelector(surf.ready, { timeout: 15000 }); |
| 414 | await s.page.waitForTimeout(300); |
| 415 | return s; |
| 416 | }; |
| 417 | |
| 418 | let s0; |
| 419 | try { s0 = await start(); } |
| 420 | catch (e) { |
| 421 | skip(surf.name, 'could not be reached: ' + (e && e.message ? e.message : e)); |
| 422 | continue; |
| 423 | } |
| 424 | // The noise floor, measured before anything is pressed: whatever moves on |
| 425 | // its own over the same interval the search waits for. |
| 426 | const sig1 = await s0.page.evaluate(SIGNATURE, arg); |
| 427 | await s0.page.waitForTimeout(2600); |
| 428 | const sig2 = await s0.page.evaluate(SIGNATURE, arg); |
| 429 | const noise = noiseBetween(sig1, sig2); |
| 430 | const base = key(sig2, noise); |
| 431 | const initial = await s0.page.evaluate(CONTROLS, arg); |
| 432 | await s0.close(); |
| 433 | if (!initial.length) { |
| 434 | skip(surf.name, surf.needs |
| 435 | ? `nothing to search: it is drawn from ${surf.needs}, which is not running here` |
| 436 | : 'no controls found in ' + surf.root, !!surf.needs); |
| 437 | continue; |
| 438 | } |
| 439 | console.log(` ${initial.length} controls on offer: ${initial.map(c => c.label || c.id).join(' | ')}`); |
| 440 | if (noise.vis.size || noise.labels.size) { |
| 441 | console.log(' note: moving on its own, so not compared — ' |
| 442 | + [...noise.vis, ...noise.labels].map(s => JSON.stringify(s.slice(0, 40))).join(', ')); |
| 443 | } |
| 444 | |
| 445 | for (const c of initial) { |
| 446 | if (isLeaf(surf, c)) continue; |
| 447 | |
| 448 | const s = await start(); |
| 449 | const { page } = s; |
| 450 | await page.evaluate(CLICK_NTH, { ...arg, idx: c.idx }); |
| 451 | await page.waitForTimeout(250); |
| 452 | const after = key(await page.evaluate(SIGNATURE, arg), noise); |
| 453 | |
| 454 | if (after === base) { await s.close(); continue; } // changed nothing; not a door |
| 455 | |
| 456 | // Something changed -- but a control that reports on itself ("Copy" becomes |
| 457 | // "Copied" for two seconds) changes the screen without moving the user |
| 458 | // anywhere, and undoes itself on a timer rather than on a click. Give it |
| 459 | // long enough to put itself back before calling it a door, or every piece |
| 460 | // of transient feedback in the app reads as a trap. |
| 461 | await page.waitForTimeout(2600); |
| 462 | const settled = key(await page.evaluate(SIGNATURE, arg), noise); |
| 463 | if (settled === base) { await s.close(); continue; } |
| 464 | |
| 465 | // It moved somewhere. Is there a way home? Try each control now on offer -- |
| 466 | // each in its OWN session, from a fresh page. |
| 467 | // |
| 468 | // An earlier version tried them all in one session, clicking the original |
| 469 | // control again between candidates to "put the state back". That is wrong |
| 470 | // whenever the original control is a toggle, which is exactly the case |
| 471 | // worth testing: the restoring click moved the state on instead of back, so |
| 472 | // later candidates were answered from the wrong place. It reported a |
| 473 | // correct screen as broken, and credited an unrelated button with the |
| 474 | // escape. Isolation is cheaper to reason about than bookkeeping. |
| 475 | const now = await page.evaluate(CONTROLS, arg); |
| 476 | await s.close(); |
| 477 | |
| 478 | let home = null; |
| 479 | for (const back of now) { |
| 480 | if (isLeaf(surf, back) || isCommit(surf, back)) continue; |
| 481 | const t = await start(); |
| 482 | await t.page.evaluate(CLICK_NTH, { ...arg, idx: c.idx }); |
| 483 | await t.page.waitForTimeout(250); |
| 484 | await t.page.evaluate(CLICK_NTH, { ...arg, idx: back.idx }); |
| 485 | await t.page.waitForTimeout(350); |
| 486 | const sig = key(await t.page.evaluate(SIGNATURE, arg), noise); |
| 487 | await t.close(); |
| 488 | if (sig === base) { home = back; break; } |
| 489 | } |
| 490 | |
| 491 | check(home !== null, |
| 492 | `"${c.label || c.id}" can be undone`, |
| 493 | home ? `via "${home.label || home.id}"` : 'NOTHING on this screen returns to where the user was'); |
| 494 | } |
| 495 | } |
| 496 | |
| 497 | // A surface whose declared dependency is absent is reported loudly but does |
| 498 | // not fail the run: under run_all no account service ever exists, so treating |
| 499 | // it as a skip or a failure would keep the suite off green forever, for a |
| 500 | // reason the suite can never remove. The "SKIPPED:" token is reserved by |
| 501 | // run_all for a verifier that did not run at all -- do not print it here. |
| 502 | if (skipped.length) console.log('\nnot searchable here (dependency absent): ' + skipped.join('; ')); |
| 503 | console.log(failures === 0 |
| 504 | ? `\nreversible: every door on every searchable surface swings both ways${skips ? ` (${skips} unsearchable)` : ''}.` |
| 505 | : `\nreversible: ${failures} one-way door(s)${skips ? `, ${skips} surface(s) unsearchable` : ''}.`); |
| 506 | process.exit(failures === 0 ? 0 : 1); |