fix(browser): preserve malformed capture gaps and protocol session identity

This commit is contained in:
morluto committed 2026-10-14 09:49:49 +08:00
1 parent e572c38dcf
commit bffdd91053
22 files changed
+966 -210

No files matched your search

+248 -1
View File
@@ -2,13 +2,258 @@ import { describe, expect, it } from "vitest";
import {
captureAccessibility,
captureDom,
captureFrames,
captureResources,
} from "./CdpCaptureDocuments.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
const origin = "https://app.example.test";
describe("CDP document capture", () => {
describe("CDP DOM capture", () => {
it("preserves node names and parents when aligned arrays are valid", () => {
const generator = captureDom(
{
strings: [origin + "/app", "#document", "DIV", "SPAN", ""],
documents: [
{
documentURL: 0,
baseURL: 0,
nodes: {
nodeType: [9, 1, 1],
nodeName: [1, 2, 3],
nodeValue: [4, 4, 4],
parentIndex: [-1, 0, 1],
attributes: [[], [], []],
},
},
],
},
new Set([origin]),
inspectWebPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
allowed_origins: [origin],
target_id: "page-1",
}),
);
let captured = generator.next([]);
while (!captured.done) captured = generator.next([]);
expect(captured.value.nodes.map((node) => node.node_name)).toEqual([
"#document",
"DIV",
"SPAN",
]);
expect(captured.value.nodes.map((node) => node.parent_index)).toEqual([
-1, 0, 1,
]);
expect(captured.value.nodes.map((node) => node.node_value_length)).toEqual([
0, 0, 0,
]);
});
it.each(["nodeName", "parentIndex", "nodeValue", "attributes"] as const)(
"preserves DOM fact positions and reports malformed %s entries",
(field) => {
const strings = [
origin + "/app",
"#document",
"DIV",
"SPAN",
"href",
"title",
"/page",
];
const nodeArrays: Record<string, unknown> = {
nodeType: [9, 1, 1],
nodeName: [1, 2, 3],
nodeValue: [0, 0, 0],
parentIndex: [-1, 0, 1],
attributes: [[], [], []],
};
if (field === "nodeName") nodeArrays.nodeName = [1, "malformed", 3];
if (field === "parentIndex")
nodeArrays.parentIndex = [-1, "malformed", 1];
if (field === "nodeValue") nodeArrays.nodeValue = [0, 0, "malformed"];
if (field === "attributes")
nodeArrays.attributes = [[], [4, "malformed", 5, 6], []];
const completeness = new CdpCaptureCompleteness();
const generator = captureDom(
{
strings,
documents: [
{
documentURL: 0,
baseURL: 0,
nodes: nodeArrays,
},
],
},
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.total).toBe(3);
expect(captured.value.nodes.map((node) => node.node_name)).toEqual(
field === "nodeValue" ? ["#document", "DIV"] : ["#document", "SPAN"],
);
expect(captured.value.nodes.map((node) => node.index)).toEqual(
field === "nodeValue" ? [0, 1] : [0, 2],
);
expect(captured.value.nodes.map((node) => node.parent_index)).toEqual(
field === "nodeValue" ? [-1, 0] : [-1, 1],
);
if (field === "attributes") {
expect(
captured.value.nodes.map((node) => node.attribute_names),
).toEqual([[], []]);
expect(captured.value.urls).toEqual([]);
}
expect(completeness.snapshot()).toMatchObject({
unavailable_sections: expect.arrayContaining(["dom"]),
excluded: expect.arrayContaining([
{ section: "dom", reason: "invalid_protocol_value", count: 1 },
]),
});
},
);
});
describe("CDP DOM string and namespace validity", () => {
it.each([
{ 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)(
"omits a node when its $field string index is invalid ($index)",
({ field, index, malformedString }) => {
const strings: unknown[] = [
origin + "/app",
"#document",
"DIV",
"SPAN",
"href",
"/good",
];
const nodeArrays: Record<string, unknown> = {
nodeType: [9, 1, 1],
nodeName: [1, 2, 3],
nodeValue: [0, 0, 0],
parentIndex: [-1, 0, 0],
attributes: [[], [], []],
};
if (field === "nodeName") nodeArrays.nodeName = [1, index, 3];
if (field === "nodeValue") nodeArrays.nodeValue = [0, index, 0];
if (field === "attributeName")
nodeArrays.attributes = [[], [index, 5], []];
if (field === "attributeValue")
nodeArrays.attributes = [[], [4, index], []];
if (malformedString === true) strings[index === 2 ? 2 : 5] = 42;
const completeness = new CdpCaptureCompleteness();
const generator = captureDom(
{
strings,
documents: [
{
documentURL: 0,
baseURL: 0,
nodes: nodeArrays,
},
],
},
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.total).toBe(3);
expect(captured.value.nodes.map((node) => node.index)).toEqual([0, 2]);
expect(captured.value.nodes.map((node) => node.node_name)).toEqual([
"#document",
"SPAN",
]);
expect(completeness.snapshot().excluded).toContainEqual({
section: "dom",
reason: "invalid_protocol_value",
count: 1,
});
},
);
it("does not infer SVG ancestry through an element with malformed namespace data", () => {
const strings: unknown[] = [
origin + "/app",
"#document",
"svg",
"a",
"xmlns:xlink",
"http://www.w3.org/1999/xlink",
"xlink:href",
"/svg-link",
];
const completeness = new CdpCaptureCompleteness();
const generator = captureDom(
{
strings,
documents: [
{
documentURL: 0,
baseURL: 0,
nodes: {
nodeType: [9, 1, 1],
nodeName: [1, 2, 3],
nodeValue: [0, 0, 0],
parentIndex: [-1, 0, 1],
attributes: [[], [4, 99], [6, 7]],
},
},
],
},
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) => node.index)).toEqual([0, 2]);
expect(captured.value.nodes[1]?.attribute_names).toEqual(["xlink:href"]);
expect(captured.value.urls).toEqual([]);
expect(
completeness
.snapshot()
.excluded.some(
({ section, reason }) =>
section === "metadata" && reason === "invalid_protocol_value",
),
).toBe(true);
});
});
describe("CDP frame capture", () => {
it("walks deeply nested frame trees and returns every frame and resource", () => {
let frameTree: Record<string, unknown> = {
frame: { id: "frame-0", url: `${origin}/0` },
@@ -30,7 +275,9 @@ describe("CDP document capture", () => {
expect(frames.items).toHaveLength(2_000);
expect(resources.items).toHaveLength(2_000);
});
});
describe("CDP accessibility capture", () => {
it("retains full approved accessibility text", () => {
const capture = captureAccessibility(
[
+112 -37
View File
@@ -174,7 +174,7 @@ export function* captureDom(
> {
const root = requiredRecord(result);
const strings = Array.isArray(root.strings)
? root.strings.map((value) => cdpStringValue(value) ?? "")
? root.strings.map(cdpStringValue)
: [];
const nodes: WebPageInspection["dom"]["nodes"] = [];
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
@@ -184,44 +184,100 @@ export function* captureDom(
let unresolvedSvgNodes = 0;
for (const document of recordsValue(root.documents)) {
const documentUrl = indexedString(strings, document.documentURL);
if (documentUrl === undefined) {
completeness?.exclude("dom", "invalid_protocol_value");
continue;
}
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
completeness?.exclude("dom", exclusionReasonForUrl(documentUrl));
continue;
}
const baseUrl = indexedString(strings, document.baseURL) || documentUrl;
const baseUrlValue =
document.baseURL === undefined
? documentUrl
: indexedString(strings, document.baseURL);
if (baseUrlValue === undefined) {
completeness?.exclude("dom", "invalid_protocol_value");
continue;
}
const baseUrl = baseUrlValue || documentUrl;
const documentNodes = recordValue(document.nodes);
if (documentNodes === undefined) continue;
const nodeTypes = numberArray(documentNodes.nodeType);
const nodeNames = numberArray(documentNodes.nodeName);
const nodeValues = numberArray(documentNodes.nodeValue);
const parents = numberArray(documentNodes.parentIndex);
const attributes = Array.isArray(documentNodes.attributes)
const rawNodeTypes = numberArray(documentNodes.nodeType);
const rawNodeNames = numberArray(documentNodes.nodeName);
const rawNodeValues = numberArray(documentNodes.nodeValue);
const rawParents = numberArray(documentNodes.parentIndex);
const rawAttributes = Array.isArray(documentNodes.attributes)
? documentNodes.attributes
: [];
: undefined;
if (
rawNodeTypes === undefined ||
rawNodeNames === undefined ||
rawNodeValues === undefined ||
rawParents === undefined ||
rawAttributes === undefined ||
rawNodeNames.length !== rawNodeTypes.length ||
rawNodeValues.length !== rawNodeTypes.length ||
rawParents.length !== rawNodeTypes.length
) {
completeness?.exclude("dom", "invalid_protocol_value");
continue;
}
if (rawAttributes.length > rawNodeTypes.length)
completeness?.exclude(
"dom",
"invalid_protocol_value",
rawAttributes.length - rawNodeTypes.length,
);
const backendNodeIds = Array.isArray(documentNodes.backendNodeId)
? documentNodes.backendNodeId
: [];
const baseIndex = nodes.length;
const baseIndex = total;
total += rawNodeTypes.length;
const ancestry = createDocumentSvgAncestry({
nodeTypes,
nodeNames,
parents,
nodeTypes: rawNodeTypes,
nodeNames: rawNodeNames,
parents: rawParents,
strings,
attributes,
attributes: rawAttributes,
});
total += nodeTypes.length;
const pendingMetadata: PendingDomMetadata[] = [];
for (let index = 0; index < nodeTypes.length; index += 1) {
const attributeIndexes = numberArray(attributes[index]);
const parent = Math.trunc(parents[index] ?? -1);
const nodeIndex = nodes.length;
const nodeName = indexedString(strings, nodeNames[index]);
const attributeNames = uniqueAttributeNames(attributeIndexes, strings);
for (let index = 0; index < rawNodeTypes.length; index += 1) {
const nodeType = rawNodeTypes[index];
const nodeNameIndex = rawNodeNames[index];
const nodeValueIndex = rawNodeValues[index];
const parent = rawParents[index];
const attributeIndexes = numberArray(rawAttributes[index]);
const nodeName = indexedString(strings, nodeNameIndex);
const nodeValue = indexedString(strings, nodeValueIndex);
if (
nodeType === undefined ||
nodeName === undefined ||
nodeValue === undefined ||
parent === undefined ||
parent < -1 ||
parent === index ||
parent >= rawNodeTypes.length ||
attributeIndexes === undefined ||
attributeIndexes.length % 2 !== 0 ||
attributeIndexes.some(
(value) =>
value === undefined || indexedString(strings, value) === undefined,
)
) {
completeness?.exclude("dom", "invalid_protocol_value");
continue;
}
const attributes = attributeIndexes as readonly number[];
const nodeIndex = baseIndex + index;
const attributeNames = uniqueAttributeNames(attributes, strings);
const svgLink = domSvgLink(ancestry, index);
if (svgLink.xmlDocument === undefined)
completeness?.exclude("metadata", "invalid_protocol_value");
let svgQuery: DomSvgNodeQuery | null | undefined;
if (
!svgLink.xmlDocument &&
nodeTypes[index] === 1 &&
svgLink.xmlDocument === false &&
nodeType === 1 &&
attributeNames.includes("xlink:href") &&
!attributeNames.includes("href")
) {
@@ -236,16 +292,16 @@ export function* captureDom(
nodes.push({
index: nodeIndex,
parent_index: parent < 0 ? -1 : baseIndex + parent,
node_type: Math.max(0, Math.trunc(nodeTypes[index] ?? 0)),
node_type: nodeType,
node_name: nodeName,
node_value_length: indexedString(strings, nodeValues[index]).length,
node_value_length: nodeValue.length,
attribute_names: attributeNames,
});
pendingMetadata.push({
svgQuery,
options: {
strings,
attributes: attributeIndexes,
attributes,
// The BASE element itself resolves against the document fallback URL.
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
documentUrl,
@@ -257,7 +313,7 @@ export function* captureDom(
});
if (
pendingMetadata.length === DOM_METADATA_BATCH_SIZE ||
index === nodeTypes.length - 1
index === rawNodeTypes.length - 1
) {
const metadata = yield* captureDomMetadata(
pendingMetadata,
@@ -270,6 +326,14 @@ export function* captureDom(
pendingMetadata.length = 0;
}
}
if (pendingMetadata.length > 0) {
const metadata = yield* captureDomMetadata(pendingMetadata, completeness);
urls.push(...metadata.urls);
agentHints.push(...metadata.agentHints);
excludedUrls += metadata.excludedUrls;
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
pendingMetadata.length = 0;
}
}
return {
total,
@@ -450,19 +514,25 @@ const accessibilityStates = (
const axText = (value: unknown): string | null =>
cdpStringValue(recordValue(value)?.value) ?? null;
const indexedString = (strings: readonly string[], index: unknown): string => {
const indexedString = (
strings: readonly (string | undefined)[],
index: unknown,
): string | undefined => {
const integer = numberValue(index);
return integer === undefined ? "" : (strings[Math.trunc(integer)] ?? "");
if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0)
return undefined;
return strings[integer];
};
const uniqueAttributeNames = (
attributeIndexes: readonly number[],
strings: readonly string[],
strings: readonly (string | undefined)[],
): string[] => {
const names: string[] = [];
const seen = new Set<string>();
for (let index = 0; index < attributeIndexes.length; index += 2) {
const name = indexedString(strings, attributeIndexes[index]);
if (name === undefined) continue;
if (seen.has(name)) continue;
seen.add(name);
names.push(name);
@@ -470,16 +540,20 @@ const uniqueAttributeNames = (
return names;
};
const numberArray = (value: unknown): readonly number[] =>
Array.isArray(value)
? value.flatMap((item) => {
const number = numberValue(item);
return number === undefined ? [] : [number];
})
: [];
const numberArray = (
value: unknown,
): readonly (number | undefined)[] | undefined => {
if (!Array.isArray(value)) return undefined;
return value.map((item) => {
const number = numberValue(item);
return number !== undefined && Number.isSafeInteger(number)
? number
: undefined;
});
};
interface DomMetadataOptions {
readonly strings: readonly string[];
readonly strings: readonly (string | undefined)[];
readonly attributes: readonly number[];
readonly baseUrl: string;
readonly documentUrl: string;
@@ -525,6 +599,7 @@ const domMetadata = (
// Chrome already folds HTML names; SVG/XML names remain case-sensitive.
const name = indexedString(strings, attributes[index]);
const value = indexedString(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);
}
+102 -42
View File
@@ -4,10 +4,10 @@ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
interface DomSnapshotNodes {
readonly nodeTypes: readonly number[];
readonly nodeNames: readonly number[];
readonly parents: readonly number[];
readonly strings: readonly string[];
readonly nodeTypes: readonly (number | undefined)[];
readonly nodeNames: readonly (number | undefined)[];
readonly parents: readonly (number | undefined)[];
readonly strings: readonly (string | undefined)[];
readonly attributes: readonly unknown[];
}
@@ -17,7 +17,7 @@ interface XmlSvgLink {
}
interface DocumentSvgAncestry {
readonly xml: boolean;
readonly xml: boolean | undefined;
readonly xmlLinks: Map<number, XmlSvgLink>;
}
@@ -48,50 +48,59 @@ export const capturedSvgNodeKind = (
interface DomSvgLink {
readonly svgElement: boolean;
readonly xmlDocument: boolean;
readonly xmlDocument: boolean | undefined;
readonly xlinkHref: string | undefined;
}
const UNRESOLVED: XmlSvgLink = {
svgElement: false,
xlinkHref: undefined,
};
const stringAt = (strings: readonly string[], index: unknown): string => {
const stringAt = (
strings: readonly (string | undefined)[],
index: unknown,
): string | undefined => {
const integer = numberValue(index);
return integer === undefined ? "" : (strings[Math.trunc(integer)] ?? "");
if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0)
return undefined;
return strings[integer];
};
const numberArray = (value: unknown): readonly number[] =>
const numberArray = (value: unknown): readonly (number | undefined)[] =>
Array.isArray(value)
? value.flatMap((item) => {
? value.map((item) => {
const number = numberValue(item);
return number === undefined ? [] : [number];
return number !== undefined && Number.isSafeInteger(number)
? number
: undefined;
})
: [];
// DOMSnapshot has no namespace URI. HTML documents use uppercase `HTML` and
// the parser's local names. XML documents preserve prefixed names and xmlns
// declarations, including those on standalone SVG roots.
const xmlDocument = (nodes: DomSnapshotNodes): boolean => {
const documentIndex = nodes.nodeTypes.findIndex(
(nodeType) => Math.trunc(nodeType) === 9,
);
const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => {
const documentIndex = nodes.nodeTypes.findIndex((nodeType) => nodeType === 9);
if (documentIndex < 0) return false;
if (
!validSvgSourceNode(nodes, documentIndex) ||
nodes.parents[documentIndex] !== -1
)
return undefined;
for (let index = 0; index < nodes.nodeTypes.length; index += 1) {
if (Math.trunc(nodes.nodeTypes[index] ?? 0) !== 1) continue;
if (Math.trunc(nodes.parents[index] ?? -1) !== documentIndex) continue;
if (nodes.nodeTypes[index] !== 1) continue;
if (nodes.parents[index] !== documentIndex) continue;
const name = stringAt(nodes.strings, nodes.nodeNames[index]);
if (name === undefined || !validSvgSourceNode(nodes, index))
return undefined;
if (name === "HTML") return false;
if (name === "html" || name.includes(":")) return true;
// Explicit namespaces also identify standalone SVG and other XML roots.
// Namespace-free synthetic snapshots retain the legacy HTML behavior.
const attributes = numberArray(nodes.attributes[index]);
return attributes.some(
(value, offset) =>
offset % 2 === 0 &&
xmlnsPrefix(stringAt(nodes.strings, value)) !== undefined,
);
for (let offset = 0; offset + 1 < attributes.length; offset += 2) {
const value = attributes[offset];
const attributeName = stringAt(nodes.strings, value);
if (attributeName === undefined) return undefined;
if (xmlnsPrefix(attributeName) !== undefined) return true;
}
return false;
}
return false;
};
@@ -102,7 +111,7 @@ export const createDocumentSvgAncestry = (
const xml = xmlDocument(nodes);
return {
xml,
xmlLinks: xml ? resolveXmlElements(nodes) : new Map(),
xmlLinks: xml === true ? resolveXmlElements(nodes) : new Map(),
};
};
@@ -110,14 +119,28 @@ export const domSvgLink = (
ancestry: DocumentSvgAncestry,
index: number,
): DomSvgLink => {
if (!ancestry.xml) {
if (ancestry.xml === false) {
return {
svgElement: false,
xmlDocument: false,
xlinkHref: undefined,
};
}
const resolved = ancestry.xmlLinks.get(index) ?? UNRESOLVED;
if (ancestry.xml === undefined) {
return {
svgElement: false,
xmlDocument: undefined,
xlinkHref: undefined,
};
}
const resolved = ancestry.xmlLinks.get(index);
if (resolved === undefined) {
return {
svgElement: false,
xmlDocument: undefined,
xlinkHref: undefined,
};
}
return {
svgElement: resolved.svgElement,
xmlDocument: true,
@@ -143,7 +166,9 @@ const resolveXmlElements = (
const children = new Map<number, number[]>();
const pending: XmlWalkStep[] = [];
for (let node = 0; node < nodes.nodeNames.length; node += 1) {
const parent = Math.trunc(nodes.parents[node] ?? -1);
const parent = nodes.parents[node];
if (parent === undefined || parent < -1 || parent >= nodes.nodeNames.length)
continue;
if (parent < 0) pending.push({ node });
else if (parent < nodes.nodeNames.length && parent !== node) {
const siblings = children.get(parent);
@@ -164,10 +189,11 @@ const resolveXmlElements = (
continue;
}
const { node } = step;
if (!validSvgSourceNode(nodes, node)) continue;
const attributes = numberArray(nodes.attributes[node]);
const restore = applyDeclarations(bindings, attributes, nodes.strings);
const svgElement =
Math.trunc(nodes.nodeTypes[node] ?? 0) === 1 &&
nodes.nodeTypes[node] === 1 &&
elementNamespace(
stringAt(nodes.strings, nodes.nodeNames[node]),
bindings,
@@ -188,24 +214,30 @@ const resolveXmlElements = (
const applyDeclarations = (
bindings: Map<string, string>,
attributes: readonly number[],
strings: readonly string[],
attributes: readonly (number | undefined)[],
strings: readonly (string | undefined)[],
): readonly NamespaceRestore[] => {
const restore: NamespaceRestore[] = [];
const seen = new Set<string>();
for (let index = 0; index + 1 < attributes.length; index += 2) {
const prefix = xmlnsPrefix(stringAt(strings, attributes[index]));
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);
if (name === undefined || value === undefined) continue;
const prefix = xmlnsPrefix(name);
if (prefix === undefined || seen.has(prefix)) continue;
seen.add(prefix);
restore.push({ prefix, previous: bindings.get(prefix) });
const value = stringAt(strings, attributes[index + 1]);
if (value.length === 0) bindings.delete(prefix);
else bindings.set(prefix, value);
}
return restore;
};
const xmlnsPrefix = (name: string): string | undefined => {
const xmlnsPrefix = (name: string | undefined): string | undefined => {
if (name === undefined) return undefined;
if (name === "xmlns") return "";
if (!name.startsWith("xmlns:")) return undefined;
const prefix = name.slice("xmlns:".length);
@@ -215,9 +247,10 @@ const xmlnsPrefix = (name: string): string | undefined => {
};
const elementNamespace = (
nodeName: string,
nodeName: string | undefined,
bindings: ReadonlyMap<string, string>,
): string => {
if (nodeName === undefined) return "";
const colon = nodeName.indexOf(":");
if (colon < 0) return bindings.get("") ?? "";
const prefix = nodeName.slice(0, colon);
@@ -235,13 +268,17 @@ const elementNamespace = (
// First prefixed href bound to the XLink namespace. Duplicate names keep the
// earlier value. Plain href is applied by the caller and wins even when empty.
const xlinkHrefValue = (
attributes: readonly number[],
strings: readonly string[],
attributes: readonly (number | undefined)[],
strings: readonly (string | undefined)[],
bindings: ReadonlyMap<string, string>,
): string | undefined => {
const seen = new Set<string>();
for (let index = 0; index + 1 < attributes.length; index += 2) {
const name = stringAt(strings, attributes[index]);
const nameIndex = attributes[index];
const valueIndex = attributes[index + 1];
if (nameIndex === undefined || valueIndex === undefined) continue;
const name = stringAt(strings, nameIndex);
if (name === undefined) continue;
if (seen.has(name)) continue;
seen.add(name);
const colon = name.indexOf(":");
@@ -249,7 +286,30 @@ 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, attributes[index + 1]);
return stringAt(strings, valueIndex);
}
return undefined;
};
const validSvgSourceNode = (
nodes: DomSnapshotNodes,
index: number,
): boolean => {
if (
nodes.nodeTypes[index] === undefined ||
stringAt(nodes.strings, nodes.nodeNames[index]) === undefined
)
return false;
const rawAttributes = nodes.attributes[index];
if (!Array.isArray(rawAttributes) || rawAttributes.length % 2 !== 0)
return false;
const attributes = numberArray(rawAttributes);
return (
attributes.length === rawAttributes.length &&
attributes.every(
(attribute) =>
attribute !== undefined &&
stringAt(nodes.strings, attribute) !== undefined,
)
);
};
+58
View File
@@ -82,3 +82,61 @@ it.each([false, true])(
expect(value.fingerprints_complete).toBe(!mixed);
},
);
it("keeps an omitted IndexedDB remote value unknown", async () => {
const replies: Record<string, unknown> = {
"Storage.getUsageAndQuota": { usage: 0, quota: 1000 },
"DOMStorage.getDOMStorageItems": { entries: [] },
"IndexedDB.requestDatabaseNames": { databaseNames: ["db"] },
"CacheStorage.requestCacheNames": { caches: [] },
"Network.getCookies": { cookies: [] },
"IndexedDB.requestDatabase": {
databaseWithObjectStores: {
name: "db",
objectStores: [
{
name: "records",
keyPath: { type: "string", string: "id" },
autoIncrement: false,
indexes: [],
},
],
},
},
"IndexedDB.requestData": {
objectStoreDataEntries: [
{
key: { type: "number", value: 1 },
primaryKey: { type: "number", value: 1 },
},
],
hasMore: false,
},
};
const connection = {
async send(method: string): Promise<unknown> {
if (!(method in replies)) throw new Error(method);
return replies[method];
},
} as unknown as CdpConnection;
const { value } = await captureStorage(
{
connection,
sessionId: undefined,
input: inspectWebPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
allowed_origins: ["https://example.test"],
target_id: "page-1",
include_storage_keys: true,
include_storage_fingerprints: true,
}),
},
"https://example.test/app",
[],
);
const record = value.content_fingerprints.find(
(item) => item.scope === "indexed_db_record",
);
expect(record).toMatchObject({ value_sha256: null, complete: false });
expect(value.fingerprints_complete).toBe(false);
});
+4 -1
View File
@@ -301,7 +301,10 @@ const addIndexedDbRecords = async (
for (const entry of entries) {
const key = stableValue(entry.key);
const primaryKey = stableValue(entry.primaryKey);
const value = stableValue(entry.value);
// A missing remote object is an omission, not the JavaScript value
// "undefined". Keep the unavailable digest null and coverage partial.
const value =
entry.value === undefined ? undefined : stableValue(entry.value);
addFingerprint(run.state, {
scope: "indexed_db_record",
identity: {
+3 -3
View File
@@ -243,9 +243,9 @@ export class CdpConnection {
const hasResult = "result" in message;
const hasError = "error" in message;
if (
("sessionId" in message &&
(typeof message.sessionId !== "string" ||
message.sessionId !== pending.sessionId)) ||
(pending.sessionId === undefined
? "sessionId" in message
: message.sessionId !== pending.sessionId) ||
hasResult === hasError
) {
this.#failPending("protocol_error");
+64
View File
@@ -0,0 +1,64 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import { captureFrames } from "./CdpElectronInspection.js";
const temporary: string[] = [];
afterEach(async () => {
await Promise.all(
temporary
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
describe("Electron frame capture", () => {
it.each([7, undefined])(
"classifies a malformed child frame ID as unavailable: %s",
async (childId) => {
const root = await mkdtemp(join(tmpdir(), "rea-electron-frame-"));
temporary.push(root);
const main = join(root, "index.html");
const child = join(root, "child.html");
await Promise.all([writeFile(main, "main"), writeFile(child, "child")]);
const mainUrl = pathToFileURL(main).href;
const childUrl = pathToFileURL(child).href;
const completeness = new CdpCaptureCompleteness();
const frames = await captureFrames(
{
frameTree: {
frame: { id: "frame-main", url: mainUrl },
childFrames: [
{
frame: {
...(childId === undefined ? {} : { id: childId }),
url: childUrl,
},
},
],
},
},
completeness,
);
expect(frames.map((frame) => frame.frame_id)).toEqual(["frame-main"]);
expect(completeness.snapshot()).toMatchObject({
unavailable_sections: ["frames"],
excluded: [
{ section: "frames", reason: "invalid_protocol_value", count: 1 },
],
});
expect(completeness.snapshot().policy_filtered_sections).not.toContain(
"frames",
);
expect(completeness.snapshot().conditions).toContain("attach_limited");
},
);
});
+7 -3
View File
@@ -232,7 +232,7 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => {
return undefined;
};
const captureFrames = async (
export const captureFrames = async (
result: unknown,
completeness: CdpCaptureCompleteness,
): Promise<ElectronPageInspection["frames"]> => {
@@ -245,8 +245,12 @@ const captureFrames = async (
)) {
const frame = recordValue(tree.frame);
const frameId = cdpStringValue(frame?.id);
const path = await authorizedElectronFile(cdpStringValue(frame?.url) ?? "");
if (frameId === undefined || path === undefined) {
if (frame === undefined || frameId === undefined) {
completeness.exclude("frames", "invalid_protocol_value");
continue;
}
const path = await authorizedElectronFile(cdpStringValue(frame.url) ?? "");
if (path === undefined) {
completeness.exclude("frames", "out_of_target_scope");
continue;
}
+1 -1
View File
@@ -94,7 +94,7 @@ export const captureCdpScreenshot = async (
const bytes =
encoded === undefined ? undefined : decodeCanonicalBase64(encoded);
if (bytes === undefined)
throw new BrowserObservationError("inspect_web_page", "payload_limit");
throw new BrowserObservationError("inspect_web_page", "protocol_error");
const dimensions = readPngHeaderDimensions(bytes);
if (dimensions === undefined)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
@@ -40,12 +40,15 @@ it("projects hook retention and dropped event families through the result schema
estimated_retained_bytes: 1_024,
event_serialized_byte_upper_bound: 512,
retained: 1,
dropped: 1,
dropped: 2,
dropped_ipc: 0,
dropped_runtime: 1,
dropped_event_families: [{ family: "renderer-ipc", count: 1 }],
dropped_runtime: 2,
dropped_event_families: [
{ family: "renderer-ipc", count: 1 },
{ family: "preload-configuration", count: 1 },
],
dropped_event_roles: [{ role: "renderer", count: 1 }],
observed: 2,
observed: 3,
observed_ipc: 1,
observed_runtime: 1,
hook_error: false,
@@ -58,18 +61,29 @@ it("projects hook retention and dropped event families through the result schema
estimated_retained_bytes: 1_024,
event_serialized_byte_upper_bound: 512,
retained: 1,
dropped: 1,
dropped_event_families: [{ family: "renderer-ipc", count: 1 }],
dropped: 2,
dropped_event_families: [
{ family: "renderer-ipc", count: 1 },
{ family: "preload-configuration", count: 1 },
],
dropped_event_roles: [{ role: "renderer", count: 1 }],
});
expect(result.ipc).toMatchObject({ observed: 1, dropped: 0 });
expect(result.timeline).toMatchObject({ observed: 2, dropped: 1 });
expect(result.timeline).toMatchObject({ observed: 3, dropped: 2 });
expect(result.coverage.observed_event_families).toContain("renderer-ipc");
expect(result.coverage.unavailable_event_families).not.toContain(
"renderer-ipc",
);
expect(result.coverage.observed_roles).toContain("renderer");
expect(result.coverage.unavailable_event_families).toContain("preload");
expect(result.coverage.observed_event_families).toContain(
"preload-configuration",
);
expect(result.coverage.unavailable_event_families).not.toContain(
"preload-configuration",
);
expect(result.limitations).not.toContain(
"Preload configuration was not exposed by the observed Electron window metadata. Preload paths and execution remain unavailable.",
);
});
it("keeps renderer coverage unavailable when only the main process was observed", () => {
@@ -113,6 +113,18 @@ const createCoverage = (hookSnapshot: ElectronHookSnapshot) => {
} as const;
};
const hasObservedPreloadConfiguration = (
hookSnapshot: ElectronHookSnapshot,
): boolean =>
hookSnapshot.events.some(
({ kind, process_type }) =>
kind === "preload" &&
(process_type === "main" || process_type === "browser"),
) ||
hookSnapshot.dropped_event_families.some(
({ family, count }) => family === "preload-configuration" && count > 0,
);
/** Project one provider hook snapshot into the canonical public result. */
export const projectElectronActiveCapture = (
paths: ElectronCapturePaths,
@@ -166,7 +178,7 @@ export const projectElectronActiveCapture = (
"IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.",
"The runtime timeline records lifecycle, navigation, shell, permission, popup, download, protocol, preload, native-addon, process, and IPC events; activity before hook installation is unavailable.",
"The preload and renderer process contexts are not instrumented by the main-process -r hook; preload configuration and contextBridge API-shape events are main-boundary observations, not proof of renderer-side execution.",
...(!hookSnapshot.events.some(({ kind }) => kind === "preload")
...(!hasObservedPreloadConfiguration(hookSnapshot)
? [
"Preload configuration was not exposed by the observed Electron window metadata. Preload paths and execution remain unavailable.",
]
@@ -73,6 +73,23 @@ const ownership = {
expectedParentPid: 1,
} satisfies OwnedProcessGroup;
const emptyHookSnapshot = () => ({
events: [],
retention_budget_bytes: 0,
estimated_retained_bytes: 0,
event_serialized_byte_upper_bound: 0,
retained: 0,
dropped: 0,
dropped_ipc: 0,
dropped_runtime: 0,
dropped_event_families: [],
dropped_event_roles: [],
observed: 0,
observed_ipc: 0,
observed_runtime: 0,
hook_error: false,
});
it("does not signal a Windows Electron PID when lineage is unavailable", async () => {
const terminateTree = vi.fn(async () => ({
cleaned: true as const,
@@ -138,27 +155,11 @@ it("retains the partial capture without taskkill when Windows lineage is unavail
const terminateTree = vi
.spyOn(processOwnership, "cleanupWindowsProcessTree")
.mockResolvedValue({ cleaned: true, signaled: true });
const hookSnapshot = {
events: [],
retention_budget_bytes: 0,
estimated_retained_bytes: 0,
event_serialized_byte_upper_bound: 0,
retained: 0,
dropped: 0,
dropped_ipc: 0,
dropped_runtime: 0,
dropped_event_families: [],
dropped_event_roles: [],
observed: 0,
observed_ipc: 0,
observed_runtime: 0,
hook_error: false,
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue({
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot,
hookSnapshot: emptyHookSnapshot(),
});
const application = {
process: () => ({ pid: 424242 }),
@@ -206,3 +207,49 @@ it("retains the partial capture without taskkill when Windows lineage is unavail
Object.defineProperty(process, "platform", originalPlatform);
}
});
it("retains the initial snapshot when the final state read fails", async () => {
const initialState = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState")
.mockResolvedValueOnce(initialState)
.mockRejectedValueOnce(new Error("final renderer state unavailable"));
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const provider = new PlaywrightElectronActiveProvider(
{},
async () =>
application as unknown as Awaited<
ReturnType<typeof import("playwright-core")._electron.launch>
>,
);
const result = await provider.capture({
...electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: process.execPath,
actions: [],
}),
application_root: dirname(process.execPath),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected final state capture to fail");
const partialObservation = result.error.partialObservation;
expect(partialObservation).toMatchObject({
kind: "electron-active-observation",
capture: {
application: { electron_version: "44.0.0" },
limitations: expect.arrayContaining([
expect.stringContaining("initial pre-action snapshot"),
]),
},
});
});
+25 -21
View File
@@ -145,11 +145,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
)
: ok(capture);
} catch (cause: unknown) {
if (
options.signal?.aborted &&
paths !== undefined &&
initialState !== undefined
) {
if (paths !== undefined && initialState !== undefined) {
const retainedCapture = projectElectronActiveCapture(
paths,
actions,
@@ -159,25 +155,33 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
...retainedCapture,
limitations: [
...retainedCapture.limitations,
`Final Electron state was unavailable after cancellation: ${cause instanceof Error ? cause.message : String(cause)}. Windows, metrics, IPC, and timeline are the snapshot collected before actions and may omit their effects.`,
`Capture stopped after the initial pre-action snapshot: ${cause instanceof Error ? cause.message : String(cause)}. Final Windows, metrics, IPC, and timeline state is unknown; the retained snapshot may omit action effects.`,
],
};
}
outcome = err(
options.signal?.aborted === true
? new BrowserObservationError(OPERATION, "cancelled", {
cause,
...(capture === undefined
? {}
: {
partialObservation: {
kind: "electron-active-observation" as const,
capture,
},
}),
})
: providerError(cause),
);
if (options.signal?.aborted === true)
outcome = err(
new BrowserObservationError(OPERATION, "cancelled", {
cause,
...(capture === undefined
? {}
: {
partialObservation: {
kind: "electron-active-observation" as const,
capture,
},
}),
}),
);
else {
const error = providerError(cause);
if (capture !== undefined)
error.retainPartialObservation({
kind: "electron-active-observation",
capture,
});
outcome = err(error);
}
}
if (application !== undefined) {
if (ownership !== undefined) {
+9 -1
View File
@@ -11,6 +11,10 @@ import { isUrlLikeModuleSpecifier } from "../domain/webBundleAnalyzerAst.js";
import { analyzeParsedJavaScriptReferences } from "../domain/javascript/javascriptSemanticAnalysis.js";
import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js";
import {
isTypeOnlyExportDeclaration,
isTypeOnlyImportDeclaration,
} from "../domain/javascript/javascriptModuleDeclaration.js";
import { stringValue } from "../domain/javascript/javascriptStaticAnalysisHelpers.js";
import type {
AnalyzeWebBundleInput,
@@ -1061,7 +1065,11 @@ const originalDependency = (
t.isExportAllDeclaration(node) ||
t.isExportNamedDeclaration(node)
)
return node.source === null || node.source === undefined
return node.source === null ||
node.source === undefined ||
(t.isImportDeclaration(node)
? isTypeOnlyImportDeclaration(node)
: isTypeOnlyExportDeclaration(node))
? null
: { kind: "static_import", specifier: node.source.value };
if (t.isImportExpression(node)) {
+9 -7
View File
@@ -333,6 +333,13 @@ const networkDimension = (
);
const beforeWebSockets = webSocketGroups(before);
const afterWebSockets = webSocketGroups(after);
const inventoryComplete = [before, after].every((inspection) =>
sectionsComplete(inspection, [
"network_requests",
"websocket_connections",
"websocket_frames",
]),
);
const [beforeWebSocketPayload, afterWebSocketPayload] = webSocketPayloadMaps(
beforeWebSockets,
afterWebSockets,
@@ -349,17 +356,12 @@ const networkDimension = (
...webSocketMetadataMap(afterWebSockets),
...afterWebSocketPayload,
]),
[before, after].every((inspection) =>
sectionsComplete(inspection, [
"network_requests",
"websocket_connections",
"websocket_frames",
]),
) &&
inventoryComplete &&
shapesComplete &&
webSocketPayloadBytesComplete &&
webSocketShapesComplete,
"Network capture or selected HTTP/WebSocket payload-shape coverage is attach-window limited, unavailable, or incomplete.",
inventoryComplete,
);
return compared.status === "unchanged" && !responseFieldsComplete
? {
@@ -28,6 +28,7 @@ const INVALID_REPLIES = [
}),
],
["invalid result", (id: number) => ({ id, result: [] })],
["missing session id", (id: number) => ({ id, result: {} })],
["invalid session id", (id: number) => ({ id, result: {}, sessionId: 7 })],
[
"foreign session id",
@@ -254,6 +254,7 @@ describe("CDP SVG query concurrency", () => {
browser.emitRawMessage(
JSON.stringify({
id: command.id,
sessionId: command.sessionId,
result: {
node: {
backendNodeId: id,
@@ -295,6 +296,7 @@ describe("CDP SVG query concurrency", () => {
browser.emitRawMessage(
JSON.stringify({
id: batch.at(-1)?.id,
sessionId: batch.at(-1)?.sessionId,
error: { code: -32_602, message: "Invalid parameters" },
}),
);
@@ -258,7 +258,62 @@ describe("CdpElectronProvider packaged pages", () => {
});
});
describe("CdpElectronProvider capture", () => {
describe("CdpElectronProvider frame capture", () => {
it.each([7, undefined])(
"reports a malformed child frame ID as unavailable protocol evidence: %s",
async (childId) => {
const root = await electronFixture();
const main = pathToFileURL(join(root, "index.html")).href;
const child = pathToFileURL(join(root, "child.html")).href;
const browser = await startFakeCdpBrowser({
electronFileUrl: main,
commandResult: (command) =>
command.method === "Page.getFrameTree"
? {
frameTree: {
frame: { id: "frame-main", url: main },
childFrames: [
{
frame: {
...(childId === undefined ? {} : { id: childId }),
url: child,
},
},
],
},
}
: 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;
expect(result.value.frames.map((frame) => frame.frame_id)).toEqual([
"frame-main",
]);
expect(result.value.completeness).toMatchObject({
unavailable_sections: ["frames"],
});
expect(result.value.completeness.excluded).toContainEqual({
section: "frames",
reason: "invalid_protocol_value",
count: 1,
});
expect(result.value.completeness.policy_filtered_sections).not.toContain(
"frames",
);
expect(result.value.completeness.conditions).toContain("attach_limited");
},
);
});
describe("CdpElectronProvider malformed source replies", () => {
it.each([{}, { scriptSource: null }, { scriptSource: 123 }])(
"rejects malformed Electron source replies instead of inventing source: %j",
async (reply) => {
@@ -291,7 +346,9 @@ describe("CdpElectronProvider capture", () => {
);
},
);
});
describe("CdpElectronProvider script source capture", () => {
it("retains an explicitly empty Electron producer script", async () => {
const root = await electronFixture();
const browser = await startFakeCdpBrowser({
@@ -3,6 +3,30 @@ import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
describe("screenshot committed document identity", () => {
it.each([
["missing screenshot data", {}],
["invalid base64 screenshot data", { data: "not base64%%%" }],
])("reports %s as a protocol error", async (_label, result) => {
const browser = await startFakeCdpBrowser({
commandResult: ({ method }) =>
method === "Page.captureScreenshot" ? result : undefined,
});
onTestFinished(async () => {
await browser.close();
});
const observation = await new CdpBrowserProvider().captureScreenshot(
captureWebScreenshotInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
}),
);
expect(observation).toMatchObject({
ok: false,
error: { reason: "protocol_error" },
});
});
it.each([
{ offset: 8, value: 14 },
{ offset: 16, value: 0x8000_0000 },
@@ -684,6 +684,44 @@ describe("web capture WebSocket payload byte coverage", () => {
});
describe("web capture diff", () => {
it("reports request membership changes when selected body 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,
}),
);
if (!captured.ok) throw captured.error;
const before = structuredClone(captured.value);
const after = structuredClone(captured.value);
const original = before.network.requests[0];
if (original === undefined) throw new Error("Missing request fixture");
after.network.requests.push({
...structuredClone(original),
request_id: `${original.request_id}-added`,
url: `${browser.allowedOrigin}/added`,
});
for (const inspection of [before, after]) {
markSectionsComplete(inspection, [
"network_requests",
"websocket_connections",
"websocket_frames",
]);
for (const request of inspection.network.requests)
request.body_shapes.status = "unavailable";
}
expect(compareNetwork(before, after)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [expect.objectContaining({ change: "added" })],
});
});
it.each(["status", "mime_type"] as const)(
"keeps an independently unavailable HTTP %s unknown",
async (field) => {
@@ -1102,6 +1102,21 @@ describe("source-map dependency coverage of hard-to-parse sources", () => {
'import lib = require("./lib.js");',
["./lib.js"],
],
[
"whole-statement type-only import",
'import type { Value } from "./types.js";',
[],
],
[
"whole-statement type-only export",
'export type { Value } from "./types.js";',
[],
],
[
"inline type import retains module side effects",
'import { type Value } from "./types.js";',
["./types.js"],
],
[
"a local import alias",
'import lib = localAlias;\nimport "./a.js";',
+87 -66
View File
@@ -31,44 +31,9 @@ export const startFakeCdpBrowser = async (
let openedConnections = 0;
let frameTreeReads = 0;
let port = 0;
const http = createServer((request, response) => {
httpRequests.push({
url: request.url ?? "",
authorization: request.headers.authorization,
cookie: request.headers.cookie,
referer: request.headers.referer,
});
response.setHeader("content-type", "application/json");
if (serveSourceMap(request, response, options)) return;
if (request.url === "/json/version") {
response.end(
options.oversizedDiscovery === true
? " ".repeat(65 * 1_024)
: options.malformedDiscovery === true
? "{not-json"
: JSON.stringify({
Browser: "FakeChrome/1.0",
"Protocol-Version": "1.3",
"User-Agent": "FakeChrome",
"V8-Version": "13.0",
"WebKit-Version": "fake-revision",
webSocketDebuggerUrl:
options.invalidBrowserWebSocket === true
? `ws://localhost:${String(port)}/devtools/invalid/fake`
: options.pageScopedVersionWebSocket === true
? `ws://localhost:${String(port)}/devtools/page/${versionTargetId(options)}`
: `ws://localhost:${String(port)}/devtools/browser/fake`,
}),
);
return;
}
if (request.url === "/json/list") {
response.end(JSON.stringify(targets(port, options)));
return;
}
response.statusCode = 404;
response.end("{}");
});
const http = createServer((request, response) =>
respondToHttpRequest(request, response, httpRequests, options, () => port),
);
const webSockets = new WebSocketServer({ noServer: true });
http.on("upgrade", (request, socket, head) => {
if (
@@ -94,7 +59,7 @@ export const startFakeCdpBrowser = async (
commands.push(command);
const commandError = options.commandError?.(command);
if (commandError !== undefined) {
socket.send(JSON.stringify({ id: command.id, error: commandError }));
sendCommandReply(socket, command, { error: commandError });
return;
}
if (options.closeOnMethod === command.method) {
@@ -107,12 +72,9 @@ export const startFakeCdpBrowser = async (
return;
}
if (options.unsupportedMethods?.includes(command.method) === true) {
socket.send(
JSON.stringify({
id: command.id,
error: { code: -32_601, message: "Method not found" },
}),
);
sendCommandReply(socket, command, {
error: { code: -32_601, message: "Method not found" },
});
return;
}
if (
@@ -120,12 +82,9 @@ export const startFakeCdpBrowser = async (
(command.method === "Target.attachToTarget" ||
command.sessionId !== undefined)
) {
socket.send(
JSON.stringify({
id: command.id,
error: { code: -32_600, message: "Direct page socket expected" },
}),
);
sendCommandReply(socket, command, {
error: { code: -32_600, message: "Direct page socket expected" },
});
return;
}
if (command.method === "Page.getFrameTree") frameTreeReads += 1;
@@ -164,6 +123,57 @@ export const startFakeCdpBrowser = async (
};
};
const respondToHttpRequest = (
request: IncomingMessage,
response: ServerResponse,
requests: {
url: string;
authorization: string | undefined;
cookie: string | undefined;
referer: string | undefined;
}[],
options: FakeOptions,
port: () => number,
): void => {
requests.push({
url: request.url ?? "",
authorization: request.headers.authorization,
cookie: request.headers.cookie,
referer: request.headers.referer,
});
response.setHeader("content-type", "application/json");
const portNumber = port();
if (serveSourceMap(request, response, options)) return;
if (request.url === "/json/version") {
response.end(
options.oversizedDiscovery === true
? " ".repeat(65 * 1_024)
: options.malformedDiscovery === true
? "{not-json"
: JSON.stringify({
Browser: "FakeChrome/1.0",
"Protocol-Version": "1.3",
"User-Agent": "FakeChrome",
"V8-Version": "13.0",
"WebKit-Version": "fake-revision",
webSocketDebuggerUrl:
options.invalidBrowserWebSocket === true
? `ws://localhost:${String(portNumber)}/devtools/invalid/fake`
: options.pageScopedVersionWebSocket === true
? `ws://localhost:${String(portNumber)}/devtools/page/${versionTargetId(options)}`
: `ws://localhost:${String(portNumber)}/devtools/browser/fake`,
}),
);
return;
}
if (request.url === "/json/list") {
response.end(JSON.stringify(targets(portNumber, options)));
return;
}
response.statusCode = 404;
response.end("{}");
};
const boundPort = (
address: string | null | { readonly port: number },
): number => {
@@ -172,6 +182,25 @@ const boundPort = (
return address.port;
};
/** Every ordinary reply uses the request's flattened target session. */
const sendCommandReply = (
socket: WebSocket,
command: FakeCdpCommand,
payload:
| { readonly result: unknown }
| { readonly error: { readonly code: number; readonly message: string } },
): void => {
socket.send(
JSON.stringify({
id: command.id,
...(command.sessionId === undefined
? {}
: { sessionId: command.sessionId }),
...payload,
}),
);
};
/** Emit a configured producer reply and its associated events. */
const replyToCommand = (
socket: WebSocket,
@@ -187,24 +216,16 @@ const replyToCommand = (
frameTreeReads,
) ?? resultFor(command, port, options, frameTreeReads);
if (result === undefined) {
socket.send(
JSON.stringify({
id: command.id,
error: {
code: -32_601,
message: `Method not found: ${command.method}`,
},
}),
);
sendCommandReply(socket, command, {
error: {
code: -32_601,
message: `Method not found: ${command.method}`,
},
});
return;
}
const reply = () => {
socket.send(
JSON.stringify({
id: command.id,
result,
}),
);
sendCommandReply(socket, command, { result });
if (options.malformedEventOnMethod === command.method)
socket.send("{not-json");
if (options.malformedEventShapeOnMethod === command.method)