oxedyne/daimond/dev/verify_reloadloop.mjs
9.5 KiB, 1 run
created by r2519314175:643, 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_reloadloop.mjs — a tab the gateway keeps refusing must not reload for ever. |
| 2 | // |
| 3 | // THE BUG THIS IS FOR. An iPhone reported, three times across three sessions: |
| 4 | // unlock with a passkey, the app appears for about a second, the lock screen is |
| 5 | // back, repeat. It was diagnosed twice from reading code — once as sync parcel |
| 6 | // size, once as crystal snapshots — and both were wrong, because nobody could |
| 7 | // see a console on iOS and so nobody had any evidence at all. |
| 8 | // |
| 9 | // Reading the updater turned up a forced-reload path with NO loop guard: |
| 10 | // |
| 11 | // window.addEventListener('daimond:idle', function () { |
| 12 | // if (stale) { apply(true); return; } // <- reloads, every time |
| 13 | // |
| 14 | // `apply(true)` is a forced reload. Once `stale` was true — the gateway having |
| 15 | // refused this build once — every idle event reloaded the tab, for ever, three |
| 16 | // lines below the once-per-build guard that `onStale` does consult. A tab that |
| 17 | // reloads is a tab back at its lock screen. |
| 18 | // |
| 19 | // WHETHER THAT IS THE PHONE'S BUG IS NOT SETTLED. It is a real defect, it |
| 20 | // produces exactly the reported symptom, and it is fixed here. This file proves |
| 21 | // the fix rather than the diagnosis. |
| 22 | // |
| 23 | // WHAT IS LOCKED DOWN. |
| 24 | // |
| 25 | // A. One forced reload, then no more: a page told it is stale over and over |
| 26 | // reloads ONCE and then stops, leaving the chip red for the user to press. |
| 27 | // B. The guard survives the reload it is guarding against — it is in |
| 28 | // localStorage, not sessionStorage, because a standalone PWA on iOS can |
| 29 | // start a fresh session on every launch, and a loop that reloads the app is |
| 30 | // a loop that would clear its own guard. |
| 31 | // C. BOTH doors are guarded: `daimond:stale` and `daimond:idle`-while-stale. |
| 32 | // The second was the unguarded one. |
| 33 | // D. A reload that actually landed on a new build clears the counter, so one |
| 34 | // bad afternoon does not make the device refuse the next real update. |
| 35 | // E. The trail records enough to tell a reload loop from a lock: several |
| 36 | // `boot` rows seconds apart, and no `lockApp`. |
| 37 | // F. The lock screen shows the trail by itself once the app has booted three |
| 38 | // times in ninety seconds — and NOT before, because diagnostics offered to |
| 39 | // somebody whose app works are noise. |
| 40 | // |
| 41 | // PROVED RED with `--break guard`, which restores the unguarded `apply(true)`. |
| 42 | // |
| 43 | // node dev/verify_reloadloop.mjs |
| 44 | // node dev/verify_reloadloop.mjs --break guard # must fail, loudly |
| 45 | // |
| 46 | // Needs dev/serve.mjs. No gateway: the refusal is injected, because the point is |
| 47 | // what the CLIENT does when refused. |
| 48 | |
| 49 | import { open, signInAs, scratch } from './harness.mjs'; |
| 50 | import fs from 'node:fs'; |
| 51 | |
| 52 | const BREAK = process.argv.includes('--break'); |
| 53 | |
| 54 | const out = []; |
| 55 | let bad = 0; |
| 56 | const check = (ok, what, detail) => { |
| 57 | out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`); |
| 58 | if (!ok) bad++; |
| 59 | return ok; |
| 60 | }; |
| 61 | |
| 62 | const PROFILE = scratch('pw', 'reloadloop-' + process.pid); |
| 63 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 64 | |
| 65 | const s = await open({ name: 'reloadloop', connect: false, profile: PROFILE }); |
| 66 | const p = s.page; |
| 67 | |
| 68 | if (BREAK) { |
| 69 | await p.route('**/js/updater.js', async (route) => { |
| 70 | const res = await route.fetch(); |
| 71 | let body = await res.text(); |
| 72 | // The defect, restored: the idle door forces a reload with no guard. |
| 73 | body = body.replace('if (stale) { force(); return; }', 'if (stale) { apply(true); return; }'); |
| 74 | await route.fulfill({ response: res, body, |
| 75 | headers: { ...res.headers(), 'content-type': 'text/javascript; charset=utf-8' } }); |
| 76 | }); |
| 77 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 78 | await signInAs(s, 'reloadloop'); |
| 79 | await p.waitForTimeout(1200); |
| 80 | } |
| 81 | |
| 82 | /// Count boots by watching the page's own trail, which survives reloads. |
| 83 | async function boots() { |
| 84 | return p.evaluate(() => { |
| 85 | try { return (window.DaimondTrail.rows() || []).filter((r) => r.w === 'boot').length; } |
| 86 | catch (e) { return -1; } |
| 87 | }); |
| 88 | } |
| 89 | |
| 90 | check(await boots() > 0, 'the app records its own boots in a durable trail', String(await boots())); |
| 91 | |
| 92 | // ── A + C. Say "stale" repeatedly, through BOTH doors ─────────────── |
| 93 | const before = await boots(); |
| 94 | for (let i = 0; i < 6; i++) { |
| 95 | await p.evaluate(() => { |
| 96 | try { window.dispatchEvent(new Event('daimond:stale')); } catch (e) {} |
| 97 | try { window.dispatchEvent(new Event('daimond:idle')); } catch (e) {} |
| 98 | }).catch(() => { /* a reload mid-evaluate is the very fault under test */ }); |
| 99 | await p.waitForTimeout(900); |
| 100 | // A reload lands on the lock screen; get back in the way a person does. |
| 101 | const gate = await p.$('#id-pass'); |
| 102 | if (gate && await gate.isVisible().catch(() => false)) { |
| 103 | await signInAs(s, 'reloadloop').catch(() => {}); |
| 104 | } |
| 105 | } |
| 106 | await p.waitForTimeout(800); |
| 107 | const after = await boots(); |
| 108 | const reloads = after - before; |
| 109 | |
| 110 | check(reloads <= 1, |
| 111 | 'six refusals through BOTH doors cause at most ONE reload, not six', |
| 112 | `${reloads} reload(s) (boots ${before} -> ${after})`); |
| 113 | |
| 114 | // NOT VACUOUS. "At most one" is satisfied by zero, and zero would mean the |
| 115 | // forced reload had been broken rather than guarded. The trail says which: |
| 116 | // the guard has to have REFUSED at least one of the six, which it can only do |
| 117 | // after something spent it. |
| 118 | { |
| 119 | const trail = await p.evaluate(() => { try { return DaimondTrail.text(); } catch (e) { return ''; } }); |
| 120 | const refused = (trail.match(/forced reload REFUSED/g) || []).length; |
| 121 | const forced = (trail.match(/forced reload {2}/g) || []).length; |
| 122 | check(refused > 0, |
| 123 | 'and the guard is what refused them — not a forced reload that no longer works', |
| 124 | `${forced} allowed, ${refused} refused`); |
| 125 | } |
| 126 | |
| 127 | // ── B. The guard is in localStorage, so it survives the reload ────── |
| 128 | { |
| 129 | const where = await p.evaluate(() => ({ |
| 130 | local: Object.keys(localStorage).filter((k) => /forced/.test(k)), |
| 131 | session: Object.keys(sessionStorage).filter((k) => /forced/.test(k)), |
| 132 | })); |
| 133 | check(where.local.length > 0, |
| 134 | 'the loop guard is written where a reload cannot clear it', |
| 135 | JSON.stringify(where)); |
| 136 | } |
| 137 | |
| 138 | // ── E. The trail tells a reload from a lock ───────────────────────── |
| 139 | { |
| 140 | const trail = await p.evaluate(() => { |
| 141 | try { return window.DaimondTrail.text(); } catch (e) { return ''; } |
| 142 | }); |
| 143 | check(/boot/.test(trail), 'the trail names the boot'); |
| 144 | check(!/lockApp/.test(trail), |
| 145 | 'and does NOT name lockApp — so a reload loop cannot be mistaken for a log-out', |
| 146 | JSON.stringify(trail.split('\n').slice(-2))); |
| 147 | check(!/[A-Za-z0-9_-]{32,}/.test(trail), |
| 148 | 'and carries nothing that looks like a key or a token', |
| 149 | JSON.stringify((trail.match(/[A-Za-z0-9_-]{32,}/) || [])[0] || 'none')); |
| 150 | } |
| 151 | |
| 152 | // ── F. The lock screen offers the trail once, and only once, it loops ── |
| 153 | { |
| 154 | // Not yet: a working app shows nothing. |
| 155 | await p.evaluate(() => { |
| 156 | try { DaimondTrail.clear(); } catch (e) {} |
| 157 | try { DaimondTrail.note('boot', 'test'); } catch (e) {} |
| 158 | }); |
| 159 | await p.evaluate(() => { try { DaimondCore.showIdentity('unlock'); } catch (e) {} }); |
| 160 | await p.waitForTimeout(300); |
| 161 | let panel = await p.$('#id-trail'); |
| 162 | check(panel === null, 'a working app is offered no diagnostics'); |
| 163 | |
| 164 | // The app's own word for "Copy", asked of the app rather than spelled here: |
| 165 | // this suite runs under whatever locale the browser is in, and a hardcoded |
| 166 | // English label would be a second literal to go stale. |
| 167 | const t_copy = await p.evaluate(() => { |
| 168 | try { return DaimondI18n.t('trail.copy'); } catch (e) { return 'Copy'; } |
| 169 | }); |
| 170 | |
| 171 | // Three boots inside ninety seconds is not something a person does. |
| 172 | await p.evaluate(() => { |
| 173 | try { |
| 174 | DaimondTrail.clear(); |
| 175 | for (var i = 0; i < 3; i++) DaimondTrail.note('boot', 'test'); |
| 176 | } catch (e) {} |
| 177 | }); |
| 178 | await p.evaluate(() => { try { DaimondCore.showIdentity('unlock'); } catch (e) {} }); |
| 179 | await p.waitForTimeout(300); |
| 180 | panel = await p.$('#id-trail'); |
| 181 | const shown = await p.evaluate(() => { |
| 182 | const el = document.getElementById('id-trail'); |
| 183 | if (!el) return null; |
| 184 | return { |
| 185 | lead: (el.querySelector('.id-trail-lead') || {}).textContent || '', |
| 186 | lines: ((el.querySelector('.id-trail-text') || {}).textContent || '').split('\n').length, |
| 187 | acts: [...el.querySelectorAll('.id-trail-btn')].map((b) => b.textContent), |
| 188 | onLockCard: !!el.closest('#identity-modal'), |
| 189 | }; |
| 190 | }); |
| 191 | check(panel !== null && shown && shown.onLockCard, |
| 192 | 'a looping app puts the trail on the lock screen by itself', JSON.stringify(shown && shown.lead)); |
| 193 | // NAMED, not counted. This read `acts.length === 2` and went red the day a |
| 194 | // third control -- a safe start -- was added to the same row, which is the |
| 195 | // literal-count fault this suite has now committed three times: it fails on |
| 196 | // the release that makes the panel MORE useful, and says nothing about the |
| 197 | // thing it is for. What matters is that the trail is there and that a person |
| 198 | // with no console can get it off the device. |
| 199 | const copyable = !!shown && shown.acts.some((a) => a === t_copy); |
| 200 | check(!!shown && shown.lines >= 3 && copyable, |
| 201 | 'with the trail in it and a way to copy it', JSON.stringify(shown)); |
| 202 | } |
| 203 | |
| 204 | const noise = /favicon|401|402|426|502|Unauthorized|Payment|Bad Gateway/i; |
| 205 | const errs = s.errs.filter((e) => !noise.test(e)); |
| 206 | check(errs.length === 0, 'no console errors', JSON.stringify(errs.slice(0, 3))); |
| 207 | |
| 208 | await s.close(); |
| 209 | try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) { /* gone */ } |
| 210 | |
| 211 | console.log(out.join('\n')); |
| 212 | const total = out.filter((l) => /^(PASS|FAIL)/.test(l)).length; |
| 213 | if (BREAK) { |
| 214 | console.log(`\nBROKEN RUN: ${bad} of ${total} failed. ` |
| 215 | + (bad > 0 ? 'Good — the guard is what stops the loop.' : 'BAD — a check that cannot fail is not evidence.')); |
| 216 | process.exit(bad > 0 ? 0 : 1); |
| 217 | } |
| 218 | console.log(bad === 0 ? `\nALL ${total} CHECKS PASSED` : `\n${bad} of ${total} FAILED`); |
| 219 | process.exit(bad === 0 ? 0 : 1); |