#!/usr/bin/env node /** * Headless-browser connected-flow smoke for the core tabs — Tools, Resources, * Prompts (#2148). * * ── What was missing ──────────────────────────────────────────────────────── * * The web client had exactly one connected-flow smoke, and it was the Apps tab. * `smoke:web:browser` proves the bundle boots and paints; it never connects. * `smoke:web:app` / `smoke:web:elicit` connect, but only ever drive the Apps * surface. So the three highest-traffic tabs — the ones nearly every session * actually uses — were unexercised through the prod bundle end to end. * * Everything that *did* cover them mocks or bypasses the prod server: unit * tests render a screen with fixture props, Storybook play functions do the * same in a browser, and the integration project drives `InspectorClient` * without a browser at all. A regression living only in the built bundle, or in * the prod Hono backend's wiring of a tab, passes all of them. That is the same * class `smoke:web:browser` was added for (#1615) and `smoke:web:app` extended * (#1859). * * ── What it drives ────────────────────────────────────────────────────────── * * One server, one browser, one page, three tabs: * * 1. **Tools** — the list renders, `list_items` is selected and executed, and * the result panel populates *including* its `structuredContent` section. * That last part is the half #1908 fixed: a tool declaring an * `outputSchema` returns its real payload there and the `content[]` blocks * only summarize it, so a result panel that renders text alone looks * correct while dropping the data. * 2. **Resources** — the list renders, a resource is read, and the preview * panel renders its contents. Templates are asserted present (the RFC 6570 * fixture, #1919), which is what proves `resources/templates/list` reached * the screen — that list is fetched by a *different* call than * `resources/list` and can fail on its own. * 3. **Prompts** — the list renders, `simple_prompt` is fetched (selecting an * argument-less prompt auto-fetches), and the messages panel renders. * * The read/get stages assert the RPC status **and** the rendered panel, not the * status alone. Each `data-*-status` flips the moment its call resolves, so a * smoke stopping there would stay green with the panel deleted — which is the * opposite of what a connected-flow smoke is for. * * Deliberately **one script over one browser launch** rather than one per tab: * launching the browser and booting the server dominate the cost, and * `npm run local:gate` is already several minutes. Measured added wall-clock is ~5s * on a warm checkout (macOS, Chromium), the test-server build included. * * ── What it asserts on ────────────────────────────────────────────────────── * * `data-*` attributes, never copy. Each screen carries a readiness contract * mirroring the `data-app-status` precedent — `data-testid="tools-screen"` with * `data-tool-count` / `data-call-status`, and the Resources / Prompts * equivalents. See clients/web/README.md#core-tab-automation-contract. A smoke * that waited on visible text would fail the next time a label is reworded, * which is noise rather than signal. * * The connect half is `lib/deep-link-connect.mjs`, shared with every other * browser smoke — see that header for why a copied deep link fails as an opaque * timeout rather than a mismatch. * * ── Scope ─────────────────────────────────────────────────────────────────── * * **Chromium only, and not in `ENGINE_SMOKES`.** The engine tiers exist for the * MCP Apps sandbox, which is built out of the primitives that genuinely diverge * between engines (`srcdoc` CSP inheritance, nested sandboxed iframes, * cross-frame `postMessage`). These three tabs are ordinary React and Mantine; * running them a second time under Firefox would double their cost for * coverage that is not engine-sensitive. Cross-engine parameterization is * #2031's third bullet and is tracked separately. * * The deeper tabs — Network, Protocol, Logs, Tasks — are **not** covered here. * Each needs its own server config and more waiting, and #2148 explicitly * allows splitting. They remain unsmoked; that is a stated gap, not an * oversight. * * Expects `clients/web/dist` and `clients/launcher/build` to be built first — * the validate / CI ordering guarantees this. `test-servers/build` is rebuilt on * every run; see `scripts/lib/ensure-test-servers.mjs` for why presence is not * freshness (#2111). */ import { resolve } from "node:path"; import { startProdWebServer } from "./lib/prod-web-server.mjs"; import { stopChild } from "./lib/child-cleanup.mjs"; import { attachPageDiagnostics, loadBrowser } from "./lib/headless-browser.mjs"; import { resolveRequestedBrowser } from "./install-smoke-browser.mjs"; import { buildConnectDeepLink, connectViaDeepLink, } from "./lib/deep-link-connect.mjs"; import { startMcpAppServer } from "./lib/mcp-app-flow.mjs"; import { BROWSER_TIMEOUTS } from "./lib/browser-timeouts.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); // The engine comes from an explicit ARGUMENT first, and only then from // `SMOKE_BROWSER`. That precedence is the load-bearing part: `smoke:web:tabs` // passes `chromium` to both this and the installer, so an ambient // `SMOKE_BROWSER=firefox npm run local:gate` cannot silently redirect a gated tier that // claims to be Chromium-only — the same invariant `run-engine-smokes.test.mjs` // pins for the engine tiers (Copilot, #2148). Running another engine by hand is // still one argument away: `node scripts/smoke-web-tabs.mjs firefox`. // // Resolved before anything starts, so an unsupported value fails immediately // rather than after a web server and an MCP server are up. Every message // carries the engine, so a failure names which run it came from. let BROWSER; try { BROWSER = resolveRequestedBrowser(process.argv.slice(2), process.env); } catch (err) { console.error( `smoke:web:tabs FAILED — ${err instanceof Error ? err.message : String(err)}`, ); process.exit(1); } const LABEL = `smoke:web:tabs [${BROWSER}]`; const HOST = "127.0.0.1"; // Distinct from the other web smokes (6299 / 6298 / 6297 / 6296) so a prior // run whose port is still in TIME_WAIT can't EADDRINUSE this one. const PORT = process.env.SMOKE_WEB_TABS_PORT ?? "6295"; const TOKEN = "smoke-web-tabs-token"; /** The fixture's tool with an `outputSchema`, so its result carries * `structuredContent` (#1908) as well as a `content[]` summary. */ const TOOL = "list_items"; /** The fixture's plain resource — `foobar://events`, listed as "events". */ const RESOURCE = "events"; /** The fixture's argument-less prompt; selecting it auto-fetches. */ const PROMPT = "simple_prompt"; let mcpServer = null; let browser = null; const web = startProdWebServer({ host: HOST, port: PORT, token: TOKEN, label: LABEL, }); async function shutdown() { if (browser) { try { await browser.close(); } catch { // best-effort } browser = null; } await web.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); } /** * Switch to a main-view tab. * * The tabs are a Mantine `SegmentedControl`: a visually-hidden radio plus a * sibling `