import { afterEach, describe, expect, it } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; import type { WebPageInspection } from "../../../src/domain/browserObservationSchemas.js"; import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js"; import { compareWebCaptures } from "../../../src/domain/webCaptureDiff.js"; import { compareWebCapturesInputSchema, webCaptureDiffSchema, } from "../../../src/domain/webCaptureDiffSchemas.js"; import { startFakeCdpBrowser, type FakeCdpBrowser, } from "../../fixtures/fakeCdpBrowser.js"; const browsers: FakeCdpBrowser[] = []; const inspectStorage = async ( browser: FakeCdpBrowser, fingerprints = false, ) => { const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, include_storage_keys: true, include_storage_fingerprints: fingerprints, }), ); if (!captured.ok) throw captured.error; return captured.value; }; const compareStorage = (left: WebPageInspection, right: WebPageInspection) => compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: left }, after: { inspection: right }, }), ).dimensions.storage; it.each([ ["local", "DOMStorage.getDOMStorageItems", { entries: [null] }], [ "session", "DOMStorage.getDOMStorageItems", { entries: [["known", "value"], null] }, ], ["indexed", "IndexedDB.requestDatabaseNames", { databaseNames: [null] }], ["cache", "CacheStorage.requestCacheNames", { caches: [null] }], ] as const)( "does not invent membership changes after an incomplete %s inventory", async (kind, method, malformed) => { let failure: "unsupported" | "malformed" | undefined; const matches = (command: { method: string; params: Record; }) => { if (command.method !== method) return false; if (kind !== "local" && kind !== "session") return true; return ( (command.params.storageId as { isLocalStorage: boolean }) .isLocalStorage === (kind === "local") ); }; const browser = await startFakeCdpBrowser({ commandError: (command) => failure === "unsupported" && matches(command) ? { code: -32601, message: "Method not found" } : undefined, commandResult: (command) => failure === "malformed" && matches(command) ? malformed : undefined, }); browsers.push(browser); const before = await inspectStorage(browser); expect(before.completeness.unavailable_sections).not.toContain( "storage_keys", ); for (const [mode, reason] of [ ["unsupported", "provider_unavailable"], ["malformed", "invalid_protocol_value"], ] as const) { failure = mode; const after = await inspectStorage(browser); expect(after.completeness.excluded).toContainEqual({ section: "storage_keys", reason, count: null, }); expect(after.completeness.unavailable_sections).toContain("storage_keys"); for (const [left, right] of [ [before, after], [after, before], ] as const) expect(compareStorage(left, right)).toMatchObject({ status: "unknown", total_changes: 0, changes: [], }); if (kind === "session" && mode === "malformed") expect(after.storage.session_storage_keys).toEqual(["known"]); } }, ); it("preserves shared content changes when a peer inventory is unavailable", async () => { let value = "before"; const browser = await startFakeCdpBrowser({ unsupportedMethods: ["CacheStorage.requestCacheNames"], commandResult: (command) => command.method === "DOMStorage.getDOMStorageItems" ? { entries: [["shared-key", value]] } : undefined, }); browsers.push(browser); const before = await inspectStorage(browser, true); expect(before.completeness.unavailable_sections).toContain("storage_keys"); expect(compareStorage(before, before)).toMatchObject({ status: "unknown", total_changes: 0, }); value = "after"; const after = await inspectStorage(browser, true); for (const [left, right] of [ [before, after], [after, before], ] as const) expect(compareStorage(left, right)).toMatchObject({ status: "changed", total_changes: 2, changes: [ expect.objectContaining({ change: "modified" }), expect.objectContaining({ change: "modified" }), ], }); for (const fingerprint of after.storage.content_fingerprints) if (["local_storage", "session_storage"].includes(fingerprint.scope)) fingerprint.complete = false; expect(compareStorage(before, after)).toMatchObject({ status: "unknown", total_changes: 0, }); }); it("reports complete storage key additions and removals without content fingerprints", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); const before = await inspectStorage(browser); expect(before.storage.fingerprints_complete).toBe(false); const after = structuredClone(before); after.storage.local_storage_keys.push("new-local"); after.storage.session_storage_keys.push("new-session"); after.storage.indexed_db_names.push("new-db"); after.storage.cache_names.push("new-cache"); const identities = [ "storage:cache:new-cache", "storage:indexed_db:new-db", "storage:local_storage:new-local", "storage:session_storage:new-session", ]; for (const [left, right, change] of [ [before, after, "added"], [after, before, "removed"], ] as const) { expect(compareStorage(left, right)).toMatchObject({ status: "changed", total_changes: 4, changes: identities.map((identity) => ({ identity, change })), }); } expect(compareStorage(after, after)).toMatchObject({ status: "unknown", total_changes: 0, }); const missingQuota = structuredClone(after); missingQuota.storage.quota_bytes = null; expect(compareStorage(before, missingQuota)).toMatchObject({ status: "changed", total_changes: 4, }); const partialInventory = structuredClone(after); partialInventory.completeness.unavailable_sections.push("storage_keys"); expect(compareStorage(before, partialInventory)).toMatchObject({ status: "unknown", total_changes: 0, }); expect(compareStorage(partialInventory, before)).toMatchObject({ status: "unknown", total_changes: 0, }); }); const expectInvalidDiff = (input: unknown): void => { expect(webCaptureDiffSchema.safeParse(input).success).toBe(false); }; afterEach(async () => { await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); }); describe("web capture diff", () => { it.each([ { missing: "before", changed: false, expected: "unknown" }, { missing: "after", changed: false, expected: "unknown" }, { missing: "before", changed: true, expected: "changed" }, { missing: "after", changed: true, expected: "changed" }, { missing: "neither", changed: false, expected: "changed" }, ] as const)( "preserves consequential uncertainty: $missing, other change $changed", async ({ missing, changed, expected }) => { const browser = await startFakeCdpBrowser({ webMcpTools: true }); browsers.push(browser); const provider = new CdpBrowserProvider(); const request = { cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }; const inspection = await provider.inspectPage( inspectWebPageInputSchema.parse(request), ); const discovery = await provider.discoverWebMcpTools( discoverWebMcpToolsInputSchema.parse(request), ); if (!inspection.ok) throw inspection.error; if (!discovery.ok) throw discovery.error; const before = structuredClone(discovery.value); const after = structuredClone(discovery.value); markSectionsComplete(before, ["webmcp_tools"]); markSectionsComplete(after, ["webmcp_tools"]); const left = before.tools.items[0]; const right = after.tools.items[0]; if (left === undefined || right === undefined) throw new Error("missing WebMCP registration fixture"); left.annotations.consequential = true; right.annotations.consequential = false; if (missing === "before") delete left.annotations.consequential; if (missing === "after") delete right.annotations.consequential; if (changed) right.description += " changed"; const result = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: inspection.value, webmcp: before }, after: { inspection: inspection.value, webmcp: after }, }), ); expect(result.dimensions.webmcp).toMatchObject({ status: expected, total_changes: expected === "changed" ? 1 : 0, }); if (expected === "changed") expect(result.dimensions.webmcp.changes[0]).toMatchObject({ change: "modified", }); }, ); }); describe("web capture dimension uncertainty", () => { it("reports stable observed changes while preserving unknown dimensions", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!captured.ok) throw captured.error; markSectionsComplete(captured.value, ["scripts"]); const after = structuredClone(captured.value); after.scripts.items = []; const request = after.network.requests[0]; if (request !== undefined) { request.status = 204; request.redirects = [ { url: `${browser.allowedOrigin}/prior`, response_url: `${browser.allowedOrigin}/prior`, method: "GET", resource_type: "Fetch", status: 302, mime_type: "text/plain", encoded_data_length: 12, request_timestamp: 1, redirect_event_timestamp: 2, }, ]; } const result = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: captured.value }, after: { inspection: after }, }), ); expect(result.overall_status).toBe("changed"); expect(result.dimensions.scripts).toMatchObject({ status: "changed", total_changes: 1, changes: [expect.objectContaining({ change: "removed" })], }); expect(result.dimensions.network).toMatchObject({ status: "changed", changes: [expect.objectContaining({ change: "modified" })], }); expect(result.dimensions.webmcp).toMatchObject({ status: "unknown", total_changes: 0, }); expectInvalidDiff({ ...result, overall_status: "unchanged" }); expectInvalidDiff({ ...result, dimensions: { ...result.dimensions, webmcp: { ...result.dimensions.webmcp, reason: null }, }, }); expectInvalidDiff({ ...result, dimensions: { ...result.dimensions, scripts: { ...result.dimensions.scripts, total_changes: 2 }, }, }); const { accessibility: _accessibility, ...missingAccessibility } = result.dimensions; expectInvalidDiff({ ...result, dimensions: missingAccessibility, }); const { storage: _storage, ...missingStorage } = result.dimensions; expectInvalidDiff({ ...result, dimensions: missingStorage }); }); it("does not claim unchanged when a relevant section is incomplete", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!captured.ok) throw captured.error; const incomplete = structuredClone(captured.value); incomplete.completeness.truncated_sections.push("dom"); const result = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: incomplete }, after: { inspection: captured.value }, }), ); expect(result.dimensions.dom_structure.status).toBe("unknown"); expect(result.dimensions.dom_structure.reason).toContain("incomplete"); }); }); describe("web capture WebSocket comparison", () => { it("compares complete ordered WebSocket streams by source URL, not request IDs", async () => { const browser = await startFakeCdpBrowser({ binaryWebSocketEvent: true }); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, include_websocket_shapes: true, }), ); if (!captured.ok) throw captured.error; const before = structuredClone(captured.value); markSectionsComplete(before, [ "network_requests", "websocket_connections", "websocket_frames", "websocket_shapes", ]); expect(before.network.websocket_connections).toHaveLength(1); expect(before.network.websocket_connections[0]?.url).toBe( `ws://${new URL(browser.allowedOrigin).host}/live?token=websocket-url-secret`, ); expect(before.network.websocket_connections[0]?.events).toHaveLength(2); const idOnlyChange = structuredClone(before); const idOnlyConnection = idOnlyChange.network.websocket_connections[0]; if (idOnlyConnection === undefined) throw new Error("Missing captured WebSocket connection"); idOnlyConnection.request_id = "capture-local-id-2"; expect(compareNetwork(before, idOnlyChange)).toMatchObject({ status: "unchanged", total_changes: 0, }); const changeFrame = ( update: ( event: WebPageInspection["network"]["websocket_connections"][number]["events"][number], ) => void, ): WebPageInspection => { const changed = structuredClone(before); const event = changed.network.websocket_connections[0]?.events[0]; if (event === undefined) throw new Error("Missing captured WebSocket frame"); update(event); return changed; }; const eventChanges: readonly (( event: WebPageInspection["network"]["websocket_connections"][number]["events"][number], ) => void)[] = [ (event) => { event.direction = "received"; }, (event) => { event.opcode = 2; }, (event) => { event.payload_shape = { format: "binary", json_shape: null }; }, ]; for (const update of eventChanges) expect(compareNetwork(before, changeFrame(update)).status).toBe( "changed", ); const changed = structuredClone(before); const connection = changed.network.websocket_connections[0]; const event = connection?.events[0]; if (connection === undefined || event === undefined) throw new Error("Missing captured WebSocket frame"); if (event.payload_bytes === null) throw new Error("Expected captured WebSocket byte count"); connection.request_id = "capture-local-id-3"; event.direction = "received"; event.opcode = 2; event.payload_bytes += 1; event.payload_shape = { format: "binary", json_shape: null }; expect(compareNetwork(before, changed)).toMatchObject({ status: "changed", changes: [expect.objectContaining({ change: "modified" })], }); const reordered = structuredClone(before); reordered.network.websocket_connections[0]?.events.reverse(); expect(compareNetwork(before, reordered)).toMatchObject({ status: "changed", changes: [expect.objectContaining({ change: "modified" })], }); const addedEmptyConnection = structuredClone(before); addedEmptyConnection.network.websocket_connections.push({ request_id: "new-empty-connection", url: before.network.websocket_connections[0]!.url, events: [], }); expect(compareNetwork(before, addedEmptyConnection)).toMatchObject({ status: "changed", changes: [expect.objectContaining({ change: "modified" })], }); }); }); describe("web capture WebSocket shape comparison", () => { it("aligns WebSocket shape streams only through common known coverage", async () => { const browser = await startFakeCdpBrowser({ binaryWebSocketEvent: true }); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, include_websocket_shapes: true, }), ); if (!captured.ok) throw captured.error; const before = structuredClone(captured.value); markSectionsComplete(before, [ "network_requests", "websocket_connections", "websocket_frames", "websocket_shapes", ]); const sameUrlStreams = structuredClone(before); sameUrlStreams.network.websocket_connections.push({ ...structuredClone(sameUrlStreams.network.websocket_connections[0]!), request_id: "second-local-id", }); const reorderedConnections = structuredClone(sameUrlStreams); reorderedConnections.network.websocket_connections.reverse(); for (const [ index, item, ] of reorderedConnections.network.websocket_connections.entries()) item.request_id = `new-local-id-${String(index)}`; expect(compareNetwork(sameUrlStreams, reorderedConnections)).toMatchObject({ status: "unchanged", total_changes: 0, }); const correlatedBefore = structuredClone(before); const correlatedConnection = correlatedBefore.network.websocket_connections[0]!; const shape = (root_type: "string" | "number") => ({ format: "json" as const, json_shape: { root_type, node_count: 1, max_depth_observed: 0, properties: [], }, }); const firstShape = shape("string"); const secondShape = shape("number"); for (const connection of [ correlatedConnection, { ...structuredClone(correlatedConnection), request_id: "second-correlated-connection", }, ]) { connection.events[0]!.payload_shape = structuredClone(firstShape); connection.events[1]!.payload_shape = structuredClone(secondShape); } correlatedBefore.network.websocket_connections = [ correlatedConnection, { ...structuredClone(correlatedConnection), request_id: "second-correlated-connection", }, ]; const crossed = structuredClone(correlatedBefore); crossed.network.websocket_connections[0]!.events[1]!.payload_shape = structuredClone(firstShape); crossed.network.websocket_connections[1]!.events[0]!.payload_shape = structuredClone(secondShape); crossed.network.websocket_connections[1]!.events[1]!.payload_shape = structuredClone(firstShape); expect(compareNetwork(correlatedBefore, crossed).status).toBe("changed"); const partialShapes = structuredClone(correlatedBefore); partialShapes.network.websocket_connections[0]!.events[1]!.payload_shape = null; const ambiguousShapeChange = structuredClone(partialShapes); ambiguousShapeChange.network.websocket_connections[1]!.events[1]!.payload_shape = structuredClone(firstShape); expect(compareNetwork(partialShapes, ambiguousShapeChange)).toMatchObject({ status: "unknown", total_changes: 0, changes: [], }); const changedKnownShape = structuredClone(partialShapes); changedKnownShape.network.websocket_connections[0]!.events[0]!.payload_shape = structuredClone(secondShape); expect(compareNetwork(partialShapes, changedKnownShape).status).toBe( "changed", ); }); }); describe("web capture WebSocket shape coverage", () => { it("keeps unchanged unknown when selected WebSocket shapes are unavailable", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, include_websocket_shapes: true, }), ); if (!captured.ok) throw captured.error; const unavailable = structuredClone(captured.value); markSectionsComplete(unavailable, [ "network_requests", "websocket_connections", "websocket_frames", ]); unavailable.completeness.unavailable_sections.push("websocket_shapes"); for (const connection of unavailable.network.websocket_connections) for (const event of connection.events) event.payload_shape = null; expect( compareNetwork(unavailable, structuredClone(unavailable)), ).toMatchObject({ status: "unknown", total_changes: 0, changes: [], }); const observed = structuredClone(unavailable); for (const connection of observed.network.websocket_connections) for (const event of connection.events) event.payload_shape = { format: "binary", json_shape: null }; for (const [left, right] of [ [unavailable, observed], [observed, unavailable], ] as const) expect(compareNetwork(left, right)).toMatchObject({ status: "unknown", total_changes: 0, changes: [], }); const metadataChanged = structuredClone(observed); metadataChanged.network.websocket_connections[0]!.events[0]!.opcode = 2; expect(compareNetwork(unavailable, metadataChanged).status).toBe("changed"); const truncated = structuredClone(observed); truncated.network.websocket_connections[0]!.events[0]!.payload_shape = null; truncated.completeness.truncated_sections.push("websocket_shapes"); for (const [left, right] of [ [truncated, observed], [observed, truncated], ] as const) expect(compareNetwork(left, right)).toMatchObject({ status: "unknown", total_changes: 0, changes: [], }); }); }); describe("web capture WebSocket payload byte coverage", () => { it("keeps unknown byte counts out of differences while comparing known metadata", async () => { const browser = await startFakeCdpBrowser({ binaryWebSocketEvent: true }); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!captured.ok) throw captured.error; const known = structuredClone(captured.value); markSectionsComplete(known, [ "network_requests", "websocket_connections", "websocket_frames", ]); const unknownBytes = structuredClone(known); unknownBytes.network.websocket_connections[0]!.events[1]!.payload_bytes = null; for (const [left, right] of [ [unknownBytes, known], [known, unknownBytes], ] as const) expect(compareNetwork(left, right)).toMatchObject({ status: "unknown", total_changes: 0, changes: [], }); const knownMetadataChanged = structuredClone(unknownBytes); knownMetadataChanged.network.websocket_connections[0]!.events[1]!.direction = "sent"; expect(compareNetwork(unknownBytes, knownMetadataChanged).status).toBe( "changed", ); const knownBytesChanged = structuredClone(known); const knownBytesEvent = knownBytesChanged.network.websocket_connections[0]!.events[1]!; if (knownBytesEvent.payload_bytes === null) throw new Error("Expected captured WebSocket byte count"); knownBytesEvent.payload_bytes += 1; expect(compareNetwork(known, knownBytesChanged).status).toBe("changed"); }); }); describe("web capture diff", () => { it.each(["status", "mime_type"] as const)( "keeps an independently unavailable HTTP %s unknown", async (field) => { const browser = await startFakeCdpBrowser(); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!captured.ok) throw captured.error; const observed = structuredClone(captured.value); const request = observed.network.requests[0]; if (request === undefined) throw new Error("Missing network request"); request.status = 200; request.mime_type = "application/json"; request.encoded_data_length = 100; markSectionsComplete(observed, [ "network_requests", "websocket_connections", "websocket_frames", "json_body_shapes", ]); for (const item of observed.network.requests) item.body_shapes.status = "not_approved"; expect(compareNetwork(observed, observed).status).toBe("unchanged"); const partial = structuredClone(observed); partial.network.requests[0]![field] = null; for (const [left, right] of [ [partial, observed], [observed, partial], [partial, partial], ] as const) expect(compareNetwork(left, right)).toMatchObject({ status: "unknown", total_changes: 0, changes: [], }); }, ); it("ignores transient request IDs and capture-approval state", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!captured.ok) throw captured.error; const after = structuredClone(captured.value); const request = after.network.requests[0]; if (request !== undefined) request.request_id = "different-cdp-request-id"; const response = after.metadata.responses[0]; if (response !== undefined) response.request_id = "different-cdp-request-id"; const script = after.scripts.items[0]; if (script !== undefined && !script.source.included) script.source.reason = "different approval explanation"; markSectionsComplete(captured.value, ["scripts", "metadata"]); markSectionsComplete(after, ["scripts", "metadata"]); const result = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: captured.value }, after: { inspection: after }, }), ); expect(result.dimensions.scripts.status).toBe("unchanged"); expect(result.dimensions.metadata.status).toBe("unchanged"); expect(result.dimensions.network.status).toBe("unknown"); expect(result.dimensions.network.total_changes).toBe(0); }); }); describe("web capture redirect comparison", () => { it("ignores redirect event times but compares redirect semantics", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!captured.ok) throw captured.error; const before = structuredClone(captured.value); const beforeRequest = before.network.requests[0]; if (beforeRequest === undefined) throw new Error("Missing network request"); beforeRequest.redirects = [ { url: `${browser.allowedOrigin}/prior`, response_url: `${browser.allowedOrigin}/prior`, method: "GET", resource_type: "Fetch", status: 302, mime_type: "text/plain", encoded_data_length: 12, request_timestamp: 1, redirect_event_timestamp: 2, }, ]; markSectionsComplete(before, [ "network_requests", "websocket_connections", "websocket_frames", ]); const after = structuredClone(before); const afterRequest = after.network.requests[0]; if (afterRequest === undefined) throw new Error("Missing network request"); const hop = afterRequest.redirects?.[0]; if (hop === undefined) throw new Error("Missing redirect hop"); hop.request_timestamp = 10; hop.redirect_event_timestamp = 20; const compare = (inspection: typeof before) => compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: before }, after: { inspection }, }), ).dimensions.network; expect(compare(after)).toMatchObject({ status: "unchanged", total_changes: 0, }); const changed = structuredClone(after); const changedHop = changed.network.requests[0]?.redirects?.[0]; if (changedHop === undefined) throw new Error("Missing redirect hop"); changedHop.status = 307; expect(compare(changed)).toMatchObject({ status: "changed", changes: [expect.objectContaining({ change: "modified" })], }); }); }); describe("web capture diff incomplete inventories", () => { it.each(["truncated_sections", "unavailable_sections"] as const)( "does not infer script additions or removals from %s", async (section) => { const browser = await startFakeCdpBrowser(); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, }), ); if (!captured.ok) throw captured.error; const complete = structuredClone(captured.value); markSectionsComplete(complete, ["scripts"]); expect(complete.scripts.items).toHaveLength(1); const incomplete = structuredClone(complete); incomplete.scripts.items = []; incomplete.completeness[section].push("scripts"); const compare = (before: typeof complete, after: typeof complete) => compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: before }, after: { inspection: after }, }), ).dimensions.scripts; expect(compare(complete, incomplete)).toMatchObject({ status: "unknown", total_changes: 0, changes: [], }); expect(compare(incomplete, complete)).toMatchObject({ status: "unknown", total_changes: 0, changes: [], }); markSectionsComplete(incomplete, ["scripts"]); expect(compare(complete, incomplete).changes).toEqual([ expect.objectContaining({ change: "removed" }), ]); expect(compare(incomplete, complete).changes).toEqual([ expect.objectContaining({ change: "added" }), ]); const modified = structuredClone(complete); modified.completeness[section].push("scripts"); const script = modified.scripts.items[0]; if (script === undefined) throw new Error("Missing captured script"); script.url = `${script.url}?revision=2`; expect(compare(complete, modified).changes).toEqual([ expect.objectContaining({ change: "modified" }), ]); }, ); }); describe("web capture diff semantics and fingerprints", () => { it("detects accessibility semantics and storage inventory changes", async () => { const browser = await startFakeCdpBrowser({ extraCollections: true }); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, include_accessibility_text: true, include_storage_keys: true, include_storage_fingerprints: true, }), ); if (!captured.ok) throw captured.error; const compareAfter = (after: typeof captured.value) => compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: captured.value }, after: { inspection: after }, }), ); const roleChanged = structuredClone(captured.value); const namedNode = roleChanged.accessibility.nodes[0]; if (namedNode === undefined) throw new Error("Expected accessibility nodes"); namedNode.role = "menuitem"; const nameChanged = structuredClone(captured.value); const renamedNode = nameChanged.accessibility.nodes[0]; if (renamedNode === undefined) throw new Error("Expected accessibility nodes"); renamedNode.name = "Send report"; const stateChanged = structuredClone(captured.value); const stateNode = stateChanged.accessibility.nodes[0]; const disabledState = stateNode?.states.find( ({ name }) => name === "disabled", ); if (disabledState === undefined) throw new Error("Expected an accessibility state"); disabledState.value = true; const hierarchyChanged = structuredClone(captured.value); const childNode = hierarchyChanged.accessibility.nodes[1]; if (childNode === undefined) throw new Error("Expected an accessibility child"); childNode.parent_id = null; const storageChanged = structuredClone(captured.value); storageChanged.storage.local_storage_keys.push("new-key"); for (const after of [ roleChanged, nameChanged, stateChanged, hierarchyChanged, ]) expect(compareAfter(after).dimensions.accessibility).toMatchObject({ status: "changed", changes: [ { identity: "accessibility_tree", change: "modified", }, ], }); expect(compareAfter(storageChanged).dimensions.storage).toMatchObject({ status: "changed", changes: [expect.objectContaining({ change: "added" })], }); }); }); describe("web capture diff completeness", () => { it("uses complete redacted fingerprints but keeps incomplete evidence unknown", async () => { const browser = await startFakeCdpBrowser({ extraCollections: true }); browsers.push(browser); const captured = await new CdpBrowserProvider().inspectPage( inspectWebPageInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 0, include_accessibility_text: true, include_storage_keys: true, include_storage_fingerprints: true, }), ); if (!captured.ok) throw captured.error; const identical = structuredClone(captured.value); const identity = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: captured.value }, after: { inspection: identical }, }), ); expect(identity.dimensions.accessibility.status).toBe("unchanged"); expect(identity.dimensions.storage).toMatchObject({ status: "unchanged", total_changes: 0, }); const changedFingerprint = structuredClone(captured.value); const fingerprint = changedFingerprint.storage.content_fingerprints.find( ({ complete, value_sha256: valueSha256 }) => complete && valueSha256 !== null, ); if (fingerprint === undefined || fingerprint.value_sha256 === null) throw new Error("Expected a complete content fingerprint"); fingerprint.value_sha256 = "f".repeat(64); const storageChanged = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: captured.value }, after: { inspection: changedFingerprint }, }), ); expect(storageChanged.dimensions.storage).toMatchObject({ status: "changed", total_changes: 1, changes: [expect.objectContaining({ change: "modified" })], }); const incompleteStorage = structuredClone(captured.value); incompleteStorage.storage.fingerprints_complete = false; const storageUnknown = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: captured.value }, after: { inspection: incompleteStorage }, }), ); expect(storageUnknown.dimensions.storage).toMatchObject({ status: "unknown", total_changes: 0, }); const evidenceQualityChanged = structuredClone(captured.value); evidenceQualityChanged.storage.fingerprints_complete = false; const incompleteFingerprint = evidenceQualityChanged.storage.content_fingerprints[0]; if (incompleteFingerprint === undefined) throw new Error("Expected a content fingerprint"); incompleteFingerprint.complete = false; const evidenceOnly = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: captured.value }, after: { inspection: evidenceQualityChanged }, }), ); expect(evidenceOnly.dimensions.storage).toMatchObject({ status: "unknown", total_changes: 0, }); const withoutText = structuredClone(captured.value); withoutText.accessibility.text_capture.status = "not_approved"; withoutText.accessibility.text_capture.excluded_fields = 1; for (const node of withoutText.accessibility.nodes) { node.name = null; node.description = null; } const missingText = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: captured.value }, after: { inspection: withoutText }, }), ); expect(missingText.dimensions.accessibility).toMatchObject({ status: "unknown", total_changes: 0, }); const truncated = structuredClone(captured.value); truncated.accessibility.total_nodes += 1; truncated.completeness.truncated_sections.push("accessibility"); const incompleteTree = compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: truncated }, after: { inspection: structuredClone(truncated) }, }), ); expect(incompleteTree.dimensions.accessibility).toMatchObject({ status: "unknown", total_changes: 0, }); }); }); const markSectionsComplete = ( inspection: { completeness: { policy_filtered_sections: string[]; attach_limited_sections: string[]; truncated_sections: string[]; unavailable_sections: string[]; }; }, sections: readonly string[], ): void => { const completed = new Set(sections); for (const key of [ "policy_filtered_sections", "attach_limited_sections", "truncated_sections", "unavailable_sections", ] as const) inspection.completeness[key] = inspection.completeness[key].filter( (section) => !completed.has(section), ); }; const compareNetwork = (before: WebPageInspection, after: WebPageInspection) => compareWebCaptures( compareWebCapturesInputSchema.parse({ before: { inspection: before }, after: { inspection: after }, }), ).dimensions.network;