// 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 // // 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 "); 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");