aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-17 03:40:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-17 03:40:52 +0200
commitf4fd6db8faa15bf02f38a14b652cc46936f8d6bf (patch)
tree396cbe7fe188fb565231be937ccfa6419384814f /packages/meshbay-hub/tests/harness
parent929bfcccf8928a30d86d48a8b0137db0dd4a1ee0 (diff)
downloadmeshbay-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/meshbay-hub/tests/harness')
-rw-r--r--packages/meshbay-hub/tests/harness/boot_guard_probe.py260
1 files changed, 260 insertions, 0 deletions
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())