inspector/scripts/lib/mcp-app-flow.test.mjs
cliffhall 3f7ba20ae6 chore: state the smoke-helper and CI wall-clock budgets (#2333)
The five rows #2323 left as "assess", each either stated with its
reasoning and measurement, or shared with a constant that already has one.

- F4/F5: `driveAppFlow` and `connectViaDeepLink` default to
  `BROWSER_TIMEOUTS.ui` / `.roundTrip` instead of restating 30_000 /
  45_000 by hand; values unchanged, now one place to raise. A test in each
  helper pins the defaults.
- F1: `announced-child.mjs` states `DEFAULTS` ({ timeoutMs: 30_000,
  pollMs: 250 }) with the measurement — boot-to-announce of the composable
  test server is 135ms median at load average 15, and a passing start pays
  one poll interval, not the budget.
- F7: `pty.mjs` names `SCRIPT_PROBE_TIMEOUT_MS` (5s; the probe spawns in
  1.3ms median, 3.6ms max under load) and stops folding ETIMEDOUT into
  "no script(1)": a starved probe now fails `smoke:tui` loudly instead of
  turning it into an exit-0 skip.
- G1: every job in `.github/workflows/main.yml` declares
  `timeout-minutes` as a hung-job guard — roughly twice the slowest run
  observed over the last 40 push runs (and the last three releases for the
  publish jobs), rounded up to the next five minutes, with the range stated
  per job.

AGENTS.md's timeout section now points at where these live instead of at
this issue.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 01:50:33 -04:00

221 lines
7.8 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 { BROWSER_TIMEOUTS } from "./browser-timeouts.mjs";
import {
buildAppDeepLink,
driveAppFlow,
encodeAppArgs,
sandboxProxyPageFor,
} from "./mcp-app-flow.mjs";
/**
* Minimal Playwright `page` stand-in. `plan` maps a selector to its behavior.
* `waits` records the budget each `waitFor` was given, so a test can pin the
* defaults without a real browser.
*/
function fakePage(plan, { gotoStatus = 200 } = {}) {
const waits = {};
return {
waits,
goto: async () => ({
ok: () => gotoStatus === 200,
status: () => gotoStatus,
}),
locator: (selector) => {
const entry = plan[selector] ?? {};
return {
waitFor: async (opts) => {
waits[selector] = opts?.timeout;
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("shares its connect budgets with connectViaDeepLink and waits a roundTrip for ready (#2333)", async () => {
// The connect pair is handed straight through, so `ui`/`roundTrip` here
// are the same two `connectViaDeepLink` defaults to; `ready` is a round
// trip of its own (sandbox load + bridge handshake), not a `nested` render.
const page = fakePage(happyPlan());
await driveAppFlow({ page, url: "http://x/" });
assert.equal(page.waits[STATUS], BROWSER_TIMEOUTS.ui);
assert.equal(page.waits[CONNECTED], BROWSER_TIMEOUTS.roundTrip);
assert.equal(page.waits[READY], BROWSER_TIMEOUTS.roundTrip);
});
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\)"/,
);
});
});