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 | |
| 36 | import fs from 'node:fs'; |
| 37 | import path from 'node:path'; |
| 38 | import { fileURLToPath } from 'node:url'; |
| 39 | import { open, signInAs, shot, errors } from './harness.mjs'; |
| 40 | |
| 41 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 42 | const WWW = path.join(HERE, '..', 'www'); |
| 43 | |
| 44 | const ok = [], bad = []; |
| 45 | const check = (name, pass, detail) => { |
| 46 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 47 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 48 | }; |
| 49 | const 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. |
| 58 | const BREAK = (() => { const i = process.argv.indexOf('--break'); return i > 0 ? process.argv[i + 1] : ''; })(); |
| 59 | const 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 | |
| 72 | function 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 | |
| 87 | async 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 ─────────────────────────────────────────────── |
| 97 | const 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. |
| 112 | const s = await open({ name: 'reopen', route: serveBreaks, connect: false }); |
| 113 | const 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. |
| 120 | async 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. |
| 140 | async 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. |
| 157 | async 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. |
| 176 | await newChatOnRail(); |
| 177 | async 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 | } |
| 191 | check('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. |
| 198 | const 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 | }); |
| 204 | check('0 a Diamond exists on the rail to open', !!picked, picked || 'no tile with data-id'); |
| 205 | await sleep(400); |
| 206 | |
| 207 | const before = await where(p); |
| 208 | check('1a opening a Diamond makes it current', before.diamond === picked, |
| 209 | `current ${before.diamond || '(none)'}, clicked ${picked}`); |
| 210 | check('1b and the app remembers which one', before.keys.openDiamond === picked, |
| 211 | `key ${before.keys.openDiamond || '(unset)'}`); |
| 212 | |
| 213 | await reboot(); |
| 214 | |
| 215 | const after = await settle(w => w.diamond === picked); |
| 216 | check('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}`); |
| 219 | await 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. |
| 229 | await newChatOnRail(); |
| 230 | await sleep(600); |
| 231 | const onChat = await where(p); |
| 232 | check('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 | |
| 236 | await 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. |
| 241 | const back = await settle(w => w.diamond === '' && !!w.keys.openChat, 15000); |
| 242 | check('2b A RELOAD AFTER A CHAT DOES NOT DRAG THE DIAMOND BACK', |
| 243 | back.diamond === '', |
| 244 | `came back into ${back.diamond || '(none)'}`); |
| 245 | check('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 ────────────────────── |
| 250 | const errs = errors(s).filter(e => !/502|Account service/.test(e)); |
| 251 | check('3 the boot restore raised nothing in the console', errs.length === 0, |
| 252 | errs.slice(0, 2).join(' | ')); |
| 253 | |
| 254 | await s.close(); |
| 255 | |
| 256 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 257 | if (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 | } |
| 263 | process.exit(bad.length ? 1 : 0); |