import { digestCanonicalValue } from "../../../src/domain/canonicalDigest.js"; import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { compareWebCaptures } from "../../../src/domain/webCaptureDiff.js"; import { compareWebCapturesInputSchema } from "../../../src/domain/webCaptureDiffSchemas.js"; import { describe, expect, it } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js"; import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js"; import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { trackBrowser } from "./cdpBrowserProvider.support.js"; describe("CdpBrowserProvider: approved accessibility and source capture", () => { it("captures approved accessibility text inline", async () => { const browser = await startFakeCdpBrowser(); trackBrowser(browser); const request = inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, include_accessibility_text: true, }); const result = await new CdpBrowserProvider().inspectPage(request); if (!result.ok) throw result.error; expect(result.value.accessibility).toMatchObject({ text_capture: { status: "included", retained_bytes: 27, }, nodes: [expect.objectContaining({ name: "Submit report" })], }); }); it("includes allowed script source only after explicit approval", async () => { const browser = await startFakeCdpBrowser(); trackBrowser(browser); const provider = new CdpBrowserProvider(); const result = await provider.inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, include_script_sources: true, }), ); if (!result.ok) throw result.error; expect(result.value.scripts.items[0]?.source).toEqual( expect.objectContaining({ included: true, artifact: expect.objectContaining({ text: "export const observed = 'source-secret';", bytes: 40, sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), }), }), ); expect(browser.commands.map((command) => command.method)).toContain( "Debugger.getScriptSource", ); }); it.each([{}, { scriptSource: null }, { scriptSource: 123 }])( "rejects malformed source replies instead of inventing empty source: %j", async (reply) => { const browser = await startFakeCdpBrowser({ commandResult: ({ method }) => method === "Debugger.getScriptSource" ? reply : undefined, }); trackBrowser(browser); const result = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, target_id: "allowed-page", observation_ms: 0, include_script_sources: true, }), ); expect(result).toMatchObject({ ok: false, error: { operation: "inspect_web_page", reason: "protocol_error", userMessage: expect.stringContaining("scriptSource must be a string"), }, }); expect(browser.commands.map(({ method }) => method)).toContain( "Target.detachFromTarget", ); }, ); it("retains an explicitly empty producer script", async () => { const browser = await startFakeCdpBrowser({ commandResult: ({ method }) => method === "Debugger.getScriptSource" ? { scriptSource: "" } : undefined, }); trackBrowser(browser); const result = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, target_id: "allowed-page", observation_ms: 0, include_script_sources: true, }), ); if (!result.ok) throw result.error; expect(result.value.scripts.items[0]?.source).toMatchObject({ included: true, artifact: { text: "", bytes: 0 }, }); }); it("fetches and validates source maps only after separate approval", async () => { const browser = await startFakeCdpBrowser({ sourceMapBody: JSON.stringify({ version: 3, names: [], sources: ["src/main.ts"], sourcesContent: ["export const original = true;"], mappings: "AAAA", }), }); trackBrowser(browser); const result = await new CdpBrowserProvider().analyzeBundle( analyzeWebBundleInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, fetch_source_maps: true, }), ); if (!result.ok) throw result.error; expect(result.value.observations.source_maps).toMatchObject({ status: "included", items: [ { status: "included", declared_url: `${browser.allowedOrigin}/app.js.map?token=map-secret`, original_sources: [ expect.objectContaining({ source: `${browser.allowedOrigin}/src/main.ts`, }), ], mappings: [expect.any(Object)], }, ], }); const sourceMapRequest = browser.httpRequests.find(({ url }) => url.startsWith("/app.js.map"), ); expect(sourceMapRequest).toEqual({ url: "/app.js.map?token=map-secret", authorization: undefined, cookie: undefined, referer: undefined, }); }); }); describe("CdpBrowserProvider: transient WebMCP frames", () => { it("drops stale child tools and reports partial coverage when a blank commit remains", async () => { const browser = await startFakeCdpBrowser({ webMcpTools: true, webMcpChildTransientBlank: true, extraCollections: true, }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!result.ok) throw result.error; expect(result.value.tools.items.map(({ name }) => name)).not.toContain( "child_tool", ); expect(result.value.completeness.attach_limited_sections).toContain( "webmcp_tools", ); expect( result.value.completeness.excluded.find( ({ section }) => section === "webmcp_tools", )?.count, ).toBe(1); }); it("accepts fresh child registrations after a stable frame commit", async () => { const browser = await startFakeCdpBrowser({ webMcpTools: true, webMcpChildRecoversAfterTransient: true, webMcpChildTransientBlank: true, extraCollections: true, }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!result.ok) throw result.error; expect(result.value.tools.items).toContainEqual( expect.objectContaining({ name: "child_tool" }), ); expect(result.value.completeness.attach_limited_sections).not.toContain( "webmcp_tools", ); }); it("drops registrations from a child document when that frame commits a replacement", async () => { const browser = await startFakeCdpBrowser({ webMcpTools: true, webMcpChildNavigatesAllowed: true, extraCollections: true, }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!result.ok) throw result.error; expect(result.value.tools.items.map(({ name }) => name)).not.toContain( "child_tool", ); expect( result.value.completeness.excluded.find( ({ section }) => section === "webmcp_tools", )?.count, ).toBe(1); }); }); describe("CdpBrowserProvider: WebMCP discovery and completeness", () => { it.each([true, false])( "discovers untrusted WebMCP declarations with consequential=%s without invoking them", async (consequential) => { const browser = await startFakeCdpBrowser({ webMcpTools: true, webMcpConsequential: consequential, }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!result.ok) throw result.error; expect(result.value).toMatchObject({ status: "available", tools: { total: 1, items: [ { name: "search_orders", description: "Search orders; authorization=Bearer tool-secret", declaration_kind: "declarative", owner_origin: browser.allowedOrigin, annotations: { read_only: true, untrusted_content: true, consequential, autosubmit: false, }, trust: "page-declared-untrusted", registration_source: { url: `${browser.allowedOrigin}/app.js?token=tool-source-secret`, line: 12, column: 4, }, }, ], }, }); expect(result.value.completeness.excluded).toContainEqual( expect.objectContaining({ section: "webmcp_tools", reason: "out_of_target_scope", }), ); expect(result.value.completeness.unavailable_sections).not.toContain( "webmcp_tools", ); const serialized = JSON.stringify(result.value); expect(serialized).toContain("tool-secret"); expect(serialized).toContain("tool-source-secret"); expect(serialized).toContain("schema-secret"); expect(serialized).not.toContain("private-tool-secret"); const methods = browser.commands.map(({ method }) => method); expect(methods).toContain("WebMCP.enable"); expect(methods).not.toContain("WebMCP.invokeTool"); expect(methods).not.toContain("Runtime.evaluate"); }, ); it("reports WebMCP unavailable when the experimental domain is absent", async () => { const browser = await startFakeCdpBrowser({ unsupportedMethods: ["WebMCP.enable"], }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!result.ok) throw result.error; expect(result.value.status).toBe("unavailable"); expect(result.value.tools.items).toEqual([]); expect(result.value.completeness.unavailable_sections).toContain( "webmcp_tools", ); }); it("rejects WebMCP evidence when the page leaves its approved origin", async () => { const browser = await startFakeCdpBrowser({ webMcpTools: true, frameUrlAfterFirstRead: "https://private.example.test/tools", }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); expect(result).toMatchObject({ ok: false, error: { _tag: "BrowserObservationError", operation: "discover_webmcp_tools", reason: "target_not_allowed", }, }); expect(browser.commands.map(({ method }) => method)).not.toContain( "WebMCP.invokeTool", ); }); }); describe("CdpBrowserProvider: WebMCP replay inventory", () => { it("returns every WebMCP registration in the replay", async () => { const browser = await startFakeCdpBrowser({ webMcpTools: true, extraCollections: true, }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!result.ok) throw result.error; expect(result.value.tools).toMatchObject({ total: 2, items: [expect.any(Object), expect.any(Object)], }); expect(result.value.completeness.truncated_sections).not.toContain( "webmcp_tools", ); }); it("retains registrations from authorized frames beyond the former frame cap", async () => { const browser = await startFakeCdpBrowser({ webMcpTools: true, webMcpFrameCount: 1_001, }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!result.ok) throw result.error; expect(result.value.tools.items).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "beyond_frame_limit_tool", frame_id: "webmcp-frame-1000", }), ]), ); expect(result.value.completeness.truncated_sections).not.toContain( "webmcp_tools", ); }); }); describe("CdpBrowserProvider WebMCP malformed replay completeness", () => { it.each(["toolsAdded", "toolsRemoved"] as const)( "keeps malformed %s replay entries from proving WebMCP equality", async (malformedEvent) => { let malformed = false; const registration = { name: "read_item", frameId: "frame-main", inputSchema: { type: "object" }, }; const browser = await startFakeCdpBrowser({ commandEvents: (command) => { if (command.method !== "WebMCP.enable") return undefined; const events: { readonly method: string; readonly params: unknown; readonly sessionId?: string; }[] = [ { method: "WebMCP.toolsAdded", ...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }), params: { tools: malformed && malformedEvent === "toolsAdded" ? [registration, null] : [registration], }, }, ]; if (malformed && malformedEvent === "toolsRemoved") events.push({ method: "WebMCP.toolsRemoved", ...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }), params: { tools: [{ name: registration.name }, null] }, }); return events; }, }); trackBrowser(browser); const provider = new CdpBrowserProvider(); const input = { cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }; const inspection = await provider.inspectPage( inspectWebPageInputSchema.parse(input), ); if (!inspection.ok) throw inspection.error; const discover = async () => { const result = await provider.discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse(input), ); if (!result.ok) throw result.error; return result.value; }; const before = await discover(); expect(before.tools.items).toHaveLength(1); expect(before.completeness.unavailable_sections).not.toContain( "webmcp_tools", ); malformed = true; const after = await discover(); expect(after.tools.items).toHaveLength(1); expect(after.completeness.unavailable_sections).toContain("webmcp_tools"); expect(after.completeness.excluded).toContainEqual( expect.objectContaining({ section: "webmcp_tools", reason: "invalid_protocol_value", }), ); const comparison = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: inspection.value, webmcp: before }, after: { inspection: inspection.value, webmcp: after }, }), ); expect(comparison.dimensions.webmcp).toMatchObject({ status: "unknown", total_changes: 0, }); }, ); }); describe("CdpBrowserProvider WebMCP schema comparison", () => { it("compares full untrusted declarations independently of object-key order", async () => { const declaration: JsonValue = { type: "object", properties: { value: { type: "string", enum: ["one", "two"] } }, required: ["value"], $ref: "https://untrusted.invalid/schema.json", examples: [{ token: "caller-schema-value" }], }; const options: { webMcpTools: boolean; webMcpInputSchema: JsonValue } = { webMcpTools: true, webMcpInputSchema: declaration, }; const browser = await startFakeCdpBrowser(options); trackBrowser(browser); const provider = new CdpBrowserProvider(); const input = { cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }; const inspection = await provider.inspectPage( inspectWebPageInputSchema.parse(input), ); if (!inspection.ok) throw inspection.error; const discover = async () => { const result = await provider.discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse(input), ); if (!result.ok) throw result.error; return result.value; }; const before = await discover(); expect(before.tools.items[0]).toMatchObject({ input_schema: declaration, input_schema_sha256: digestCanonicalValue(declaration), trust: "page-declared-untrusted", }); const compare = async (schema: JsonValue) => { options.webMcpInputSchema = schema; const after = await discover(); const diff = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: inspection.value, webmcp: before }, after: { inspection: inspection.value, webmcp: after }, }), ); return { after, dimension: diff.dimensions.webmcp }; }; const reordered = Object.fromEntries(Object.entries(declaration).reverse()); const equivalent = await compare(reordered); expect(equivalent.dimension).toMatchObject({ status: "unknown", total_changes: 0, }); expect(equivalent.after.tools.items[0]?.input_schema_sha256).toBe( before.tools.items[0]?.input_schema_sha256, ); for (const changed of [ { ...declaration, properties: { value: { type: "number", enum: ["one", "two"] } }, }, { ...declaration, required: [] }, { ...declaration, properties: { value: { type: "string", enum: ["two", "three"] } }, }, false, ]) { const result = await compare(changed); expect(result.dimension).toMatchObject({ status: "changed", total_changes: 1, changes: [expect.objectContaining({ change: "modified" })], }); } }); it("retains every field in a large declared WebMCP input schema", async () => { const propertyCount = 5_001; const browser = await startFakeCdpBrowser({ webMcpTools: true, webMcpSchemaPropertyCount: propertyCount, }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!result.ok) throw result.error; const tool = result.value.tools.items[0]; expect(tool?.input_schema).toMatchObject({ type: "object", properties: { field_5000: { type: "string" } }, }); if ( tool?.input_schema === null || typeof tool?.input_schema !== "object" || Array.isArray(tool.input_schema) ) throw new Error("Expected an observed schema object"); expect(Object.keys(tool.input_schema.properties ?? {})).toHaveLength( propertyCount, ); expect(tool.input_schema_sha256).toMatch(/^[a-f0-9]{64}$/u); }); }); describe("CdpBrowserProvider: frame scope and screenshots", () => { it("removes WebMCP declarations after their child frame leaves scope", async () => { const browser = await startFakeCdpBrowser({ webMcpTools: true, extraCollections: true, webMcpChildLeavesScope: true, }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!result.ok) throw result.error; const names = result.value.tools.items.map(({ name }) => name); expect(names).toEqual( expect.arrayContaining(["search_orders", "update_order"]), ); expect(names).not.toEqual( expect.arrayContaining(["child_tool", "escaped_child_tool"]), ); expect(result.value.completeness.policy_filtered_sections).toContain( "webmcp_tools", ); expect(JSON.stringify(result.value)).not.toContain( "cross-origin-child-secret", ); }); it("captures an explicitly approved content-addressed viewport screenshot", async () => { const browser = await startFakeCdpBrowser(); trackBrowser(browser); const result = await new CdpBrowserProvider().captureScreenshot( captureWebScreenshotInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", }), ); if (!result.ok) throw result.error; expect(result.value).toMatchObject({ viewport: { width: 1, height: 1 }, artifact: { sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), bytes: 70, media_type: "image/png", }, }); expect(browser.commands.map(({ method }) => method)).toContain( "Page.captureScreenshot", ); }); it("discards screenshot pixels when the main frame navigates during capture", async () => { const browser = await startFakeCdpBrowser({ navigateDuringScreenshotUrl: "https://private.example.test/screenshot", }); trackBrowser(browser); const result = await new CdpBrowserProvider().captureScreenshot( captureWebScreenshotInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", }), ); expect(result).toMatchObject({ ok: false, error: { _tag: "BrowserObservationError", operation: "capture_web_screenshot", reason: "target_not_allowed", }, }); }); }); describe("CdpBrowserProvider: complete passive inventories", () => { it("returns every frame, resource, worker, accessibility, and storage inventory item", async () => { const browser = await startFakeCdpBrowser({ extraCollections: true }); trackBrowser(browser); const request = inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, include_storage_keys: true, }); const result = await new CdpBrowserProvider().inspectPage(request); if (!result.ok) throw result.error; expect(result.value.completeness.truncated_sections).toEqual([]); expect(result.value.frames.length).toBeGreaterThan(1); expect(result.value.resources.length).toBeGreaterThan(1); expect(result.value.workers.length).toBeGreaterThan(1); expect(result.value.accessibility).toMatchObject({ total_nodes: 4 }); expect(result.value.accessibility.nodes).toHaveLength(4); expect(result.value.storage.local_storage_keys.length).toBeGreaterThan(1); expect(result.value.storage.session_storage_keys.length).toBeGreaterThan(1); expect(result.value.storage.indexed_db_names).toHaveLength(2); expect(result.value.storage.cache_names).toHaveLength(2); expect(result.value.storage.content_fingerprints).toEqual([]); }); }); describe("CdpBrowserProvider: WebMCP registration owners", () => { it.each(["retain", "remove-second"] as const)( "keeps same-URL frame owners separate: %s", async (mode) => { const browser = await startFakeCdpBrowser({ webMcpSameUrlRegistrations: mode, }); trackBrowser(browser); const result = await new CdpBrowserProvider().discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!result.ok) throw result.error; const tools = result.value.tools.items; expect(tools).toHaveLength(mode === "retain" ? 2 : 1); expect(result.value.tools.total).toBe(tools.length); // Declarations without annotations report absent hints as null, never // collapsed to a declared false. for (const tool of tools) expect(tool.annotations).toEqual({ read_only: null, untrusted_content: null, consequential: null, autosubmit: null, }); expect(tools).toContainEqual( expect.objectContaining({ name: "read_item", frame_id: "webmcp-frame-0", description: "First owner updated", }), ); if (mode === "retain") { expect(tools).toContainEqual( expect.objectContaining({ name: "read_item", frame_id: "webmcp-frame-1", description: "Second owner", }), ); expect(new Set(tools.map((tool) => tool.tool_key)).size).toBe(2); expect(new Set(tools.map((tool) => tool.frame_url)).size).toBe(1); } expect(browser.commands.map(({ method }) => method)).not.toContain( "WebMCP.invokeTool", ); expect(browser.commands.map(({ method }) => method)).not.toContain( "Runtime.evaluate", ); }, ); });