Merge remote-tracking branch 'origin/v2/main' into v2/chore/2146-rename-local-gate

Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	.github/copilot-instructions.md
#	AGENTS.md
#	README.md
This commit is contained in:
cliffhall 2026-08-27 00:16:14 -04:00
commit fc83d2a6c2
7 changed files with 1539 additions and 219 deletions

View file

@ -0,0 +1,87 @@
/**
* The **connect** half of a headless web smoke: build the deep link, navigate
* to it, and wait until the Inspector reports a live connection (#2148).
*
* Every browser-driven smoke that touches a server starts the same way — the
* `?serverUrl=…&transport=http&autoConnect=<token>` link, the `data-deeplink`
* token gate, and the `data-status="connected"` wait. `smoke:web:app` had it
* inside `driveAppFlow`, and `smoke:web:elicit` carried a second, hand-rolled
* copy; a third copy for the tab smoke is what this exists to prevent.
*
* Sharing it matters more than the line count. The deep link's shape is owned
* by clients/web/README.md#deep-link-auto-connect and enforced by
* `parseDeepLink`, which **ignores** a link it cannot validate rather than
* reporting one — so a drifted copy does not fail as a mismatch, it fails as an
* opaque 45s connect timeout in whichever smoke was not updated. One copy is
* one thing to keep right.
*
* The two gates are separate on purpose and both are worth asserting:
*
* - `data-deeplink` distinguishes "the link was rejected by the CSRF token
* gate" from "no deep link was present at all". Without it a bad token
* reads as a connect that simply never happened.
* - `data-status` is the connection itself.
*
* Takes only the slice of Playwright's `page` it uses (`goto`, `locator` →
* `waitFor`/`getAttribute`), which is what lets its failure branches be
* unit-tested against a stand-in — a smoke only ever drives its happy path.
*/
/**
* The deep link that connects to `mcpUrl`.
*
* `autoConnect` must equal the session's `MCP_INSPECTOR_API_TOKEN`: it is the
* CSRF gate, and a link that fails it is silently ignored. Shape owned by
* clients/web/README.md#deep-link-auto-connect.
*/
export function buildConnectDeepLink({
baseUrl,
mcpUrl,
token,
transport = "http",
}) {
return (
`${baseUrl}/?serverUrl=${encodeURIComponent(mcpUrl)}` +
`&transport=${transport}&autoConnect=${token}`
);
}
/**
* Navigate to `url` and wait for a connected Inspector.
*
* `expectDeepLink: false` skips the token-gate assertion — useful when
* re-navigating an already-proven page to a second server, where a rejected
* link would fail at the connect wait anyway and the gate is not the subject.
*/
export async function connectViaDeepLink({
page,
url,
expectDeepLink = true,
gotoTimeoutMs = 30_000,
connectTimeoutMs = 45_000,
}) {
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: gotoTimeoutMs,
});
if (!response || !response.ok()) {
throw new Error(
`GET / returned HTTP ${response ? response.status() : "no response"}`,
);
}
const status = page.locator('[data-testid="connection-status"]');
if (expectDeepLink) {
await status.waitFor({ state: "attached", timeout: gotoTimeoutMs });
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: connectTimeoutMs });
}

View file

@ -0,0 +1,148 @@
/**
* Unit tests for the shared deep-link connect step (#2148).
*
* These cover what no smoke can check itself. Every consumer only ever drives
* the happy path — the page connects — so both failure branches below are dead
* code from their point of view, and the link's *shape* is asserted by nothing
* once it is built rather than inlined. Those are exactly the parts that rot
* silently: `parseDeepLink` **ignores** a link it cannot validate rather than
* reporting one, so a dropped `autoConnect` token or a mangled `serverUrl`
* produces a page that simply never connects, reported as an opaque 45s
* timeout in whichever smoke was not updated.
*
* `connectViaDeepLink` is driven against a stand-in page rather than a real
* browser — it takes only `goto` and `locator(...)` → `waitFor`/`getAttribute`,
* which is what makes that possible.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildConnectDeepLink,
connectViaDeepLink,
} from "./deep-link-connect.mjs";
/** Minimal Playwright `page` stand-in. `plan` maps a selector to its behavior. */
function fakePage(plan, { gotoStatus = 200 } = {}) {
const gotos = [];
return {
gotos,
goto: async (url) => {
gotos.push(url);
return { 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,
};
},
};
}
const STATUS = '[data-testid="connection-status"]';
const CONNECTED = '[data-testid="connection-status"][data-status="connected"]';
const happyPlan = () => ({
[STATUS]: { attrs: { "data-deeplink": "parsed" } },
[CONNECTED]: {},
});
describe("buildConnectDeepLink", () => {
const link = buildConnectDeepLink({
baseUrl: "http://127.0.0.1:6399",
mcpUrl: "http://127.0.0.1:3130/mcp?a=1",
token: "tok",
});
const params = new URL(link).searchParams;
it("percent-encodes the server URL so its own query can't leak", () => {
// Unencoded, the fixture's `?a=1` would terminate `serverUrl` and become a
// sibling param — the link would still parse, just against a different
// server than the caller asked for.
assert.ok(
link.includes(encodeURIComponent("http://127.0.0.1:3130/mcp?a=1")),
);
assert.equal(params.get("serverUrl"), "http://127.0.0.1:3130/mcp?a=1");
});
it("carries the session token on the CSRF gate", () => {
assert.equal(params.get("autoConnect"), "tok");
});
it("defaults to streamable HTTP and honors an explicit transport", () => {
assert.equal(params.get("transport"), "http");
assert.equal(
new URL(
buildConnectDeepLink({
baseUrl: "http://x",
mcpUrl: "http://y",
token: "t",
transport: "sse",
}),
).searchParams.get("transport"),
"sse",
);
});
});
describe("connectViaDeepLink", () => {
it("resolves once the gate passes and the status reads connected", async () => {
const page = fakePage(happyPlan());
await connectViaDeepLink({ page, url: "http://x/" });
assert.deepEqual(page.gotos, ["http://x/"]);
});
it("reports a non-200 document instead of waiting on selectors", async () => {
await assert.rejects(
connectViaDeepLink({
page: fakePage(happyPlan(), { gotoStatus: 500 }),
url: "http://x/",
}),
/GET \/ returned HTTP 500/,
);
});
it("distinguishes a rejected deep link from an absent one", async () => {
// The whole reason the gate is asserted separately: both `rejected` and
// `none` leave the connection idle, and only this attribute says which.
for (const value of ["rejected", "none"]) {
await assert.rejects(
connectViaDeepLink({
page: fakePage({
...happyPlan(),
[STATUS]: { attrs: { "data-deeplink": value } },
}),
url: "http://x/",
}),
new RegExp(`data-deeplink="${value}"`),
);
}
});
it("skips the gate assertion when the caller opts out", async () => {
// A re-navigate of an already-proven page: the gate was established on the
// first navigate, and a rejected one still fails at the connect wait.
await connectViaDeepLink({
page: fakePage({
...happyPlan(),
[STATUS]: { attrs: { "data-deeplink": "none" } },
}),
url: "http://x/",
expectDeepLink: false,
});
});
it("propagates a connect timeout", async () => {
await assert.rejects(
connectViaDeepLink({
page: fakePage({ ...happyPlan(), [CONNECTED]: { waitFails: true } }),
url: "http://x/",
}),
/timeout/,
);
});
});

View file

@ -30,6 +30,10 @@
import { join } from "node:path";
import { startAnnouncedChild } from "./announced-child.mjs";
import {
buildConnectDeepLink,
connectViaDeepLink,
} from "./deep-link-connect.mjs";
import {
ensureTestServers,
testServerEntryPath,
@ -116,6 +120,9 @@ export function encodeAppArgs(args) {
* The deep link that connects, switches to the Apps tab, pre-selects the app
* tool, and fires "Open App". `autoConnect`/`autoOpen` must equal the session
* token (CSRF gate). Shape owned by clients/web/README.md#deep-link-auto-connect.
*
* The connect half comes from `deep-link-connect.mjs`, shared with every other
* browser smoke; only the three App params (#1859) are added here.
*/
export function buildAppDeepLink({
baseUrl,
@ -125,8 +132,8 @@ export function buildAppDeepLink({
appArgs = {},
}) {
return (
`${baseUrl}/?serverUrl=${encodeURIComponent(mcpUrl)}` +
`&transport=http&autoConnect=${token}&openApp=${appTool}` +
`${buildConnectDeepLink({ baseUrl, mcpUrl, token })}` +
`&openApp=${appTool}` +
`&appArgs=${encodeAppArgs(appArgs)}&autoOpen=${token}`
);
}
@ -171,32 +178,14 @@ export async function driveAppFlow({
connectTimeoutMs = 45_000,
readyTimeoutMs = 45_000,
}) {
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: gotoTimeoutMs,
// 1-2. Navigated, the deep link accepted by the token gate, and connected.
await connectViaDeepLink({
page,
url,
expectDeepLink,
gotoTimeoutMs,
connectTimeoutMs,
});
if (!response || !response.ok()) {
throw new Error(
`GET / returned HTTP ${response ? response.status() : "no response"}`,
);
}
// 1. The deep link must be accepted (not rejected by the token gate).
if (expectDeepLink) {
const status = page.locator('[data-testid="connection-status"]');
await status.waitFor({ state: "attached", timeout: gotoTimeoutMs });
const deeplink = await status.getAttribute("data-deeplink");
if (deeplink !== "parsed") {
throw new Error(
`deep link was not accepted (data-deeplink="${deeplink}") — expected "parsed"`,
);
}
}
// 2. Connected to the test server.
await page
.locator('[data-testid="connection-status"][data-status="connected"]')
.waitFor({ state: "attached", timeout: connectTimeoutMs });
// 3. The widget rendered inside the sandbox and completed its handshake.
try {

View file

@ -100,6 +100,14 @@ describe("findWorkflowViolations", () => {
text: " - run: npm run smoke:web:chromium\n",
rules: [],
},
{
// #2148's tab smoke pins Chromium on both halves of its npm script and is
// deliberately outside ENGINE_SMOKES — it is not an engine pass, and the
// guard must not read `smoke:web:` as forbidden by itself.
name: "allows the Chromium-only tab smoke",
text: " - run: npm run smoke:web:tabs\n",
rules: [],
},
{
name: "allows smoke:tui, which self-skips under CI on its own",
text: " - run: npm run smoke:tui\n",

View file

@ -90,6 +90,23 @@ describe("every engine tier consumes ENGINE_SMOKES", () => {
}
});
it("the Chromium-only tab smoke pins its engine on BOTH halves", () => {
// `smoke:web:tabs` is not engine-sensitive and deliberately stays out of
// ENGINE_SMOKES — but "Chromium-only" is a claim the script has to keep.
// Without the literal engine it followed ambient `SMOKE_BROWSER`, so
// `SMOKE_BROWSER=firefox npm run local:gate` would have run it under Firefox
// (Copilot, #2148). Both halves are pinned because they must AGREE: an
// install of one engine and a launch of another surfaces as a confusing
// "Executable doesn't exist" rather than as the wiring error it is.
assert.match(
scripts["smoke:web:tabs"],
/install-smoke-browser\.mjs chromium .*smoke-web-tabs\.mjs chromium$/,
);
// And it must not be in ENGINE_SMOKES, or the Firefox tier would pick it
// up regardless of what its own script pins.
assert.ok(!ENGINE_SMOKES.includes("smoke-web-tabs.mjs"));
});
it("each gated tier names its engine explicitly rather than reading the env", () => {
// An ambient SMOKE_BROWSER must not be able to redirect a gate: without the
// literal engine, `SMOKE_BROWSER=firefox npm run local:gate` would run Firefox twice

View file

@ -66,6 +66,10 @@ import {
ensureTestServers,
testServerEntryPath,
} from "./lib/ensure-test-servers.mjs";
import {
buildConnectDeepLink,
connectViaDeepLink,
} from "./lib/deep-link-connect.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
@ -176,31 +180,22 @@ async function shot(page, name) {
console.log(`${LABEL} — captured ${name}.png`);
}
/** Connect to `mcpUrl` through the deep link and wait for the Tools list. */
/**
* Connect to `mcpUrl` through the deep link.
*
* Shared with every other browser smoke (#2148) rather than hand-rolled here:
* a link `parseDeepLink` cannot validate is *ignored* rather than reported, so
* a drifted copy surfaces as an opaque connect timeout instead of a mismatch.
*/
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,
await connectViaDeepLink({
page,
url: buildConnectDeepLink({
baseUrl: web.baseUrl,
mcpUrl,
token: TOKEN,
}),
});
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. */

366
scripts/smoke-web-tabs.mjs Normal file
View file

@ -0,0 +1,366 @@
#!/usr/bin/env node
/**
* Headless-browser connected-flow smoke for the core tabs — Tools, Resources,
* Prompts (#2148).
*
* ── What was missing ────────────────────────────────────────────────────────
*
* The web client had exactly one connected-flow smoke, and it was the Apps tab.
* `smoke:web:browser` proves the bundle boots and paints; it never connects.
* `smoke:web:app` / `smoke:web:elicit` connect, but only ever drive the Apps
* surface. So the three highest-traffic tabs — the ones nearly every session
* actually uses — were unexercised through the prod bundle end to end.
*
* Everything that *did* cover them mocks or bypasses the prod server: unit
* tests render a screen with fixture props, Storybook play functions do the
* same in a browser, and the integration project drives `InspectorClient`
* without a browser at all. A regression living only in the built bundle, or in
* the prod Hono backend's wiring of a tab, passes all of them. That is the same
* class `smoke:web:browser` was added for (#1615) and `smoke:web:app` extended
* (#1859).
*
* ── What it drives ──────────────────────────────────────────────────────────
*
* One server, one browser, one page, three tabs:
*
* 1. **Tools** — the list renders, `list_items` is selected and executed, and
* the result panel populates *including* its `structuredContent` section.
* That last part is the half #1908 fixed: a tool declaring an
* `outputSchema` returns its real payload there and the `content[]` blocks
* only summarize it, so a result panel that renders text alone looks
* correct while dropping the data.
* 2. **Resources** — the list renders, a resource is read, and the preview
* panel renders its contents. Templates are asserted present (the RFC 6570
* fixture, #1919), which is what proves `resources/templates/list` reached
* the screen — that list is fetched by a *different* call than
* `resources/list` and can fail on its own.
* 3. **Prompts** — the list renders, `simple_prompt` is fetched (selecting an
* argument-less prompt auto-fetches), and the messages panel renders.
*
* The read/get stages assert the RPC status **and** the rendered panel, not the
* status alone. Each `data-*-status` flips the moment its call resolves, so a
* smoke stopping there would stay green with the panel deleted — which is the
* opposite of what a connected-flow smoke is for.
*
* Deliberately **one script over one browser launch** rather than one per tab:
* launching the browser and booting the server dominate the cost, and
* `npm run local:gate` is already several minutes. Measured added wall-clock is ~5s
* on a warm checkout (macOS, Chromium), the test-server build included.
*
* ── What it asserts on ──────────────────────────────────────────────────────
*
* `data-*` attributes, never copy. Each screen carries a readiness contract
* mirroring the `data-app-status` precedent — `data-testid="tools-screen"` with
* `data-tool-count` / `data-call-status`, and the Resources / Prompts
* equivalents. See clients/web/README.md#core-tab-automation-contract. A smoke
* that waited on visible text would fail the next time a label is reworded,
* which is noise rather than signal.
*
* The connect half is `lib/deep-link-connect.mjs`, shared with every other
* browser smoke — see that header for why a copied deep link fails as an opaque
* timeout rather than a mismatch.
*
* ── Scope ───────────────────────────────────────────────────────────────────
*
* **Chromium only, and not in `ENGINE_SMOKES`.** The engine tiers exist for the
* MCP Apps sandbox, which is built out of the primitives that genuinely diverge
* between engines (`srcdoc` CSP inheritance, nested sandboxed iframes,
* cross-frame `postMessage`). These three tabs are ordinary React and Mantine;
* running them a second time under Firefox would double their cost for
* coverage that is not engine-sensitive. Cross-engine parameterization is
* #2031's third bullet and is tracked separately.
*
* The deeper tabs — Network, Protocol, Logs, Tasks — are **not** covered here.
* Each needs its own server config and more waiting, and #2148 explicitly
* allows splitting. They remain unsmoked; that is a stated gap, not an
* oversight.
*
* Expects `clients/web/dist` and `clients/launcher/build` to be built first —
* the validate / CI ordering guarantees this. `test-servers/build` is rebuilt on
* every run; see `scripts/lib/ensure-test-servers.mjs` for why presence is not
* freshness (#2111).
*/
import { resolve } from "node:path";
import { startProdWebServer } from "./lib/prod-web-server.mjs";
import { stopChild } from "./lib/child-cleanup.mjs";
import { attachPageDiagnostics, loadBrowser } from "./lib/headless-browser.mjs";
import { resolveRequestedBrowser } from "./install-smoke-browser.mjs";
import {
buildConnectDeepLink,
connectViaDeepLink,
} from "./lib/deep-link-connect.mjs";
import { startMcpAppServer } from "./lib/mcp-app-flow.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
// The engine comes from an explicit ARGUMENT first, and only then from
// `SMOKE_BROWSER`. That precedence is the load-bearing part: `smoke:web:tabs`
// passes `chromium` to both this and the installer, so an ambient
// `SMOKE_BROWSER=firefox npm run local:gate` cannot silently redirect a gated tier that
// claims to be Chromium-only — the same invariant `run-engine-smokes.test.mjs`
// pins for the engine tiers (Copilot, #2148). Running another engine by hand is
// still one argument away: `node scripts/smoke-web-tabs.mjs firefox`.
//
// Resolved before anything starts, so an unsupported value fails immediately
// rather than after a web server and an MCP server are up. Every message
// carries the engine, so a failure names which run it came from.
let BROWSER;
try {
BROWSER = resolveRequestedBrowser(process.argv.slice(2), process.env);
} catch (err) {
console.error(
`smoke:web:tabs FAILED — ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
const LABEL = `smoke:web:tabs [${BROWSER}]`;
const HOST = "127.0.0.1";
// Distinct from the other web smokes (6299 / 6298 / 6297 / 6296) so a prior
// run whose port is still in TIME_WAIT can't EADDRINUSE this one.
const PORT = process.env.SMOKE_WEB_TABS_PORT ?? "6295";
const TOKEN = "smoke-web-tabs-token";
/** The fixture's tool with an `outputSchema`, so its result carries
* `structuredContent` (#1908) as well as a `content[]` summary. */
const TOOL = "list_items";
/** The fixture's plain resource — `foobar://events`, listed as "events". */
const RESOURCE = "events";
/** The fixture's argument-less prompt; selecting it auto-fetches. */
const PROMPT = "simple_prompt";
let mcpServer = null;
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();
if (mcpServer) {
const child = mcpServer;
mcpServer = null;
await stopChild(child, { label: LABEL, what: "MCP test server" });
}
}
async function fail(message) {
console.error(`${LABEL} FAILED — ${message}`);
await shutdown();
process.exit(1);
}
/**
* Switch to a main-view tab.
*
* The tabs are a Mantine `SegmentedControl`: a visually-hidden radio plus a
* sibling `<label for>`. There is no `role="tab"` here and the radio has no hit
* box, so the label is the clickable element.
*/
async function openTab(page, name) {
await page
.locator(`label[for$="-${name}"]`)
.first()
.click({ timeout: 30_000 });
}
/**
* Wait for `selector`, reporting the screen's own status attributes on failure
* instead of a bare timeout — so a failure names the stage it stalled at.
*/
async function waitForStage(page, { selector, screen, attrs, what }) {
try {
await page
.locator(selector)
.waitFor({ state: "attached", timeout: 45_000 });
} catch {
const root = page.locator(screen);
const present = (await root.count()) > 0;
const observed = present
? (
await Promise.all(
attrs.map(async (a) => `${a}="${await root.getAttribute(a)}"`),
)
).join(", ")
: `(no ${screen})`;
throw new Error(`${what} — last observed: ${observed}`);
}
}
try {
// Reuses the App smoke's spawner: it is the composable test server either
// way, and it publishes the child through `onSpawn` before the readiness
// wait, so teardown reaches it even when startup throws (#2000). The
// announced URL is authoritative over the config's port — `findAvailablePort`
// walks upward when the configured one is taken.
const mcpUrl = await startMcpAppServer({
repoRoot,
config: "web-tabs-http",
onSpawn: (child) => {
mcpServer = child;
},
label: LABEL,
});
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 stays a
// diagnostic so benign font/React noise can't flake CI.
const diagnostics = attachPageDiagnostics(page);
const drive = async () => {
await connectViaDeepLink({
page,
url: buildConnectDeepLink({ baseUrl: web.baseUrl, mcpUrl, token: TOKEN }),
});
// ── Tools ──────────────────────────────────────────────────────────────
await openTab(page, "Tools");
await waitForStage(page, {
// `:not([data-tool-count="0"])` rather than a numeric comparison, which
// a CSS attribute selector cannot express — an empty list and a missing
// list are the two failures worth telling apart, and both are excluded.
selector: '[data-testid="tools-screen"]:not([data-tool-count="0"])',
screen: '[data-testid="tools-screen"]',
attrs: ["data-tool-count", "data-call-status"],
what: "the Tools list never populated",
});
await page
.getByRole("button", { name: TOOL, exact: true })
.click({ timeout: 30_000 });
await page
.getByRole("button", { name: /execute tool/i })
.click({ timeout: 30_000 });
await waitForStage(page, {
selector: '[data-testid="tools-screen"][data-call-status="ok"]',
screen: '[data-testid="tools-screen"]',
attrs: ["data-call-status"],
what: `\`${TOOL}\` did not complete successfully`,
});
// The half #1908 fixed: the payload lives in `structuredContent`, and a
// panel rendering only the `content[]` summary looks right while dropping
// the data.
await page
.locator('[data-testid="structured-output"]')
.waitFor({ state: "attached", timeout: 15_000 });
// ── Resources ──────────────────────────────────────────────────────────
await openTab(page, "Resources");
await waitForStage(page, {
// Both counts, in one selector: `resources/list` and
// `resources/templates/list` are separate calls and either can fail
// alone, so asserting only the first would pass on a screen with no
// templates at all.
selector:
'[data-testid="resources-screen"]:not([data-resource-count="0"])' +
':not([data-template-count="0"])',
screen: '[data-testid="resources-screen"]',
attrs: ["data-resource-count", "data-template-count", "data-read-status"],
what: "the Resources / templates lists never populated",
});
// The sidebar sections are an accordion whose open set is a persisted
// preference, so it may legitimately start collapsed. Open it only when it
// is closed rather than clicking blind, which would toggle it shut.
const uris = page.getByRole("button", { name: /^URIs/ });
if ((await uris.getAttribute("aria-expanded")) !== "true") {
await uris.click({ timeout: 15_000 });
}
// Selecting a resource reads it — there is no separate Read button here.
await page
.getByRole("button", { name: RESOURCE, exact: true })
.click({ timeout: 30_000 });
await waitForStage(page, {
// Both the RPC status AND the rendered preview. The status flips the
// moment `resources/read` resolves, so stopping there would stay green
// with the preview panel removed entirely (Copilot, #2148) — and #2148
// asks for the content to *render*, not merely for the call to return.
selector:
'[data-testid="resources-screen"][data-read-status="ok"]' +
' [data-testid="resource-preview"]',
screen: '[data-testid="resources-screen"]',
attrs: ["data-read-status"],
what: `reading \`${RESOURCE}\` did not render a preview`,
});
// ── Prompts ────────────────────────────────────────────────────────────
await openTab(page, "Prompts");
await waitForStage(page, {
selector: '[data-testid="prompts-screen"]:not([data-prompt-count="0"])',
screen: '[data-testid="prompts-screen"]',
attrs: ["data-prompt-count", "data-get-status"],
what: "the Prompts list never populated",
});
// An argument-less prompt is fetched the moment it is selected, so no
// submit step follows.
await page
.getByRole("button", { name: new RegExp(`^${PROMPT}`) })
.click({ timeout: 30_000 });
await waitForStage(page, {
// As with the resource preview: the messages panel must have rendered,
// not merely `prompts/get` returned (Copilot, #2148).
selector:
'[data-testid="prompts-screen"][data-get-status="ok"]' +
' [data-testid="prompt-messages"]',
screen: '[data-testid="prompts-screen"]',
attrs: ["data-get-status"],
what: `getting \`${PROMPT}\` did not render its messages`,
});
};
// Race against launcher death so a mid-run server crash is reported as the
// real cause instead of a downstream timeout.
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 every assertion 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("; ")}`);
}
const benign = diagnostics.benignConsole();
if (benign.length > 0) {
console.log(
`${LABEL} note — ${benign.length} non-fatal console error(s): ${benign.join("; ")}`,
);
}
console.log(
`${LABEL} OK — connected to ${mcpUrl}; Tools ran "${TOOL}" with structured ` +
`output, Resources read "${RESOURCE}" with templates listed, and Prompts ` +
`fetched "${PROMPT}"`,
);
await shutdown();
process.exit(0);
} catch (err) {
await fail(err instanceof Error ? err.message : String(err));
}