chore(web): connected-flow smoke for the Tools, Resources and Prompts tabs
The web client had exactly one connected-flow smoke and it was the Apps tab. `smoke:web:browser` proves the bundle boots and paints but never connects; `smoke:web:app` / `smoke:web:elicit` connect but only drive the Apps surface. So the three highest-traffic tabs were unexercised through the prod bundle end to end — everything covering them (unit tests, Storybook, the integration project) mocks or bypasses the prod server, so a regression living only in the built bundle or in the prod Hono backend's wiring of a tab passed all of them. `scripts/smoke-web-tabs.mjs` drives all three against one server on one browser launch (~5s added wall-clock): Tools runs `list_items` and asserts its `structuredContent` section rendered (#1908), Resources reads `events` with the template list asserted separately — `resources/list` and `resources/templates/list` are different calls and either can fail alone — and Prompts fetches the argument-less `simple_prompt`. Every assertion is on a `data-*` attribute, never on copy, mirroring the `data-app-status` precedent: each screen now carries a readiness contract (`data-tool-count` / `data-call-status` and its Resources / Prompts equivalents), tabulated in the web README and pinned by screen unit tests so a rename fails loudly there rather than as an opaque timeout here. The deep-link connect step is extracted to `lib/deep-link-connect.mjs` rather than copied a third time. That matters because `parseDeepLink` ignores a link it cannot validate rather than reporting one, so a drifted copy fails as an opaque connect timeout in whichever smoke was not updated. `smoke:web:elicit`'s hand-rolled copy is retired onto it. Chromium-only and deliberately not in `ENGINE_SMOKES`: the engine tiers exist for the MCP Apps sandbox's genuinely divergent primitives, and these tabs are ordinary React and Mantine. Network, Protocol, Logs and Tasks remain uncovered — a stated gap, not an oversight. Closes #2148 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
833ad40a45
commit
f62410a9b3
3 changed files with 806 additions and 56 deletions
87
scripts/lib/deep-link-connect.mjs
Normal file
87
scripts/lib/deep-link-connect.mjs
Normal 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 });
|
||||
}
|
||||
148
scripts/lib/deep-link-connect.test.mjs
Normal file
148
scripts/lib/deep-link-connect.test.mjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue