inspector/scripts/smoke-web-app.mjs
cliffhall ef4259a544 test(smoke): run the headless web smokes in Firefox and WebKit, not just Chromium (#2086)
Every browser-driven check in this repo launched Chromium and nothing else.
That is fine for most of the web client, whose behavior is React and Mantine.
It is not fine for the MCP Apps sandbox, which is built out of the primitives
that genuinely diverge between engines: a CSP <meta> injected as the first
<head> child of a srcdoc document, a nested sandboxed iframe, a
Permissions-Policy allow attribute, and postMessage origin discipline across
those two frames. Nothing else covers that surface — sandbox-csp.test.ts
asserts which policy STRING is built (environment-independent by
construction), and no Storybook story reaches the sandbox at all, since all
three App stories use a data: placeholder iframe and a mock bridge.

The engine is now a parameter:

  SMOKE_BROWSER=webkit npm run smoke:web:app
  SMOKE_BROWSER=firefox npm run smoke:web:engine   # all three smokes

Unset means chromium, so `npm run ci` and `npm run smoke` are unchanged. An
unrecognized value is an error rather than a fallback — falling back would
report a green Chromium run under a job labelled "webkit", claiming coverage
that never ran — and a launch failure names the engine that failed and its own
`playwright install` invocation.

Selection, launching, and the fatal-vs-benign page-diagnostics split now live
in one shared scripts/lib/headless-browser.mjs. The three smokes had each
hand-rolled the diagnostics split and two had their own copy of loadChromium;
pack:verify goes through the same helper, pinned to Chromium explicitly since
it is a packaging check.

The npm scripts drop their `cd clients/web && npx playwright install chromium`
prefix for scripts/install-smoke-browser.mjs. The engine is a variable now, and
a ${VAR:-default} expansion does not expand under Windows' cmd.exe; npx is also
a .cmd shim there that a shell-free spawn cannot start, so this resolves the
Playwright CLI through resolveNodeBin — the #1939 problem solved the #1939 way.

CI gains a `Sandbox smokes (<engine>)` matrix job rather than matrixing `build`,
which also runs validate, the coverage gate, two verify gates and Storybook —
all engine-independent. Both publish jobs now need it.

WebKit is supported by the tooling but deliberately NOT in the CI matrix yet:
pointing the smokes at it immediately found #2132, a pre-existing Safari
incompatibility in the web client's SSE transport that strands the last message
on /api/mcp/events, so an MCP App never leaves "loading". That is a product bug
affecting real Safari users, with its own diagnosis and a verified fix, and it
is not a matrix change. Adding `webkit` here is a one-word diff once it lands.
An engine is either green in CI or absent from it; a continue-on-error job would
report coverage nobody is held to.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 20:44:11 -04:00

294 lines
11 KiB
JavaScript

#!/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.
*
* A second phase (#2056) drives the same app declaring `_meta.ui.domain`, and
* asserts the thing that field exists for: the app document is served from the
* backend's dedicated app-origin listener and runs at a REAL origin, not the
* opaque one whose requests carry `Origin: null`. Both phases end at
* `data-app-status="ready"`, so only reading the inner frame's own
* `location.origin` can tell them apart.
*
* The flow both phases share 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 phase 1's 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 — and this is the only one of the two that drives phase 2. 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.
*
* ── Which engine ────────────────────────────────────────────────────────────
*
* `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or
* `webkit`); CI runs all three (#2086). This smoke is one of the two places the
* MCP Apps sandbox is genuinely exercised, and the sandbox is built out of the
* primitives that actually diverge between engines — `srcdoc` CSP inheritance,
* nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. See
* `lib/headless-browser.mjs`, including why a green WebKit run is not a Safari
* guarantee.
*
* 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, as in smoke:cli — see `scripts/lib/ensure-test-servers.mjs` for why
* presence isn't freshness. This smoke is the one that found that out: a
* `v2/main` merge touching `preset-registry.ts` made it report the dedicated
* app-origin path as broken when it was really driving the pre-merge fixture.
*/
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 {
attachPageDiagnostics,
loadBrowser,
resolveBrowserName,
} from "./lib/headless-browser.mjs";
import {
APP_TOOL,
buildAppDeepLink,
driveAppFlow,
sandboxProxyPageFor,
startMcpAppServer,
} from "./lib/mcp-app-flow.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
// Resolved before anything is started, so an unsupported SMOKE_BROWSER fails
// immediately rather than after a web server and two MCP servers are up. Every
// message this smoke prints carries the engine, so a matrix failure names which
// one broke without the reader having to match it to a job title.
let BROWSER;
try {
BROWSER = resolveBrowserName();
} catch (err) {
console.error(
`smoke:web:app FAILED — ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
const LABEL = `smoke:web:app [${BROWSER}]`;
// 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 each test server announces on startup — authoritative over the
// config's port; see startMcpAppServer.
let mcpUrl = null;
let mcpServer = null;
let domainMcpServer = 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" });
}
if (domainMcpServer) {
const child = domainMcpServer;
domainMcpServer = null;
await stopChild(child, { label: LABEL, what: "MCP test server (domain)" });
}
}
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 loadBrowser(repoRoot, BROWSER);
const page = await browser.newPage();
const diagnostics = attachPageDiagnostics(page);
const deepLink = (target) =>
buildAppDeepLink({
baseUrl: server.baseUrl,
mcpUrl: target,
token: TOKEN,
appArgs: { title: LABEL },
});
/** The frames a failure should name, so "never rendered" reads differently
* from "rendered at the wrong origin". */
const frameList = () =>
`frames: ${page
.frames()
.map((f) => f.url() || "(blank)")
.join(", ")}`;
/**
* Phase 2 (#2056): the same app, this time declaring `_meta.ui.domain`.
*
* Phase 1 proves the default render works; it cannot distinguish it from the
* dedicated-origin render, because both end at `data-app-status="ready"`. The
* thing #2056 is actually about is the *origin the app document runs at* — an
* opaque origin sends `Origin: null`, which no CORS / OAuth-callback /
* API-key allowlist can admit. So this phase asserts the two facts that only
* hold on the dedicated path: the inner frame was navigated to a published
* document on the backend's app-origin listener, and the document's own
* `location.origin` is a real origin rather than the literal "null".
*
* It reuses the running web server and browser page — only a second MCP
* server and a second deep-link navigate are added. The deep-link gate is not
* re-asserted (`expectDeepLink: false`): it was proven on the first navigate,
* and a rejected one would fail at the connect wait regardless.
*/
const driveDedicatedOrigin = async () => {
const domainUrl = await startMcpAppServer({
repoRoot,
config: "mcp-app-domain-http",
onSpawn: (child) => {
domainMcpServer = child;
},
label: LABEL,
});
await driveAppFlow({
page,
url: deepLink(domainUrl),
expectDeepLink: false,
what: "dedicated-origin app",
extraDiagnostics: frameList,
});
const appFrame = page
.frames()
.find((f) => /\/app-document\/[0-9a-f]{32}$/.test(f.url()));
if (!appFrame) {
throw new Error(
"app declaring _meta.ui.domain was not served from the dedicated app " +
`origin — no frame at /app-document/<id> (${frameList()})`,
);
}
const origin = await appFrame.evaluate(() => window.location.origin);
if (!/^http:\/\/[^/]+$/.test(origin)) {
throw new Error(
`app document ran at origin "${origin}" — expected a real http origin. ` +
"An opaque origin sends `Origin: null`, which is the bug #2056 fixes.",
);
}
return { origin, url: appFrame.url() };
};
// Race against launcher death so a mid-run server crash is reported as the
// real cause instead of a downstream timeout.
let dedicated = null;
try {
await Promise.race([
server.whenChildExits(),
driveAppFlow({ page, url: deepLink(mcpUrl) }),
]);
dedicated = await Promise.race([
server.whenChildExits(),
driveDedicatedOrigin(),
]);
} 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; ` +
`and an app declaring _meta.ui.domain rendered from the dedicated origin ` +
`${dedicated?.origin} (${dedicated?.url})`,
);
await shutdown();
process.exit(0);
} catch (err) {
await fail(err instanceof Error ? err.message : String(err));
}