inspector/scripts/lib/deep-link-connect.test.mjs
cliffhall f62410a9b3 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>
2026-08-26 23:09:44 -04:00

148 lines
4.8 KiB
JavaScript

/**
* 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/,
);
});
});