test(mcp): cover navigation errors and source-map cancellation

Exercise real MCP navigation error delivery and preserve a custom caller abort cause through source-map cancellation. Document doctor stderr retention.

Co-authored-by: yasshmittal <myash0091@gmail.com>
This commit is contained in:
morluto
2026-10-14 14:46:39 +08:00
parent 16e1cc65ed
commit dc6872c66f
3 changed files with 135 additions and 1 deletions
+8
View File
@@ -301,6 +301,14 @@ user permissions; consult the chosen guide for host requirements and effects.
## Output and exit status
`rea mcp doctor --json` checks production MCP startup and reports child logs in
`diagnostics`. It drains stderr while retaining at most its first 64 KiB of raw
bytes as text, omitting a final UTF-8 code point if it crosses that byte limit.
`stderr_bytes` counts all observed bytes, including omitted output;
`stderr_truncated` indicates that output exceeded the retention limit. The same
diagnostics appear inside the report when `--full-output` is selected. Log
truncation alone does not change the diagnostic health or exit status.
The default terminal format is TOON. Use `--json` when saving results for a
JSON consumer. `--json` is indented on a terminal and compact when piped or
redirected, which keeps large results smaller for agents and scripts; pipe
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import {
inspectWebPageInputSchema,
listBrowserTargetsInputSchema,
} from "../../../src/domain/browserObservation.js";
import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js";
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js";
import {
@@ -514,6 +515,71 @@ describe("CdpBrowserProvider: navigation commit and target identity", () => {
});
});
describe("CdpBrowserProvider: source-map cancellation", () => {
it("classifies an AnalysisError abort reason as cancellation", async () => {
const browser = await startFakeCdpBrowser({
sourceMapBody: JSON.stringify({
version: 3,
sources: ["app.ts"],
names: [],
mappings: "",
}),
});
trackBrowser(browser);
const controller = new AbortController();
const reason = new AnalysisInputError("caller_operation");
let markSourceMapRequested!: () => void;
const sourceMapRequested = new Promise<void>((resolve) => {
markSourceMapRequested = resolve;
});
const originalFetch = globalThis.fetch;
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockImplementation((resource, init) => {
if (String(resource).includes("/app.js.map")) {
markSourceMapRequested();
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (signal?.aborted === true) {
reject(signal.reason);
return;
}
signal?.addEventListener("abort", () => reject(signal.reason), {
once: true,
});
});
}
return originalFetch(resource, init);
});
try {
const pending = new CdpBrowserProvider().analyzeBundle(
analyzeWebBundleInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
fetch_source_maps: true,
}),
{ signal: controller.signal },
);
await sourceMapRequested;
controller.abort(reason);
const result = await pending;
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "analyze_web_bundle",
},
});
if (result.ok) throw new Error("Expected source-map cancellation");
expect(result.error.cause).toBe(reason);
} finally {
fetchMock.mockRestore();
}
});
});
const waitForCommand = async (
browser: FakeCdpBrowser,
method: string,
@@ -0,0 +1,60 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { expect, it } from "vitest";
import { ok } from "../../../src/domain/result.js";
import { createServer } from "../../../src/server/createServer.js";
import { observed } from "../../fixtures/analysisExecution.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
const navigate = async (
selection: Readonly<Record<string, unknown>>,
): Promise<CallToolResult> => {
const session = createTestBinarySession(() => ({
execute: (operation) =>
Promise.resolve(observed(selection[operation] ?? null)),
close: () => Promise.resolve(ok(null)),
}));
const server = createServer({ kind: "session", session });
const client = new Client({ name: "navigation-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
await client.callTool({
name: "open_binary",
arguments: { path: process.execPath },
});
return (await client.callTool({
name: "get_navigation_context",
arguments: {},
})) as CallToolResult;
} finally {
await Promise.allSettled([client.close(), server.close(), session.close()]);
}
};
it.each([
{
selection: { current_document: null, current_address: "0x401000" },
operation: "current_document",
},
{
selection: { current_document: "fixture", current_address: null },
operation: "current_address",
},
])(
"delivers a typed MCP failure when $operation is not a string",
async ({ selection, operation }) => {
const result = await navigate(selection);
expect(JSON.stringify(result.content)).not.toContain(
"Output validation error",
);
expect(parseMcpToolError(result).error).toMatchObject({
code: "unreadable_output",
details: { operation },
});
},
);