diff --git a/src/browser/CdpCaptureEvents.redirectBodyShape.test.ts b/src/browser/CdpCaptureEvents.redirectBodyShape.test.ts index 6faa983683..26cb7593ee 100644 --- a/src/browser/CdpCaptureEvents.redirectBodyShape.test.ts +++ b/src/browser/CdpCaptureEvents.redirectBodyShape.test.ts @@ -39,7 +39,9 @@ it("preserves an already captured response shape without marking it unavailable" }, }, }); - events.ingestResponseBody("request-1", { + const candidate = events.responseBodyRequests()[0]; + expect(candidate).toBeDefined(); + events.ingestResponseBody(candidate!, { body: '{"response":true}', base64Encoded: false, }); @@ -62,7 +64,7 @@ it("preserves an already captured response shape without marking it unavailable" capturedShape, ); expect(events.network.get("request-1")?.body_shapes.status).toBe("included"); - expect(events.responseBodyRequestIds()).toEqual([]); + expect(events.responseBodyRequests()).toEqual([]); expect(events.completeness.snapshot().unavailable_sections).not.toContain( "json_body_shapes", ); diff --git a/src/browser/CdpCaptureEvents.ts b/src/browser/CdpCaptureEvents.ts index 547d31b3a4..b3f1ce41b8 100644 --- a/src/browser/CdpCaptureEvents.ts +++ b/src/browser/CdpCaptureEvents.ts @@ -7,11 +7,18 @@ import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js"; import type { CapturedScript, NetworkState } from "./CdpCaptureEventTypes.js"; import * as handlers from "./CdpCaptureEventHandlers.js"; import { isJsonMediaType } from "./CdpCaptureEventHelpers.js"; -import { recordValue } from "./CdpCaptureValues.js"; +import { cdpStringValue, recordValue } from "./CdpCaptureValues.js"; + +export interface ResponseBodyCandidate { + readonly requestId: string; + readonly occurrence: number; +} /** Event accumulator that validates and normalizes payloads at ingestion. */ export class CdpCaptureEvents implements CdpCaptureEventsState { #scriptMetadataBudgetExclusionsRecorded = false; + readonly #networkRequestOccurrences = new Map(); + readonly #responseBodyExaminedOccurrences = new Map(); readonly scripts = new Map(); readonly scriptMetadataBudgetOmissionsById = new Map(); rejectedScriptMetadataBudgetCount = 0; @@ -64,6 +71,8 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { this.executionContextFrames.clear(); this.network.clear(); this.networkRequestTimestamps.clear(); + this.#networkRequestOccurrences.clear(); + this.#responseBodyExaminedOccurrences.clear(); this.malformedRedirectRequestIds.clear(); this.webSocketConnectionsById.clear(); this.console.length = 0; @@ -128,9 +137,18 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { case "Debugger.scriptParsed": handlers.handleScriptParsed(this, params); break; - case "Network.requestWillBeSent": + case "Network.requestWillBeSent": { + const requestId = cdpStringValue(params.requestId); + const previous = + requestId === undefined ? undefined : this.network.get(requestId); handlers.handleRequestWillBeSent(this, params); + if (requestId !== undefined && this.network.get(requestId) !== previous) + this.#networkRequestOccurrences.set( + requestId, + (this.#networkRequestOccurrences.get(requestId) ?? 0) + 1, + ); break; + } case "Network.responseReceived": handlers.handleResponseReceived(this, params); break; @@ -165,24 +183,68 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { : null; } - responseBodyRequestIds(): readonly string[] { + responseBodyRequests(): readonly ResponseBodyCandidate[] { if (!this.input.include_json_body_shapes) return []; return [...this.network.values()] .filter( (request) => !this.malformedRedirectRequestIds.has(request.request_id), ) .filter((request) => isJsonMediaType(request.mime_type)) - .map((request) => request.request_id); + .flatMap((request) => { + const occurrence = this.#networkRequestOccurrences.get( + request.request_id, + ); + return occurrence === undefined + ? [] + : [{ requestId: request.request_id, occurrence }]; + }); } - responseBodyUnavailable(requestId: string): void { - if (this.malformedRedirectRequestIds.has(requestId)) return; - bodyShapes.updateResponseBodyShape(this, requestId, null); + currentResponseBodyRequest( + candidate: ResponseBodyCandidate, + ): NetworkState | undefined { + if ( + candidate.occurrence !== + this.#networkRequestOccurrences.get(candidate.requestId) + ) + return undefined; + return this.network.get(candidate.requestId); + } + + responseBodyUnavailable(candidate: ResponseBodyCandidate): void { + if (this.currentResponseBodyRequest(candidate) === undefined) return; + this.#responseBodyExaminedOccurrences.set( + candidate.requestId, + candidate.occurrence, + ); + if (this.malformedRedirectRequestIds.has(candidate.requestId)) return; + bodyShapes.updateResponseBodyShape(this, candidate.requestId, null); this.completeness.unavailable("json_body_shapes"); } - ingestResponseBody(requestId: string, value: unknown): void { - if (this.malformedRedirectRequestIds.has(requestId)) return; - bodyShapes.ingestResponseBodyShape(this, requestId, value); + /** + * Mark eligible response bodies that arrived after the fetch pass was + * enumerated. + */ + settleUnexaminedResponseBodies(): void { + for (const candidate of this.responseBodyRequests()) { + const request = this.currentResponseBodyRequest(candidate); + if ( + request?.body_shapes.response === null && + this.#responseBodyExaminedOccurrences.get(candidate.requestId) !== + candidate.occurrence + ) + this.responseBodyUnavailable(candidate); + } + } + + ingestResponseBody(candidate: ResponseBodyCandidate, value: unknown): void { + if (this.currentResponseBodyRequest(candidate) === undefined) return; + this.#responseBodyExaminedOccurrences.set( + candidate.requestId, + candidate.occurrence, + ); + if (this.malformedRedirectRequestIds.has(candidate.requestId)) return; + bodyShapes.ingestResponseBodyShape(this, candidate.requestId, value); } } diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 4de472660a..0d97576b5e 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -153,6 +153,7 @@ const captureAuthorizedPage = async ( ); if (!input.include_storage_keys) state.events.completeness.exclude("storage_keys", "not_approved", null); + await report(context.progress, 3, "Normalizing browser evidence"); const completedFrameResult = await authorizedMainFrame({ connection: context.connection, sessionId: context.sessionId, @@ -165,7 +166,10 @@ const captureAuthorizedPage = async ( throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); if (state.events.navigationDuringCapture || completedUrl !== attachedUrl) throw new BrowserObservationError("inspect_web_page", "target_changed"); - await report(context.progress, 3, "Normalizing browser evidence"); + // Body IDs were enumerated before asynchronous CDP reads. Events remain + // enabled through the final capture, so account for eligible responses that + // arrived while those reads were pending. + state.events.settleUnexaminedResponseBodies(); state.events.recordScriptMetadataBudgetExclusions(); return { inspection: normalizedInspection(state, { @@ -185,14 +189,14 @@ const captureAuthorizedPage = async ( const captureJsonResponseBodies = async ( state: CaptureState, ): Promise => { - const requestIds = state.events.responseBodyRequestIds(); - for (let index = 0; index < requestIds.length; index += 1) { - const requestId = requestIds[index]; - if (requestId === undefined) continue; - const request = state.events.network.get(requestId); - if (request === undefined) continue; - if (request.encoded_data_length === null) { - state.events.responseBodyUnavailable(requestId); + const requests = state.events.responseBodyRequests(); + for (let index = 0; index < requests.length; index += 1) { + const candidate = requests[index]; + if (candidate === undefined) continue; + const current = state.events.currentResponseBodyRequest(candidate); + if (current === undefined) continue; + if (current.encoded_data_length === null) { + state.events.responseBodyUnavailable(candidate); continue; } let result: unknown; @@ -200,7 +204,7 @@ const captureJsonResponseBodies = async ( result = await optionalCdpCommand( state.context, "Network.getResponseBody", - { requestId }, + { requestId: candidate.requestId }, state.limitations, ); } catch (cause: unknown) { @@ -211,17 +215,17 @@ const captureJsonResponseBodies = async ( cause.reportedMessage === null ) throw cause; - state.events.responseBodyUnavailable(requestId); + state.events.responseBodyUnavailable(candidate); state.limitations.push( - `Response body unavailable for request ${requestId}: ${cause.userMessage}`, + `Response body unavailable for request ${candidate.requestId}: ${cause.userMessage}`, ); continue; } if (result !== undefined) { - state.events.ingestResponseBody(requestId, result); + state.events.ingestResponseBody(candidate, result); continue; } - for (const remaining of requestIds.slice(index)) + for (const remaining of requests.slice(index)) state.events.responseBodyUnavailable(remaining); return; } diff --git a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts index 0005a19772..527b0b4631 100644 --- a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts +++ b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts @@ -1,172 +1,200 @@ -import { describe, expect, it } from "vitest"; +import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; -describe("Electron API identity", () => { - it("recovers APIs through an ES module default import of Electron", () => { - const analysis = analyzeJavaScriptStaticSource(` - import electron from "electron"; - const { ipcMain } = electron; - new electron.BrowserWindow({ webPreferences: { preload: "./preload.js" } }); - electron.contextBridge.exposeInMainWorld("api", { ping: true }); - electron.ipcRenderer.invoke("default:ping"); - ipcMain.handle("destructured:ping", () => "pong"); - electron.default.ipcMain.handle("nested-default", () => "local"); - `); +it("recovers APIs through an ES module default import of Electron", () => { + const analysis = analyzeJavaScriptStaticSource(` + import electron from "electron"; + const { ipcMain } = electron; + new electron.BrowserWindow({ webPreferences: { preload: "./preload.js" } }); + electron.contextBridge.exposeInMainWorld("api", { ping: true }); + electron.ipcRenderer.invoke("default:ping"); + ipcMain.handle("destructured:ping", () => "pong"); + electron.default.ipcMain.handle("nested-default", () => "local"); + `); - expect(analysis.electron.browser_windows).toMatchObject([ - { preload_path: "./preload.js" }, - ]); - expect(analysis.electron.context_bridge_apis).toMatchObject([ - { api_key: "api", members: ["ping"] }, - ]); - expect( - analysis.electron.ipc.map(({ side, operation, channel }) => ({ - side, - operation, - channel, - })), - ).toEqual([ - { side: "renderer", operation: "invoke", channel: "default:ping" }, - { side: "main", operation: "handle", channel: "destructured:ping" }, - ]); - }); - - it("recovers APIs and sender checks written with optional chaining", () => { - const analysis = analyzeJavaScriptStaticSource(` - const { contextBridge, ipcMain, utilityProcess } = require("electron"); - contextBridge?.exposeInMainWorld("api", {}); - utilityProcess?.fork("./worker.js"); - ipcMain?.handle("optional:ping", (event) => { - if (!event.senderFrame?.url.startsWith("file://")) return null; - return event.sender?.getURL() === "file:///index.html"; - }); - `); - - expect(analysis.electron.context_bridge_apis).toMatchObject([ - { api_key: "api" }, - ]); - expect(analysis.electron.utility_processes).toMatchObject([ - { module_path: "./worker.js" }, - ]); - expect(analysis.electron.ipc).toMatchObject([ - { side: "main", operation: "handle", channel: "optional:ping" }, - ]); - expect( - analysis.electron.sender_validations.map( - ({ subject, mechanism, expected }) => ({ - subject, - mechanism, - expected: expected.value, - }), - ), - ).toEqual([ - { - subject: "sender-url", - mechanism: "call:startsWith", - expected: "file://", - }, - { - subject: "sender-url", - mechanism: "comparison:===", - expected: "file:///index.html", - }, - ]); - }); - - it("follows bundler namespace interop helpers without accepting other writes", () => { - const analysis = analyzeJavaScriptStaticSource(` - var __toESM = (mod) => mod; - var __importStar = (mod) => mod; - let electron = require("electron"); - electron = __toESM(electron); - new electron.BrowserWindow({ webPreferences: { preload: "./rolldown.js" } }); - var import_electron = __toESM(require("electron")); - new import_electron.BrowserWindow({ webPreferences: { preload: "./esbuild.js" } }); - function _interopRequireWildcard(mod) { return mod; } - const Electron = __importStar(require("electron")); - new Electron.BrowserWindow({ webPreferences: { preload: "./tsc.js" } }); - - let replaced = require("electron"); - replaced = __toESM(require("./fake")); - new replaced.BrowserWindow({ webPreferences: { preload: "./replaced.js" } }); - let rewritten = require("electron"); - rewritten = __toESM(rewritten); - rewritten = fake; - new rewritten.BrowserWindow({ webPreferences: { preload: "./rewritten.js" } }); - const babel = _interopRequireWildcard(require("electron")); - new babel.BrowserWindow({ webPreferences: { preload: "./babel.js" } }); - function nested() { - const __toESM = (mod) => mod; - const scoped = __toESM(require("electron")); - new scoped.BrowserWindow({ webPreferences: { preload: "./nested.js" } }); - } - `); - - expect( - analysis.electron.browser_windows.map(({ preload_path }) => preload_path), - ).toEqual(["./rolldown.js", "./esbuild.js", "./tsc.js", "./babel.js"]); - }); - - it("does not trust unbound helper names or non-function helper values", () => { - const analysis = analyzeJavaScriptStaticSource(` - const __toESM = makeLookalike(); - const electron = __toESM(require("electron")); - new electron.BrowserWindow({}); - let unbound = require("electron"); - unbound = __importStar(unbound); - new unbound.BrowserWindow({}); - `); - - expect(analysis.electron.browser_windows).toEqual([]); - }); - - it("follows stable declaration aliases but not an assigned default parameter", () => { - const analysis = analyzeJavaScriptStaticSource(` - const direct = require("electron"); - const constAlias = direct; - new constAlias.BrowserWindow({webPreferences:{preload:"./const.js"}}); - - let letAlias = require("electron"); - new letAlias.BrowserWindow({webPreferences:{preload:"./let.js"}}); - - const { BrowserWindow } = require("electron"); - new BrowserWindow({webPreferences:{preload:"./destructured.js"}}); - - function withDefault(electron = require("./not-electron")) { - if (flag) electron = require("electron"); - new electron.BrowserWindow({webPreferences:{preload:"./default.js"}}); - } - `); - - expect( - analysis.electron.browser_windows.map(({ preload_path }) => preload_path), - ).toEqual(["./const.js", "./let.js", "./destructured.js"]); - }); - - it("preserves one initializer across variable redeclarations without accepting writes", () => { - const analysis = analyzeJavaScriptStaticSource(` - var electron; - var electron = require("electron"); - var electron; - new electron.BrowserWindow({webPreferences:{preload:"./redeclared.js"}}); - - var replaced; - var replaced = require("electron"); - replaced = fake; - new replaced.BrowserWindow({webPreferences:{preload:"./replaced.js"}}); - - var initializedTwice = require("electron"); - var initializedTwice = fake; - new initializedTwice.BrowserWindow({}); - - var conditional; - if (flag) { var conditional = require("electron"); } - new conditional.BrowserWindow({}); - `); - - expect( - analysis.electron.browser_windows.map(({ preload_path }) => preload_path), - ).toEqual(["./redeclared.js"]); - }); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "./preload.js" }, + ]); + expect(analysis.electron.context_bridge_apis).toMatchObject([ + { api_key: "api", members: ["ping"] }, + ]); + expect( + analysis.electron.ipc.map(({ side, operation, channel }) => ({ + side, + operation, + channel, + })), + ).toEqual([ + { side: "renderer", operation: "invoke", channel: "default:ping" }, + { side: "main", operation: "handle", channel: "destructured:ping" }, + ]); +}); + +it("recovers APIs and sender checks written with optional chaining", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcMain, utilityProcess } = require("electron"); + contextBridge?.exposeInMainWorld("api", {}); + utilityProcess?.fork("./worker.js"); + ipcMain?.handle("optional:ping", (event) => { + if (!event.senderFrame?.url.startsWith("file://")) return null; + return event.sender?.getURL() === "file:///index.html"; + }); + `); + + expect(analysis.electron.context_bridge_apis).toMatchObject([ + { api_key: "api" }, + ]); + expect(analysis.electron.utility_processes).toMatchObject([ + { module_path: "./worker.js" }, + ]); + expect(analysis.electron.ipc).toMatchObject([ + { side: "main", operation: "handle", channel: "optional:ping" }, + ]); + expect( + analysis.electron.sender_validations.map( + ({ subject, mechanism, expected }) => ({ + subject, + mechanism, + expected: expected.value, + }), + ), + ).toEqual([ + { + subject: "sender-url", + mechanism: "call:startsWith", + expected: "file://", + }, + { + subject: "sender-url", + mechanism: "comparison:===", + expected: "file:///index.html", + }, + ]); +}); + +it("preserves empty and computed literal contextBridge member keys", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { contextBridge } = require("electron"); + const dynamicKey = getKey(); + contextBridge.exposeInMainWorld("api", { + "": { nested: { "": true } }, + ["computed"]: true, + }); + contextBridge.exposeInMainWorld("dynamic", { + [dynamicKey]: true, + }); + `); + + const finding = analysis.electron.context_bridge_apis.find( + ({ api_key }) => api_key === "api", + ); + expect(finding).toMatchObject({ + api_status: "object-literal", + members: ["", ".nested", ".nested.", "computed"], + unknown_members: 0, + }); + expect(finding?.resolution_basis).toBeUndefined(); + expect(analysis.electron.context_bridge_apis).toContainEqual( + expect.objectContaining({ + api_key: "dynamic", + unknown_members: 1, + }), + ); +}); + +it("follows bundler namespace interop helpers without accepting other writes", () => { + const analysis = analyzeJavaScriptStaticSource(` + var __toESM = (mod) => mod; + var __importStar = (mod) => mod; + let electron = require("electron"); + electron = __toESM(electron); + new electron.BrowserWindow({ webPreferences: { preload: "./rolldown.js" } }); + var import_electron = __toESM(require("electron")); + new import_electron.BrowserWindow({ webPreferences: { preload: "./esbuild.js" } }); + function _interopRequireWildcard(mod) { return mod; } + const Electron = __importStar(require("electron")); + new Electron.BrowserWindow({ webPreferences: { preload: "./tsc.js" } }); + + let replaced = require("electron"); + replaced = __toESM(require("./fake")); + new replaced.BrowserWindow({ webPreferences: { preload: "./replaced.js" } }); + let rewritten = require("electron"); + rewritten = __toESM(rewritten); + rewritten = fake; + new rewritten.BrowserWindow({ webPreferences: { preload: "./rewritten.js" } }); + const babel = _interopRequireWildcard(require("electron")); + new babel.BrowserWindow({ webPreferences: { preload: "./babel.js" } }); + function nested() { + const __toESM = (mod) => mod; + const scoped = __toESM(require("electron")); + new scoped.BrowserWindow({ webPreferences: { preload: "./nested.js" } }); + } + `); + + expect( + analysis.electron.browser_windows.map(({ preload_path }) => preload_path), + ).toEqual(["./rolldown.js", "./esbuild.js", "./tsc.js", "./babel.js"]); +}); + +it("does not trust unbound helper names or non-function helper values", () => { + const analysis = analyzeJavaScriptStaticSource(` + const __toESM = makeLookalike(); + const electron = __toESM(require("electron")); + new electron.BrowserWindow({}); + let unbound = require("electron"); + unbound = __importStar(unbound); + new unbound.BrowserWindow({}); + `); + + expect(analysis.electron.browser_windows).toEqual([]); +}); + +it("follows stable declaration aliases but not an assigned default parameter", () => { + const analysis = analyzeJavaScriptStaticSource(` + const direct = require("electron"); + const constAlias = direct; + new constAlias.BrowserWindow({webPreferences:{preload:"./const.js"}}); + + let letAlias = require("electron"); + new letAlias.BrowserWindow({webPreferences:{preload:"./let.js"}}); + + const { BrowserWindow } = require("electron"); + new BrowserWindow({webPreferences:{preload:"./destructured.js"}}); + + function withDefault(electron = require("./not-electron")) { + if (flag) electron = require("electron"); + new electron.BrowserWindow({webPreferences:{preload:"./default.js"}}); + } + `); + + expect( + analysis.electron.browser_windows.map(({ preload_path }) => preload_path), + ).toEqual(["./const.js", "./let.js", "./destructured.js"]); +}); + +it("preserves one initializer across variable redeclarations without accepting writes", () => { + const analysis = analyzeJavaScriptStaticSource(` + var electron; + var electron = require("electron"); + var electron; + new electron.BrowserWindow({webPreferences:{preload:"./redeclared.js"}}); + + var replaced; + var replaced = require("electron"); + replaced = fake; + new replaced.BrowserWindow({webPreferences:{preload:"./replaced.js"}}); + + var initializedTwice = require("electron"); + var initializedTwice = fake; + new initializedTwice.BrowserWindow({}); + + var conditional; + if (flag) { var conditional = require("electron"); } + new conditional.BrowserWindow({}); + `); + + expect( + analysis.electron.browser_windows.map(({ preload_path }) => preload_path), + ).toEqual(["./redeclared.js"]); }); diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 1fb8d366dd..2fe9d5858f 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -4,6 +4,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import type { ElectronStaticValue } from "./electronStaticAnalysisTypes.js"; import { readExactJavaScriptLiteral, + semanticStaticPropertyKey, semanticStaticPropertyName, } from "./javascriptAstValues.js"; import { sourceSlice } from "./javascriptStaticAnalysisHelpers.js"; @@ -124,7 +125,7 @@ export const collectContextBridgeMembers = ( members: [], unknown: 0, }; - collectMembersAt(node, "", 0, state); + collectMembersAt(node, [], state); const members = [...new Set(state.members)].sort(compareUnicodeCodePoints); return { status: "object-literal", @@ -135,11 +136,10 @@ export const collectContextBridgeMembers = ( const collectMembersAt = ( object: t.ObjectExpression, - prefix: string, - depth: number, + prefix: readonly string[], state: { members: string[]; unknown: number }, ): void => { - if (depth >= 8) { + if (prefix.length >= 8) { state.unknown += 1; return; } @@ -148,15 +148,16 @@ const collectMembersAt = ( state.unknown += 1; continue; } - const name = semanticStaticPropertyName(property.key, property.computed); - if (name === "" || property.computed) { + const name = semanticStaticPropertyKey(property.key, property.computed); + if (name === null) { state.unknown += 1; continue; } - const path = prefix === "" ? name : `${prefix}.${name}`; + const segments = [...prefix, name]; + const path = segments.join("."); state.members.push(path); if (t.isObjectProperty(property) && t.isObjectExpression(property.value)) - collectMembersAt(property.value, path, depth + 1, state); + collectMembersAt(property.value, segments, state); } }; diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index b39ac7b691..47d61004a5 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -503,6 +503,38 @@ it("resolves renamed default and namespace import helpers from their syntax", () .every((step) => step.label.length > 0), ).toBe(true); }); +it("resolves an empty bootstrap module key through an exported Electron bridge", () => { + const result = analyzeJavaScriptStaticSource(`(() => { + const modules = { + ""(module) { module.exports = require("electron"); }, + 9(module, exports, load) { + const electron = load(""); + electron.contextBridge.exposeInMainWorld("desktop", { + copy: () => electron.ipcRenderer.send("test:empty-module"), + }); + }, + }; + function load(id) { + const module = { exports: {} }; + modules[id].call(module.exports, module, module.exports, load); + return module.exports; + } + load(9); + })();`); + expect(result.bundler_registrations).toMatchObject([ + { + entry_module_keys: ["9"], + modules: [{ module_key: "" }, { module_key: "9" }], + }, + ]); + expect(result.electron.call_paths).toContainEqual( + expect.objectContaining({ + entry: "desktop.copy", + channel: "test:empty-module", + terminal: "electron.ipcRenderer.send", + }), + ); +}); it("does not trust helper names or follow returns after a definite throw", () => { for (const helper of [ `function __toESM() {return {ipcRenderer:{send(){}}}}`, diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 8038edf44f..40ffe6e1f6 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -13,6 +13,7 @@ import type { } from "./javascriptStaticAnalysisTypes.js"; import { readExactJavaScriptLiteral, + semanticStaticPropertyKey, semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -131,15 +132,15 @@ export const moduleFactory = ( /** Derive a literal or explicitly computed module key. */ export const modulePropertyName = ( property: t.ObjectMethod | t.ObjectProperty | t.SpreadElement, -): string => - t.isObjectMethod(property) || t.isObjectProperty(property) - ? property.computed && - !t.isStringLiteral(property.key) && - !t.isNumericLiteral(property.key) - ? `[computed@${String(property.start ?? -1)}]` - : semanticStaticPropertyName(property.key, property.computed) || - `[unknown@${String(property.start ?? -1)}]` +): string => { + if (!t.isObjectMethod(property) && !t.isObjectProperty(property)) + return `[unknown@${String(property.start ?? -1)}]`; + const key = semanticStaticPropertyKey(property.key, property.computed); + if (key !== null) return key; + return property.computed + ? `[computed@${String(property.start ?? -1)}]` : `[unknown@${String(property.start ?? -1)}]`; +}; /** Read the factory-local bundler require parameter when declared. */ export const factoryRequireName = ( diff --git a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts index 3109e9d003..7c0d07445e 100644 --- a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts @@ -14,6 +14,36 @@ import { import { trackBrowser } from "./cdpBrowserProvider.support.js"; describe("CdpBrowserProvider: cancellation and session events", () => { + it("honors cancellation during the final progress callback before publishing inspection", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const controller = new AbortController(); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }), + { + signal: controller.signal, + progress: { + report(update) { + if (update.completed === 3) controller.abort(); + return Promise.resolve(); + }, + }, + }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "inspect_web_page", + }, + }); + }); + it("projects direct-session cancellation onto the requested operation", async () => { const browser = await startFakeCdpBrowser({ pageScopedVersionWebSocket: true, diff --git a/tests/boundary/browser/cdpBrowserProvider.network.test.ts b/tests/boundary/browser/cdpBrowserProvider.network.test.ts index 65ed14180e..a481af2cea 100644 --- a/tests/boundary/browser/cdpBrowserProvider.network.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.network.test.ts @@ -131,6 +131,203 @@ it("preserves unavailable response observations and captures completed peers", a ).toEqual(["missing-body", "available"]); }); +it("marks JSON response bodies unavailable when their events arrive during body capture", async () => { + const browser = await startFakeCdpBrowser({ + commandEvents(command, origin) { + const session = + command.sessionId === undefined ? {} : { sessionId: command.sessionId }; + if (command.method === "Network.enable") { + const url = `${origin}/initial.json`; + return [ + { + ...session, + method: "Network.requestWillBeSent", + params: { + requestId: "initial", + request: { url, method: "GET" }, + initiator: { type: "other" }, + }, + }, + { + ...session, + method: "Network.responseReceived", + params: { + requestId: "initial", + response: { url, status: 200, mimeType: "application/json" }, + }, + }, + { + ...session, + method: "Network.loadingFinished", + params: { requestId: "initial", encodedDataLength: 16 }, + }, + ]; + } + if (command.method !== "Network.getResponseBody") return undefined; + const url = `${origin}/late.json`; + return [ + { + ...session, + method: "Network.requestWillBeSent", + params: { + requestId: "late", + request: { url, method: "GET" }, + initiator: { type: "other" }, + }, + }, + { + ...session, + method: "Network.responseReceived", + params: { + requestId: "late", + response: { url, status: 200, mimeType: "application/json" }, + }, + }, + { + ...session, + method: "Network.loadingFinished", + params: { requestId: "late", encodedDataLength: 14 }, + }, + ]; + }, + commandResult(command) { + return command.method === "Network.getResponseBody" + ? { body: '{"initial":true}', base64Encoded: false } + : undefined; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + include_json_body_shapes: true, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.network.requests).toEqual([ + expect.objectContaining({ + request_id: "initial", + body_shapes: { + status: "included", + request: null, + response: expect.any(Object), + }, + }), + expect.objectContaining({ + request_id: "late", + body_shapes: { status: "unavailable", request: null, response: null }, + }), + ]); + expect(result.value.completeness.unavailable_sections).toContain( + "json_body_shapes", + ); +}); + +it("does not attach a redirect response body to the reused request ID", async () => { + const browser = await startFakeCdpBrowser({ + commandEvents(command, origin) { + const session = + command.sessionId === undefined ? {} : { sessionId: command.sessionId }; + if (command.method === "Network.enable") { + const url = `${origin}/before-redirect.json`; + return [ + { + ...session, + method: "Network.requestWillBeSent", + params: { + requestId: "reused", + request: { url, method: "GET" }, + initiator: { type: "other" }, + }, + }, + { + ...session, + method: "Network.responseReceived", + params: { + requestId: "reused", + response: { url, status: 302, mimeType: "application/json" }, + }, + }, + { + ...session, + method: "Network.loadingFinished", + params: { requestId: "reused", encodedDataLength: 12 }, + }, + ]; + } + if (command.method !== "Network.getResponseBody") return undefined; + const previousUrl = `${origin}/before-redirect.json`; + const nextUrl = `${origin}/after-redirect.json`; + return [ + { + ...session, + method: "Network.requestWillBeSent", + params: { + requestId: "reused", + request: { url: nextUrl, method: "GET" }, + initiator: { type: "other" }, + redirectResponse: { + url: previousUrl, + status: 302, + mimeType: "application/json", + encodedDataLength: 12, + }, + }, + }, + { + ...session, + method: "Network.responseReceived", + params: { + requestId: "reused", + response: { + url: nextUrl, + status: 200, + mimeType: "application/json", + }, + }, + }, + { + ...session, + method: "Network.loadingFinished", + params: { requestId: "reused", encodedDataLength: 14 }, + }, + ]; + }, + commandResult(command) { + return command.method === "Network.getResponseBody" + ? { body: '{"belongs":"redirect"}', base64Encoded: false } + : undefined; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + include_json_body_shapes: true, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.network.requests).toEqual([ + expect.objectContaining({ + request_id: "reused", + url: `${browser.allowedOrigin}/after-redirect.json`, + redirects: [expect.objectContaining({ status: 302 })], + body_shapes: { status: "unavailable", request: null, response: null }, + }), + ]); + expect(result.value.completeness.unavailable_sections).toContain( + "json_body_shapes", + ); +}); + it("marks malformed JSON body shapes unavailable while preserving partial request shapes", async () => { const browser = await startFakeCdpBrowser({ commandEvents(command, origin) { @@ -301,6 +498,12 @@ it("rejects malformed response-body envelopes and preserves request shapes", asy reason: "invalid_protocol_value", count: 2, }); + expect(result.value.completeness.excluded).not.toContainEqual( + expect.objectContaining({ + section: "json_body_shapes", + reason: "provider_unavailable", + }), + ); }); it.each([ diff --git a/tests/boundary/browser/cdpJsonShapeComparison.test.ts b/tests/boundary/browser/cdpJsonShapeComparison.test.ts index 900e21c670..677e8bd044 100644 --- a/tests/boundary/browser/cdpJsonShapeComparison.test.ts +++ b/tests/boundary/browser/cdpJsonShapeComparison.test.ts @@ -339,8 +339,10 @@ const captureBody = (source: BodySource, body: string): WebPageInspection => { encodedDataLength: Buffer.byteLength(responseBody), }, }); - expect(events.responseBodyRequestIds()).toEqual(["request-1"]); - events.ingestResponseBody("request-1", { + const candidate = events.responseBodyRequests()[0]; + expect(candidate?.requestId).toBe("request-1"); + expect(candidate).toBeDefined(); + events.ingestResponseBody(candidate!, { body: source === "base64 response" ? Buffer.from(responseBody).toString("base64")