diff --git a/src/browser/CdpCaptureDocuments.test.ts b/src/browser/CdpCaptureDocuments.test.ts index c07dfdbe59..3629516051 100644 --- a/src/browser/CdpCaptureDocuments.test.ts +++ b/src/browser/CdpCaptureDocuments.test.ts @@ -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 = { + 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 = { + 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 = { 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( [ diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index f95904fc0e..da7eb80192 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -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(); 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); } diff --git a/src/browser/CdpCaptureDomSvg.ts b/src/browser/CdpCaptureDomSvg.ts index 4d021766c9..2ffd8e31fd 100644 --- a/src/browser/CdpCaptureDomSvg.ts +++ b/src/browser/CdpCaptureDomSvg.ts @@ -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; } @@ -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(); 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, - attributes: readonly number[], - strings: readonly string[], + attributes: readonly (number | undefined)[], + strings: readonly (string | undefined)[], ): readonly NamespaceRestore[] => { const restore: NamespaceRestore[] = []; const seen = new Set(); 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 => { + 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 | undefined => { const seen = new Set(); 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, + ) + ); +}; diff --git a/src/browser/CdpCaptureStorage.test.ts b/src/browser/CdpCaptureStorage.test.ts index 51bb49a08d..72665f1b43 100644 --- a/src/browser/CdpCaptureStorage.test.ts +++ b/src/browser/CdpCaptureStorage.test.ts @@ -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 = { + "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 { + 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); +}); diff --git a/src/browser/CdpCaptureStorageFingerprints.ts b/src/browser/CdpCaptureStorageFingerprints.ts index ec7a068500..9f8f80c525 100644 --- a/src/browser/CdpCaptureStorageFingerprints.ts +++ b/src/browser/CdpCaptureStorageFingerprints.ts @@ -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: { diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index 3c7e5e86a9..e970056686 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -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"); diff --git a/src/browser/CdpElectronInspection.test.ts b/src/browser/CdpElectronInspection.test.ts new file mode 100644 index 0000000000..925b10e9f1 --- /dev/null +++ b/src/browser/CdpElectronInspection.test.ts @@ -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"); + }, + ); +}); diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index e1eb616f97..cf6613f774 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -232,7 +232,7 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => { return undefined; }; -const captureFrames = async ( +export const captureFrames = async ( result: unknown, completeness: CdpCaptureCompleteness, ): Promise => { @@ -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; } diff --git a/src/browser/CdpScreenshot.ts b/src/browser/CdpScreenshot.ts index b29ab121db..e4093479b1 100644 --- a/src/browser/CdpScreenshot.ts +++ b/src/browser/CdpScreenshot.ts @@ -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"); diff --git a/src/browser/PlaywrightElectronActiveProjection.test.ts b/src/browser/PlaywrightElectronActiveProjection.test.ts index baec9b2194..09a5b6910e 100644 --- a/src/browser/PlaywrightElectronActiveProjection.test.ts +++ b/src/browser/PlaywrightElectronActiveProjection.test.ts @@ -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", () => { diff --git a/src/browser/PlaywrightElectronActiveProjection.ts b/src/browser/PlaywrightElectronActiveProjection.ts index e7b79d633f..71f5581b28 100644 --- a/src/browser/PlaywrightElectronActiveProjection.ts +++ b/src/browser/PlaywrightElectronActiveProjection.ts @@ -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.", ] diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 50f9905d51..9bdaaf3cdf 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -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 + >, + ); + + 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"), + ]), + }, + }); +}); diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 816f864890..68c8513524 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -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) { diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 4ab805f43a..0148cb9a29 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -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)) { diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index ba23228b11..39e88e6d6a 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -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 ? { diff --git a/tests/boundary/browser/cdpConnection.test.ts b/tests/boundary/browser/cdpConnection.test.ts index 993a44e6d6..71ef6a2456 100644 --- a/tests/boundary/browser/cdpConnection.test.ts +++ b/tests/boundary/browser/cdpConnection.test.ts @@ -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", diff --git a/tests/boundary/browser/cdpDomSvgIdentity.test.ts b/tests/boundary/browser/cdpDomSvgIdentity.test.ts index 7d42ae3366..eeaebf2e7a 100644 --- a/tests/boundary/browser/cdpDomSvgIdentity.test.ts +++ b/tests/boundary/browser/cdpDomSvgIdentity.test.ts @@ -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" }, }), ); diff --git a/tests/boundary/browser/cdpElectronProvider.test.ts b/tests/boundary/browser/cdpElectronProvider.test.ts index f115846109..307f4cbbf6 100644 --- a/tests/boundary/browser/cdpElectronProvider.test.ts +++ b/tests/boundary/browser/cdpElectronProvider.test.ts @@ -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({ diff --git a/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts b/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts index b3fcbc592e..b768485989 100644 --- a/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts +++ b/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts @@ -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 }, diff --git a/tests/boundary/browser/webCaptureDiff.test.ts b/tests/boundary/browser/webCaptureDiff.test.ts index 4d372650e3..11d992fa4d 100644 --- a/tests/boundary/browser/webCaptureDiff.test.ts +++ b/tests/boundary/browser/webCaptureDiff.test.ts @@ -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) => { diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index 84ef1506fe..782ff8cf07 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -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";', diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index aa476a58b0..37e59b77b6 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -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)