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