From 1b75c0d2f9fcaa89171aa1afdfc95b9b7ecb8dbc Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:54:39 +0000 Subject: [PATCH] fix(browser): read DOMSnapshot's -1 string index as an empty value Chrome's DOMSnapshot interns empty or null strings as index -1. bffdd9105 began rejecting every negative string index, so real Chrome snapshots lost each element node (whose nodeValue is -1) and any node with an empty attribute value, and the headless Chrome SVG xlink:href lanes found no anchors. Simulated snapshots interned "" as a table entry and missed it. Parse snapshot strings at one owner: names still require a table entry, while node values, attribute values, and baseURL accept -1 as "". The SVG ancestry projection shares the same parser instead of its own copy. Co-Authored-By: Claude Opus 5.5 --- src/browser/CdpCaptureDocuments.test.ts | 62 ++++++++++++++++++++++++- src/browser/CdpCaptureDocuments.ts | 35 ++++++-------- src/browser/CdpCaptureDomSvg.ts | 40 ++++++---------- src/browser/CdpSnapshotStrings.ts | 39 ++++++++++++++++ 4 files changed, 127 insertions(+), 49 deletions(-) create mode 100644 src/browser/CdpSnapshotStrings.ts diff --git a/src/browser/CdpCaptureDocuments.test.ts b/src/browser/CdpCaptureDocuments.test.ts index fe67f90aa..be8656e8a 100644 --- a/src/browser/CdpCaptureDocuments.test.ts +++ b/src/browser/CdpCaptureDocuments.test.ts @@ -130,14 +130,13 @@ describe("CDP DOM string and namespace validity", () => { { field: "nodeName", index: -1 }, { field: "nodeName", index: 20 }, { field: "nodeName", index: 2, malformedString: true }, - { field: "nodeValue", index: -1 }, { field: "nodeValue", index: 20 }, { field: "nodeValue", index: 5, malformedString: true }, { field: "attributeName", index: -1 }, { field: "attributeName", index: 20 }, - { field: "attributeValue", index: -1 }, { field: "attributeValue", index: 20 }, ] as const)( + // -1 is DOMSnapshot's empty string for values, never for names. "omits a node when its $field string index is invalid ($index)", ({ field, @@ -353,3 +352,62 @@ describe("CDP accessibility capture", () => { }); }); }); + +describe("CDP DOM snapshot empty strings", () => { + it("reads Chrome's -1 string index as an empty node or attribute value", () => { + const completeness = new CdpCaptureCompleteness(); + const generator = captureDom( + { + strings: [ + origin + "/app", + "#document", + "A", + "href", + "hidden", + "#text", + "go", + ], + documents: [ + { + documentURL: 0, + baseURL: -1, + nodes: { + nodeType: [9, 1, 3], + nodeName: [1, 2, 5], + nodeValue: [-1, -1, 6], + parentIndex: [-1, 0, 1], + attributes: [[], [3, -1, 4, -1], []], + }, + }, + ], + }, + new Set([origin]), + inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [origin], + target_id: "page-1", + }), + completeness, + ); + let captured = generator.next([]); + while (!captured.done) captured = generator.next([]); + expect( + captured.value.nodes.map( + ({ node_name, node_value_length, attribute_names }) => ({ + node_name, + node_value_length, + attribute_names, + }), + ), + ).toEqual([ + { node_name: "#document", node_value_length: 0, attribute_names: [] }, + { + node_name: "A", + node_value_length: 0, + attribute_names: ["href", "hidden"], + }, + { node_name: "#text", node_value_length: 2, attribute_names: [] }, + ]); + expect(completeness.snapshot().excluded).toEqual([]); + }); +}); diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 8aeee7e9a..f9f97d375 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -9,6 +9,11 @@ import { cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; +import { + cdpSnapshotAttributesResolve, + cdpSnapshotString, + cdpSnapshotValueString, +} from "./CdpSnapshotStrings.js"; import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js"; import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { @@ -183,7 +188,7 @@ export function* captureDom( let total = 0; let unresolvedSvgNodes = 0; for (const document of recordsValue(root.documents)) { - const documentUrl = indexedString(strings, document.documentURL); + const documentUrl = cdpSnapshotString(strings, document.documentURL); if (documentUrl === undefined) { completeness?.exclude("dom", "invalid_protocol_value"); continue; @@ -195,7 +200,7 @@ export function* captureDom( const baseUrlValue = document.baseURL === undefined ? documentUrl - : indexedString(strings, document.baseURL); + : cdpSnapshotValueString(strings, document.baseURL); if (baseUrlValue === undefined) { completeness?.exclude("dom", "invalid_protocol_value"); continue; @@ -248,8 +253,8 @@ export function* captureDom( const nodeValueIndex = rawNodeValues[index]; const parent = rawParents[index]; const attributeIndexes = numberArray(rawAttributes[index]); - const nodeName = indexedString(strings, nodeNameIndex); - const nodeValue = indexedString(strings, nodeValueIndex); + const nodeName = cdpSnapshotString(strings, nodeNameIndex); + const nodeValue = cdpSnapshotValueString(strings, nodeValueIndex); if ( nodeType === undefined || nodeName === undefined || @@ -259,11 +264,7 @@ export function* captureDom( parent === index || parent >= rawNodeTypes.length || attributeIndexes === undefined || - attributeIndexes.length % 2 !== 0 || - attributeIndexes.some( - (value) => - value === undefined || indexedString(strings, value) === undefined, - ) + !cdpSnapshotAttributesResolve(strings, attributeIndexes) ) { completeness?.exclude("dom", "invalid_protocol_value"); continue; @@ -514,16 +515,6 @@ const accessibilityStates = ( const axText = (value: unknown): string | null => cdpStringValue(recordValue(value)?.value) ?? null; -const indexedString = ( - strings: readonly (string | undefined)[], - index: unknown, -): string | undefined => { - const integer = numberValue(index); - if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) - return undefined; - return strings[integer]; -}; - const uniqueAttributeNames = ( attributeIndexes: readonly number[], strings: readonly (string | undefined)[], @@ -531,7 +522,7 @@ const uniqueAttributeNames = ( const names: string[] = []; const seen = new Set(); for (let index = 0; index < attributeIndexes.length; index += 2) { - const name = indexedString(strings, attributeIndexes[index]); + const name = cdpSnapshotString(strings, attributeIndexes[index]); if (name === undefined) continue; if (seen.has(name)) continue; seen.add(name); @@ -597,8 +588,8 @@ const domMetadata = ( const pairs = new Map(); for (let index = 0; index + 1 < attributes.length; index += 2) { // Chrome already folds HTML names; SVG/XML names remain case-sensitive. - const name = indexedString(strings, attributes[index]); - const value = indexedString(strings, attributes[index + 1]); + const name = cdpSnapshotString(strings, attributes[index]); + const value = cdpSnapshotValueString(strings, attributes[index + 1]); if (name === undefined || value === undefined) continue; // HTML keeps the first declaration. A later repeat must not move dom_urls. if (!pairs.has(name)) pairs.set(name, value); diff --git a/src/browser/CdpCaptureDomSvg.ts b/src/browser/CdpCaptureDomSvg.ts index 2ffd8e31f..877bab06d 100644 --- a/src/browser/CdpCaptureDomSvg.ts +++ b/src/browser/CdpCaptureDomSvg.ts @@ -1,4 +1,9 @@ import { numberValue, recordValue } from "./CdpCaptureValues.js"; +import { + cdpSnapshotAttributesResolve, + cdpSnapshotString, + cdpSnapshotValueString, +} from "./CdpSnapshotStrings.js"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"; @@ -52,16 +57,6 @@ interface DomSvgLink { readonly xlinkHref: string | undefined; } -const stringAt = ( - strings: readonly (string | undefined)[], - index: unknown, -): string | undefined => { - const integer = numberValue(index); - if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) - return undefined; - return strings[integer]; -}; - const numberArray = (value: unknown): readonly (number | undefined)[] => Array.isArray(value) ? value.map((item) => { @@ -86,7 +81,7 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => { for (let index = 0; index < nodes.nodeTypes.length; index += 1) { if (nodes.nodeTypes[index] !== 1) continue; if (nodes.parents[index] !== documentIndex) continue; - const name = stringAt(nodes.strings, nodes.nodeNames[index]); + const name = cdpSnapshotString(nodes.strings, nodes.nodeNames[index]); if (name === undefined || !validSvgSourceNode(nodes, index)) return undefined; if (name === "HTML") return false; @@ -96,7 +91,7 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => { const attributes = numberArray(nodes.attributes[index]); for (let offset = 0; offset + 1 < attributes.length; offset += 2) { const value = attributes[offset]; - const attributeName = stringAt(nodes.strings, value); + const attributeName = cdpSnapshotString(nodes.strings, value); if (attributeName === undefined) return undefined; if (xmlnsPrefix(attributeName) !== undefined) return true; } @@ -195,7 +190,7 @@ const resolveXmlElements = ( const svgElement = nodes.nodeTypes[node] === 1 && elementNamespace( - stringAt(nodes.strings, nodes.nodeNames[node]), + cdpSnapshotString(nodes.strings, nodes.nodeNames[node]), bindings, ) === SVG_NAMESPACE; resolved.set(node, { @@ -223,8 +218,8 @@ const applyDeclarations = ( const nameIndex = attributes[index]; const valueIndex = attributes[index + 1]; if (nameIndex === undefined || valueIndex === undefined) continue; - const name = stringAt(strings, nameIndex); - const value = stringAt(strings, valueIndex); + const name = cdpSnapshotString(strings, nameIndex); + const value = cdpSnapshotValueString(strings, valueIndex); if (name === undefined || value === undefined) continue; const prefix = xmlnsPrefix(name); if (prefix === undefined || seen.has(prefix)) continue; @@ -277,7 +272,7 @@ const xlinkHrefValue = ( const nameIndex = attributes[index]; const valueIndex = attributes[index + 1]; if (nameIndex === undefined || valueIndex === undefined) continue; - const name = stringAt(strings, nameIndex); + const name = cdpSnapshotString(strings, nameIndex); if (name === undefined) continue; if (seen.has(name)) continue; seen.add(name); @@ -286,7 +281,7 @@ const xlinkHrefValue = ( const prefix = name.slice(0, colon); const local = name.slice(colon + 1); if (local !== "href" || bindings.get(prefix) !== XLINK_NAMESPACE) continue; - return stringAt(strings, valueIndex); + return cdpSnapshotValueString(strings, valueIndex); } return undefined; }; @@ -297,19 +292,14 @@ const validSvgSourceNode = ( ): boolean => { if ( nodes.nodeTypes[index] === undefined || - stringAt(nodes.strings, nodes.nodeNames[index]) === undefined + cdpSnapshotString(nodes.strings, nodes.nodeNames[index]) === undefined ) return false; const rawAttributes = nodes.attributes[index]; - if (!Array.isArray(rawAttributes) || rawAttributes.length % 2 !== 0) - return false; + if (!Array.isArray(rawAttributes)) return false; const attributes = numberArray(rawAttributes); return ( attributes.length === rawAttributes.length && - attributes.every( - (attribute) => - attribute !== undefined && - stringAt(nodes.strings, attribute) !== undefined, - ) + cdpSnapshotAttributesResolve(nodes.strings, attributes) ); }; diff --git a/src/browser/CdpSnapshotStrings.ts b/src/browser/CdpSnapshotStrings.ts new file mode 100644 index 000000000..6e8314727 --- /dev/null +++ b/src/browser/CdpSnapshotStrings.ts @@ -0,0 +1,39 @@ +import { numberValue } from "./CdpCaptureValues.js"; + +// DOMSnapshot interns an empty or null string as index -1 instead of a table +// entry. Chrome uses it for element node values and empty attribute values. +const CDP_EMPTY_STRING_INDEX = -1; + +/** Resolve a DOMSnapshot name index, which must reference a string entry. */ +export const cdpSnapshotString = ( + strings: readonly (string | undefined)[], + index: unknown, +): string | undefined => { + const integer = numberValue(index); + if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) + return undefined; + return strings[integer]; +}; + +/** Resolve a DOMSnapshot value index, where -1 is the protocol's empty string. */ +export const cdpSnapshotValueString = ( + strings: readonly (string | undefined)[], + index: unknown, +): string | undefined => + numberValue(index) === CDP_EMPTY_STRING_INDEX + ? "" + : cdpSnapshotString(strings, index); + +/** Whether flattened name/value attribute indexes all resolve. */ +export const cdpSnapshotAttributesResolve = ( + strings: readonly (string | undefined)[], + attributes: readonly (number | undefined)[], +): boolean => + attributes.length % 2 === 0 && + attributes.every( + (attribute, offset) => + attribute !== undefined && + (offset % 2 === 0 + ? cdpSnapshotString(strings, attribute) + : cdpSnapshotValueString(strings, attribute)) !== undefined, + );