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. |
| 10 | import { open } from './harness.mjs'; |
| 11 | |
| 12 | const ok = [], bad = []; |
| 13 | const check = (name, pass, detail) => { |
| 14 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 15 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 16 | }; |
| 17 | const 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 | |
| 27 | const s = await open({ signIn: false }); |
| 28 | const { page } = s; |
| 29 | |
| 30 | // Serve a chosen stamp for build.json. `stamp === null` means "no version system deployed" (404). |
| 31 | async 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 | } |
| 38 | async 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 | } |
| 50 | const state = () => page.evaluate(() => (document.getElementById('update-chip') || {}).dataset?.state || null); |
| 51 | const hidden = () => page.evaluate(() => !!(document.getElementById('update-chip') || {}).hidden); |
| 52 | const 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. |
| 63 | const setBusy = async (v) => { |
| 64 | await page.waitForFunction(() => !!window.DaimondCore, null, { timeout: 8000 }); |
| 65 | await page.evaluate((b) => { window.DaimondCore.busy = () => b; }, v); |
| 66 | }; |
| 67 | |
| 68 | try { |
| 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 | |
| 145 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 146 | process.exit(bad.length ? 1 : 0); |