inspector/scripts/smoke-web-elicitation.mjs
cliffhall 25eb2fb3bf docs(smoke): stop describing the WebKit smoke failure as a Safari bug
#2132 was filed asserting that a Safari user opening an MCP App gets a
permanently 'loading' widget. That was never tested. A manual check in
real Safari renders the App normally, so the claim is unsupported and is
removed from all six places it reached.

The failure under Playwright's WebKit is real and still keeps that engine
out of the CI matrix. What is not established is that any shipping
browser behaves this way — and Playwright ships its own WebKit build
whose networking stack is not Safari's, which is exactly the layer the
failure lives in.

headless-browser.mjs already carried the caveat that a green WebKit run
is not a Safari guarantee. The same sentence forbids the inverse
inference, which is the one that was made; it now says so explicitly, and
AGENTS.md carries the rule: reproduce in the real browser before claiming
user impact in one.

No behavior change.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 22:32:47 -04:00

321 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* Headless-browser smoke for app-rendered form elicitations (#1854).
*
* `smoke:web:app` proves an App *tool* renders. This proves the other thing an
* MCP App can now do: answer a server's `elicitation/create`. It drives the
* whole negotiated chain end to end against the public fixture —
* **connect → call the tool → server elicits → app renders in the sandbox →
* user clicks → the app's standard `ElicitResult` reaches the server** — and
* then drives the SAME tool against a server that did NOT advertise the nested
* MCP Apps `elicitation` capability, which must fall back to the Inspector's
* native elicitation form.
*
* The fallback half is the more valuable of the two. The failure mode this
* feature can produce is not "the app doesn't render" (loud, obvious) but
* "an app renders when it should not have been offered one" — a client that
* over-claims the capability strands every user of a server that never opted
* in. Asserting the native form appears is what pins that.
*
* Two nested frames matter here: the outer trusted sandbox-proxy iframe and the
* inner sandboxed iframe holding the untrusted app. Clicking the app's button
* therefore needs `frameLocator(...).frameLocator(...)`, not one hop.
*
* Set `SMOKE_SCREENSHOT_DIR` to capture PNGs of each state (used to attach
* proof to a PR); unset, it asserts only.
*
* `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or
* `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and
* **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not
* gated yet — it fails on #2132, where the SSE stream's last message is not
* delivered under Playwright's WebKit. That has NOT been reproduced in real
* Safari (an MCP App opens there fine), so treat it as a property of that
* build, not as a browser bug, until someone shows otherwise (#2086).
*
* Along with `smoke:web:app` this is one
* of the two places the MCP Apps sandbox is actually loaded, and the two nested
* frames below are precisely the surface that diverges between engines. See
* `lib/headless-browser.mjs`, including why a green WebKit run is not a Safari
* guarantee.
*
* Expects `clients/web/dist` and `clients/launcher/build` to be built first.
* `test-servers/build` is rebuilt on every run, as in smoke:web:app — see
* `scripts/lib/ensure-test-servers.mjs` for why presence isn't freshness.
*/
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { setTimeout as delay } from "node:timers/promises";
import { join, resolve } from "node:path";
import { startProdWebServer } from "./lib/prod-web-server.mjs";
import { stopChild } from "./lib/child-cleanup.mjs";
import {
attachPageDiagnostics,
loadBrowser,
resolveBrowserName,
} from "./lib/headless-browser.mjs";
import {
ensureTestServers,
testServerEntryPath,
} from "./lib/ensure-test-servers.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
// Resolved before anything is started, so an unsupported SMOKE_BROWSER fails
// immediately rather than after a web server and two MCP servers are up. Every
// message carries the engine, so a matrix failure names which one broke.
let BROWSER;
try {
BROWSER = resolveBrowserName();
} catch (err) {
console.error(
`smoke:web:elicit FAILED — ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
const LABEL = `smoke:web:elicit [${BROWSER}]`;
const composableServer = testServerEntryPath(repoRoot, "composable");
const configPath = (name) =>
join(repoRoot, "test-servers", "configs", `${name}.json`);
const HOST = "127.0.0.1";
// Distinct from the other web smokes (6299 / 6298 / 6297) so a prior run whose
// port is still in TIME_WAIT can't EADDRINUSE this one.
const PORT = process.env.SMOKE_WEB_ELICIT_PORT ?? "6296";
const TOKEN = "smoke-web-elicit-token";
const TOOL = "app_choose_option";
const SHOT_DIR = process.env.SMOKE_SCREENSHOT_DIR;
const servers = [];
let browser = null;
const web = startProdWebServer({
host: HOST,
port: PORT,
token: TOKEN,
label: LABEL,
});
async function shutdown() {
if (browser) {
try {
await browser.close();
} catch {
// best-effort
}
browser = null;
}
await web.stop();
while (servers.length) {
await stopChild(servers.pop(), {
label: LABEL,
what: "MCP test server",
});
}
}
async function fail(message) {
console.error(`${LABEL} FAILED — ${message}`);
await shutdown();
process.exit(1);
}
/**
* Spawn a composable test server and wait for the URL it announces.
*
* The announced line is authoritative: `createTestServerHttp` resolves its port
* with `findAvailablePort()`, which walks upward when the configured one is
* taken. Both stdio channels are scanned because the announcement goes to
* stderr (`console.error` in server-composable.ts).
*/
async function startMcpServer(configName) {
const child = spawn(
process.execPath,
[composableServer, "--config", configPath(configName)],
{ cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] },
);
servers.push(child);
let out = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (out += d));
let exited = false;
let spawnError = null;
child.on("error", (err) => (spawnError = err));
child.on("exit", () => (exited = true));
child.on("close", () => (exited = true));
for (let attempt = 0; attempt < 120; attempt++) {
const announced = out.match(/listening at (http:\/\/\S+)/i);
if (announced) return announced[1];
if (spawnError) {
throw new Error(
`could not spawn the MCP test server (${composableServer}): ${spawnError.message}`,
);
}
if (exited) throw new Error(`MCP test server exited early:\n${out}`);
await delay(250);
}
throw new Error(`MCP test server did not start within 30s:\n${out}`);
}
async function shot(page, name) {
if (!SHOT_DIR) return;
mkdirSync(SHOT_DIR, { recursive: true });
await page.screenshot({
path: join(SHOT_DIR, `${name}.png`),
fullPage: false,
});
console.log(`${LABEL} — captured ${name}.png`);
}
/** Connect to `mcpUrl` through the deep link and wait for the Tools list. */
async function connect(page, mcpUrl) {
const url =
`${web.baseUrl}/?serverUrl=${encodeURIComponent(mcpUrl)}` +
`&transport=http&autoConnect=${TOKEN}`;
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: 30_000,
});
if (!response || !response.ok()) {
throw new Error(
`GET / returned HTTP ${response ? response.status() : "no response"}`,
);
}
const status = page.locator('[data-testid="connection-status"]');
await status.waitFor({ state: "attached", timeout: 30_000 });
const deeplink = await status.getAttribute("data-deeplink");
if (deeplink !== "parsed") {
throw new Error(
`deep link was not accepted (data-deeplink="${deeplink}") — expected "parsed"`,
);
}
await page
.locator('[data-testid="connection-status"][data-status="connected"]')
.waitFor({ state: "attached", timeout: 45_000 });
}
/** Select the elicitation tool in the Tools tab and run it. */
async function runTool(page) {
// The main-view tabs are a Mantine SegmentedControl: a visually-hidden radio
// plus a sibling <label for>. The label is the clickable element — there is
// no role="tab" here, and the radio itself has no hit box.
await page.locator('label[for$="-Tools"]').first().click({ timeout: 30_000 });
await page
.getByRole("button", { name: TOOL, exact: true })
.click({ timeout: 30_000 });
await page
.getByRole("button", { name: /execute tool/i })
.click({ timeout: 30_000 });
}
try {
// Rebuilt on every run — presence is not freshness (#2111).
ensureTestServers({
repoRoot,
label: LABEL,
requires: ["composable"],
});
const appUrl = await startMcpServer("app-elicitation-http");
const nativeUrl = await startMcpServer("app-elicitation-native-http");
await web.waitForReady();
browser = await loadBrowser(repoRoot, BROWSER);
const page = await browser.newPage({
viewport: { width: 1280, height: 900 },
});
// Uncaught *synchronous* page errors, plus their *async* twin (an unhandled
// rejection or a failed dynamic import), which arrives on the console channel
// instead. Both are hard failures; every other console error is only a
// diagnostic, so benign font/React noise cannot flake CI. Shared with
// smoke:web:app rather than re-hand-rolled — the split is subtle enough that
// two copies would drift, and it is documented at length on the helper.
const diagnostics = attachPageDiagnostics(page);
const drive = async () => {
// ── 1. Negotiated: the server's app answers the elicitation ────────────
await connect(page, appUrl);
await runTool(page);
const modal = page.locator(
'[data-testid="app-elicitation"][data-app-elicitation-status="ready"]',
);
try {
await modal.waitFor({ state: "attached", timeout: 45_000 });
} catch {
const any = page.locator('[data-testid="app-elicitation"]');
const last = (await any.count())
? await any.getAttribute("data-app-elicitation-status")
: "(no app-elicitation modal)";
throw new Error(
`the app never rendered the elicitation (last status: "${last}") — ` +
`either the negotiation gates rejected it or the sandbox/bridge failed`,
);
}
await shot(page, "app-elicitation-rendered");
// The app lives two frames deep: trusted sandbox proxy → sandboxed app.
const app = page
.frameLocator('[data-testid="app-elicitation"] iframe')
.frameLocator("iframe");
await app.locator('[data-testid="choose-a"]').click({ timeout: 30_000 });
// The tool echoes the ElicitResult it received, so the result pane proves
// the app's standard result reached the SERVER — not merely the host.
await page
.getByText(/"action":"accept".*"choice":"option-a"/)
.first()
.waitFor({ state: "attached", timeout: 45_000 });
await shot(page, "app-elicitation-result");
// ── 2. Not negotiated: the same tool falls back to the native form ─────
await connect(page, nativeUrl);
await runTool(page);
// Scoped to the dialog on purpose: the prompt string also appears inside
// the (hidden) Protocol-tab payload, which a bare text lookup matches first.
const nativeDialog = page.getByRole("dialog", {
name: /elicitation request/i,
});
await nativeDialog.waitFor({ state: "visible", timeout: 45_000 });
await nativeDialog
.getByText("Choose option A or B.")
.first()
.waitFor({ state: "visible", timeout: 15_000 });
// The modal fades in; a screenshot taken on the first visible frame catches
// a transparent overlay. Nothing is asserted on this delay.
await delay(600);
if (await page.locator('[data-testid="app-elicitation"]').count()) {
throw new Error(
"an app was rendered for a server that never advertised the nested " +
"MCP Apps elicitation capability — the negotiation gate is not holding",
);
}
await shot(page, "native-elicitation-fallback");
};
try {
await Promise.race([web.whenChildExits(), drive()]);
} 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("; ")}` : ""
}`,
);
}
// A drive that reached all of its assertions still fails if the page threw on
// the way: without this the smoke prints OK over a broken bundle.
const fatal = diagnostics.fatal();
if (fatal.length > 0) {
await fail(`page logged uncaught error(s): ${fatal.join("; ")}`);
}
console.log(`${LABEL} OK — app-rendered and native paths both drive`);
await shutdown();
} catch (err) {
await fail(err instanceof Error ? err.message : String(err));
}