inspector/scripts/lib/mcp-app-flow.test.mjs
cliffhall df938777b1 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>
2026-08-24 09:05:53 -04:00

202 lines
6.9 KiB
JavaScript

/**
* 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("skips the deep-link gate when the caller re-navigates an open page", async () => {
// smoke:web:app phase 2 (#2056) re-navigates the SAME page to a second
// server. The gate was proven on the first navigate, and a rejected
// re-navigate fails at the connect wait anyway — so a stale/absent
// data-deeplink must not fail the phase.
const plan = happyPlan();
plan[STATUS] = { attrs: {} };
await driveAppFlow({
page: fakePage(plan),
url: "http://x/",
expectDeepLink: false,
});
});
it("names the phase and appends its diagnostics on a ready timeout", async () => {
// A phase-2 timeout that read as a phase-1 one would send the reader to the
// wrong half of the smoke; the frame list is what separates "never
// rendered" from "rendered at the wrong origin".
const plan = happyPlan();
plan[READY] = { waitFails: true };
plan[FORM] = { count: 1, attrs: { "data-app-status": "loading" } };
await assert.rejects(
driveAppFlow({
page: fakePage(plan),
url: "http://x/",
what: "dedicated-origin app",
extraDiagnostics: () => "frames: a, b",
}),
(err) => {
assert.match(err.message, /^dedicated-origin app never reached/);
assert.match(err.message, /frames: a, b$/);
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\)"/,
);
});
});