#!/usr/bin/env node /** * Headless-browser MCP Apps smoke for the prod web client (#1859). * * `smoke:web:browser` proves the bundle boots and paints its first frame. It * stops there — it never connects to a server, so everything downstream of the * connect (the Apps tab, the sandbox controller, the UI-protocol bridge) is * unexercised by any smoke. This closes that gap: it drives the full * **connect → open app → widget ready** chain against a real MCP App server. * * The flow itself lives in `lib/mcp-app-flow.mjs`, shared with `pack:verify` * (#2003) — see that header for why it is shared rather than copied, and for * the `data-app-status="ready"` contract the assertion rests on. * * ── What this does and does NOT catch ─────────────────────────────────────── * * This runs against the **repo build tree**, like every other `smoke:*`. That * matters for the bug that motivated it: #1859 was a *packaging* failure — * `clients/web/static/sandbox_proxy.html` was missing from the published * tarball's "files" allowlist. In the repo that file is always present, so this * smoke would have stayed green through that entire bug. * * The packaging dimension is owned by `npm run pack:verify`, which asserts the * file in the tarball packlist and on disk after a real install — and, since * #2003, drives this same App flow against the **installed** bin. Keep both: * pack:verify covers the published artifact, this covers the repo tree on every * `npm run ci`, where pack:verify (network-bound, local/release-only) does not * run. Neither subsumes the other. * * As a cheap extra, this does assert the proxy page exists at the location the * runtime resolves it from (`clients/web/build/../static/…`, see * server/sandbox-controller.ts) — which catches the file being *moved or * renamed* without its reader being updated, a repo-tree failure pack:verify * would only find later. * * Expects `clients/web/dist` and `clients/launcher/build` to be built first — * the validate / CI ordering guarantees this. `test-servers/build` is built on * demand if missing, as in smoke:cli. */ import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { startProdWebServer } from "./lib/prod-web-server.mjs"; import { stopChild } from "./lib/child-cleanup.mjs"; import { APP_TOOL, attachPageDiagnostics, buildAppDeepLink, driveAppFlow, loadChromium, sandboxProxyPageFor, startMcpAppServer, } from "./lib/mcp-app-flow.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const LABEL = "smoke:web:app"; // Resolved exactly as the runtime does, from the built runner's directory. const sandboxProxyPage = sandboxProxyPageFor( join(repoRoot, "clients", "web", "build"), ); const HOST = "127.0.0.1"; // Distinct from smoke:web (6299) and smoke:web:browser (6298) so a prior smoke // whose port is still bound — slow teardown, TIME_WAIT, or a parallel run — // can't EADDRINUSE this one. The three run back-to-back in `npm run smoke`. const PORT = process.env.SMOKE_WEB_APP_PORT ?? "6297"; const TOKEN = "smoke-web-app-token"; // The URL the test server announces on startup — authoritative over the // config's port; see startMcpAppServer. let mcpUrl = null; let mcpServer = null; let browser = null; const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN, label: LABEL, }); async function shutdown() { if (browser) { try { await browser.close(); } catch { // best-effort } browser = null; } await server.stop(); if (mcpServer) { const child = mcpServer; mcpServer = null; await stopChild(child, { label: LABEL, what: "MCP test server" }); } } async function fail(message) { console.error(`${LABEL} FAILED — ${message}`); await shutdown(); process.exit(1); } try { // Cheap structural check first: the sandbox proxy page must exist where the // runtime looks for it. Fails fast with a clear cause instead of surfacing as // an opaque "app never reached ready" 45s timeout below. if (!existsSync(sandboxProxyPage)) { await fail( `sandbox proxy page missing at ${sandboxProxyPage} — clients/web/server/sandbox-controller.ts ` + `reads it as \`join(__dirname, "../static/sandbox_proxy.html")\`; if it moved, update both ` + `(and the "files" allowlist in the root package.json, see #1859)`, ); } // `startMcpAppServer` publishes the child through `onSpawn` itself, so // teardown reaches it even when this throws before returning (#2000). mcpUrl = await startMcpAppServer({ repoRoot, onSpawn: (child) => { mcpServer = child; }, label: LABEL, }); await server.waitForReady(); browser = await loadChromium(repoRoot); const page = await browser.newPage(); const diagnostics = attachPageDiagnostics(page); const url = buildAppDeepLink({ baseUrl: server.baseUrl, mcpUrl, token: TOKEN, appArgs: { title: LABEL }, }); // Race against launcher death so a mid-run server crash is reported as the // real cause instead of a downstream timeout. try { await Promise.race([server.whenChildExits(), driveAppFlow({ page, url })]); } catch (err) { const notes = [ ...diagnostics.pageErrors, ...diagnostics.fatalConsole().map((m) => `console: ${m}`), ]; await fail( `${err instanceof Error ? err.message : String(err)}${ notes.length ? ` — page diagnostics: ${notes.join("; ")}` : "" }`, ); } // Hard failures: any uncaught sync page error, plus the console errors that // are the async half of the same class. const fatal = diagnostics.fatal(); if (fatal.length > 0) { await fail(`app logged uncaught error(s): ${fatal.join("; ")}`); } // Non-fatal console errors: surface them so a real problem isn't invisible, // without failing on benign subresource/warning noise. const benign = diagnostics.benignConsole(); if (benign.length > 0) { console.log( `${LABEL} note — ${benign.length} non-fatal console error(s): ${benign.join("; ")}`, ); } console.log( `${LABEL} OK — connected to ${mcpUrl}, opened "${APP_TOOL}", ` + `widget reached data-app-status="ready" through the sandbox proxy`, ); await shutdown(); process.exit(0); } catch (err) { await fail(err instanceof Error ? err.message : String(err)); }