chore(ci): render an MCP App from the installed tarball in pack:verify (#2003)

Nothing covered the intersection #1859 fell through. `smoke:web:app` drives
the App path but against the repo build tree; `pack:verify` runs against the
installed tarball but only asserted the sandbox proxy page *exists*. So a
packaging regression that ships the file present-but-unreachable — a rename
with a stale reader, a wrong position relative to clients/web/build, a future
"files"/.npmignore edit — passes both.

Extract the drive-the-App-flow half of the smoke into
scripts/lib/mcp-app-flow.mjs and call it from both, rather than copying it:
the deep-link shape is the part that rots silently, and a drifted link fails
as an opaque timeout rather than a mismatch. pack:verify then drives
connect -> open app -> data-app-status="ready" in headless Chromium against
the installed `--web` boot it already starts.

The client under test comes from the install; the App test server stays a
repo fixture.

Also isolates pack:verify's `--web` catalog (the #1977 treatment the smokes
already have). Untidy while the step only did `GET /`; the App deep link
persists a server row, which would make it destructive and non-deterministic
against the developer's real ~/.mcp-inspector/mcp.json.

Unit tests cover what neither consumer can check itself — both CSRF gates and
the appArgs encoding on the deep link, and driveAppFlow's failure branches
against a stand-in page, all dead code from a happy-path smoke's view.

Verified: pack:verify passes end to end against the real tarball, and fails
at the new step alone when sandbox-controller.ts is pointed at a renamed
proxy page (the tarball and on-disk assertions both still pass — the exact
gap this closes).

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 07:44:58 -04:00
commit 17c33080f9
4 changed files with 676 additions and 242 deletions

View file

@ -0,0 +1,282 @@
/**
* The MCP Apps drive-the-App-flow half of `smoke:web:app`, extracted so
* `pack:verify` can run the same flow against the **installed tarball** (#2003).
*
* Two checks bracketed the bug that motivated all of this (#1859, the missing
* `clients/web/static/sandbox_proxy.html`) without either one catching it:
*
* | check | runs against | drives the App path? |
* | --------------- | ------------------ | -------------------- |
* | `smoke:web:app` | the repo build tree | yes |
* | `pack:verify` | the installed tarball | no — only `GET /` |
*
* So a packaging regression that leaves the file present-but-unreachable — a
* rename with a stale reader, a wrong relative position, a future `.npmignore`
* or `"files"` edit — passes both: the smoke never installs, and `pack:verify`
* only asserted the path *exists*. Sharing the flow is what closes it, and it
* has to be *shared* rather than copied: the deep-link shape is the part that
* silently rots, and two copies would drift without either failing.
*
* Note the split of responsibilities that survives here. The **client** under
* test comes from wherever the caller booted it (repo tree or install); the
* **test server** is always a repo fixture — it is not in the tarball and
* should not be.
*
* Playwright is resolved with a `createRequire` based at
* clients/web/package.json rather than a bare `import("playwright")`: a bare ESM
* specifier resolves relative to `scripts/`, not the cwd, so a `cd clients/web`
* in the npm script would NOT make it resolvable. Same gotcha as
* smoke-web-browser.mjs; see its header.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
import { startAnnouncedChild } from "./announced-child.mjs";
import { resolveNodeBin } from "./resolve-node-bin.mjs";
/** The App tool the `mcp-app-http.json` fixture serves. */
export const APP_TOOL = "mcp_app_demo";
/**
* Console messages that are the async half of the uncaught-crash class (an
* unhandled rejection or a failed dynamic import). Hard failures; every other
* console error is a diagnostic, so benign font-CDN / React-warning noise can't
* flake CI. Kept identical to smoke-web-browser.mjs, which documents the
* reasoning at length.
*/
export const FATAL_CONSOLE =
/^Uncaught\b|Failed to fetch dynamically imported module/;
/** Path to the composable test server build, relative to the repo root. */
export function composableServerPath(repoRoot) {
return join(repoRoot, "test-servers", "build", "server-composable.js");
}
/** Path to a `test-servers/configs/<name>.json` fixture. */
export function testServerConfigPath(repoRoot, name) {
return join(repoRoot, "test-servers", "configs", `${name}.json`);
}
/**
* The path `clients/web/server/sandbox-controller.ts` resolves at runtime, given
* the directory holding the built web runner. Kept in sync with the
* `join(__dirname, "../static/sandbox_proxy.html")` there — the position
* *relative to* the runner is what matters, which is why callers pass the
* runner dir rather than a package root.
*/
export function sandboxProxyPageFor(webBuildDir) {
return join(webBuildDir, "..", "static", "sandbox_proxy.html");
}
/**
* Build the composable test server bundle if it isn't present yet.
*
* The root-installed tsc is run via this Node — `npx` is a `.cmd` shim on
* Windows that a shell-free spawnSync can't start (ENOENT — #1939).
*/
export function ensureComposableTestServer(repoRoot, label) {
const entry = composableServerPath(repoRoot);
if (existsSync(entry)) return entry;
console.log(`${label} — building test-servers (missing build output)...`);
const r = spawnSync(
process.execPath,
[
resolveNodeBin("typescript", "tsc", repoRoot),
"-p",
"test-servers",
"--noCheck",
],
{ cwd: repoRoot, stdio: "inherit" },
);
if (r.status !== 0 || !existsSync(entry)) {
throw new Error(
"could not build the test servers (test-servers/build/server-composable.js). " +
"Run `npm run test-servers:build` from clients/web.",
);
}
return entry;
}
/**
* Spawn a composable test server and wait for it to announce its URL.
*
* The announced line is authoritative rather than the config's port:
* `createTestServerHttp` resolves through `findAvailablePort()`, which walks
* upward when the configured value is taken — so the config port is a starting
* hint, not a guarantee, and assuming it fails whenever anything else holds it.
* The announcement goes to **stderr** (`console.error` in
* server-composable.ts), which is why `startAnnouncedChild` scans both channels.
*
* `onSpawn` publishes the child before the readiness wait, so the caller's
* teardown reaches it on every throw path — the timeout included (#2000).
*/
export async function startMcpAppServer({
repoRoot,
config = "mcp-app-http",
onSpawn,
label,
}) {
const entry = ensureComposableTestServer(repoRoot, label);
const { match } = await startAnnouncedChild({
command: process.execPath,
args: [entry, "--config", testServerConfigPath(repoRoot, config)],
cwd: repoRoot,
pattern: /listening at (http:\/\/\S+)/i,
onSpawn,
what: `MCP test server (${entry})`,
});
return match[1];
}
/** base64url(JSON) — the `appArgs` encoding the deep link expects. */
export function encodeAppArgs(args) {
return Buffer.from(JSON.stringify(args))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
/**
* The deep link that connects, switches to the Apps tab, pre-selects the app
* tool, and fires "Open App". `autoConnect`/`autoOpen` must equal the session
* token (CSRF gate). Shape owned by clients/web/README.md#deep-link-auto-connect.
*/
export function buildAppDeepLink({
baseUrl,
mcpUrl,
token,
appTool = APP_TOOL,
appArgs = {},
}) {
return (
`${baseUrl}/?serverUrl=${encodeURIComponent(mcpUrl)}` +
`&transport=http&autoConnect=${token}&openApp=${appTool}` +
`&appArgs=${encodeAppArgs(appArgs)}&autoOpen=${token}`
);
}
/**
* Attach the two error channels a headless page reports on.
*
* An uncaught *synchronous* page error fires `pageerror`; its async twin — an
* unhandled rejection or a failed dynamic import — is not a `pageerror` at all,
* Chromium reports it on the console channel instead. Both are captured; only
* `fatal()` is a failure, so ordinary console noise stays a diagnostic.
*/
export function attachPageDiagnostics(page) {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (err) =>
pageErrors.push(err instanceof Error ? err.message : String(err)),
);
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
return {
pageErrors,
consoleErrors,
fatalConsole: () => consoleErrors.filter((m) => FATAL_CONSOLE.test(m)),
benignConsole: () => consoleErrors.filter((m) => !FATAL_CONSOLE.test(m)),
fatal: () => [
...pageErrors,
...consoleErrors.filter((m) => FATAL_CONSOLE.test(m)),
],
};
}
/** Launch headless Chromium, resolved from the web client's install. */
export async function loadChromium(repoRoot) {
const requireFromWeb = createRequire(
join(repoRoot, "clients", "web", "package.json"),
);
let chromium;
try {
({ chromium } = requireFromWeb("playwright"));
} catch (err) {
// Not resolvable means devDependencies are missing — fixed by `npm install`
// at the repo root, NOT by `playwright install` (which fetches binaries).
throw new Error(
`could not resolve the Playwright package from clients/web — run \`npm install\` at the repo root (${err instanceof Error ? err.message : String(err)})`,
);
}
try {
return await chromium.launch({ headless: true });
} catch (err) {
throw new Error(
`chromium failed to launch — on a bare Linux box run \`npx playwright install --with-deps chromium\` for the system libraries (${err instanceof Error ? err.message : String(err)})`,
);
}
}
/**
* Drive **connect → open app → widget ready** on an already-open page.
*
* The load-bearing assertion is the `data-app-status="ready"` contract
* documented in clients/web/README.md ("MCP Apps screen automation contract"):
* the renderer reports `ready` only once the widget has loaded inside the
* sandbox iframe *and* fired `notifications/initialized` back through the
* bridge. So one attribute covers the whole path — the sandbox controller
* serving the proxy page, the proxy loading the UI resource, and the bridge
* completing its handshake.
*
* Throws with the last observed `data-app-status` / `data-app-error` rather than
* a bare timeout, so a failure names its stage.
*
* Takes only the small slice of Playwright's `page` it uses (`goto`, `locator`
* → `waitFor`/`getAttribute`/`count`), which is what lets its failure paths be
* unit-tested against a stand-in — a smoke only ever exercises its happy path.
*/
export async function driveAppFlow({
page,
url,
gotoTimeoutMs = 30_000,
connectTimeoutMs = 45_000,
readyTimeoutMs = 45_000,
}) {
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: gotoTimeoutMs,
});
if (!response || !response.ok()) {
throw new Error(
`GET / returned HTTP ${response ? response.status() : "no response"}`,
);
}
// 1. The deep link must be accepted (not rejected by the token gate).
const status = page.locator('[data-testid="connection-status"]');
await status.waitFor({ state: "attached", timeout: gotoTimeoutMs });
const deeplink = await status.getAttribute("data-deeplink");
if (deeplink !== "parsed") {
throw new Error(
`deep link was not accepted (data-deeplink="${deeplink}") — expected "parsed"`,
);
}
// 2. Connected to the test server.
await page
.locator('[data-testid="connection-status"][data-status="connected"]')
.waitFor({ state: "attached", timeout: connectTimeoutMs });
// 3. The widget rendered inside the sandbox and completed its handshake.
try {
await page
.locator('[data-testid="apps-form"][data-app-status="ready"]')
.waitFor({ state: "attached", timeout: readyTimeoutMs });
} catch {
const form = page.locator('[data-testid="apps-form"]');
const present = (await form.count()) > 0;
const appStatus = present
? await form.getAttribute("data-app-status")
: "(no apps-form)";
const appError = present ? await form.getAttribute("data-app-error") : null;
throw new Error(
`app never reached data-app-status="ready" (last: "${appStatus}"` +
`${appError ? `, data-app-error="${appError}"` : ""}) — the sandbox proxy ` +
`or the UI-protocol bridge failed to complete`,
);
}
}

View file

@ -0,0 +1,166 @@
/**
* Unit tests for the shared MCP Apps flow (#2003).
*
* These cover the halves neither consumer can check itself. `smoke:web:app` and
* `pack:verify` only ever exercise the happy path — the widget reaches `ready`,
* so every failure branch below is dead code from their point of view, and the
* deep-link shape is asserted by nothing at all once it is built rather than
* inlined. Both are exactly the parts that rot silently: a mangled `appArgs`
* encoding or a dropped `autoOpen` token produces a page that simply never
* connects, reported as an opaque timeout.
*
* `driveAppFlow` is driven against a stand-in page rather than Chromium — it
* takes only `goto` and `locator(...)` → `waitFor`/`getAttribute`/`count`,
* which is what makes that possible.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildAppDeepLink,
driveAppFlow,
encodeAppArgs,
sandboxProxyPageFor,
} from "./mcp-app-flow.mjs";
/** Minimal Playwright `page` stand-in. `plan` maps a selector to its behavior. */
function fakePage(plan, { gotoStatus = 200 } = {}) {
return {
goto: async () => ({
ok: () => gotoStatus === 200,
status: () => gotoStatus,
}),
locator: (selector) => {
const entry = plan[selector] ?? {};
return {
waitFor: async () => {
if (entry.waitFails) throw new Error(`timeout: ${selector}`);
},
getAttribute: async (name) => entry.attrs?.[name] ?? null,
count: async () => entry.count ?? 0,
};
},
};
}
const STATUS = '[data-testid="connection-status"]';
const CONNECTED = '[data-testid="connection-status"][data-status="connected"]';
const FORM = '[data-testid="apps-form"]';
const READY = '[data-testid="apps-form"][data-app-status="ready"]';
/** The plan under which every stage succeeds. */
const happyPlan = () => ({
[STATUS]: { attrs: { "data-deeplink": "parsed" } },
[CONNECTED]: {},
[READY]: {},
});
describe("encodeAppArgs", () => {
it("encodes JSON as base64url with no padding", () => {
const encoded = encodeAppArgs({ title: "a/b+c?" });
assert.doesNotMatch(encoded, /[+/=]/);
assert.deepEqual(JSON.parse(Buffer.from(encoded, "base64url").toString()), {
title: "a/b+c?",
});
});
});
describe("buildAppDeepLink", () => {
const link = buildAppDeepLink({
baseUrl: "http://127.0.0.1:6399",
mcpUrl: "http://127.0.0.1:3130/mcp",
token: "tok",
appArgs: { title: "t" },
});
const params = new URL(link).searchParams;
it("percent-encodes the server URL so its own query/scheme can't leak", () => {
assert.ok(link.includes(encodeURIComponent("http://127.0.0.1:3130/mcp")));
assert.equal(params.get("serverUrl"), "http://127.0.0.1:3130/mcp");
});
it("carries the session token on BOTH gates", () => {
// autoConnect and autoOpen are separate CSRF gates; dropping either leaves
// the page silently idle rather than erroring.
assert.equal(params.get("autoConnect"), "tok");
assert.equal(params.get("autoOpen"), "tok");
});
it("defaults to the fixture's app tool and round-trips appArgs", () => {
assert.equal(params.get("openApp"), "mcp_app_demo");
assert.equal(params.get("transport"), "http");
assert.deepEqual(
JSON.parse(Buffer.from(params.get("appArgs"), "base64url").toString()),
{ title: "t" },
);
});
});
describe("sandboxProxyPageFor", () => {
it("resolves relative to the runner dir, as sandbox-controller.ts does", () => {
assert.match(
sandboxProxyPageFor("/pkg/clients/web/build").replace(/\\/g, "/"),
/\/pkg\/clients\/web\/(build\/\.\.\/)?static\/sandbox_proxy\.html$/,
);
});
});
describe("driveAppFlow", () => {
it("resolves when every stage succeeds", async () => {
await driveAppFlow({ page: fakePage(happyPlan()), url: "http://x/" });
});
it("reports a non-200 document instead of waiting on selectors", async () => {
await assert.rejects(
driveAppFlow({
page: fakePage(happyPlan(), { gotoStatus: 500 }),
url: "http://x/",
}),
/GET \/ returned HTTP 500/,
);
});
it("names a rejected deep link rather than timing out on connect", async () => {
// The token gate refusing the link is the failure a bad `autoConnect`
// produces; without this branch it surfaces as a 45s connect timeout.
const plan = happyPlan();
plan[STATUS] = { attrs: { "data-deeplink": "rejected" } };
await assert.rejects(
driveAppFlow({ page: fakePage(plan), url: "http://x/" }),
/deep link was not accepted \(data-deeplink="rejected"\)/,
);
});
it("surfaces the last app status and error when ready never arrives", async () => {
const plan = happyPlan();
plan[READY] = { waitFails: true };
plan[FORM] = {
count: 1,
attrs: {
"data-app-status": "error",
"data-app-error": "Sandbox not loaded",
},
};
await assert.rejects(
driveAppFlow({ page: fakePage(plan), url: "http://x/" }),
(err) => {
// The #1859 shape: the widget never renders because the proxy page the
// runtime reads is not where it was shipped. The message has to name
// that, not just "timeout".
assert.match(err.message, /last: "error"/);
assert.match(err.message, /data-app-error="Sandbox not loaded"/);
return true;
},
);
});
it("says so when the apps form never mounted at all", async () => {
const plan = happyPlan();
plan[READY] = { waitFails: true };
plan[FORM] = { count: 0 };
await assert.rejects(
driveAppFlow({ page: fakePage(plan), url: "http://x/" }),
/last: "\(no apps-form\)"/,
);
});
});

View file

@ -29,7 +29,19 @@
* 4. runs the installed `mcp-inspector` bin: `--help`, `--cli`/`--tui` help
* dispatch, a real `--cli` `tools/list` over stdio, and a prod `--web` boot
* that must serve `/` (HTTP 200) with the injected auth-token global from
* the shipped `dist` — all from the INSTALLED location, not the repo.
* the shipped `dist` — all from the INSTALLED location, not the repo;
* 5. drives the **MCP Apps** path in headless Chromium against that same
* installed `--web` server — connect → open app → `data-app-status="ready"`
* (#2003). Asserting the sandbox proxy page merely *exists* (step 2/3) is
* not the same as loading a widget through it: a rename with a stale
* reader, or a wrong position relative to `clients/web/build`, ships a file
* that is present and unreachable. The flow is shared with `smoke:web:app`
* via `lib/mcp-app-flow.mjs` so the deep-link shape cannot drift between
* the repo-tree check and this one.
*
* The **client** under test comes from the install; the **test server** stays a
* repo fixture (`test-servers/build/server-composable.js`) — it is not in the
* tarball and should not be.
*
* Exits non-zero on the first failure. Requires network access (step 3 pulls the
* package's runtime dependencies from the registry) — it is a local / release
@ -44,6 +56,15 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { hasExited, removeSafe, stopChild } from "./lib/child-cleanup.mjs";
import {
APP_TOOL,
attachPageDiagnostics,
buildAppDeepLink,
composableServerPath,
driveAppFlow,
loadChromium,
startMcpAppServer,
} from "./lib/mcp-app-flow.mjs";
import { winShellArgs } from "./lib/win-shell-args.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
@ -53,6 +74,7 @@ const testServer = join(
"build",
"test-server-stdio.js",
);
const composableServer = composableServerPath(repoRoot);
// Mirrors INSPECTOR_API_TOKEN_GLOBAL in core/mcp/remote/constants.ts; kept as a
// literal because this plain .mjs script can't import the TS source.
@ -70,6 +92,10 @@ let workDir = null;
// the port and could even serve a stale false-200 to a later run (verifyWeb's
// own `finally { stop() }` is skipped when fail() calls process.exit()).
let webChild = null;
// The composable MCP App test server spawned for the step-5 App render, if any.
// Same reasoning as `webChild`: fail() calls process.exit(), which skips the
// `finally` that would otherwise stop it, and an orphan holds its port.
let appServerChild = null;
const LABEL = "pack:verify";
@ -82,8 +108,10 @@ function fail(message) {
// exiting 1, so an ENOTEMPTY could only bury the real diagnostic under an
// rmSync stack (and skip the "tarball retained" hint below), never turn a
// green run red.
if (webChild && !hasExited(webChild)) {
webChild.kill("SIGTERM");
for (const child of [webChild, appServerChild]) {
if (child && !hasExited(child)) {
child.kill("SIGTERM");
}
}
if (workDir) {
removeSafe(workDir, { label: LABEL });
@ -117,14 +145,22 @@ function runInherit(command, args, cwd = repoRoot) {
return r.status;
}
/** Build the bundled stdio test server if it isn't present yet. */
/**
* Build the bundled test servers if they aren't present yet — both the stdio
* entry (step 4b) and the composable one (step 5). One `tsc -p test-servers`
* emits both, so this builds once and checks each entry.
*/
function ensureTestServer() {
if (existsSync(testServer)) return;
if (existsSync(testServer) && existsSync(composableServer)) return;
step("building test-servers (missing build output)...");
const status = runInherit("npx", ["tsc", "-p", "test-servers", "--noCheck"]);
if (status !== 0 || !existsSync(testServer)) {
if (
status !== 0 ||
!existsSync(testServer) ||
!existsSync(composableServer)
) {
fail(
"could not build the stdio test server (test-servers/build/test-server-stdio.js)",
"could not build the test servers (test-servers/build/{test-server-stdio,server-composable}.js)",
);
}
}
@ -376,7 +412,8 @@ try {
console.log(
"\npack:verify OK — published tarball installs clean and the real bin drives " +
"web (prod / served dist), cli (stdio tools/list), and tui (help) end to end.",
"web (prod / served dist + an MCP App rendered through the shipped sandbox " +
"proxy), cli (stdio tools/list), and tui (help) end to end.",
);
} catch (err) {
// Unexpected throw (not via fail()) — route through fail() for consistent
@ -404,6 +441,15 @@ async function verifyWeb(bin, cwd) {
CLIENT_PORT: port,
HOST: host,
MCP_INSPECTOR_API_TOKEN: token,
// Isolate the catalog, for the reason #1977 gave the web smokes: without
// it the backend falls back to the developer's real
// ~/.mcp-inspector/mcp.json. That was merely untidy while this step only
// did `GET /`; the App deep link below *persists a server row*, which
// would make this both destructive and non-deterministic (a second run
// finds the row already there and races hydration). Assigned after the
// spread so an inherited MCP_CATALOG_PATH is overridden, not obeyed. The
// file lives in the throwaway consumer dir, removed with it.
MCP_CATALOG_PATH: join(cwd, "web-catalog.json"),
MCP_AUTO_OPEN_ENABLED: "false",
},
stdio: ["ignore", "inherit", "inherit"],
@ -464,7 +510,123 @@ async function verifyWeb(bin, cwd) {
if (!body.includes(token)) {
fail("`--web` served HTML is missing the injected auth-token value");
}
// Step 5, riding the same boot: drive the MCP Apps path in a real browser
// against this installed server. Throws rather than calling fail() so the
// `finally` below still stops the `--web` child — the outer catch routes
// the throw back through fail() for the usual cleanup and exit.
//
// `whenExits` lets a mid-run server death be reported as the real cause
// instead of two stacked 45s selector timeouts. Promise.race attaches a
// handler to it either way, so a rejection arriving later (during stop())
// is never an unhandled one.
const whenExits = new Promise((_, reject) => {
child.on("exit", (code) =>
reject(
new Error(
`\`--web\` exited (code ${code}) during the App render — see output above`,
),
),
);
});
await verifyAppRender(`http://${host}:${port}`, token, whenExits);
} finally {
await stop();
}
}
/**
* Drive **connect → open app → widget ready** against the installed `--web`
* server (#2003).
*
* This is what makes the sandbox proxy's *reachability* a checked property
* rather than an inferred one. Steps 2 and 3 assert the file is in the tarball
* and on disk at the right path; neither loads a widget through it, so a
* renamed file with a stale reader, or a future `"files"` / `.npmignore` edit
* that moves it relative to `clients/web/build`, ships present-but-unreachable
* and passes both — exactly the shape of #1859.
*
* The App test server is a repo fixture spawned here; only the *client* comes
* from the install. Everything downstream of the deep link is shared with
* `smoke:web:app` through `lib/mcp-app-flow.mjs`, so the two cannot drift.
*/
async function verifyAppRender(baseUrl, token, whenWebServerExits) {
step(
"verifying an MCP App renders end to end against the installed `--web` server...",
);
ensureTestServer();
let browser = null;
let mcpUrl = null;
try {
// `startMcpAppServer` publishes the child through `onSpawn` before it waits
// for readiness, so teardown reaches it on every throw path — the readiness
// timeout included (#2000).
mcpUrl = await startMcpAppServer({
repoRoot,
onSpawn: (child) => {
appServerChild = child;
},
label: LABEL,
});
browser = await loadChromium(repoRoot);
const page = await browser.newPage();
const diagnostics = attachPageDiagnostics(page);
try {
await Promise.race([
whenWebServerExits,
driveAppFlow({
page,
url: buildAppDeepLink({
baseUrl,
mcpUrl,
token,
appArgs: { title: LABEL },
}),
}),
]);
} catch (err) {
const notes = [
...diagnostics.pageErrors,
...diagnostics.fatalConsole().map((m) => `console: ${m}`),
];
throw new Error(
`${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. Benign console noise stays a note.
const fatal = diagnostics.fatal();
if (fatal.length > 0) {
throw new Error(`app logged uncaught error(s): ${fatal.join("; ")}`);
}
const benign = diagnostics.benignConsole();
if (benign.length > 0) {
console.log(
`pack:verify note — ${benign.length} non-fatal console error(s): ${benign.join("; ")}`,
);
}
} finally {
if (browser) {
try {
await browser.close();
} catch {
// best-effort
}
}
if (appServerChild) {
const child = appServerChild;
appServerChild = null;
await stopChild(child, { label: LABEL, what: "MCP App test server" });
}
}
console.log(
`pack:verify — installed \`--web\` opened "${APP_TOOL}" from ${mcpUrl}; ` +
`widget reached data-app-status="ready" through the shipped sandbox proxy`,
);
}

View file

@ -8,12 +8,9 @@
* 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 assertion is the `data-app-status="ready"` contract documented in
* clients/web/README.md ("MCP Apps screen automation contract"): the renderer
* only reports `ready` once the widget has loaded inside the sandbox iframe and
* fired `notifications/initialized` back through the bridge. So a single
* attribute covers the whole path — sandbox controller serving the proxy page,
* the proxy loading the UI resource, and the bridge completing its handshake.
* 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 ───────────────────────────────────────
*
@ -24,11 +21,11 @@
* smoke would have stayed green through that entire bug.
*
* The packaging dimension is owned by `npm run pack:verify`, which asserts the
* file both in the tarball packlist and on disk after a real install. Keep both:
* pack:verify proves the file *ships*, this proves the App path *works*. Neither
* subsumes the other, and the failure this one is positioned to catch is a
* regression in the sandbox/bridge code itself — which pack:verify, driving only
* `GET /`, would not notice.
* 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
@ -36,51 +33,31 @@
* renamed* without its reader being updated, a repo-tree failure pack:verify
* would only find later.
*
* Playwright is resolved with a `createRequire` based at clients/web/package.json
* rather than a bare `import("playwright")` — a bare ESM specifier resolves
* relative to scripts/, not the cwd, so `cd clients/web` in the npm script would
* NOT make it resolvable. Same gotcha as smoke:web:browser; see its header.
*
* 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 { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { join, resolve } from "node:path";
import { startProdWebServer } from "./lib/prod-web-server.mjs";
import { stopChild } from "./lib/child-cleanup.mjs";
import { startAnnouncedChild } from "./lib/announced-child.mjs";
import { resolveNodeBin } from "./lib/resolve-node-bin.mjs";
import {
APP_TOOL,
attachPageDiagnostics,
buildAppDeepLink,
driveAppFlow,
loadChromium,
sandboxProxyPageFor,
startMcpAppServer,
} from "./lib/mcp-app-flow.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
const requireFromWeb = createRequire(
resolve(repoRoot, "clients/web/package.json"),
);
const LABEL = "smoke:web:app";
const composableServer = join(
repoRoot,
"test-servers",
"build",
"server-composable.js",
);
const appConfig = join(
repoRoot,
"test-servers",
"configs",
"mcp-app-http.json",
);
// The path clients/web/server/sandbox-controller.ts resolves at runtime, from
// the built runner at clients/web/build/. Kept in sync with the `join(__dirname,
// "../static/sandbox_proxy.html")` there.
const sandboxProxyPage = join(
repoRoot,
"clients",
"web",
"static",
"sandbox_proxy.html",
// 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";
@ -89,18 +66,8 @@ const HOST = "127.0.0.1";
// 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";
const APP_TOOL = "mcp_app_demo";
// Console messages that are the async half of the uncaught-crash class (an
// unhandled rejection or a failed dynamic import). Hard failures; every other
// console error is a diagnostic, so benign font-CDN / React-warning noise can't
// flake CI. Kept identical to smoke-web-browser.mjs, which documents the
// reasoning at length.
const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/;
// The URL the test server announces on startup. NOT derived from the config's
// port: createTestServerHttp resolves its port with findAvailablePort(), which
// walks UPWARD from the configured value when it's taken — so the config port is
// a starting hint, not a guarantee, and assuming it makes this smoke fail
// whenever anything else holds that port. The announced line is authoritative.
// The URL the test server announces on startup — authoritative over the
// config's port; see startMcpAppServer.
let mcpUrl = null;
let mcpServer = null;
@ -109,7 +76,7 @@ const server = startProdWebServer({
host: HOST,
port: PORT,
token: TOKEN,
label: "smoke:web:app",
label: LABEL,
});
async function shutdown() {
@ -125,104 +92,20 @@ async function shutdown() {
if (mcpServer) {
const child = mcpServer;
mcpServer = null;
await stopChild(child, { label: "smoke:web:app", what: "MCP test server" });
await stopChild(child, { label: LABEL, what: "MCP test server" });
}
}
async function fail(message) {
console.error(`smoke:web:app FAILED — ${message}`);
console.error(`${LABEL} FAILED — ${message}`);
await shutdown();
process.exit(1);
}
/** Build the composable test server bundle if it isn't present yet. */
function ensureTestServer() {
if (existsSync(composableServer)) return;
console.log(
"smoke:web:app — building test-servers (missing build output)...",
);
// The root-installed tsc, run via this Node — `npx` is a `.cmd` shim on
// Windows that a shell-free spawnSync can't start (ENOENT — #1939).
const r = spawnSync(
process.execPath,
[
resolveNodeBin("typescript", "tsc", repoRoot),
"-p",
"test-servers",
"--noCheck",
],
{ cwd: repoRoot, stdio: "inherit" },
);
if (r.status !== 0 || !existsSync(composableServer)) {
throw new Error(
"could not build the test servers (test-servers/build/server-composable.js). " +
"Run `npm run test-servers:build` from clients/web.",
);
}
}
/**
* Spawn the MCP App test server and wait for it to announce its URL.
*
* The spawn/readiness ownership lives in `lib/announced-child.mjs` so the
* failure path is testable: this smoke's happy path always gets the
* announcement, so nothing here could prove that a child which stays alive
* through the readiness timeout is still reachable by `shutdown()` — the case
* that used to orphan a live server holding its port (#2000). `onSpawn`
* publishes the child to `mcpServer` before the wait, so every throw path is
* covered by teardown.
*
* The announced URL is authoritative rather than the config's port:
* createTestServerHttp resolves through findAvailablePort(), which walks upward
* when the configured value is taken.
*/
async function startMcpServer() {
const { match } = await startAnnouncedChild({
command: process.execPath,
args: [composableServer, "--config", appConfig],
cwd: repoRoot,
pattern: /listening at (http:\/\/\S+)/i,
onSpawn: (child) => {
mcpServer = child;
},
what: `MCP test server (${composableServer})`,
});
return { url: match[1] };
}
/** base64url(JSON) — the appArgs encoding the deep link expects. */
function encodeAppArgs(args) {
return Buffer.from(JSON.stringify(args))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
async function loadChromium() {
let chromium;
try {
({ chromium } = requireFromWeb("playwright"));
} catch (err) {
// Not resolvable means devDependencies are missing — fixed by `npm install`
// at the repo root, NOT by `playwright install` (which fetches binaries).
throw new Error(
`could not resolve the Playwright package from clients/web — run \`npm install\` at the repo root (${err instanceof Error ? err.message : String(err)})`,
);
}
try {
return await chromium.launch({ headless: true });
} catch (err) {
throw new Error(
`chromium failed to launch — on a bare Linux box run \`npx playwright install --with-deps chromium\` for the system libraries (${err instanceof Error ? err.message : String(err)})`,
);
}
}
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" 30s timeout below.
// 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 ` +
@ -231,120 +114,61 @@ try {
);
}
ensureTestServer();
// `startMcpServer` publishes the child to `mcpServer` itself, so teardown
// reaches it even when this throws before returning.
({ url: mcpUrl } = await startMcpServer());
await server.waitForReady();
browser = await loadChromium();
const page = await browser.newPage();
// Uncaught *synchronous* page errors. Their *async* twin — an unhandled
// rejection or a failed dynamic import — is not a `pageerror`; Chromium
// reports it on the console channel instead, so both are captured and both
// are hard failures. Same split as smoke:web:browser; see FATAL_CONSOLE there.
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (err) =>
pageErrors.push(err instanceof Error ? err.message : String(err)),
);
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
// `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,
});
const fatalConsole = () => consoleErrors.filter((m) => FATAL_CONSOLE.test(m));
await server.waitForReady();
browser = await loadChromium(repoRoot);
const page = await browser.newPage();
const diagnostics = attachPageDiagnostics(page);
// Deep link: connect, switch to the Apps tab, pre-select the app tool, and
// fire "Open App". autoConnect/autoOpen must equal the session token (CSRF
// gate). Shape owned by clients/web/README.md#deep-link-auto-connect.
const url =
`${server.baseUrl}/?serverUrl=${encodeURIComponent(mcpUrl)}` +
`&transport=http&autoConnect=${TOKEN}&openApp=${APP_TOOL}` +
`&appArgs=${encodeAppArgs({ title: "smoke:web:app" })}&autoOpen=${TOKEN}`;
const drive = async () => {
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: 30_000,
});
if (!response || !response.ok()) {
throw new Error(
`GET / returned HTTP ${response ? response.status() : "no response"}`,
);
}
// 1. The deep link must be accepted (not rejected by the token gate).
const status = page.locator('[data-testid="connection-status"]');
await status.waitFor({ state: "attached", timeout: 30_000 });
const deeplink = await status.getAttribute("data-deeplink");
if (deeplink !== "parsed") {
throw new Error(
`deep link was not accepted (data-deeplink="${deeplink}") — expected "parsed"`,
);
}
// 2. Connected to the test server.
await page
.locator('[data-testid="connection-status"][data-status="connected"]')
.waitFor({ state: "attached", timeout: 45_000 });
// 3. The widget rendered inside the sandbox and completed its handshake.
// This is the load-bearing assertion — see the header comment.
try {
await page
.locator('[data-testid="apps-form"][data-app-status="ready"]')
.waitFor({ state: "attached", timeout: 45_000 });
} catch {
const form = page.locator('[data-testid="apps-form"]');
const appStatus = (await form.count())
? await form.getAttribute("data-app-status")
: "(no apps-form)";
const appError = (await form.count())
? await form.getAttribute("data-app-error")
: null;
throw new Error(
`app never reached data-app-status="ready" (last: "${appStatus}"` +
`${appError ? `, data-app-error="${appError}"` : ""}) — the sandbox proxy ` +
`or the UI-protocol bridge failed to complete`,
);
}
};
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(), drive()]);
await Promise.race([server.whenChildExits(), driveAppFlow({ page, url })]);
} catch (err) {
const diagnostics = [
...pageErrors,
...fatalConsole().map((m) => `console: ${m}`),
const notes = [
...diagnostics.pageErrors,
...diagnostics.fatalConsole().map((m) => `console: ${m}`),
];
await fail(
`${err instanceof Error ? err.message : String(err)}${
diagnostics.length
? ` — page diagnostics: ${diagnostics.join("; ")}`
: ""
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 = [...pageErrors, ...fatalConsole()];
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 benignConsole = consoleErrors.filter((m) => !FATAL_CONSOLE.test(m));
if (benignConsole.length > 0) {
const benign = diagnostics.benignConsole();
if (benign.length > 0) {
console.log(
`smoke:web:app note — ${benignConsole.length} non-fatal console error(s): ${benignConsole.join("; ")}`,
`${LABEL} note — ${benign.length} non-fatal console error(s): ${benign.join("; ")}`,
);
}
console.log(
`smoke:web:app OK — connected to ${mcpUrl}, opened "${APP_TOOL}", ` +
`${LABEL} OK — connected to ${mcpUrl}, opened "${APP_TOOL}", ` +
`widget reached data-app-status="ready" through the sandbox proxy`,
);
await shutdown();