eleven / ocho Public

tools: commit the boot smoke; docs: close out the shell-modules ladder (#984)

The smoke caught five runtime-only regressions across the modularisation
but lived in a session scratchpad — critical operational knowledge
belongs in the repo, not agent memory. docs/shell-modules.md records the
outcome and the two structural lessons (module-refs test; write the
checker before the first slice).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell dfb63a78c82e31f663c2fdfbda7ceab4c0ead2ac parent 5da23c5
2 files changed, +133 −6
  • docs/shell-modules.md +26 −6
  • tools/boot-smoke.mjs +107 −0
diff --git a/docs/shell-modules.md b/docs/shell-modules.md
index 7293f01..d2d90ec 100644
--- a/docs/shell-modules.md
+++ b/docs/shell-modules.md
@@ -63,11 +63,31 @@ is.)
functions (`openMentionMenu`, `typeaheadItems`) and vice versa.
5. CLAUDE.md's frontend module list gains one line per new module.
+## Outcome (2026-07-15)
+
+The ladder completed over two overnight runs: 13 modules out of shell.js
+(12,057 → ~7,650 lines), the state seam (`shellstate.js`) and the behaviour
+seam (`shellverbs.js`, ~43 registered verbs) both in place, every slice
+deployed to a pinned canary and verified before the next.
+
+Two lessons paid for in bug reports, now structural:
+
+- **A verbatim slice's failure mode is the dropped identifier** — reads that
+ worked in the old module throw `ReferenceError` in the new one, at runtime
+ only, on paths tests don't walk. Call-site audits are not enough: bare
+ *references* (`if (embed)`, `sealedBranding.name`) crash the same way.
+ `test/module-refs.test.mjs` now scans every extracted module for unresolved
+ identifiers and runs in `npm test`. **Write that checker before the first
+ slice of any future effort like this.**
+- **The boot smoke** (`tools/boot-smoke.mjs`) walks sign-in → boot → send →
+ search → picker headlessly and fails on any page error; each escape became
+ a new step. Run it against a `--clean` dev server for anything touching the
+ shell's boot or module graph.
+
## Remaining ladder
-`shellstate.js` conversion, then one PR each: moderation (#578 cluster),
-message search, group actions, the new-chat picker, add-a-device,
-avatars + profile, and the onboarding cluster (one-Eleven flows + quick
-forms + flow bridge — traced as one cohesive unit, not separable). The
-core that stays in shell.js at the end: boot, keys, data loading, and the
-WebSocket dispatch.
+All done (see Outcome). What deliberately stays in shell.js: boot, keys,
+data loading, the WebSocket dispatch, and the render plumbing the verbs
+registry fronts. Possible future work, none owed: verb implementations
+gaining module homes (the registry entry moves with them), and searchui's
+pre-seam `wireSearch` migrating to `verbs.*`.
diff --git a/tools/boot-smoke.mjs b/tools/boot-smoke.mjs
new file mode 100644
index 0000000..6e9bfe9
--- /dev/null
+++ b/tools/boot-smoke.mjs
@@ -0,0 +1,107 @@
+// boot-smoke.mjs — drive the web app end-to-end in headless Chromium against
+// a local dev server: sign in, boot the shell, open the seeded thread, send a
+// message and see it render, run a message search and click the result, open
+// the new-chat picker — failing on any pageerror, console.error, or missing
+// surface. Born during the shell.js modularisation (docs/shell-modules.md),
+// where it caught five runtime-only regressions the unit tests can't see.
+//
+// Run (playwright must be importable — `npm i --no-save playwright`):
+// node tools/lchat-dev-up.mjs --clean # in one terminal; wait for the link
+// node tools/boot-smoke.mjs <the printed invite/sign-in link>
+//
+// Always start the dev server --clean: the sign-in link's key-invite claim is
+// one-time-ish and the db saturates after repeated device sign-ins (403s).
+import { chromium } from "playwright";
+
+const url = process.argv[2];
+if (!url) { console.error("usage: node boot-smoke.mjs <signin-url>"); process.exit(2); }
+
+const browser = await chromium.launch();
+const page = await browser.newPage();
+const problems = [];
+page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`));
+page.on("console", (m) => { if (m.type() === "error") problems.push(`console.error: ${m.text()}`); });
+page.on("response", (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()}: ${new URL(r.url()).pathname}`); });
+
+await page.goto(url, { waitUntil: "networkidle" }).catch((e) => problems.push(`goto: ${e.message}`));
+await page.waitForTimeout(2000);
+
+// An invite link lands on the join ceremony — complete it (name + Join), which
+// generates keys and drops us into the booted shell.
+// The iOS add-to-homescreen interstitial can front the ceremony — skip it.
+const skipHS = page.locator("#hs-open");
+if (await skipHS.waitFor({ timeout: 3000 }).then(() => true).catch(() => false)) await skipHS.click();
+console.log("card text:", (await page.locator("#card").innerText().catch(() => "(no card)")).slice(0, 120).replace(/\n/g, " | "));
+// The dev link is a sign-in (add-device) link — take the browser path.
+const signin = page.getByText("Sign in here in the browser");
+if (await signin.count()) {
+ await signin.click().catch((e) => problems.push(`signin click: ${e.message}`));
+ await page.waitForURL((u) => !u.pathname.startsWith("/join"), { timeout: 20000 }).catch((e) => problems.push(`post-signin nav: ${e.message}`));
+ await page.waitForTimeout(5000);
+}
+const nameBox = page.locator("#join-form #name");
+if (await nameBox.waitFor({ timeout: 8000 }).then(() => true).catch(() => false)) {
+ await nameBox.fill("Smokey");
+ await page.locator('#join-form button[type="submit"]').click().catch((e) => problems.push(`join: ${e.message}`));
+ // Key generation + redirect to the chat page, then the shell's own boot.
+ await page.waitForURL((u) => !u.pathname.startsWith("/join"), { timeout: 20000 }).catch((e) => problems.push(`post-join nav: ${e.message}`));
+ await page.waitForTimeout(5000);
+}
+console.log(`landed: ${page.url().replace(/[#?].*$/, "")}`);
+await page.waitForTimeout(2000);
+
+// If the shell booted, a thread list exists; open the first thread to drive
+// openThread → setActiveId, the ws connect, and the chat app mount.
+const row = page.locator("#thread-list li, #thread-list button, #thread-list > *:not(.thread-empty-note):not(#chat-search)").first();
+if (await row.count()) {
+ await row.click({ timeout: 3000 }).catch((e) => problems.push(`thread click: ${e.message}`));
+ await page.waitForTimeout(2500);
+}
+const composer = await page.locator(".chatapp-root .composer .composer-input").count();
+console.log(`composer mounted: ${composer > 0}`);
+
+// Type into the composer and send a message end-to-end (drives ws send).
+if (composer) {
+ await page.locator(".chatapp-root .composer .composer-input").first().type("boot smoke ping", { delay: 20 }).catch((e) => problems.push(`type: ${e.message}`));
+ await page.keyboard.press("Enter").catch(() => {});
+ await page.waitForTimeout(2000);
+ const sent = await page.locator(".chatapp-feed .bubble, .feed .bubble").filter({ hasText: "boot smoke ping" }).count();
+ console.log(`message sent+rendered: ${sent > 0}`);
+ if (!sent) problems.push("sent message did not render in the feed");
+}
+
+// Message search end-to-end: query the message we just sent, expect a result,
+// click it, and land back in the thread (drives searchui.js's full path).
+const filter = page.locator("#chat-filter");
+if (await filter.count()) {
+ await filter.fill("boot smoke");
+ const hit = page.locator("#msg-results button, #msg-results [role=button], #msg-results li").filter({ hasText: "boot smoke" }).first();
+ let found = 0;
+ for (let i = 0; i < 10 && !found; i++) {
+ await page.waitForTimeout(1000);
+ found = await hit.count();
+ }
+ console.log(`search result found: ${found > 0}`);
+ if (found) await hit.click().catch((e) => problems.push(`result click: ${e.message}`));
+ else problems.push("message search returned no result for a just-sent message");
+ await page.waitForTimeout(1500);
+}
+
+// The new-chat picker: open it and require actual content in the sheet
+// (an empty modal = a mid-render crash, seen once in the wild).
+const compose = page.locator("#new-chat");
+if (await compose.count()) {
+ await compose.click().catch((e) => problems.push(`compose click: ${e.message}`));
+ await page.waitForTimeout(1500);
+ const sheetText = await page.locator(".sheet").last().innerText().catch(() => "");
+ console.log(`picker rendered: ${sheetText.trim().length > 0}`);
+ if (!sheetText.trim()) problems.push("new-chat sheet opened empty");
+ await page.keyboard.press("Escape").catch(() => {});
+}
+
+await browser.close();
+if (problems.length) {
+ console.error("BOOT-SMOKE FAIL:\n" + problems.map((p) => " " + p).join("\n"));
+ process.exit(1);
+}
+console.log("BOOT-SMOKE PASS");