diff --git a/docs/cli.md b/docs/cli.md index 76dde4f4fb..5b075fe373 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts index 7c0d07445e..01b1f54da6 100644 --- a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts @@ -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((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((_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, diff --git a/tests/boundary/mcp/navigationContextMcp.test.ts b/tests/boundary/mcp/navigationContextMcp.test.ts new file mode 100644 index 0000000000..7bcf1d363d --- /dev/null +++ b/tests/boundary/mcp/navigationContextMcp.test.ts @@ -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>, +): Promise => { + 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 }, + }); + }, +);