diff --git a/src/browser/CdpElectronWorkers.test.ts b/src/browser/CdpElectronWorkers.test.ts new file mode 100644 index 0000000000..58191440a1 --- /dev/null +++ b/src/browser/CdpElectronWorkers.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import type { InspectElectronPageInput } from "../domain/javascript/electronObservation.js"; +import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; +import type { CdpConnection } from "./CdpConnection.js"; +import type { CdpEndpointTarget } from "./CdpEndpoint.js"; +import { captureElectronWorkers } from "./CdpElectronWorkers.js"; + +/** `authorizedElectronFile` only admits a file the host can resolve. */ +const localFileUrl = import.meta.url; + +const workerTarget = ( + over: Readonly> = {}, +): Readonly> => ({ + targetId: "worker-1", + type: "service_worker", + url: localFileUrl, + openerId: "electron-page", + attached: true, + ...over, +}); + +/** An Electron worker target whose `TargetInfo.type` is not delivered at all. */ +const targetWithoutType: Readonly> = { + targetId: "worker-1", + url: localFileUrl, + openerId: "electron-page", + attached: true, +}; + +const capture = async (inventory: unknown) => { + const completeness = new CdpCaptureCompleteness(); + const workers = await captureElectronWorkers({ + connection: { + async send() { + return inventory; + }, + } as unknown as CdpConnection, + target: { + id: "electron-page", + type: "page", + title: "Fixture", + url: localFileUrl, + attached: false, + } satisfies CdpEndpointTarget, + request: {} as InspectElectronPageInput, + frameIds: new Set(), + completeness, + limitations: [], + }); + return { workers, completeness: completeness.snapshot() }; +}; + +describe("Electron worker target inventory", () => { + it("preserves an explicit empty inventory", async () => { + const result = await capture({ targetInfos: [] }); + + expect(result.workers).toEqual([]); + expect(result.completeness.unavailable_sections).not.toContain("workers"); + expect(result.completeness.excluded).not.toContainEqual( + expect.objectContaining({ section: "workers" }), + ); + }); + + it.each([undefined, { length: 1 }, "worker-one"])( + "reports a missing or non-array target inventory as unknown: %s", + async (targetInfos) => { + const result = await capture({ targetInfos }); + + expect(result.workers).toEqual([]); + expect(result.completeness).toMatchObject({ + unavailable_sections: ["workers"], + excluded: [ + { section: "workers", reason: "invalid_protocol_value", count: null }, + ], + }); + }, + ); + + it("records a target entry that carries no type discriminator", async () => { + const result = await capture({ targetInfos: [targetWithoutType] }); + + expect(result.workers).toEqual([]); + expect(result.completeness.unavailable_sections).toContain("workers"); + expect(result.completeness.excluded).toContainEqual({ + section: "workers", + reason: "invalid_protocol_value", + count: 1, + }); + }); + + it.each([ + ["null", null], + ["numeric", 42], + ["empty", ""], + ] as const)( + "records a target entry whose type is an undecodable %s value", + async (_label, type) => { + const result = await capture({ targetInfos: [workerTarget({ type })] }); + + expect(result.workers).toEqual([]); + expect(result.completeness.excluded).toContainEqual({ + section: "workers", + reason: "invalid_protocol_value", + count: 1, + }); + }, + ); + + it("keeps a decodable non-worker type out of the malformed ledger", async () => { + const result = await capture({ + targetInfos: [workerTarget({ type: "page" })], + }); + + expect(result.workers).toEqual([]); + expect(result.completeness.unavailable_sections).not.toContain("workers"); + expect(result.completeness.excluded).not.toContainEqual( + expect.objectContaining({ section: "workers" }), + ); + }); + + it("retains valid worker records beside malformed entries", async () => { + const result = await capture({ + targetInfos: [ + null, + targetWithoutType, + { url: localFileUrl, type: "worker", openerId: "electron-page" }, + workerTarget({ targetId: "worker-2" }), + ], + }); + + expect(result.workers.map((worker) => worker.target_id)).toEqual([ + "worker-2", + ]); + expect(result.completeness.unavailable_sections).toContain("workers"); + expect(result.completeness.excluded).toContainEqual({ + section: "workers", + reason: "invalid_protocol_value", + count: 3, + }); + expect(result.completeness.excluded).not.toContainEqual( + expect.objectContaining({ + section: "workers", + reason: "out_of_target_scope", + }), + ); + }); +}); diff --git a/src/browser/CdpElectronWorkers.ts b/src/browser/CdpElectronWorkers.ts index 06a26eaf3a..f9f2361bb7 100644 --- a/src/browser/CdpElectronWorkers.ts +++ b/src/browser/CdpElectronWorkers.ts @@ -4,7 +4,11 @@ import type { } from "../domain/javascript/electronObservation.js"; import type { CdpConnection } from "./CdpConnection.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; -import { recordValue, cdpStringValue } from "./CdpCaptureValues.js"; +import { + admitRecords, + recordValue, + cdpStringValue, +} from "./CdpCaptureValues.js"; import type { CdpEndpointTarget } from "./CdpEndpoint.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; @@ -36,18 +40,24 @@ export const captureElectronWorkers = async (input: { } const workers: ElectronPageInspection["workers"] = []; // A missing target list or a non-record entry is unreadable, not empty. - const targetInfos = recordValue(result)?.targetInfos; - if (!Array.isArray(targetInfos)) { + const targetInfos = admitRecords(recordValue(result)?.targetInfos); + if (targetInfos === undefined) { input.completeness.exclude("workers", "invalid_protocol_value", null); return []; } + let malformedEntries = 0; for (const entry of targetInfos) { const target = recordValue(entry); if (target === undefined) { - input.completeness.exclude("workers", "invalid_protocol_value"); + malformedEntries += 1; + continue; + } + // An undecodable discriminator cannot prove the entry is not a worker. + const type = cdpStringValue(target.type); + if (type === undefined || type.length === 0) { + malformedEntries += 1; continue; } - const type = cdpStringValue(target.type) ?? ""; if (!type.includes("worker")) continue; const openerTargetId = boundedTargetField(target.openerId); const parentFrameId = boundedTargetField(target.parentFrameId); @@ -65,11 +75,11 @@ export const captureElectronWorkers = async (input: { } const targetId = boundedTargetField(target.targetId); if (targetId === null) { - input.completeness.exclude("workers", "invalid_protocol_value"); + malformedEntries += 1; continue; } if (typeof target.attached !== "boolean") { - input.completeness.exclude("workers", "invalid_protocol_value"); + malformedEntries += 1; continue; } workers.push({ @@ -81,6 +91,12 @@ export const captureElectronWorkers = async (input: { parent_frame_id: parentFrameId, }); } + if (malformedEntries > 0) + input.completeness.exclude( + "workers", + "invalid_protocol_value", + malformedEntries, + ); return workers.sort((left, right) => compareUnicodeCodePoints(left.target_id, right.target_id), ); diff --git a/src/browser/CdpPageCaptureWorkers.test.ts b/src/browser/CdpPageCaptureWorkers.test.ts index 77768ad997..9936dec1e3 100644 --- a/src/browser/CdpPageCaptureWorkers.test.ts +++ b/src/browser/CdpPageCaptureWorkers.test.ts @@ -43,6 +43,22 @@ const capture = async (inventory: unknown) => { return { workers, completeness: events.completeness.snapshot() }; }; +/** A page-owned worker `TargetInfo` with no `type` discriminator at all. */ +const targetWithoutType = () => ({ + targetId: "worker-1", + url: `${origin}/worker.js`, + openerId: "page", + attached: true, +}); + +const workerTarget = ( + over: Readonly> = {}, +): Readonly> => ({ + ...targetWithoutType(), + type: "service_worker", + ...over, +}); + describe("web worker target inventory", () => { it("preserves an explicit empty inventory", async () => { const result = await capture({ targetInfos: [] }); @@ -74,13 +90,7 @@ describe("web worker target inventory", () => { const result = await capture({ targetInfos: [ null, - { - targetId: "worker-1", - type: "service_worker", - url: `${origin}/worker.js`, - attached: true, - openerId: "page", - }, + workerTarget(), { targetId: "worker-missing-url", type: "worker", @@ -124,4 +134,40 @@ describe("web worker target inventory", () => { }), ); }); + + it.each([ + ["absent", undefined], + ["null", null], + ["numeric", 42], + ["empty", ""], + ] as const)( + "records a target entry whose type is %s instead of deciding it is not a worker", + async (label, type) => { + const result = await capture({ + targetInfos: [ + label === "absent" ? targetWithoutType() : workerTarget({ type }), + ], + }); + + expect(result.workers).toEqual([]); + expect(result.completeness.unavailable_sections).toContain("workers"); + expect(result.completeness.excluded).toContainEqual({ + section: "workers", + reason: "invalid_protocol_value", + count: 1, + }); + }, + ); + + it("keeps a decodable non-worker type out of the malformed ledger", async () => { + const result = await capture({ + targetInfos: [workerTarget({ type: "page" })], + }); + + expect(result.workers).toEqual([]); + expect(result.completeness.unavailable_sections).not.toContain("workers"); + expect(result.completeness.excluded).not.toContainEqual( + expect.objectContaining({ section: "workers" }), + ); + }); });