Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_reopen.mjs

12.1 KiB, 1 run

created by r2519314175:649, 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_reopen.mjs — a reload comes back where you were, whichever it was.
2//
3// ── WHY ──────────────────────────────────────────────────────────────────────
4//
5// A boot restored the CHAT you were reading and dropped the DIAMOND you were
6// in. Open a Diamond, talk to its daimon, reload: the stage came back as an
7// empty "+ New chat" on the crystal face, with `DaimondDiamond.current()` null.
8// The Diamond was still in the rail — you just had to find it and click it
9// again, every reload, on the surface Daimond is developed from.
10//
11// It was never a policy. `renderAll` read `daimond-open-chat` and looked it up
12// with `c => !c.diamondId && c.id === want`, so a daimon's record could not be
13// the answer and no key held a Diamond at all. The comment beside it already
14// made the whole argument for restoring — "on a phone, where resuming the app
15// is a boot, that reads as the transcript you were reading having vanished" —
16// and the argument was simply never carried across to the other half of the
17// rail.
18//
19// ── WHAT IT ASSERTS, WHICH IS THE PROPERTY AND NOT THE FIX ───────────────────
20//
21// Not "a Diamond is restored". The property is that **whichever of the two was
22// selected last is the one that comes back**, so the two keys cannot both claim
23// the answer. That is why the chat arm is here too: a fix that restored the
24// Diamond unconditionally would pass a Diamond-only test and lose the chat of
25// anybody who opened a Diamond once, a fortnight ago.
26//
27// node dev/verify_reopen.mjs
28// node dev/verify_reopen.mjs --break nodiamond # the defect, restored
29// node dev/verify_reopen.mjs --break nochatclear # the two keys both claiming
30//
31// A `--break` run EXPECTS to fail: exit 0 when something reddened, 1 when
32// nothing did, because a break that changes nothing is itself a failing run.
33//
34// Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway.
35
36import fs from 'node:fs';
37import path from 'node:path';
38import { fileURLToPath } from 'node:url';
39import { open, signInAs, shot, errors } from './harness.mjs';
40
41const HERE = path.dirname(fileURLToPath(import.meta.url));
42const WWW = path.join(HERE, '..', 'www');
43
44const ok = [], bad = [];
45const check = (name, pass, detail) => {
46 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
47 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
48};
49const sleep = ms => new Promise(r => setTimeout(r, ms));
50
51// ── The breaks ───────────────────────────────────────────────────────────────
52//
53// Each one is scoped to survive every check but the one it proves. `nodiamond`
54// restores the original defect exactly: the key is still written, and nothing
55// reads it. `nochatclear` leaves the restore in and stops `selectChat` clearing
56// the Diamond key, which is the state where both keys are set and the Diamond
57// always wins — the failure a Diamond-only test would not have seen.
58const BREAK = (() => { const i = process.argv.indexOf('--break'); return i > 0 ? process.argv[i + 1] : ''; })();
59const BREAKS = {
60 nodiamond: [{
61 file: 'js/daimond.js',
62 find: " if (!wantF) return;",
63 with: " if (!wantF) return;\n return; // --break nodiamond",
64 }],
65 nochatclear: [{
66 file: 'js/daimond.js',
67 find: " localStorage.removeItem(OPEN_DIAMOND_KEY); // a chat is where you are now",
68 with: " /* --break nochatclear */",
69 }],
70};
71
72function damagedFiles() {
73 const byFile = new Map();
74 for (const spec of (BREAKS[BREAK] || [])) {
75 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
76 if (!src.includes(spec.find)) {
77 // A break whose anchor is not there patches nothing and launders a
78 // plain run as proof. Loud, and fatal.
79 console.error(`--break ${BREAK}: anchor not found in ${spec.file}. The break is stale.`);
80 process.exit(1);
81 }
82 byFile.set(spec.file, src.replace(spec.find, spec.with));
83 }
84 return byFile;
85}
86
87async function serveBreaks(page) {
88 if (!BREAK) return;
89 for (const [file, body] of damagedFiles()) {
90 await page.route('**/' + file, r => r.fulfill({
91 status: 200, contentType: 'application/javascript', body,
92 }));
93 }
94}
95
96// ── Where the app thinks it is ───────────────────────────────────────────────
97const where = (p) => p.evaluate(() => {
98 const d = window.DaimondDiamond && window.DaimondDiamond.current
99 ? window.DaimondDiamond.current() : null;
100 let openChat = '', openDiamond = '';
101 try { openChat = localStorage.getItem('daimond-open-chat') || ''; } catch (e) {}
102 try { openDiamond = localStorage.getItem('daimond-open-diamond') || ''; } catch (e) {}
103 return {
104 diamond: d ? (d.id || '') : '',
105 name: d ? (d.name || '') : '',
106 keys: { openChat, openDiamond },
107 };
108});
109
110// `connect: false` -- nothing here talks to a model, and it drops the
111// requirement for a mock this run owns.
112const s = await open({ name: 'reopen', route: serveBreaks, connect: false });
113const p = s.page;
114
115// A RELOAD IS A LOCK. `boot()` finds the stored identity and returns before
116// `renderAll` ever runs, so the restore happens after the passphrase, not
117// before it — which is also what the user meets. A reload that only waits for
118// `__DAIMOND_READY` is looking at the lock screen and reads every restore as
119// absent.
120async function reboot() {
121 await p.reload({ waitUntil: 'domcontentloaded' });
122 await until(() => p.evaluate(() => window.__DAIMOND_READY === true), 40000);
123 // Sign in again if the lock is up, and TRY MORE THAN ONCE. Under load the
124 // unlock has been seen to need a second go, and the first shape of this
125 // function threw on the wait that followed -- taking the whole run with it and
126 // attributing the failure to no check at all.
127 for (var i = 0; i < 3; i++) {
128 if (!(await p.evaluate(() => document.body.classList.contains('locked')))) break;
129 try { await signInAs(s, 'reopen'); } catch (e) { /* the button may have gone already */ }
130 if (await until(() => p.evaluate(
131 () => !document.body.classList.contains('locked')), 20000)) break;
132 }
133 // Not asserted here: a lock that never lifts is the business of the checks
134 // below, which say what they were looking for and what they found instead.
135}
136
137/// Poll a predicate to a deadline. Answers whether it came true, and NEVER throws:
138/// a timeout in a helper is a crash with no check attached to it, which is exactly
139/// how this file failed in the gate while passing locally.
140async function until(fn, ms) {
141 const stop = Date.now() + ms;
142 while (Date.now() < stop) {
143 try { if (await fn()) return true; } catch (e) { /* mid-navigation */ }
144 await sleep(250);
145 }
146 return false;
147}
148
149/// Poll until `want()` matches, then hand back what it last saw.
150///
151/// NOT `page.waitForFunction`, which throws — and a throw here killed the whole
152/// run in the gate's world while passing locally, turning a slow boot into a
153/// crash with no check attributed to it. The restore hangs off the end of an
154/// async step, so it is genuinely a race; what it must never be is a race that
155/// takes the process with it. A deadline that expires is a RED CHECK with the
156/// last state printed, which is a fact somebody can act on.
157async function settle(want, ms = 30000) {
158 const until = Date.now() + ms;
159 let last = await where(p);
160 while (Date.now() < until) {
161 if (want(last)) return last;
162 await sleep(250);
163 last = await where(p);
164 }
165 return last;
166}
167
168// ── 0. A chat exists FIRST, because that is the world a real user is in ──────
169//
170// This is the ordering the gate caught and this file originally missed. `boot`
171// restores the chat BEFORE the rail is read, and restoring a chat forgets the
172// Diamond -- so a boot with any stored chat lost the Diamond and a boot with
173// none did not. Locally there was no chat yet and every check passed; in the
174// gate's world there was one and 1c failed honestly. Making the chat first is
175// what stops this file passing by fixture ordering rather than by behaviour.
176await newChatOnRail();
177async function newChatOnRail() {
178 await p.evaluate(() => {
179 const b = document.getElementById('new-session-btn');
180 if (b) b.click();
181 });
182 // A deadline that expires is a red check, never a thrown stack: see `settle`.
183 const until = Date.now() + 15000;
184 while (Date.now() < until) {
185 const n = await p.evaluate(() => document.querySelectorAll('#session-list [data-id]').length);
186 if (n > 0) return true;
187 await sleep(250);
188 }
189 return false;
190}
191check('0z a chat is on the rail before the Diamond is opened',
192 (await p.evaluate(() => document.querySelectorAll('#session-list [data-id]').length)) > 0);
193
194// ── 1. A Diamond ─────────────────────────────────────────────────────────────
195//
196// The rail is seeded with two default Diamonds on a first boot, so there is one
197// to open without minting anything.
198const picked = await p.evaluate(() => {
199 const el = document.querySelector('#diamond-list [data-id]');
200 if (!el) return '';
201 el.click(); // NOT page.click: force-clicking a tile is
202 return el.dataset.id || ''; // silently inert headless.
203});
204check('0 a Diamond exists on the rail to open', !!picked, picked || 'no tile with data-id');
205await sleep(400);
206
207const before = await where(p);
208check('1a opening a Diamond makes it current', before.diamond === picked,
209 `current ${before.diamond || '(none)'}, clicked ${picked}`);
210check('1b and the app remembers which one', before.keys.openDiamond === picked,
211 `key ${before.keys.openDiamond || '(unset)'}`);
212
213await reboot();
214
215const after = await settle(w => w.diamond === picked);
216check('1c A RELOAD COMES BACK TO THE DIAMOND YOU WERE IN',
217 after.diamond === picked,
218 `came back to ${after.diamond || '(none)'}, was in ${picked}`);
219await shot(s, 'reopen-diamond');
220
221// ── 2. A chat, which must still win when it was selected last ────────────────
222//
223// Made rather than found: a fresh profile has no chat on the rail, and a click
224// that lands on nothing leaves the Diamond current — which would read as this
225// arm passing for the wrong reason. The rail's own `+`, not `harness.newChat`,
226// because that one waits for the composer to be VISIBLE and this session is
227// deliberately unconnected — no provider, so no composer, and a chat that
228// exists perfectly well without one.
229await newChatOnRail();
230await sleep(600);
231const onChat = await where(p);
232check('2a selecting a chat leaves no Diamond current and forgets the Diamond key',
233 onChat.diamond === '' && onChat.keys.openDiamond === '',
234 `current ${onChat.diamond || '(none)'}, key ${onChat.keys.openDiamond || '(unset)'}`);
235
236await reboot();
237
238// The Diamond arm settles on a value appearing; this one settles on the rail
239// having been read at all, because "no Diamond" is also the state before the
240// step runs — a check that did not wait would pass while proving nothing.
241const back = await settle(w => w.diamond === '' && !!w.keys.openChat, 15000);
242check('2b A RELOAD AFTER A CHAT DOES NOT DRAG THE DIAMOND BACK',
243 back.diamond === '',
244 `came back into ${back.diamond || '(none)'}`);
245check('2c and it is the chat that was remembered',
246 back.keys.openChat === onChat.keys.openChat && !!back.keys.openChat,
247 `key ${back.keys.openChat || '(unset)'}`);
248
249// ── The console, which a restore that throws would fill ──────────────────────
250const errs = errors(s).filter(e => !/502|Account service/.test(e));
251check('3 the boot restore raised nothing in the console', errs.length === 0,
252 errs.slice(0, 2).join(' | '));
253
254await s.close();
255
256console.log(`\n${ok.length} passed, ${bad.length} failed`);
257if (BREAK) {
258 // A break that reddened nothing proves nothing about the check it names.
259 console.log(bad.length ? `--break ${BREAK}: reddened ${bad.length} check(s), as it must`
260 : `--break ${BREAK}: CHANGED NOTHING — the check it names is not testing what it says`);
261 process.exit(bad.length ? 0 : 1);
262}
263process.exit(bad.length ? 1 : 0);