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>
166 lines
5.6 KiB
JavaScript
166 lines
5.6 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("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\)"/,
|
|
);
|
|
});
|
|
});
|