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:
parent
7346397106
commit
1b75c0d2f9
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<string, string>();
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
Loading…
Reference in New Issue