Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_updates.mjs

8.4 KiB, 1 run

created by r2519314175:779, 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_updates.mjs — a running tab notices a new build and applies it safely.
2//
3// The contract: the tab reads build.json at boot to learn its own version, re-reads it to notice
4// a newer one, lights the header chip, and reloads only at a safe moment -- never over a running
5// turn, and in the foreground only on a click. After the reload it says, briefly, that it updated.
6//
7// build.json is faked per-scenario with Playwright request interception, so the test drives the
8// whole state machine without a real deploy. The updater runs on the locked screen too (it is
9// independent of identity), so no sign-in is needed.
10import { open } from './harness.mjs';
11
12const ok = [], bad = [];
13const check = (name, pass, detail) => {
14 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
15 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
16};
17const until = async (page, fn, ms = 5000) => {
18 const t0 = Date.now();
19 for (;;) {
20 // A reload destroys the execution context mid-poll; treat that as "not yet" and keep trying.
21 try { if (await page.evaluate(fn)) return true; } catch (e) { /* mid-navigation */ }
22 if (Date.now() - t0 > ms) return false;
23 await new Promise(r => setTimeout(r, 50));
24 }
25};
26
27const s = await open({ signIn: false });
28const { page } = s;
29
30// Serve a chosen stamp for build.json. `stamp === null` means "no version system deployed" (404).
31async function serve(stamp) {
32 await page.unroute('**/build.json').catch(() => {});
33 await page.route('**/build.json', route => route.fulfill(
34 stamp === null
35 ? { status: 404, contentType: 'text/plain', body: 'none' }
36 : { status: 200, contentType: 'application/json', body: JSON.stringify(stamp) }));
37}
38async function reboot(stamp) {
39 await serve(stamp);
40 await page.reload({ waitUntil: 'domcontentloaded' });
41 // Wait for init's async stamp read to settle, not merely for the module to exist: `booted` is
42 // set after `await readStamp()`, so checking too early races it.
43 const want = stamp === null ? null : stamp.build;
44 // Also wait for DaimondCore: later scenarios set DaimondCore.busy, which races
45 // the module's own init otherwise (and a larger wasm makes the race show).
46 await page.waitForFunction(
47 w => !!window.DaimondUpdater && window.DaimondUpdater.booted() === w && !!window.DaimondCore,
48 want, { timeout: 6000 }).catch(() => {});
49}
50const state = () => page.evaluate(() => (document.getElementById('update-chip') || {}).dataset?.state || null);
51const hidden = () => page.evaluate(() => !!(document.getElementById('update-chip') || {}).hidden);
52const title = () => page.evaluate(() => (document.getElementById('update-chip') || {}).title || '');
53
54/// Pretend a turn is running, or is not.
55///
56/// It waits for `DaimondCore` first, and that wait is the whole reason this is a
57/// function. Every one of these assignments races the module's own init, and the
58/// race is decided by how long the wasm takes to instantiate — so it was invisible
59/// for as long as the bundle stayed the size it was, and then a release that added
60/// two modules to it turned "sometimes" into "every time". The failure reads
61/// `Cannot set properties of undefined`, which points at this file rather than at
62/// the timing, and the note at the top of `reboot` above had already predicted it.
63const setBusy = async (v) => {
64 await page.waitForFunction(() => !!window.DaimondCore, null, { timeout: 8000 });
65 await page.evaluate((b) => { window.DaimondCore.busy = () => b; }, v);
66};
67
68try {
69 // ── A. Boot reads the stamp; the chip is present and quiet. ──────────────
70 await reboot({ build: 'AAA', note: 'first' });
71 check('boot records its own build id', (await page.evaluate(() => DaimondUpdater.booted())) === 'AAA');
72 check('chip is visible and "current" at boot', (await state()) === 'current' && !(await hidden()));
73
74 // ── B. A newer stamp is noticed; chip goes "ready"; NO auto-reload in the foreground. ──
75 await serve({ build: 'BBB', note: 'second' });
76 await page.evaluate(() => (window.__m = 1));
77 await page.evaluate(() => DaimondUpdater.check());
78 const sawPending = await until(page, () => DaimondUpdater.pending() === 'BBB');
79 check('a newer build is detected on re-check', sawPending);
80 check('foreground does NOT auto-reload', (await page.evaluate(() => window.__m)) === 1);
81 check('chip shows "ready"', (await state()) === 'ready');
82 check('chip carries the "what changed" note', (await title()).includes('second'));
83
84 // ── C. A running turn suppresses even a forced click. ───────────────────
85 await setBusy(true);
86 await page.evaluate(() => (window.__m = 1));
87 await page.evaluate(() => document.getElementById('update-chip').click());
88 await new Promise(r => setTimeout(r, 300));
89 check('a click does not reload while busy', (await page.evaluate(() => window.__m)) === 1);
90 check('chip shows "busy" while a turn runs', (await state()) === 'busy' || (await state()) === 'ready');
91 await setBusy(false);
92
93 // ── D. Idle + click → it applies, reloads, and says it updated. ─────────
94 await page.evaluate(() => { window.__m = 1; window.DaimondUpdater.check(); });
95 await until(page, () => DaimondUpdater.pending() === 'BBB');
96 await page.evaluate(() => document.getElementById('update-chip').click()).catch(() => {});
97 const reloaded = await until(page, () => typeof window.__m === 'undefined' && !!window.DaimondUpdater, 8000);
98 check('a click while idle reloads', reloaded);
99 const doneShown = await until(page, () => (document.getElementById('update-chip') || {}).dataset?.state === 'done', 4000);
100 check('after the update the chip says "updated"', doneShown);
101 check('the "updated" note is carried across the reload', (await title()).includes('second'));
102
103 // ── E. No stamp deployed → the chip stays silent. ───────────────────────
104 await reboot(null);
105 check('with no stamp the chip is hidden', await hidden());
106
107 // ── G. The gateway refuses this tab (stale) → forced reload, foreground and all. ──
108 await reboot({ build: 'GGG', note: 'g' });
109 await setBusy(false); await page.evaluate(() => { window.__m = 1; });
110 await page.evaluate(() => window.dispatchEvent(new Event('daimond:stale')));
111 const staleReloaded = await until(page, () => typeof window.__m === 'undefined' && !!window.DaimondUpdater, 8000);
112 check('a stale tab force-reloads even in the foreground', staleReloaded);
113
114 // ── H. Stale still never reloads over a running turn; it waits for idle. ──
115 await reboot({ build: 'HHH', note: 'h' });
116 await setBusy(true); await page.evaluate(() => { window.__m = 1; });
117 await page.evaluate(() => window.dispatchEvent(new Event('daimond:stale')));
118 await new Promise(r => setTimeout(r, 500));
119 check('stale does not reload over a running turn', (await page.evaluate(() => window.__m)) === 1);
120 check('the chip goes red (stale) while busy', (await state()) === 'stale');
121 await setBusy(false);
122 await page.evaluate(() => window.dispatchEvent(new Event('daimond:idle')));
123 const staleAfterIdle = await until(page, () => typeof window.__m === 'undefined' && !!window.DaimondUpdater, 8000);
124 check('stale applies the moment the turn ends', staleAfterIdle);
125
126 // ── I. Loop guard: still stale after a forced reload from the same build → no re-loop. ──
127 // (No reboot: we are on HHH with daimond-forced-from=HHH from H's reload.)
128 await setBusy(false); await page.evaluate(() => { window.__m = 1; });
129 await page.evaluate(() => window.dispatchEvent(new Event('daimond:stale')));
130 await new Promise(r => setTimeout(r, 600));
131 check('loop guard: does not re-reload from the same build', (await page.evaluate(() => window.__m)) === 1);
132 check('loop guard leaves the chip red for the user', (await state()) === 'stale');
133
134 // ── F. No console errors from any of it. ────────────────────────────────
135 // Gateway bootstrap 401s are expected here: this test runs signed-out with no gateway, so those
136 // resource-load failures are not the updater's doing. Everything else must be silent.
137 const errs = s.errs.filter(e => !/favicon|ERR_|Failed to load resource|401|Unauthorized/.test(e));
138 check('no console errors', errs.length === 0, errs.slice(0, 3).join(' | '));
139} catch (e) {
140 check('test harness ran to completion', false, String(e && e.message || e));
141} finally {
142 await s.close();
143}
144
145console.log(`\n${ok.length} passed, ${bad.length} failed`);
146process.exit(bad.length ? 1 : 0);