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>
87 lines
3.2 KiB
JavaScript
87 lines
3.2 KiB
JavaScript
/**
|
|
* 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 });
|
|
}
|