Merge branch 'v2/main' into v2/chore/2003-pack-verify-app-render

One conflict, scripts/smoke-web-app.mjs: #2056 added a second phase to the
smoke (the same app declaring _meta.ui.domain, asserting it renders from the
dedicated app origin rather than an opaque one) while this branch extracted
the smoke's flow into scripts/lib/mcp-app-flow.mjs.

Resolved by keeping both: phase 2 now runs through the shared helper too,
which grows three knobs it needs and pack:verify does not —

  - expectDeepLink: phase 2 re-navigates the SAME page, so the token gate is
    already proven and a rejected re-navigate would fail at the connect wait
    regardless;
  - what: so a phase-2 ready timeout doesn't read as a phase-1 one;
  - extraDiagnostics: phase 2 appends the frame list, which is what separates
    "never rendered" from "rendered at the wrong origin".

Each is covered by a new unit test, since neither consumer exercises a
failure branch.

pack:verify deliberately drives phase 1 only. The dedicated origin is served
by the same runner the packaging assertions already cover and reads nothing
new from disk (app-origin-controller.ts has no file reads), so a second
browser launch there would buy no packaging signal.

Verified on the merged tree: smoke:web:app passes both phases (dedicated
origin http://127.0.0.1:6278/app-document/<id>), and pack:verify passes end
to end against the real tarball.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 09:05:53 -04:00
commit df938777b1

View file

@ -8,9 +8,16 @@
* 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.
* 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 ───────────────────────────────────────
*
@ -22,10 +29,11 @@
*
* 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:
* #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. Neither subsumes the other.
* 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
@ -66,11 +74,12 @@ 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";
// The URL the test server announces on startup — authoritative over the
// 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,
@ -94,6 +103,11 @@ async function shutdown() {
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) {
@ -128,17 +142,87 @@ try {
const page = await browser.newPage();
const diagnostics = attachPageDiagnostics(page);
const url = buildAppDeepLink({
baseUrl: server.baseUrl,
mcpUrl,
token: TOKEN,
appArgs: { title: LABEL },
});
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 })]);
await Promise.race([
server.whenChildExits(),
driveAppFlow({ page, url: deepLink(mcpUrl) }),
]);
dedicated = await Promise.race([
server.whenChildExits(),
driveDedicatedOrigin(),
]);
} catch (err) {
const notes = [
...diagnostics.pageErrors,
@ -169,7 +253,9 @@ try {
console.log(
`${LABEL} OK — connected to ${mcpUrl}, opened "${APP_TOOL}", ` +
`widget reached data-app-status="ready" through the sandbox proxy`,
`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);