| 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). |
| 14 | import { chromium } from "playwright"; |
| 15 | |
| 16 | const url = process.argv[2]; |
| 17 | if (!url) { console.error("usage: node boot-smoke.mjs <signin-url>"); process.exit(2); } |
| 18 | |
| 19 | const browser = await chromium.launch(); |
| 20 | const page = await browser.newPage(); |
| 21 | const problems = []; |
| 22 | page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`)); |
| 23 | page.on("console", (m) => { if (m.type() === "error") problems.push(`console.error: ${m.text()}`); }); |
| 24 | page.on("response", (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()}: ${new URL(r.url()).pathname}`); }); |
| 25 | |
| 26 | await page.goto(url, { waitUntil: "networkidle" }).catch((e) => problems.push(`goto: ${e.message}`)); |
| 27 | await 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. |
| 32 | const skipHS = page.locator("#hs-open"); |
| 33 | if (await skipHS.waitFor({ timeout: 3000 }).then(() => true).catch(() => false)) await skipHS.click(); |
| 34 | console.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. |
| 36 | const signin = page.getByText("Sign in here in the browser"); |
| 37 | if (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 | } |
| 42 | const nameBox = page.locator("#join-form #name"); |
| 43 | if (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 | } |
| 50 | console.log(`landed: ${page.url().replace(/[#?].*$/, "")}`); |
| 51 | await 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. |
| 55 | const row = page.locator("#thread-list li, #thread-list button, #thread-list > *:not(.thread-empty-note):not(#chat-search)").first(); |
| 56 | if (await row.count()) { |
| 57 | await row.click({ timeout: 3000 }).catch((e) => problems.push(`thread click: ${e.message}`)); |
| 58 | await page.waitForTimeout(2500); |
| 59 | } |
| 60 | const composer = await page.locator(".chatapp-root .composer .composer-input").count(); |
| 61 | console.log(`composer mounted: ${composer > 0}`); |
| 62 | |
| 63 | // Type into the composer and send a message end-to-end (drives ws send). |
| 64 | if (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). |
| 75 | const filter = page.locator("#chat-filter"); |
| 76 | if (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). |
| 92 | const compose = page.locator("#new-chat"); |
| 93 | if (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 | |
| 102 | await browser.close(); |
| 103 | if (problems.length) { |
| 104 | console.error("BOOT-SMOKE FAIL:\n" + problems.map((p) => " " + p).join("\n")); |
| 105 | process.exit(1); |
| 106 | } |
| 107 | console.log("BOOT-SMOKE PASS"); |
| 108 | |