diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-17 03:40:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-17 03:40:52 +0200 |
| commit | f4fd6db8faa15bf02f38a14b652cc46936f8d6bf (patch) | |
| tree | 396cbe7fe188fb565231be937ccfa6419384814f /packages | |
| parent | 929bfcccf8928a30d86d48a8b0137db0dd4a1ee0 (diff) | |
| download | meshbay-f4fd6db8faa15bf02f38a14b652cc46936f8d6bf.tar.gz | |
spa: a blank page can never be silent again
boot-guard.js is a classic script loaded before the module graph, so it
survives the graph failing to link. If #app is still empty after ten
seconds it names what failed and offers a reset of this origin — cache,
storage, databases and the service worker, which clearing the cache does
not touch.
Two real defects found building it: openDB never settled when an upgrade
was blocked by another tab, and a connection it gave up on stayed open
and squatted the database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js | 200 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/hub-client.js | 37 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/harness/boot_guard_probe.py | 260 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_boot_guard.py | 122 |
5 files changed, 621 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 6ac5cdb..3e23961 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -180,6 +180,10 @@ _HTML = """\ and app.js's own relative imports inherit the prefix, which is the only way the module graph is guaranteed not to be a mixture of two builds. See _asset_version() and VersionedStatics. --> + <!-- First, and not a module: if the graph below never links, no module code + runs at all, and this is what keeps the page from being silently blank. + It draws nothing unless #app is still empty ten seconds from now. --> + <script src="/a/{v}/boot-guard.js"></script> <!-- Argon2id (WebAssembly, inlined) — WebCrypto has no memory-hard KDF, and the keypair bundle needs one: it is protected by the passphrase alone and sits on every node its owner joins (C4). Vendored, see static/vendor/PROVENANCE.md --> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js b/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js new file mode 100644 index 0000000..f5e3aa1 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js @@ -0,0 +1,200 @@ +/** + * The page can never stay silently blank. + * + * A reader once had nothing but white for an evening: the shell arrived, the + * modules were served from the browser's own store so not one request reached + * the hub, and nothing ever rendered. No message, no error on screen, nothing + * in the server's log to look at — the same account worked in a private window + * and in another browser, which is the signature of something wrong in this + * origin's stored state rather than in what was deployed. Clearing the site's + * data fixed it. Clearing the *cache*, three times, had not: a service worker + * and IndexedDB are not the cache, and nothing on screen said so. + * + * This answers the *silence*, not whatever caused it. A blank page is a bug + * report nobody can write, and a phone has no console to open. + * + * **A classic script, deliberately, and first.** The failure it guards against + * includes the module graph never linking — one bad module and no module code + * runs at all, so a guard inside `app.js` would be part of what failed. This + * one has no imports and cannot be stopped by them. + * + * It does nothing when the application mounts, which is the ordinary case: it + * looks once, late, and speaks only to a reader already staring at nothing. + */ +(function () { + 'use strict'; + + // Long enough that a cold phone on a slow network is never interrupted — + // argon2's wasm, forty modules and a catalogue — and short enough that + // nobody sits in front of white wondering. The app mounts in well under two + // seconds when it mounts at all. + var GIVE_UP_MS = 10000; + var problems = []; + + function note(what) { + if (problems.length < 5) problems.push(String(what).slice(0, 200)); + } + + // Capture phase: a `<script>` or stylesheet that fails to load fires `error` + // at the element, and that one does not bubble. + window.addEventListener('error', function (e) { + if (e && e.target && e.target !== window && e.target.src) { + note('could not load ' + String(e.target.src).replace(/^https?:\/\/[^/]+/, '')); + } else if (e && e.message) { + note(e.message); + } + }, true); + + window.addEventListener('unhandledrejection', function (e) { + var r = e && e.reason; + note((r && (r.message || r)) || 'a promise rejected'); + }); + + var FR = (navigator.language || '').toLowerCase().indexOf('fr') === 0; + var TEXT = FR ? { + title: 'MeshBay n’a pas pu démarrer', + body: 'L’application s’est arrêtée avant d’afficher quoi que ce soit. ' + + 'Cela vient presque toujours des données que ce navigateur garde pour ce site.', + retry: 'Réessayer', + reset: 'Réinitialiser les données de ce site', + warn: 'Vous devrez vous reconnecter. Vos fichiers et vos groupes ne sont pas ' + + 'touchés : ils vivent sur les nœuds, pas ici.', + doing: 'Réinitialisation…', + blocked: 'Un autre onglet de ce site garde la base ouverte. Fermez les autres ' + + 'onglets meshbay, puis réessayez.', + } : { + title: 'MeshBay could not start', + body: 'The application stopped before it drew anything. This is almost always ' + + 'the data this browser keeps for this site.', + retry: 'Try again', + reset: 'Reset this site’s data', + warn: 'You will have to sign in again. Your files and groups are untouched: ' + + 'they live on the nodes, not here.', + doing: 'Resetting…', + blocked: 'Another tab of this site is holding the database open. Close the ' + + 'other meshbay tabs, then try again.', + }; + + /** + * Deleting a database waits for every connection to it to close, and another + * tab of this site is a connection. Unwatched, `deleteDatabase` then does + * nothing at all and says nothing — so this page would reload into the state + * it had just promised to clear, which is the bug this file exists to end. + * Blocked deletions are named and handed back. + */ + function dropDatabase(name) { + return new Promise(function (res) { + var req; + try { req = indexedDB.deleteDatabase(name); } catch (e) { return res(null); } + var settled = false; + var end = function (v) { if (!settled) { settled = true; res(v); } }; + req.onsuccess = function () { end(null); }; + req.onerror = function () { end(null); }; + req.onblocked = function () { end(name); }; + setTimeout(function () { end(name); }, 2500); + return undefined; + }); + } + + /** Everything this origin holds, and the service worker with it. */ + function resetSiteData(done) { + var waiting = []; + var blocked = []; + try { localStorage.clear(); } catch (e) { /* private mode */ } + try { sessionStorage.clear(); } catch (e) { /* private mode */ } + + if (window.indexedDB) { + var names = indexedDB.databases + ? indexedDB.databases().then(function (dbs) { + return (dbs || []).map(function (d) { return d && d.name; }).filter(Boolean); + }).catch(function () { return ['meshbay', 'meshbay_keys']; }) + // Safari and older engines have no `databases()`; these are ours. + : Promise.resolve(['meshbay', 'meshbay_keys']); + waiting.push(names.then(function (list) { + return Promise.all(list.map(dropDatabase)).then(function (results) { + results.forEach(function (n) { if (n) blocked.push(n); }); + }); + }).catch(function () {})); + } + + if (window.caches && caches.keys) { + waiting.push(caches.keys().then(function (keys) { + return Promise.all((keys || []).map(function (k) { return caches.delete(k); })); + }).catch(function () {})); + } + + if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) { + waiting.push(navigator.serviceWorker.getRegistrations().then(function (regs) { + return Promise.all((regs || []).map(function (r) { return r.unregister(); })); + }).catch(function () {})); + } + + // Bounded: a hung unregister must not leave the reader on "Resetting…" for + // ever, which would be this bug wearing a different hat. + var fired = false; + var finish = function () { if (!fired) { fired = true; done(blocked); } }; + Promise.all(waiting).then(finish).catch(finish); + setTimeout(finish, 4000); + } + + function el(tag, style, text) { + var n = document.createElement(tag); + if (style) n.setAttribute('style', style); + if (text) n.textContent = text; + return n; + } + + function show(root) { + var BTN = 'display:block;width:100%;margin:8px 0;padding:12px 16px;font:inherit;' + + 'font-size:15px;border-radius:8px;border:1px solid #c9ccd1;background:#fff;' + + 'color:#111;cursor:pointer'; + var box = el('div', 'max-width:34em;margin:12vh auto;padding:0 20px;' + + 'font:15px/1.55 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#111'); + + box.appendChild(el('h1', 'font-size:20px;margin:0 0 12px', TEXT.title)); + box.appendChild(el('p', 'margin:0 0 16px;color:#444', TEXT.body)); + + var retry = el('button', BTN, TEXT.retry); + retry.addEventListener('click', function () { location.reload(); }); + box.appendChild(retry); + + var reset = el('button', BTN + ';border-color:#b23;color:#b23', TEXT.reset); + reset.addEventListener('click', function () { + reset.disabled = true; + reset.textContent = TEXT.doing; + resetSiteData(function (blocked) { + if (!blocked.length) { location.reload(); return; } + // Reloading here would be a lie: the database is still there, held by + // another tab, and the page would come back exactly as broken. + reset.disabled = false; + reset.textContent = TEXT.reset; + var why = el('p', 'margin:12px 0 0;color:#b23;font-size:13px', + TEXT.blocked + ' (' + blocked.join(', ') + ')'); + why.setAttribute('data-blocked', blocked.join(',')); + box.appendChild(why); + }); + }); + box.appendChild(reset); + + box.appendChild(el('p', 'margin:12px 0 0;color:#666;font-size:13px', TEXT.warn)); + + if (problems.length) { + // Verbatim, and on screen. Nothing else reaches a reader who cannot open + // a console, and it is the first thing anybody diagnosing this will ask. + var pre = el('pre', 'margin:16px 0 0;padding:10px;background:#f3f4f6;' + + 'border-radius:6px;font-size:12px;white-space:pre-wrap;word-break:break-word;' + + 'color:#333', problems.join('\n')); + box.appendChild(pre); + } + + root.appendChild(box); + } + + setTimeout(function () { + var root = document.getElementById('app'); + // Mounted, which is the ordinary case and the end of this script's + // involvement. A later render replaces whatever is here anyway. + if (!root || root.firstChild) return; + show(root); + }, GIVE_UP_MS); +}()); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js index dbd5037..eead457 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js @@ -18,6 +18,7 @@ const IDB_NAME = 'meshbay'; // disagreeing about the version is a `VersionError` thrown at whichever of // them happens to run second. const IDB_VERSION = 2; +const OPEN_DB_TIMEOUT_MS = 5000; const IDB_STORE = 'group_indexes'; const IDB_PLAYLISTS = 'playlists'; @@ -27,9 +28,32 @@ function navigate(path) { // ── IndexedDB cache ───────────────────────────────────────────────────────── +/** + * The database, opened — or refused, but never left hanging. + * + * A version upgrade waits for every other connection to this database to + * close. A second tab of this site holding version 1 open is enough to stop + * it, and `indexedDB.open` then fires **neither** `success` nor `error`: it + * fires `blocked`, and if nothing handles that the promise never settles. Every + * `await openDB()` behind it waits for ever, which reads as a feature that + * silently does nothing rather than as a failure anybody can see. + * + * So `blocked` is heard, and a deadline covers the rest. Callers already treat + * a rejection as "no local cache this time" and carry on. + */ function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VERSION); + let settled = false; + const done = (fn, arg) => { if (!settled) { settled = true; fn(arg); } }; + // Generous: the other tab is asked to close and usually does within a + // frame. This is the backstop for the one that cannot — a page suspended + // on a phone, say — not a latency budget. + const deadline = setTimeout(() => done(reject, new Error( + 'IndexedDB open timed out (another tab may hold an older version open)')), + OPEN_DB_TIMEOUT_MS); + req.onblocked = () => done(reject, new Error( + 'IndexedDB upgrade blocked by another tab of this site')); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) { @@ -43,8 +67,17 @@ function openDB() { db.createObjectStore(IDB_PLAYLISTS); } }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); + req.onsuccess = () => { + clearTimeout(deadline); + // Giving up does not cancel the request: the other tab eventually closes, + // the upgrade goes through, and this fires with a live connection nobody + // is waiting for. Left open it squats the database — blocking the next + // upgrade *and* any attempt to delete it, which is the failure this + // whole guard exists to end, arriving through the back door. + if (settled) { try { req.result.close(); } catch { /* already gone */ } return; } + done(resolve, req.result); + }; + req.onerror = () => { clearTimeout(deadline); done(reject, req.error); }; }); } diff --git a/packages/meshbay-hub/tests/harness/boot_guard_probe.py b/packages/meshbay-hub/tests/harness/boot_guard_probe.py new file mode 100644 index 0000000..01751ba --- /dev/null +++ b/packages/meshbay-hub/tests/harness/boot_guard_probe.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +The shell cannot end up silently blank, and there is a way out of it. + +A reader spent an evening on a white page: the shell arrived, every module came +from the browser's own store so not one request reached the hub, and nothing +rendered. Clearing the site's data fixed it — clearing the *cache*, three times, +did not, because the cache is not where a service worker or IndexedDB lives. + +What is measured here is the guard, not the cause. Three things have to hold: +it says nothing when the application mounts; it appears, with the failure named +on screen, when the module graph does not link; and its reset button genuinely +empties this origin — a button that claims to and does not would be worse than +none. + +The last case is why the guard is a classic script and not part of the module +graph: it has to survive the graph failing to link, which is exactly when a +reader needs it. + + boot_guard_probe.py + +Prints JSON: one entry per case. +""" + +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8763 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +# The shell, as `webapp.py` builds it: the guard first, as a classic script, +# then the module graph. `?fail=1` asks for a module that is not there. +SHELL = r"""<!doctype html><html><head><meta charset=utf-8></head><body> +<div id="app"></div> +<script src="/boot-guard.js"></script> +<script type="module" src="/__case.js"></script> +</body></html>""" + +MOUNTS = r"""// A successful mount: the guard must stay out of the way. +document.getElementById('app').appendChild( + Object.assign(document.createElement('p'), { textContent: 'the application' })); +""" + +BLOCKED = r"""<!doctype html><html><head><meta charset=utf-8></head><body> +<script type="module"> +// Version 1, held open, as a second tab of this site running yesterday's build +// holds it. The shipped `openDB` wants version 2: the upgrade cannot proceed +// while this connection lives, and `indexedDB.open` then fires neither +// `success` nor `error`. +import { openDB } from '/hub-client.js'; +const held = await new Promise((res, rej) => { + const r = indexedDB.open('meshbay', 1); + r.onupgradeneeded = () => r.result.createObjectStore('group_indexes', { keyPath: 'groupId' }); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); +}); +const started = Date.now(); +let outcome = 'never settled'; +try { + await openDB(); + outcome = 'resolved'; +} catch (err) { + outcome = 'rejected: ' + (err && err.message); +} +window.__result = { outcome, ms: Date.now() - started, held: !!held }; +// Let go: the reset case below needs a database nobody is holding. +held.close(); +</script></body></html>""" + + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head> +<body style="margin:0"><div id="frames"></div><script> +const cases = []; +const post = (o) => fetch('/log', { method: 'POST', body: JSON.stringify(o) }); +addEventListener('error', (e) => post({ error: 'page error: ' + (e.message || e) })); +addEventListener('unhandledrejection', + (e) => post({ error: 'rejection: ' + ((e.reason && (e.reason.stack || e.reason.message)) || e.reason) })); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const add = (src) => { + const f = document.createElement('iframe'); + f.src = src; + f.style.cssText = 'width:420px;height:700px;border:0;display:block'; + document.getElementById('frames').appendChild(f); + return new Promise((res) => { f.addEventListener('load', () => res(f)); }); +}; + +(async () => { + try { + const ok = await add('/shell?case=mounts'); + const broken = await add('/shell?case=fails'); + // Started now, read after the wait: it settles well inside it. + const blocked = await add('/blocked'); + + const w = broken.contentWindow; + w.localStorage.setItem('mb_auth', '{"token":"x"}'); + await new Promise((res) => { + const r = w.indexedDB.open('meshbay', 1); + r.onupgradeneeded = () => r.result.createObjectStore('group_indexes', { keyPath: 'groupId' }); + r.onsuccess = () => { r.result.close(); res(); }; + r.onerror = () => res(); + }); + + // The guard looks once, late. Waiting it out is the measurement. + await sleep(11000); + + const guardIn = (f) => { + const h = f.contentDocument.getElementById('app'); + return { text: h ? h.textContent : '', buttons: + [...f.contentDocument.querySelectorAll('#app button')].map((b) => b.textContent) }; + }; + const resetButton = (f) => [...f.contentDocument.querySelectorAll('#app button')] + .find((x) => /r.initialis|reset/i.test(x.textContent)); + + const a = guardIn(ok); + cases.push({ case: 'the application mounted', text: a.text.slice(0, 80), + buttons: a.buttons }); + + const b = guardIn(broken); + cases.push({ case: 'the module graph did not link', + text: b.text.slice(0, 400), buttons: b.buttons }); + + cases.push({ case: 'a blocked database upgrade gives up instead of hanging', + result: blocked.contentWindow.__result || 'never settled' }); + + // ── a reset another tab is holding open ────────────────────────────── + // + // `deleteDatabase` waits for every other connection to close, silently: it + // neither fails nor completes. A reset that does not watch for that + // reloads into the state it has just claimed to clear. + // No version: whatever the database is at now. Asking for a particular one + // is how this fixture quietly stopped holding anything — the store had + // moved to 2 underneath it and the open failed instead of connecting. + const holder = await new Promise((res) => { + const r = indexedDB.open('meshbay'); + r.onsuccess = () => res(r.result); + r.onerror = () => res(null); + }); + let reloadedAnyway = false; + broken.addEventListener('load', () => { reloadedAnyway = true; }, { once: true }); + const r1 = resetButton(broken); + let told = null; + if (r1) { + r1.click(); + await sleep(6000); + const m = broken.contentDocument.querySelector('#app [data-blocked]'); + told = m ? m.getAttribute('data-blocked') : null; + } + cases.push({ case: 'a reset another tab is blocking says so', + clicked: !!r1, told, reloadedAnyway, + stillOffersReset: !!resetButton(broken) }); + + // ── and with nothing in its way ────────────────────────────────────── + if (holder) holder.close(); + const r2 = resetButton(broken); + let after = null; + if (r2) { + const reloaded = new Promise((res) => broken.addEventListener('load', res, { once: true })); + r2.click(); + await Promise.race([reloaded, sleep(9000)]); + await sleep(1500); + // Read from here, not from the frame: the frame is reloading, and a + // window mid-navigation answers for whichever document happens to be + // current. Same origin, so this is the same store. + let dbs = null; + try { dbs = (await indexedDB.databases()).map((d) => d.name); } catch (e) { dbs = 'n/a'; } + after = { auth: localStorage.getItem('mb_auth'), dbs }; + } + cases.push({ case: 'the reset button empties this origin', clicked: !!r2, after }); + + fetch('/log', { method: 'POST', body: JSON.stringify({ cases }) }); + } catch (err) { + fetch('/log', { method: 'POST', + body: JSON.stringify({ error: String((err && err.stack) || err) }) }); + } +})(); +</script></body></html>""" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if self.path == "/log": + RECORDS.append(json.loads(self.rfile.read(length).decode())) + else: + self.rfile.read(length) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str, code: int = 200) -> None: + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path, _, query = self.path.partition("?") + if path == "/": + self._send(PAGE.encode(), "text/html; charset=utf-8") + elif path == "/blocked": + self._send(BLOCKED.encode(), "text/html; charset=utf-8") + elif path == "/shell": + self._send(SHELL.encode(), "text/html; charset=utf-8") + elif path == "/__case.js": + # The referring frame decides: one mounts, the other is missing — + # a 404 on a module, which is what a graph that will not link looks + # like from the outside. + if "case=fails" in (self.headers.get("Referer") or ""): + self._send(b"not here", "text/plain", 404) + else: + self._send(MOUNTS.encode(), "text/javascript") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), "text/javascript") + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=900,900", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(600): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0], indent=1, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/test_boot_guard.py b/packages/meshbay-hub/tests/test_boot_guard.py new file mode 100644 index 0000000..d802a03 --- /dev/null +++ b/packages/meshbay-hub/tests/test_boot_guard.py @@ -0,0 +1,122 @@ +""" +The page cannot end up silently blank, and there is a way out of it. + +A reader spent an evening on a white screen. The shell arrived on every reload, +every module was served from the browser's own store so not one request reached +the hub, and nothing rendered: no message, no error, nothing in the server log +to look at. The same account worked in a private window and in another browser +— the signature of something wrong in this origin's stored state rather than in +what was deployed. Clearing the site's data fixed it; clearing the *cache*, +three times, had not, because a service worker and IndexedDB are not the cache. + +What is measured here is the silence, not its cause — that evidence was +destroyed by the fix, necessarily. A blank page is a bug report nobody can +write, and on a phone there is no console to open. + +`boot-guard.js` is a classic script, loaded before the module graph, because the +failure it guards against includes the graph never linking: one bad module and +no module code runs at all, so a guard inside `app.js` would be part of what +failed. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "boot_guard_probe.py" + + +@pytest.fixture(scope="module") +def cases(): + if shutil.which("google-chrome") is None: + pytest.skip("Chrome is not available") + proc = subprocess.run([sys.executable, str(HARNESS)], + capture_output=True, text=True, timeout=300) + data = json.loads(proc.stdout) + assert "error" not in data, f"probe failed: {proc.stdout}{proc.stderr}" + return {c["case"]: c for c in data["cases"]} + + +def test_the_guard_says_nothing_when_the_application_mounts(cases): + # The ordinary case, and the one that matters most: a guard that draws over + # a working application would be a worse bug than the one it is for. + c = cases["the application mounted"] + assert c["buttons"] == [], f"the guard drew over a mounted application: {c}" + assert "application" in c["text"] + + +def test_a_graph_that_does_not_link_shows_a_message_not_a_blank_page(cases): + c = cases["the module graph did not link"] + assert len(c["text"]) > 40, f"still effectively blank: {c['text']!r}" + + +def test_the_failure_is_named_on_screen(cases): + # Verbatim, on screen, because a phone has no console and this is the first + # thing anybody diagnosing it will ask for. + c = cases["the module graph did not link"] + assert "__case.js" in c["text"], ( + f"the module that failed is not named anywhere: {c['text']!r}") + + +def test_a_way_out_is_offered(cases): + c = cases["the module graph did not link"] + assert len(c["buttons"]) == 2, c["buttons"] + joined = " ".join(c["buttons"]).lower() + assert "essayer" in joined or "try" in joined + assert "initialis" in joined or "reset" in joined + + +def test_the_reset_button_really_empties_this_origin(cases): + """A button that claims to clear and does not would be worse than none. + + So it is clicked, for real, against a seeded `localStorage` and a seeded + database — and what it leaves behind is what is read back. + """ + c = cases["the reset button empties this origin"] + assert c["clicked"], "no reset button to click" + assert c["after"]["auth"] is None, ( + f"localStorage survived the reset: {c['after']}") + assert c["after"]["dbs"] == [], ( + f"a database survived the reset: {c['after']}") + + +def test_a_reset_another_tab_is_blocking_says_so(cases): + """Deleting a database waits for every other connection to close — silently. + + Unwatched, `deleteDatabase` neither fails nor completes, so a reset that + reloads regardless comes back to exactly the state it claimed to clear. The + reader would then have tried the one thing that works, watched it appear to + work, and still be staring at the same page. + """ + c = cases["a reset another tab is blocking says so"] + assert c["clicked"], "no reset button to click" + assert c["told"] == "meshbay", ( + f"the blocked database was not named on screen: {c}") + assert not c["reloadedAnyway"], "it reloaded into the state it had not cleared" + assert c["stillOffersReset"], "the reader is left with no way to try again" + + +def test_a_blocked_database_upgrade_gives_up_instead_of_hanging(cases): + """`indexedDB.open` fires neither `success` nor `error` when an upgrade is + blocked by another connection — it fires `blocked`, and unhandled that + leaves the promise unsettled for ever. + + Version 2 arrived with the playlists, so every browser that had used this + site before it has a version 1 to upgrade, and a second tab holding one open + is all it takes. + """ + c = cases["a blocked database upgrade gives up instead of hanging"] + assert c["result"] != "never settled", c + assert c["result"]["held"], "the fixture did not hold a connection open" + assert c["result"]["outcome"].startswith("rejected:"), c["result"] + # `blocked` fires the moment the upgrade is attempted, so this is immediate. + # The deadline behind it is a backstop for the case where even `blocked` + # never arrives — tolerating five seconds here would let the backstop stand + # in for the handler and the handler could be deleted unnoticed. + assert "blocked" in c["result"]["outcome"], ( + f"gave up on a timeout rather than on the blocked event: {c['result']}") + assert c["result"]["ms"] < 1000, f"it took {c['result']['ms']}ms to give up" |