oxedyne/daimond/dev/verify_safemode.mjs
12.5 KiB, 1 run
created by r2519314175:655, 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_safemode.mjs — the app can be asked to start without the sync engine, |
| 2 | // and it never does so quietly. |
| 3 | // |
| 4 | // THE BUG THIS IS FOR. An iPhone loops: unlock with a passkey, the app appears |
| 5 | // for about a second, the lock screen is back, repeat. Six diagnoses have been |
| 6 | // made across four sessions and ALL SIX WERE WRONG, every one of them reasoned |
| 7 | // from source rather than from evidence, because a phone has no console. |
| 8 | // |
| 9 | // A safe start is not a fix. It is an EXPERIMENT the user can run, and a phone |
| 10 | // they can use while it runs. If the app stays up with sync skipped, the cause |
| 11 | // is inside what was skipped; if it loops anyway, sync is exonerated. Nothing so |
| 12 | // far has been able to say either. |
| 13 | // |
| 14 | // WHAT IS LOCKED DOWN. |
| 15 | // |
| 16 | // A. `DaimondSafe.on()` gates the sync engine at `ready()`, which is the single |
| 17 | // place every entry point in sync.js already consults — so a pull, a push, a |
| 18 | // nudge and the wake channel are all refused by one rule rather than five. |
| 19 | // B. It arms ITSELF at three boots in ninety seconds. A user whose app will not |
| 20 | // stay open long enough to be used cannot be asked to find a button. |
| 21 | // C. It is never silent: the top bar carries a chip saying sync is off, and the |
| 22 | // chip is what turns it back on. A device that quietly stopped saving to the |
| 23 | // account would be a worse bug than the one this is armed against. |
| 24 | // D. The lock screen — the only screen a looping device stays on long enough to |
| 25 | // read — offers the control too, and says which way it will go. |
| 26 | // E. It survives the reload it exists because of: localStorage, not session. |
| 27 | // F. A safe start writes a line in the durable trail, on EVERY boot and not |
| 28 | // only the one that armed it, or a later cycle reads as an ordinary boot |
| 29 | // that happened not to sync. |
| 30 | // |
| 31 | // PROVED RED with `--break gate`, which removes the check from `ready()` — the |
| 32 | // one line that makes any of it do anything. |
| 33 | // |
| 34 | // node dev/verify_safemode.mjs |
| 35 | // node dev/verify_safemode.mjs --break gate # must fail, loudly |
| 36 | // |
| 37 | // Needs dev/serve.mjs. No gateway: what is under test is whether the CLIENT |
| 38 | // starts its engine at all. |
| 39 | |
| 40 | import { open, signInAs, scratch } from './harness.mjs'; |
| 41 | import fs from 'node:fs'; |
| 42 | |
| 43 | const BREAK = process.argv.includes('--break'); |
| 44 | |
| 45 | const out = []; |
| 46 | let bad = 0; |
| 47 | const check = (ok, what, detail) => { |
| 48 | out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`); |
| 49 | if (!ok) bad++; |
| 50 | return ok; |
| 51 | }; |
| 52 | |
| 53 | const PROFILE = scratch('pw', 'safemode-' + process.pid); |
| 54 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 55 | |
| 56 | const s = await open({ name: 'safemode', connect: false, profile: PROFILE }); |
| 57 | const p = s.page; |
| 58 | |
| 59 | if (BREAK) { |
| 60 | // The gate removed from `ready()`. Everything else stays: the flag is still |
| 61 | // written, the chip is still asked for, the trail is still marked. Only the |
| 62 | // one line that makes the engine obey it is gone. |
| 63 | await p.route('**/js/sync.js', async (route) => { |
| 64 | const res = await route.fetch(); |
| 65 | let body = await res.text(); |
| 66 | body = body.replace('if (window.DaimondSafe && DaimondSafe.on()) return false;', ''); |
| 67 | await route.fulfill({ response: res, body, |
| 68 | headers: { ...res.headers(), 'content-type': 'text/javascript; charset=utf-8' } }); |
| 69 | }); |
| 70 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 71 | await signInAs(s, 'safemode'); |
| 72 | await p.waitForTimeout(800); |
| 73 | } |
| 74 | |
| 75 | // ── The module exists at all ──────────────────────────────────────── |
| 76 | check(await p.evaluate(() => !!(window.DaimondSafe && DaimondSafe.on && DaimondSafe.set)), |
| 77 | 'the app has a safe start to offer'); |
| 78 | |
| 79 | // ── A. Off by default, and the engine runs ────────────────────────── |
| 80 | { |
| 81 | const st = await p.evaluate(() => ({ |
| 82 | safe: window.DaimondSafe.on(), |
| 83 | ready: !!(window.DaimondSync && DaimondSync.status && DaimondSync.status()), |
| 84 | })); |
| 85 | check(st.safe === false, 'an ordinary start is not a safe one', JSON.stringify(st)); |
| 86 | } |
| 87 | |
| 88 | // ── E. The flag is where a reload cannot clear it ─────────────────── |
| 89 | { |
| 90 | await p.evaluate(() => DaimondSafe.set(true, 'user')); |
| 91 | const where = await p.evaluate(() => ({ |
| 92 | local: Object.keys(localStorage).filter((k) => /safe-mode/.test(k)), |
| 93 | session: Object.keys(sessionStorage).filter((k) => /safe-mode/.test(k)), |
| 94 | })); |
| 95 | check(where.local.length > 0 && where.session.length === 0, |
| 96 | 'a safe start is remembered where the reload it guards against cannot clear it', |
| 97 | JSON.stringify(where)); |
| 98 | } |
| 99 | |
| 100 | // ── A. With it on, the engine refuses to run ──────────────────────── |
| 101 | // |
| 102 | // Measured at the engine and not at the flag: `ready()` is private, so what is |
| 103 | // asked is whether a PULL reaches the mailbox at all. |
| 104 | // |
| 105 | // THE SESSION HAS TO BE FAKED, and this is the whole difficulty. There is no |
| 106 | // gateway in this world, so `ready()` is already false on the OTHER three |
| 107 | // grounds it tests — and a check written without noticing that passes whether |
| 108 | // the safe gate is there or not. It did: the first `--break gate` run went 13/13 |
| 109 | // green, which is a check that cannot fail and therefore is not evidence. |
| 110 | // |
| 111 | // So `DaimondGateway.state()` is made to answer "authed", leaving the safe gate |
| 112 | // as the ONLY thing standing between `pull()` and a request. Stubbed in both |
| 113 | // runs, so what the two are compared on is one line of the app. |
| 114 | { |
| 115 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 116 | await signInAs(s, 'safemode'); |
| 117 | await p.waitForTimeout(600); |
| 118 | |
| 119 | const posed = await p.evaluate(() => { |
| 120 | try { |
| 121 | var real = DaimondGateway.state; |
| 122 | DaimondGateway.state = function () { |
| 123 | return Object.assign({}, real(), { authed: true }); |
| 124 | }; |
| 125 | return DaimondGateway.state().authed === true; |
| 126 | } catch (e) { return false; } |
| 127 | }); |
| 128 | check(posed, 'a session can be posed, so the safe gate is the only thing left in the way'); |
| 129 | |
| 130 | const asked = []; |
| 131 | p.on('request', (r) => { if (/\/api\/sync/.test(r.url())) asked.push(r.method()); }); |
| 132 | |
| 133 | const answer = await p.evaluate(async () => { |
| 134 | try { return await DaimondSync.pull(); } catch (e) { return 'threw: ' + e; } |
| 135 | }); |
| 136 | await p.evaluate(() => { try { DaimondSync.nudge(); } catch (e) {} }); |
| 137 | await p.waitForTimeout(1500); |
| 138 | |
| 139 | check(answer === -1 && asked.length === 0, |
| 140 | 'with a safe start armed, the sync engine does not run at all', |
| 141 | `pull returned ${JSON.stringify(answer)}, ${asked.length} request(s) to the mailbox`); |
| 142 | } |
| 143 | |
| 144 | // ── C. And says so, on a chip that turns it back off ──────────────── |
| 145 | { |
| 146 | const chip = await p.evaluate(() => { |
| 147 | const c = document.getElementById('sync-chip'); |
| 148 | if (!c) return null; |
| 149 | return { |
| 150 | state: c.dataset.state, |
| 151 | text: (c.querySelector('.stext') || {}).textContent || '', |
| 152 | title: c.title || '', |
| 153 | shown: getComputedStyle(c).display !== 'none', |
| 154 | pointer: getComputedStyle(c).cursor, |
| 155 | }; |
| 156 | }); |
| 157 | check(!!chip && chip.state === 'off' && /safe/i.test(chip.text), |
| 158 | 'the rail says sync is off, so a safe start is never a silent one', |
| 159 | JSON.stringify(chip)); |
| 160 | check(!!chip && chip.pointer === 'pointer' && /click/i.test(chip.title), |
| 161 | 'and the thing that says it is the thing that undoes it', |
| 162 | JSON.stringify(chip && chip.title)); |
| 163 | } |
| 164 | |
| 165 | // ── F. The trail records it, on this boot and not only the arming one ── |
| 166 | { |
| 167 | const trail = await p.evaluate(() => { try { return DaimondTrail.text(); } catch (e) { return ''; } }); |
| 168 | check(/safe start/.test(trail), |
| 169 | 'a safe start is written into the durable trail', |
| 170 | JSON.stringify((trail.split('\n').filter((l) => /safe/.test(l)) || []).slice(-2))); |
| 171 | check(!/[A-Za-z0-9_-]{32,}/.test(trail), |
| 172 | 'and the trail still carries nothing that looks like a key or a token', |
| 173 | JSON.stringify((trail.match(/[A-Za-z0-9_-]{32,}/) || [])[0] || 'none')); |
| 174 | } |
| 175 | |
| 176 | // ── D. The lock screen offers it, and says which way the button goes ── |
| 177 | { |
| 178 | await p.evaluate(() => { |
| 179 | try { |
| 180 | DaimondSafe.set(false, 'user'); |
| 181 | DaimondTrail.clear(); |
| 182 | for (var i = 0; i < 3; i++) DaimondTrail.note('boot', 'test'); |
| 183 | DaimondCore.showIdentity('unlock'); |
| 184 | } catch (e) {} |
| 185 | }); |
| 186 | await p.waitForTimeout(300); |
| 187 | const offered = await p.evaluate(() => { |
| 188 | const b = document.getElementById('id-trail-safe'); |
| 189 | const el = document.getElementById('id-trail'); |
| 190 | return b && el ? { |
| 191 | label: b.textContent, |
| 192 | note: [...el.querySelectorAll('.id-trail-lead')].map((n) => n.textContent).join(' | ').slice(0, 90), |
| 193 | } : null; |
| 194 | }); |
| 195 | check(!!offered, 'the lock screen offers a safe start where a looping device can reach it', |
| 196 | JSON.stringify(offered && offered.label)); |
| 197 | |
| 198 | // And with it already ON, the same button is the way BACK. |
| 199 | await p.evaluate(() => { |
| 200 | try { DaimondSafe.set(true, 'user'); DaimondCore.showIdentity('unlock'); } catch (e) {} |
| 201 | }); |
| 202 | await p.waitForTimeout(300); |
| 203 | const back = await p.evaluate(() => { |
| 204 | const b = document.getElementById('id-trail-safe'); |
| 205 | return b ? b.textContent : null; |
| 206 | }); |
| 207 | check(!!offered && !!back && offered.label !== back, |
| 208 | 'and reads as the way back once it is on, rather than offering the same thing twice', |
| 209 | JSON.stringify([offered && offered.label, back])); |
| 210 | } |
| 211 | |
| 212 | // ── B. It arms itself on a loop, with nobody pressing anything ─────── |
| 213 | // |
| 214 | // Through the module's own code path, which is what runs at a boot: clear the |
| 215 | // flag, write three boot rows, and reload. safe.js reads the trail at script |
| 216 | // load and must arm without being asked. |
| 217 | { |
| 218 | await p.evaluate(() => { |
| 219 | try { |
| 220 | DaimondSafe.set(false, 'user'); |
| 221 | DaimondTrail.clear(); |
| 222 | // FOUR, and the count is worth explaining. A killed start is a `boot` |
| 223 | // with no `pagehide` between it and the boot before it, so N boots in a |
| 224 | // row are N-1 killed starts — the first has nothing before it to |
| 225 | // judge against. And the reload below is a REAL one, so it writes a |
| 226 | // real `pagehide` and its own boot does not count either. Four |
| 227 | // consecutive boots is therefore the smallest trail that presents |
| 228 | // three killed starts to a page that is about to load. |
| 229 | for (var i = 0; i < 4; i++) DaimondTrail.note('boot', 'test'); |
| 230 | } catch (e) {} |
| 231 | }); |
| 232 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 233 | await p.waitForTimeout(400); |
| 234 | const armed = await p.evaluate(() => ({ |
| 235 | on: window.DaimondSafe.on(), |
| 236 | why: window.DaimondSafe.why(), |
| 237 | killed: window.DaimondSafe.killed(), |
| 238 | })); |
| 239 | check(armed.on === true && armed.why === 'auto', |
| 240 | 'three KILLED starts in ninety seconds arms a safe start with nobody pressing anything', |
| 241 | JSON.stringify(armed)); |
| 242 | |
| 243 | // NOT VACUOUS. It must NOT arm on an app that is merely being used. |
| 244 | await p.evaluate(() => { |
| 245 | try { DaimondSafe.set(false, 'user'); DaimondTrail.clear(); } catch (e) {} |
| 246 | }); |
| 247 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 248 | await p.waitForTimeout(400); |
| 249 | const quiet = await p.evaluate(() => ({ on: window.DaimondSafe.on(), killed: window.DaimondSafe.killed() })); |
| 250 | check(quiet.on === false, |
| 251 | 'and a single ordinary start does not arm one', |
| 252 | JSON.stringify(quiet)); |
| 253 | |
| 254 | // AND THIS IS THE ONE THAT MATTERS. Three RELOADS in ninety seconds is a |
| 255 | // developer, a flaky connection, or this very suite driving the app — and |
| 256 | // counting boots alone could not tell that from a phone whose tab is being |
| 257 | // killed. Each reload leaves a `pagehide`; the phone's trail never has one. |
| 258 | // Without this distinction a safe start would arm inside the test suite and |
| 259 | // turn sync off for people whose app is working perfectly. |
| 260 | await p.evaluate(() => { |
| 261 | try { DaimondSafe.set(false, 'user'); DaimondTrail.clear(); } catch (e) {} |
| 262 | }); |
| 263 | for (let i = 0; i < 3; i++) { |
| 264 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 265 | await p.waitForTimeout(250); |
| 266 | } |
| 267 | const reloaded = await p.evaluate(() => ({ |
| 268 | on: window.DaimondSafe.on(), |
| 269 | boots: window.DaimondSafe.boots(), |
| 270 | killed: window.DaimondSafe.killed(), |
| 271 | })); |
| 272 | check(reloaded.on === false && reloaded.boots >= 3 && reloaded.killed < 3, |
| 273 | 'but three ORDINARY RELOADS do not — a reload says pagehide on its way out, and a killed tab cannot', |
| 274 | JSON.stringify(reloaded)); |
| 275 | } |
| 276 | |
| 277 | const noise = /favicon|401|402|426|502|Unauthorized|Payment|Bad Gateway/i; |
| 278 | const errs = s.errs.filter((e) => !noise.test(e)); |
| 279 | check(errs.length === 0, 'no console errors', JSON.stringify(errs.slice(0, 3))); |
| 280 | |
| 281 | await s.close(); |
| 282 | try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) { /* gone */ } |
| 283 | |
| 284 | console.log(out.join('\n')); |
| 285 | const total = out.filter((l) => /^(PASS|FAIL)/.test(l)).length; |
| 286 | if (BREAK) { |
| 287 | console.log(`\nBROKEN RUN: ${bad} of ${total} failed. ` |
| 288 | + (bad > 0 ? 'Good — the gate in ready() is what makes a safe start mean anything.' |
| 289 | : 'BAD — a check that cannot fail is not evidence.')); |
| 290 | process.exit(bad > 0 ? 0 : 1); |
| 291 | } |
| 292 | console.log(bad === 0 ? `\nALL ${total} CHECKS PASSED` : `\n${bad} of ${total} FAILED`); |
| 293 | process.exit(bad === 0 ? 0 : 1); |