#!/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. Playwright is resolved with a * `createRequire` based at clients/web/package.json for the reason documented at * length in smoke-web-browser.mjs. * * 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 { createRequire } from "node:module"; 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 { ensureTestServers, testServerEntryPath, } from "./lib/ensure-test-servers.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const requireFromWeb = createRequire( resolve(repoRoot, "clients/web/package.json"), ); 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; // The async half of the uncaught-crash class. Kept identical to // smoke-web-browser.mjs / smoke-web-app.mjs. const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/; const servers = []; let browser = null; const web = startProdWebServer({ host: HOST, port: PORT, token: TOKEN, label: "smoke:web:elicit", }); 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: "smoke:web:elicit", what: "MCP test server", }); } } async function fail(message) { console.error(`smoke:web:elicit 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 loadChromium() { let chromium; try { ({ chromium } = requireFromWeb("playwright")); } catch (err) { throw new Error( `could not resolve the Playwright package from clients/web — run \`npm install\` at the repo root (${err instanceof Error ? err.message : String(err)})`, ); } try { return await chromium.launch({ headless: true }); } catch (err) { throw new Error( `chromium failed to launch — on a bare Linux box run \`npx playwright install --with-deps chromium\` for the system libraries (${err instanceof Error ? err.message : String(err)})`, ); } } 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(`smoke:web:elicit — 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