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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-14 02:54:39 +00:00
parent 7346397106
commit 1b75c0d2f9
4 changed files with 127 additions and 49 deletions

View File

@ -130,14 +130,13 @@ describe("CDP DOM string and namespace validity", () => {
{ field: "nodeName", index: -1 }, { field: "nodeName", index: -1 },
{ field: "nodeName", index: 20 }, { field: "nodeName", index: 20 },
{ field: "nodeName", index: 2, malformedString: true }, { field: "nodeName", index: 2, malformedString: true },
{ field: "nodeValue", index: -1 },
{ field: "nodeValue", index: 20 }, { field: "nodeValue", index: 20 },
{ field: "nodeValue", index: 5, malformedString: true }, { field: "nodeValue", index: 5, malformedString: true },
{ field: "attributeName", index: -1 }, { field: "attributeName", index: -1 },
{ field: "attributeName", index: 20 }, { field: "attributeName", index: 20 },
{ field: "attributeValue", index: -1 },
{ field: "attributeValue", index: 20 }, { field: "attributeValue", index: 20 },
] as const)( ] as const)(
// -1 is DOMSnapshot's empty string for values, never for names.
"omits a node when its $field string index is invalid ($index)", "omits a node when its $field string index is invalid ($index)",
({ ({
field, 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([]);
});
});

View File

@ -9,6 +9,11 @@ import {
cdpStringValue, cdpStringValue,
type UnknownRecord, type UnknownRecord,
} from "./CdpCaptureValues.js"; } from "./CdpCaptureValues.js";
import {
cdpSnapshotAttributesResolve,
cdpSnapshotString,
cdpSnapshotValueString,
} from "./CdpSnapshotStrings.js";
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js"; import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import { import {
@ -183,7 +188,7 @@ export function* captureDom(
let total = 0; let total = 0;
let unresolvedSvgNodes = 0; let unresolvedSvgNodes = 0;
for (const document of recordsValue(root.documents)) { for (const document of recordsValue(root.documents)) {
const documentUrl = indexedString(strings, document.documentURL); const documentUrl = cdpSnapshotString(strings, document.documentURL);
if (documentUrl === undefined) { if (documentUrl === undefined) {
completeness?.exclude("dom", "invalid_protocol_value"); completeness?.exclude("dom", "invalid_protocol_value");
continue; continue;
@ -195,7 +200,7 @@ export function* captureDom(
const baseUrlValue = const baseUrlValue =
document.baseURL === undefined document.baseURL === undefined
? documentUrl ? documentUrl
: indexedString(strings, document.baseURL); : cdpSnapshotValueString(strings, document.baseURL);
if (baseUrlValue === undefined) { if (baseUrlValue === undefined) {
completeness?.exclude("dom", "invalid_protocol_value"); completeness?.exclude("dom", "invalid_protocol_value");
continue; continue;
@ -248,8 +253,8 @@ export function* captureDom(
const nodeValueIndex = rawNodeValues[index]; const nodeValueIndex = rawNodeValues[index];
const parent = rawParents[index]; const parent = rawParents[index];
const attributeIndexes = numberArray(rawAttributes[index]); const attributeIndexes = numberArray(rawAttributes[index]);
const nodeName = indexedString(strings, nodeNameIndex); const nodeName = cdpSnapshotString(strings, nodeNameIndex);
const nodeValue = indexedString(strings, nodeValueIndex); const nodeValue = cdpSnapshotValueString(strings, nodeValueIndex);
if ( if (
nodeType === undefined || nodeType === undefined ||
nodeName === undefined || nodeName === undefined ||
@ -259,11 +264,7 @@ export function* captureDom(
parent === index || parent === index ||
parent >= rawNodeTypes.length || parent >= rawNodeTypes.length ||
attributeIndexes === undefined || attributeIndexes === undefined ||
attributeIndexes.length % 2 !== 0 || !cdpSnapshotAttributesResolve(strings, attributeIndexes)
attributeIndexes.some(
(value) =>
value === undefined || indexedString(strings, value) === undefined,
)
) { ) {
completeness?.exclude("dom", "invalid_protocol_value"); completeness?.exclude("dom", "invalid_protocol_value");
continue; continue;
@ -514,16 +515,6 @@ const accessibilityStates = (
const axText = (value: unknown): string | null => const axText = (value: unknown): string | null =>
cdpStringValue(recordValue(value)?.value) ?? 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 = ( const uniqueAttributeNames = (
attributeIndexes: readonly number[], attributeIndexes: readonly number[],
strings: readonly (string | undefined)[], strings: readonly (string | undefined)[],
@ -531,7 +522,7 @@ const uniqueAttributeNames = (
const names: string[] = []; const names: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (let index = 0; index < attributeIndexes.length; index += 2) { 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 (name === undefined) continue;
if (seen.has(name)) continue; if (seen.has(name)) continue;
seen.add(name); seen.add(name);
@ -597,8 +588,8 @@ const domMetadata = (
const pairs = new Map<string, string>(); const pairs = new Map<string, string>();
for (let index = 0; index + 1 < attributes.length; index += 2) { for (let index = 0; index + 1 < attributes.length; index += 2) {
// Chrome already folds HTML names; SVG/XML names remain case-sensitive. // Chrome already folds HTML names; SVG/XML names remain case-sensitive.
const name = indexedString(strings, attributes[index]); const name = cdpSnapshotString(strings, attributes[index]);
const value = indexedString(strings, attributes[index + 1]); const value = cdpSnapshotValueString(strings, attributes[index + 1]);
if (name === undefined || value === undefined) continue; if (name === undefined || value === undefined) continue;
// HTML keeps the first declaration. A later repeat must not move dom_urls. // HTML keeps the first declaration. A later repeat must not move dom_urls.
if (!pairs.has(name)) pairs.set(name, value); if (!pairs.has(name)) pairs.set(name, value);

View File

@ -1,4 +1,9 @@
import { numberValue, recordValue } from "./CdpCaptureValues.js"; import { numberValue, recordValue } from "./CdpCaptureValues.js";
import {
cdpSnapshotAttributesResolve,
cdpSnapshotString,
cdpSnapshotValueString,
} from "./CdpSnapshotStrings.js";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"; const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
@ -52,16 +57,6 @@ interface DomSvgLink {
readonly xlinkHref: string | undefined; 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)[] => const numberArray = (value: unknown): readonly (number | undefined)[] =>
Array.isArray(value) Array.isArray(value)
? value.map((item) => { ? value.map((item) => {
@ -86,7 +81,7 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => {
for (let index = 0; index < nodes.nodeTypes.length; index += 1) { for (let index = 0; index < nodes.nodeTypes.length; index += 1) {
if (nodes.nodeTypes[index] !== 1) continue; if (nodes.nodeTypes[index] !== 1) continue;
if (nodes.parents[index] !== documentIndex) 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)) if (name === undefined || !validSvgSourceNode(nodes, index))
return undefined; return undefined;
if (name === "HTML") return false; if (name === "HTML") return false;
@ -96,7 +91,7 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => {
const attributes = numberArray(nodes.attributes[index]); const attributes = numberArray(nodes.attributes[index]);
for (let offset = 0; offset + 1 < attributes.length; offset += 2) { for (let offset = 0; offset + 1 < attributes.length; offset += 2) {
const value = attributes[offset]; const value = attributes[offset];
const attributeName = stringAt(nodes.strings, value); const attributeName = cdpSnapshotString(nodes.strings, value);
if (attributeName === undefined) return undefined; if (attributeName === undefined) return undefined;
if (xmlnsPrefix(attributeName) !== undefined) return true; if (xmlnsPrefix(attributeName) !== undefined) return true;
} }
@ -195,7 +190,7 @@ const resolveXmlElements = (
const svgElement = const svgElement =
nodes.nodeTypes[node] === 1 && nodes.nodeTypes[node] === 1 &&
elementNamespace( elementNamespace(
stringAt(nodes.strings, nodes.nodeNames[node]), cdpSnapshotString(nodes.strings, nodes.nodeNames[node]),
bindings, bindings,
) === SVG_NAMESPACE; ) === SVG_NAMESPACE;
resolved.set(node, { resolved.set(node, {
@ -223,8 +218,8 @@ const applyDeclarations = (
const nameIndex = attributes[index]; const nameIndex = attributes[index];
const valueIndex = attributes[index + 1]; const valueIndex = attributes[index + 1];
if (nameIndex === undefined || valueIndex === undefined) continue; if (nameIndex === undefined || valueIndex === undefined) continue;
const name = stringAt(strings, nameIndex); const name = cdpSnapshotString(strings, nameIndex);
const value = stringAt(strings, valueIndex); const value = cdpSnapshotValueString(strings, valueIndex);
if (name === undefined || value === undefined) continue; if (name === undefined || value === undefined) continue;
const prefix = xmlnsPrefix(name); const prefix = xmlnsPrefix(name);
if (prefix === undefined || seen.has(prefix)) continue; if (prefix === undefined || seen.has(prefix)) continue;
@ -277,7 +272,7 @@ const xlinkHrefValue = (
const nameIndex = attributes[index]; const nameIndex = attributes[index];
const valueIndex = attributes[index + 1]; const valueIndex = attributes[index + 1];
if (nameIndex === undefined || valueIndex === undefined) continue; if (nameIndex === undefined || valueIndex === undefined) continue;
const name = stringAt(strings, nameIndex); const name = cdpSnapshotString(strings, nameIndex);
if (name === undefined) continue; if (name === undefined) continue;
if (seen.has(name)) continue; if (seen.has(name)) continue;
seen.add(name); seen.add(name);
@ -286,7 +281,7 @@ const xlinkHrefValue = (
const prefix = name.slice(0, colon); const prefix = name.slice(0, colon);
const local = name.slice(colon + 1); const local = name.slice(colon + 1);
if (local !== "href" || bindings.get(prefix) !== XLINK_NAMESPACE) continue; if (local !== "href" || bindings.get(prefix) !== XLINK_NAMESPACE) continue;
return stringAt(strings, valueIndex); return cdpSnapshotValueString(strings, valueIndex);
} }
return undefined; return undefined;
}; };
@ -297,19 +292,14 @@ const validSvgSourceNode = (
): boolean => { ): boolean => {
if ( if (
nodes.nodeTypes[index] === undefined || nodes.nodeTypes[index] === undefined ||
stringAt(nodes.strings, nodes.nodeNames[index]) === undefined cdpSnapshotString(nodes.strings, nodes.nodeNames[index]) === undefined
) )
return false; return false;
const rawAttributes = nodes.attributes[index]; const rawAttributes = nodes.attributes[index];
if (!Array.isArray(rawAttributes) || rawAttributes.length % 2 !== 0) if (!Array.isArray(rawAttributes)) return false;
return false;
const attributes = numberArray(rawAttributes); const attributes = numberArray(rawAttributes);
return ( return (
attributes.length === rawAttributes.length && attributes.length === rawAttributes.length &&
attributes.every( cdpSnapshotAttributesResolve(nodes.strings, attributes)
(attribute) =>
attribute !== undefined &&
stringAt(nodes.strings, attribute) !== undefined,
)
); );
}; };

View File

@ -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,
);