Oregami
Repositories/oxedyne/daimond

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
36import fs from 'node:fs';
37import { open, scratch } from './harness.mjs';
38
39let failures = 0;
40let skips = 0;
41const 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.
48const 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.
53const 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).
66const 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.
244const 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.
258const 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.
288const 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.
311function 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.
326function 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.
336const 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).
347async 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…".
356async 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?
372function 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.
382function isCommit(surf, c) {
383 return (surf.commits || []).includes(c.id) || (surf.commits || []).includes(c.label);
384}
385
386const only = process.argv[2] || '';
387
388for (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.
502if (skipped.length) console.log('\nnot searchable here (dependency absent): ' + skipped.join('; '));
503console.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` : ''}.`);
506process.exit(failures === 0 ? 0 : 1);