Round 2's suppressed finding, and a fair one: smoke-web-app.mjs reaches the build through `startMcpAppServer` rather than calling the helper itself, so its header survived the sweep still claiming `test-servers/build` is "built on demand if missing" — the exact policy this PR removes. It is also the smoke that found the defect, so the header now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com>
267 lines
10 KiB
JavaScript
267 lines
10 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.
|
|
*
|
|
* 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 {
|
|
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 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 loadChromium(repoRoot);
|
|
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));
|
|
}
|