eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// boot-smoke.mjs — drive the web app end-to-end in headless Chromium against
2// a local dev server: sign in, boot the shell, open the seeded thread, send a
3// message and see it render, run a message search and click the result, open
4// the new-chat picker — failing on any pageerror, console.error, or missing
5// surface. Born during the shell.js modularisation (docs/shell-modules.md),
6// where it caught five runtime-only regressions the unit tests can't see.
7//
8// Run (playwright must be importable — `npm i --no-save playwright`):
9// node tools/lchat-dev-up.mjs --clean # in one terminal; wait for the link
10// node tools/boot-smoke.mjs <the printed invite/sign-in link>
11//
12// Always start the dev server --clean: the sign-in link's key-invite claim is
13// one-time-ish and the db saturates after repeated device sign-ins (403s).
14import { chromium } from "playwright";
15
16const url = process.argv[2];
17if (!url) { console.error("usage: node boot-smoke.mjs <signin-url>"); process.exit(2); }
18
19const browser = await chromium.launch();
20const page = await browser.newPage();
21const problems = [];
22page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`));
23page.on("console", (m) => { if (m.type() === "error") problems.push(`console.error: ${m.text()}`); });
24page.on("response", (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()}: ${new URL(r.url()).pathname}`); });
25
26await page.goto(url, { waitUntil: "networkidle" }).catch((e) => problems.push(`goto: ${e.message}`));
27await page.waitForTimeout(2000);
28
29// An invite link lands on the join ceremony — complete it (name + Join), which
30// generates keys and drops us into the booted shell.
31// The iOS add-to-homescreen interstitial can front the ceremony — skip it.
32const skipHS = page.locator("#hs-open");
33if (await skipHS.waitFor({ timeout: 3000 }).then(() => true).catch(() => false)) await skipHS.click();
34console.log("card text:", (await page.locator("#card").innerText().catch(() => "(no card)")).slice(0, 120).replace(/\n/g, " | "));
35// The dev link is a sign-in (add-device) link — take the browser path.
36const signin = page.getByText("Sign in here in the browser");
37if (await signin.count()) {
38 await signin.click().catch((e) => problems.push(`signin click: ${e.message}`));
39 await page.waitForURL((u) => !u.pathname.startsWith("/join"), { timeout: 20000 }).catch((e) => problems.push(`post-signin nav: ${e.message}`));
40 await page.waitForTimeout(5000);
41}
42const nameBox = page.locator("#join-form #name");
43if (await nameBox.waitFor({ timeout: 8000 }).then(() => true).catch(() => false)) {
44 await nameBox.fill("Smokey");
45 await page.locator('#join-form button[type="submit"]').click().catch((e) => problems.push(`join: ${e.message}`));
46 // Key generation + redirect to the chat page, then the shell's own boot.
47 await page.waitForURL((u) => !u.pathname.startsWith("/join"), { timeout: 20000 }).catch((e) => problems.push(`post-join nav: ${e.message}`));
48 await page.waitForTimeout(5000);
49}
50console.log(`landed: ${page.url().replace(/[#?].*$/, "")}`);
51await page.waitForTimeout(2000);
52
53// If the shell booted, a thread list exists; open the first thread to drive
54// openThread → setActiveId, the ws connect, and the chat app mount.
55const row = page.locator("#thread-list li, #thread-list button, #thread-list > *:not(.thread-empty-note):not(#chat-search)").first();
56if (await row.count()) {
57 await row.click({ timeout: 3000 }).catch((e) => problems.push(`thread click: ${e.message}`));
58 await page.waitForTimeout(2500);
59}
60const composer = await page.locator(".chatapp-root .composer .composer-input").count();
61console.log(`composer mounted: ${composer > 0}`);
62
63// Type into the composer and send a message end-to-end (drives ws send).
64if (composer) {
65 await page.locator(".chatapp-root .composer .composer-input").first().type("boot smoke ping", { delay: 20 }).catch((e) => problems.push(`type: ${e.message}`));
66 await page.keyboard.press("Enter").catch(() => {});
67 await page.waitForTimeout(2000);
68 const sent = await page.locator(".chatapp-feed .bubble, .feed .bubble").filter({ hasText: "boot smoke ping" }).count();
69 console.log(`message sent+rendered: ${sent > 0}`);
70 if (!sent) problems.push("sent message did not render in the feed");
71}
72
73// Message search end-to-end: query the message we just sent, expect a result,
74// click it, and land back in the thread (drives searchui.js's full path).
75const filter = page.locator("#chat-filter");
76if (await filter.count()) {
77 await filter.fill("boot smoke");
78 const hit = page.locator("#msg-results button, #msg-results [role=button], #msg-results li").filter({ hasText: "boot smoke" }).first();
79 let found = 0;
80 for (let i = 0; i < 10 && !found; i++) {
81 await page.waitForTimeout(1000);
82 found = await hit.count();
83 }
84 console.log(`search result found: ${found > 0}`);
85 if (found) await hit.click().catch((e) => problems.push(`result click: ${e.message}`));
86 else problems.push("message search returned no result for a just-sent message");
87 await page.waitForTimeout(1500);
88}
89
90// The new-chat picker: open it and require actual content in the sheet
91// (an empty modal = a mid-render crash, seen once in the wild).
92const compose = page.locator("#new-chat");
93if (await compose.count()) {
94 await compose.click().catch((e) => problems.push(`compose click: ${e.message}`));
95 await page.waitForTimeout(1500);
96 const sheetText = await page.locator(".sheet").last().innerText().catch(() => "");
97 console.log(`picker rendered: ${sheetText.trim().length > 0}`);
98 if (!sheetText.trim()) problems.push("new-chat sheet opened empty");
99 await page.keyboard.press("Escape").catch(() => {});
100}
101
102await browser.close();
103if (problems.length) {
104 console.error("BOOT-SMOKE FAIL:\n" + problems.map((p) => " " + p).join("\n"));
105 process.exit(1);
106}
107console.log("BOOT-SMOKE PASS");
108