From 50b2ab137f32d6099854e8d4e525a79b0ec786e2 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:27:01 +0800 Subject: [PATCH] fix(browser): preserve DOM length units in captures and comparisons (#7971) * fix(browser): declare DOM node value length units * fix(browser): preserve unknown DOM length comparisons --- docs/browser-observation.md | 8 +- docs/electron-observation.md | 7 + src/browser/CdpCaptureDocuments.ts | 2 + src/browser/CdpElectronInspection.ts | 13 +- src/browser/CdpPageCapture.ts | 6 +- src/domain/browserObservationSchemas.ts | 7 +- src/domain/cdpObservationSchemas.ts | 18 +- src/domain/javascript/electronObservation.ts | 7 +- src/domain/webCaptureDiff.ts | 44 +++- .../browser/cdpElectronProvider.test.ts | 132 +++++----- .../boundary/cli/domValueLengthUnits.test.ts | 226 ++++++++++++++++++ 11 files changed, 389 insertions(+), 81 deletions(-) create mode 100644 tests/boundary/cli/domValueLengthUnits.test.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 2adeb6ac0d..22265bd53f 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -52,7 +52,11 @@ observations may omit the coverage field; treat omission as `unknown` rather than inferring completeness from the array. Passive DOM comparison checks complete node inventories and complete frame -parent/child structure independently. Frame IDs and sibling enumeration order +parent/child structure independently. Node value lengths are comparable only +when both nonempty snapshots declare the same `node_value_length_unit`. +Missing or different units leave length comparison unknown; independent node +or frame structural differences can still prove change. Empty inventories have +no value lengths to compare. Frame IDs and sibling enumeration order are capture-local and do not establish a change; duplicate IDs, missing parents, and cyclic ancestry remain unknown. A complete frame difference can still prove change when DOM nodes are incomplete, and vice versa. Partial DOM or metadata @@ -461,7 +465,7 @@ not bound the whole process heap. REA retains local observation data and removes only URL userinfo credentials, explicitly declared scenario secrets, and values from structured credential/storage fields: - URL observations preserve the complete local URL, including query values, fragments, duplicate parameters, and their order. Only username/password userinfo is removed; malformed original URL strings remain available with `origin: null`. -- DOM snapshots retain node types, node names, value lengths, and attribute names, but not text or attribute values. +- DOM snapshots retain node types, node names, value lengths, and attribute names, but not text or attribute values. `dom.node_value_length_unit` is `utf16-code-units` for web observations and applies to every node's `node_value_length`; it counts JavaScript string code units, not Unicode code points or UTF-8 bytes. Electron observations declare `utf8-bytes` instead. Historical captures without the unit leave it unknown; reading them does not infer a unit or convert their stored lengths. - Supported DOM URL attributes are projected separately as resolved destinations. Empty `action` on HTML or XHTML forms and empty `formaction` on HTML or XHTML submit controls resolve to the document URL; other relative URL attributes resolve against the captured document base. - Accessibility structure and reported roles are retained by default, while names and descriptions require `include_accessibility_text: true`. The browser protocol may omit an individual role; that role remains unknown for comparison. Other observed accessibility differences can still establish a change. - Network observations retain method, status, MIME type, size, type, initiator stack location, and complete URLs with userinfo credentials removed. Redirects reported by CDP are attached as ordered `redirects` on the one request ID, with the prior request URL, CDP response URL, method/type, response status/MIME/encoded length, request timestamp, and the next `requestWillBeSent` event timestamp. CDP delivers `redirectResponse` with that next request event, so the latter is an event boundary rather than an exact response timestamp. Redirect response headers and bodies are discarded; hops outside the selected origins are discarded with the request chain. Headers are discarded after an allowlisted projection of length/encoding, structured CSP/Link/policy fields, and untrusted agent hints. Cookies and authorization headers are never retained. diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 810b1eb8b2..08c1984e8a 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -75,6 +75,13 @@ DOM node indices retain their snapshot positions. Invalid rows are omitted with completeness diagnostics, so `total_nodes` can exceed the retained node count and a parent index can refer to an omitted source node. +`dom.node_value_length_unit` is `utf8-bytes`: each node's `node_value_length` +counts the bytes needed to encode its value as UTF-8, not characters. Web +observations instead declare `utf16-code-units`. These existing measurements +are preserved; the unit is reported once for the whole DOM, without retaining +text values. Historical captures without the unit leave it unknown; reading +them does not infer a unit or convert their stored lengths. + Script metadata retains its execution-context frame ID when CDP supplies one and records admission separately in `frame_scope`. An `authorized` scope refers to the observed document generation; `unknown` retains the known frame ID without diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index c0af25731c..47b31f50a8 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -275,6 +275,7 @@ interface DomMetadataCapture { export interface DomCapture extends DomMetadataCapture { readonly total: number; + readonly nodeValueLengthUnit: "utf16-code-units"; readonly nodes: WebPageInspection["dom"]["nodes"]; } @@ -420,6 +421,7 @@ export function* captureDom( } return { total, + nodeValueLengthUnit: "utf16-code-units", nodes, urls, agentHints, diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index c19bd67342..ae6d30e114 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -716,6 +716,7 @@ const captureDom = async ( context: ElectronContext, completeness: CdpCaptureCompleteness, ): Promise => { + const nodeValueLengthUnit = "utf8-bytes"; const result = requiredRecord( await context.connection.send( "DOMSnapshot.captureSnapshot", @@ -728,7 +729,11 @@ const captureDom = async ( completeness.exclude("dom", "invalid_protocol_value", count); if (!Array.isArray(result.strings)) { invalid(null); - return { total_nodes: 0, nodes: [] }; + return { + total_nodes: 0, + node_value_length_unit: nodeValueLengthUnit, + nodes: [], + }; } const strings = result.strings.map(cdpStringValue); const nodes: ElectronPageInspection["dom"]["nodes"] = []; @@ -779,7 +784,11 @@ const captureDom = async ( }); } } - return { total_nodes: total, nodes }; + return { + total_nodes: total, + node_value_length_unit: nodeValueLengthUnit, + nodes, + }; }; const mainFilePath = async ( diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 7a4f8dc870..d44af1b4ab 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -391,7 +391,11 @@ const normalizedInspection = ( }, completeness: state.events.completeness.snapshot(), frames: [...captured.frames], - dom: { total_nodes: captured.dom.total, nodes: [...captured.dom.nodes] }, + dom: { + total_nodes: captured.dom.total, + node_value_length_unit: captured.dom.nodeValueLengthUnit, + nodes: [...captured.dom.nodes], + }, accessibility: { total_nodes: captured.accessibility.total, text_capture: captured.accessibility.textCapture, diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index 7ce080e08d..bafa37e644 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -3,7 +3,7 @@ import { utcDateTimeSchema } from "./offsetDateTime.js"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { - cdpDomNodeSchema, + cdpDomSnapshotSchema, cdpScriptSourceSchema, cdpScriptMetadataSchema, } from "./cdpObservationSchemas.js"; @@ -273,10 +273,7 @@ export const webPageInspectionSchema = z.object({ }), completeness: browserCompletenessSchema, frames: z.array(browserFrameSchema), - dom: z.object({ - total_nodes: z.int().min(0), - nodes: z.array(cdpDomNodeSchema), - }), + dom: cdpDomSnapshotSchema, accessibility: z.object({ total_nodes: z.int().min(0), text_capture: z.object({ diff --git a/src/domain/cdpObservationSchemas.ts b/src/domain/cdpObservationSchemas.ts index 839af85b2b..8af603df05 100644 --- a/src/domain/cdpObservationSchemas.ts +++ b/src/domain/cdpObservationSchemas.ts @@ -7,10 +7,26 @@ export const cdpDomNodeSchema = z.object({ parent_index: z.number().int().min(-1), node_type: z.number().int().min(0), node_name: z.string(), - node_value_length: z.number().int().min(0), + node_value_length: z + .number() + .int() + .min(0) + .describe("Length in the enclosing DOM snapshot's node_value_length_unit."), attribute_names: z.array(z.string()), }); +/** Unit applies to every retained node; historical omission remains unknown. */ +export const cdpDomSnapshotSchema = z.object({ + total_nodes: z.int().min(0), + node_value_length_unit: z + .enum(["utf16-code-units", "utf8-bytes"]) + .describe( + "Unit for all node_value_length values; absent in historical captures means unknown.", + ) + .exactOptional(), + nodes: z.array(cdpDomNodeSchema), +}); + /** Captured text or an explicit reason why source is unavailable. */ export const cdpScriptSourceSchema = z.discriminatedUnion("included", [ z.object({ included: z.literal(false), reason: z.string() }), diff --git a/src/domain/javascript/electronObservation.ts b/src/domain/javascript/electronObservation.ts index 15fd5b5c9f..e680d97c8f 100644 --- a/src/domain/javascript/electronObservation.ts +++ b/src/domain/javascript/electronObservation.ts @@ -6,7 +6,7 @@ import { browserCompletenessSchema } from "../browserCompleteness.js"; import { browserEndpointSchema } from "../browserObservation.js"; import { browserVersionSchema } from "../browserObservationSchemas.js"; import { - cdpDomNodeSchema, + cdpDomSnapshotSchema, cdpScriptSourceSchema, cdpScriptMetadataSchema, } from "../cdpObservationSchemas.js"; @@ -96,10 +96,7 @@ export const electronPageInspectionSchema = z.object({ file_path: z.string(), }), ), - dom: z.object({ - total_nodes: z.int().min(0), - nodes: z.array(cdpDomNodeSchema), - }), + dom: cdpDomSnapshotSchema, scripts: z.object({ total: z.int().min(0), items: z.array( diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index 8a86140677..5fc7aa6e09 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -43,6 +43,11 @@ export const compareWebCaptures = ( dimensions, limitations: [ "A changed status proves an observed difference; an unknown status means necessary comparison facts or coverage were unavailable.", + ...(!domValueLengthsComparable(before.dom, after.dom) + ? [ + "DOM value-length comparison is unknown because units are missing or different; independent structural differences can still establish a change.", + ] + : []), "Network comparison covers only activity observed after each CDP attachment. WebSocket connections with the same URL are compared as an unordered collection of ordered frame streams, retaining connection counts; their individual identities are not stable across captures.", "Accessibility structure is compared when the tree is complete; text additionally requires complete selected text capture. A missing role leaves that node's role comparison unknown; independently observed differences can still establish a change.", "Storage key inventories are compared only when selected and complete; usage and quota are compared only when reported. Redacted content is compared through complete SHA-256 fingerprints.", @@ -1052,6 +1057,37 @@ const compareAggregate = ( reason, ); +const domValueLengthsComparable = ( + before: WebPageInspection["dom"], + after: WebPageInspection["dom"], +): boolean => + (before.nodes.length === 0 && after.nodes.length === 0) || + (before.node_value_length_unit !== undefined && + before.node_value_length_unit === after.node_value_length_unit); + +/** Compare parsed node facts without serializing or copying the inventories. */ +const domNodesDiffer = ( + before: WebPageInspection["dom"]["nodes"], + after: WebPageInspection["dom"]["nodes"], + compareLengths: boolean, +): boolean => + before.length !== after.length || + before.some((node, index) => { + const next = after[index]; + return ( + next === undefined || + node.index !== next.index || + node.parent_index !== next.parent_index || + node.node_type !== next.node_type || + node.node_name !== next.node_name || + (compareLengths && node.node_value_length !== next.node_value_length) || + node.attribute_names.length !== next.attribute_names.length || + node.attribute_names.some( + (name, position) => name !== next.attribute_names[position], + ) + ); + }); + const domDimension = ( before: WebPageInspection, after: WebPageInspection, @@ -1070,10 +1106,10 @@ const domDimension = ( sectionsComplete(inspection, ["frames"]), ); const changes: Change[] = []; + const lengthsComparable = domValueLengthsComparable(before.dom, after.dom); if ( nodesComplete && - digestCanonicalValue(before.dom.nodes) !== - digestCanonicalValue(after.dom.nodes) + domNodesDiffer(before.dom.nodes, after.dom.nodes, lengthsComparable) ) changes.push({ identity: "document", change: "modified" }); if (framesComplete && beforeFrames !== afterFrames) @@ -1085,14 +1121,14 @@ const domDimension = ( changes, reason: null, }; - return nodesComplete && framesComplete + return nodesComplete && framesComplete && lengthsComparable ? { status: "unchanged", total_changes: 0, changes: [], reason: null } : { status: "unknown", total_changes: 0, changes: [], reason: - "DOM or frame capture was incomplete, or frame ancestry was unresolved in at least one observation.", + "DOM or frame capture was incomplete, frame ancestry was unresolved, or DOM value-length units were missing or different in at least one observation.", }; }; diff --git a/tests/boundary/browser/cdpElectronProvider.test.ts b/tests/boundary/browser/cdpElectronProvider.test.ts index f18b45ec17..b088ddb429 100644 --- a/tests/boundary/browser/cdpElectronProvider.test.ts +++ b/tests/boundary/browser/cdpElectronProvider.test.ts @@ -808,36 +808,43 @@ describe("CdpElectronProvider malformed source replies", () => { ); }); +const inspectInventoryReply = async ( + method: string, + reply: (fileUrl: string) => Readonly>, +) => { + const root = await electronFixture(); + const fileUrl = pathToFileURL(join(root, "index.html")).href; + const browser = await startFakeCdpBrowser({ + electronFileUrl: fileUrl, + commandResult: (command) => + command.method === method ? reply(fileUrl) : undefined, + }); + browsers.push(browser); + const result = await new CdpElectronProvider().inspectPage( + inspectElectronPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "electron-page", + observation_ms: 0, + }), + ); + if (!result.ok) throw result.error; + return result.value; +}; + describe("CdpElectronProvider malformed inventory replies", () => { - const inspectWith = async ( - method: string, - reply: (fileUrl: string) => Readonly>, - ) => { - const root = await electronFixture(); - const fileUrl = pathToFileURL(join(root, "index.html")).href; - const browser = await startFakeCdpBrowser({ - electronFileUrl: fileUrl, - commandResult: (command) => - command.method === method ? reply(fileUrl) : undefined, - }); - browsers.push(browser); - const result = await new CdpElectronProvider().inspectPage( - inspectElectronPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - target_id: "electron-page", - observation_ms: 0, + it("keeps documents unknown when the DOM string table is missing", async () => { + const inspected = await inspectInventoryReply( + "DOMSnapshot.captureSnapshot", + () => ({ + documents: [{ documentURL: 0, nodes: { nodeType: [] } }], }), ); - if (!result.ok) throw result.error; - return result.value; - }; - it("keeps documents unknown when the DOM string table is missing", async () => { - const inspected = await inspectWith("DOMSnapshot.captureSnapshot", () => ({ - documents: [{ documentURL: 0, nodes: { nodeType: [] } }], - })); - - expect(inspected.dom).toEqual({ total_nodes: 0, nodes: [] }); + expect(inspected.dom).toEqual({ + total_nodes: 0, + node_value_length_unit: "utf8-bytes", + nodes: [], + }); expect(inspected.completeness.unavailable_sections).toContain("dom"); expect(inspected.completeness.excluded).toContainEqual({ section: "dom", @@ -867,40 +874,43 @@ describe("CdpElectronProvider malformed inventory replies", () => { ] as const)( "retains valid workers while reporting %s before target policy", async (_kind, malformed) => { - const inspected = await inspectWith("Target.getTargets", (fileUrl) => ({ - targetInfos: [ - { - targetId: "selected-worker", - type: "worker", - url: fileUrl, - attached: false, - openerId: "electron-page", - }, - { - targetId: "malformed-worker", - type: "worker", - url: fileUrl, - attached: false, - openerId: "foreign-page", - ...malformed, - }, - { - targetId: "foreign-worker", - type: "shared_worker", - url: fileUrl, - attached: true, - openerId: "foreign-page", - }, - { - targetId: "unattributed-worker", - type: "service_worker", - url: fileUrl, - attached: false, - }, - // Non-worker rows do not need worker identity or relationship fields. - { type: "page", targetId: 7, openerId: null }, - ], - })); + const inspected = await inspectInventoryReply( + "Target.getTargets", + (fileUrl) => ({ + targetInfos: [ + { + targetId: "selected-worker", + type: "worker", + url: fileUrl, + attached: false, + openerId: "electron-page", + }, + { + targetId: "malformed-worker", + type: "worker", + url: fileUrl, + attached: false, + openerId: "foreign-page", + ...malformed, + }, + { + targetId: "foreign-worker", + type: "shared_worker", + url: fileUrl, + attached: true, + openerId: "foreign-page", + }, + { + targetId: "unattributed-worker", + type: "service_worker", + url: fileUrl, + attached: false, + }, + // Non-worker rows do not need worker identity or relationship fields. + { type: "page", targetId: 7, openerId: null }, + ], + }), + ); expect(inspected.workers).toEqual([ expect.objectContaining({ target_id: "selected-worker", @@ -949,7 +959,7 @@ describe("CdpElectronProvider malformed inventory replies", () => { ] as const)( "keeps a malformed %s inventory incomplete (%s)", async (section, method, reply) => { - const inspected = await inspectWith(method, reply); + const inspected = await inspectInventoryReply(method, reply); expect(inspected.completeness.status).not.toBe("complete_within_window"); expect(inspected.completeness.excluded).toContainEqual( expect.objectContaining({ diff --git a/tests/boundary/cli/domValueLengthUnits.test.ts b/tests/boundary/cli/domValueLengthUnits.test.ts new file mode 100644 index 0000000000..eb3bf4c656 --- /dev/null +++ b/tests/boundary/cli/domValueLengthUnits.test.ts @@ -0,0 +1,226 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { webPageInspectionSchema } from "../../../src/domain/browserObservationSchemas.js"; +import { electronPageInspectionSchema } from "../../../src/domain/javascript/electronObservation.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { passiveNetworkCapture } from "../../fixtures/passiveNetworkCapture.js"; +import { webCaptureDiffSchema } from "../../../src/domain/webCaptureDiffSchemas.js"; + +for (const electron of [false, true]) + cliTest( + `declares ${electron ? "Electron UTF-8" : "web UTF-16"} DOM value lengths through CLI and MCP without reinterpreting historical captures`, + async ({ cli, onTestFinished }) => { + const root = await createTestTempDirectory("rea-dom-value-units-"); + const path = join(root, "index.html"); + await writeFile(path, ""); + const fileUrl = pathToFileURL(path).href; + const values = ["ASCII", "ééé😀", "漢字", "e\u0301"]; + let missingStrings = false; + const browser = await startFakeCdpBrowser({ + ...(electron ? { electronFileUrl: fileUrl } : {}), + commandResult: ({ method }, origin) => { + if (method !== "DOMSnapshot.captureSnapshot") return undefined; + if (missingStrings) return { documents: [] }; + return { + strings: [ + electron ? fileUrl : `${origin}/app`, + "#document", + "#text", + ...values, + ], + documents: [ + { + documentURL: 0, + nodes: { + nodeType: [9, 3, 3, 3, 3], + nodeName: [1, 2, 2, 2, 2], + nodeValue: [-1, 3, 4, 5, 6], + parentIndex: [-1, 0, 0, 0, 0], + attributes: [[], [], [], [], []], + }, + }, + ], + }; + }, + }); + onTestFinished(() => browser.close()); + const command = electron ? "inspect-electron-page" : "inspect-web-page"; + const target = electron ? "electron-page" : "allowed-page"; + const input = { + cdp_endpoint: browser.endpoint, + target_id: target, + observation_ms: 0, + ...(electron ? {} : { allowed_origins: [browser.allowedOrigin] }), + }; + const result = await cli.run({ + arguments: [ + command, + browser.endpoint, + target, + "--observation-ms", + "0", + ...(electron ? [] : ["--allowed-origins", browser.allowedOrigin]), + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + const schema = electron + ? electronPageInspectionSchema + : webPageInspectionSchema; + const check = (value: unknown) => { + const evidence = parseEvidence(value); + const report = schema.parse(evidence.normalized_result); + expect(report.dom).toMatchObject({ + node_value_length_unit: electron ? "utf8-bytes" : "utf16-code-units", + total_nodes: 5, + }); + expect(report.dom.nodes.map((node) => node.node_value_length)).toEqual([ + 0, + ...(electron ? [5, 10, 6, 3] : [5, 5, 2, 2]), + ]); + expect(report.dom.nodes.map((node) => node.index)).toEqual([ + 0, 1, 2, 3, 4, + ]); + expect(report.dom.nodes[1]).not.toHaveProperty( + "node_value_length_unit", + ); + expect(JSON.stringify(report.dom)).not.toContain("ééé😀"); + const legacyDom = { + total_nodes: report.dom.total_nodes, + nodes: report.dom.nodes, + }; + expect(schema.parse({ ...report, dom: legacyDom }).dom).toEqual( + legacyDom, + ); + expect( + schema.safeParse({ + ...report, + dom: { ...legacyDom, node_value_length_unit: "characters" }, + }).success, + ).toBe(false); + }; + check(result.json); + const call = await cli.sharedMcpCall!(); + const mcp = await call(command.replaceAll("-", "_"), input); + expect(mcp.isError).not.toBe(true); + check(mcp.structuredContent); + + missingStrings = true; + const partial = await call(command.replaceAll("-", "_"), input); + expect(partial.isError).not.toBe(true); + const report = schema.parse( + parseEvidence(partial.structuredContent).normalized_result, + ); + expect(report.dom).toEqual({ + node_value_length_unit: electron ? "utf8-bytes" : "utf16-code-units", + total_nodes: 0, + nodes: [], + }); + expect(report.completeness.excluded).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + section: "dom", + reason: "invalid_protocol_value", + }), + ]), + ); + }, + ); + +cliTest( + "compares only commensurable DOM lengths while retaining structural changes through CLI and MCP", + async ({ cli }) => { + const call = await cli.sharedMcpCall!(); + for (const [beforeUnit, afterUnit, length, variation, status] of [ + ["utf16-code-units", "utf16-code-units", 5, "none", "unchanged"], + ["utf8-bytes", "utf8-bytes", 10, "none", "changed"], + [undefined, undefined, 5, "none", "unknown"], + [undefined, "utf16-code-units", 10, "none", "unknown"], + ["utf16-code-units", "utf8-bytes", 5, "none", "unknown"], + ["utf16-code-units", "utf8-bytes", 10, "none", "unknown"], + [undefined, undefined, 5, "node", "changed"], + ["utf16-code-units", "utf8-bytes", 10, "frame", "changed"], + [undefined, undefined, 5, "empty", "unchanged"], + ] as const) { + const before = passiveNetworkCapture(); + before.dom = { + total_nodes: 2, + ...(beforeUnit === undefined + ? {} + : { node_value_length_unit: beforeUnit }), + nodes: [ + { + index: 0, + parent_index: -1, + node_type: 9, + node_name: "#document", + node_value_length: 0, + attribute_names: [], + }, + { + index: 1, + parent_index: 0, + node_type: 3, + node_name: "#text", + node_value_length: 5, + attribute_names: [], + }, + ], + }; + const after = structuredClone(before); + if (afterUnit === undefined) delete after.dom.node_value_length_unit; + else after.dom.node_value_length_unit = afterUnit; + after.dom.nodes[1]!.node_value_length = length; + if (variation === "node") { + after.dom.nodes[1]!.node_type = 8; + after.dom.nodes[1]!.node_name = "#comment"; + } + if (variation === "frame") + after.frames.push({ + frame_id: "frame", + parent_frame_id: null, + url: "https://app.example.test/other", + origin: "https://app.example.test", + }); + if (variation === "empty") { + before.dom = { total_nodes: 0, nodes: [] }; + after.dom = { total_nodes: 0, nodes: [] }; + } + const result = await cli.sharedCliRun!({ + arguments: [ + "compare-web-captures", + JSON.stringify({ inspection: before }), + JSON.stringify({ inspection: after }), + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + const mcp = await call("compare_web_captures", { + before: { inspection: after }, + after: { inspection: before }, + }); + expect(mcp.isError).not.toBe(true); + const label = JSON.stringify({ + beforeUnit, + afterUnit, + length, + variation, + }); + for (const output of [result.json, mcp.structuredContent]) { + const diff = webCaptureDiffSchema.parse( + parseEvidence(output).normalized_result, + ); + expect(diff.dimensions.dom_structure.status, label).toBe(status); + if (status === "unknown") + expect(diff.dimensions.dom_structure.reason).toContain("unit"); + } + } + }, +);