Oregami
Repositories/oxedyne/daimond

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
49import { open, signInAs, scratch } from './harness.mjs';
50import fs from 'node:fs';
51
52const BREAK = process.argv.includes('--break');
53
54const out = [];
55let bad = 0;
56const check = (ok, what, detail) => {
57 out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`);
58 if (!ok) bad++;
59 return ok;
60};
61
62const PROFILE = scratch('pw', 'reloadloop-' + process.pid);
63fs.rmSync(PROFILE, { recursive: true, force: true });
64
65const s = await open({ name: 'reloadloop', connect: false, profile: PROFILE });
66const p = s.page;
67
68if (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.
83async 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
90check(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 ───────────────
93const before = await boots();
94for (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}
106await p.waitForTimeout(800);
107const after = await boots();
108const reloads = after - before;
109
110check(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
204const noise = /favicon|401|402|426|502|Unauthorized|Payment|Bad Gateway/i;
205const errs = s.errs.filter((e) => !noise.test(e));
206check(errs.length === 0, 'no console errors', JSON.stringify(errs.slice(0, 3)));
207
208await s.close();
209try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) { /* gone */ }
210
211console.log(out.join('\n'));
212const total = out.filter((l) => /^(PASS|FAIL)/.test(l)).length;
213if (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}
218console.log(bad === 0 ? `\nALL ${total} CHECKS PASSED` : `\n${bad} of ${total} FAILED`);
219process.exit(bad === 0 ? 0 : 1);