diff --git a/src/browser/CdpCaptureStorage.ts b/src/browser/CdpCaptureStorage.ts index 1e88ca74a1..9c3eae8c50 100644 --- a/src/browser/CdpCaptureStorage.ts +++ b/src/browser/CdpCaptureStorage.ts @@ -1,5 +1,6 @@ import type { InspectWebPageInput } from "../domain/browserObservation.js"; import type { WebPageInspection } from "../domain/browserObservationSchemas.js"; +import type { BrowserExclusionReason } from "../domain/browserCompleteness.js"; import { CdpConnection } from "./CdpConnection.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; import { @@ -26,6 +27,7 @@ export const captureStorage = async ( limitations: string[], ): Promise<{ readonly value: WebPageInspection["storage"]; + readonly keysExclusion: BrowserExclusionReason | undefined; }> => { const origin = new URL(pageUrl).origin; const quota = recordValue( @@ -82,7 +84,18 @@ export const captureStorage = async ( // list is an incomplete enumeration rather than an empty one. Array.isArray(cacheField) && caches.length === cacheField.length; + const keysExclusion = !context.input.include_storage_keys + ? undefined + : local.exclusion === "provider_unavailable" || + session.exclusion === "provider_unavailable" || + indexedRaw === undefined || + cacheRaw === undefined + ? "provider_unavailable" + : !local.complete || !session.complete || !structuredStorageComplete + ? "invalid_protocol_value" + : undefined; return { + keysExclusion, value: { origin, usage_bytes: numberValue(quota?.usage) ?? null, @@ -99,7 +112,11 @@ export const captureStorage = async ( }; }; -const emptyStorageItems: CapturedStorageItems = { +type StorageItemsCapture = CapturedStorageItems & { + readonly exclusion?: "provider_unavailable" | "invalid_protocol_value"; +}; + +const emptyStorageItems: StorageItemsCapture = { items: [], complete: false, }; @@ -118,7 +135,7 @@ const storageItems = async ( origin: string, isLocalStorage: boolean, limitations: string[], -): Promise => { +): Promise => { const raw = await optionalCdpCommand( context, "DOMStorage.getDOMStorageItems", @@ -127,7 +144,12 @@ const storageItems = async ( ); const entries = recordValue(raw)?.entries; if (raw === undefined || !Array.isArray(entries)) - return { items: [], complete: false }; + return { + items: [], + complete: false, + exclusion: + raw === undefined ? "provider_unavailable" : "invalid_protocol_value", + }; const items = entries.flatMap((entry) => { if (!Array.isArray(entry)) return []; const key = cdpStringValue(entry[0]); diff --git a/src/browser/CdpCaptureStorageFingerprints.ts b/src/browser/CdpCaptureStorageFingerprints.ts index 02a52b3bee..ec7a068500 100644 --- a/src/browser/CdpCaptureStorageFingerprints.ts +++ b/src/browser/CdpCaptureStorageFingerprints.ts @@ -193,7 +193,9 @@ const addIndexedDb = async ( scope: "indexed_db_schema", identity: databaseName, value: stores.map(indexedDbStoreSchema), - complete: indexedDbSchemaComplete(stores), + complete: + stores.length === database.objectStores.length && + indexedDbSchemaComplete(stores), }); for (const store of stores) { const objectStoreName = cdpStringValue(store.name); @@ -228,19 +230,37 @@ const indexedDbSchemaComplete = ( stores.every( (store) => cdpStringValue(store.name) !== undefined && - stableValueComplete(store.keyPath) && + indexedDbKeyPathComplete(store.keyPath) && typeof store.autoIncrement === "boolean" && Array.isArray(store.indexes) && recordsValue(store.indexes).length === store.indexes.length && recordsValue(store.indexes).every( (index) => cdpStringValue(index.name) !== undefined && - stableValueComplete(index.keyPath) && + indexedDbKeyPathComplete(index.keyPath) && typeof index.unique === "boolean" && typeof index.multiEntry === "boolean", ), ); +const indexedDbKeyPathComplete = (value: unknown): boolean => { + const keyPath = recordValue(value); + if (keyPath === undefined) return false; + switch (keyPath.type) { + case "null": + return true; + case "string": + return typeof keyPath.string === "string"; + case "array": + return ( + Array.isArray(keyPath.array) && + keyPath.array.every((key) => typeof key === "string") + ); + default: + return false; + } +}; + const addIndexedDbRecords = async ( run: FingerprintRun, input: { @@ -474,14 +494,49 @@ const stableValueComplete = (value: unknown): boolean => { const remoteObjectComplete = (value: unknown): boolean => { const record = recordValue(value); - if (record === undefined) return true; - return ( - record.objectId === undefined && - record.preview === undefined && - record.customPreview === undefined && - (record.type !== "object" || record.value !== undefined) && - stableValueComplete(record) - ); + if ( + record === undefined || + record.objectId !== undefined || + record.preview !== undefined || + record.customPreview !== undefined || + !stableValueComplete(record) + ) + return false; + switch (record.type) { + case "undefined": + return ( + record.value === undefined && record.unserializableValue === undefined + ); + case "string": + case "boolean": + return ( + typeof record.value === record.type && + record.unserializableValue === undefined + ); + case "number": + return record.unserializableValue === undefined + ? typeof record.value === "number" && Number.isFinite(record.value) + : record.value === undefined && + typeof record.unserializableValue === "string" && + ["-0", "NaN", "Infinity", "-Infinity"].includes( + record.unserializableValue, + ); + case "bigint": + return ( + record.value === undefined && + typeof record.unserializableValue === "string" && + /^-?\d+n$/u.test(record.unserializableValue) + ); + case "object": + return ( + record.unserializableValue === undefined && + (record.subtype === "null" + ? record.value === null + : record.value !== null && typeof record.value === "object") + ); + default: + return false; + } }; const decodeBase64 = (value: string): Buffer | null => { diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index f49d4e186c..337f99dfad 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -155,6 +155,12 @@ const captureAuthorizedPage = async ( ); if (!input.include_storage_keys) state.events.completeness.exclude("storage_keys", "not_approved", null); + else if (storageCapture.keysExclusion !== undefined) + state.events.completeness.exclude( + "storage_keys", + storageCapture.keysExclusion, + null, + ); await report(context.progress, 3, "Normalizing browser evidence"); const completedFrameResult = await authorizedMainFrame({ connection: context.connection, diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index 72c351f31e..736d609ee7 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -683,16 +683,17 @@ const storageMap = ( }), ], ]); - if (!includeKeys) return map; const add = (kind: string, keys: readonly string[]) => { for (const key of keys) { map.set(`storage:${kind}:${key}`, digestCanonicalValue(key)); } }; - add("local_storage", storage.local_storage_keys); - add("session_storage", storage.session_storage_keys); - add("indexed_db", storage.indexed_db_names); - add("cache", storage.cache_names); + if (includeKeys) { + add("local_storage", storage.local_storage_keys); + add("session_storage", storage.session_storage_keys); + add("indexed_db", storage.indexed_db_names); + add("cache", storage.cache_names); + } for (const fingerprint of storage.content_fingerprints) { const identity = `${fingerprint.scope}:${fingerprint.identity_sha256}`; if (!fingerprintIdentities.has(identity)) continue; diff --git a/src/ida/IdaWorkspaceThinning.test.ts b/src/ida/IdaWorkspaceThinning.test.ts index baba6e1450..3af1cd5eef 100644 --- a/src/ida/IdaWorkspaceThinning.test.ts +++ b/src/ida/IdaWorkspaceThinning.test.ts @@ -21,12 +21,13 @@ afterEach(async () => { } }); -/** Write a two-slice universal fixture and resolve it for the given host. */ -const universalTarget = async (hostArchitecture: "arm64" | "x64") => { +const resolveUniversalTarget = async ( + bytes: Buffer, + hostArchitecture: "arm64" | "x64", +) => { const directory = await createTestTempDirectory("rea-ida-thin-"); roots.push(directory); const path = join(directory, "universal"); - const bytes = fat([0x01000007, 0x0100000c]); await writeFile(path, bytes); const resolved = await parseBinaryTarget(path, { hostArchitecture }); if (!resolved.ok) throw resolved.error; @@ -35,6 +36,27 @@ const universalTarget = async (hostArchitecture: "arm64" | "x64") => { return { bytes, target: resolved.value }; }; +/** Write a two-slice universal fixture and resolve it for the given host. */ +const universalTarget = (hostArchitecture: "arm64" | "x64") => + resolveUniversalTarget(fat([0x01000007, 0x0100000c]), hostArchitecture); + +const copySelectedSlice = async ({ + bytes, + target, +}: Awaited>) => { + const workspace = await IdaWorkspace.allocate(target); + roots.push(workspace.directory); + await workspace.copyInput(); + const input = await readFile(workspace.inputPath); + const { offset, size } = target.universalSlice; + expect(input.length, "no byte after the slice is copied").toBe(size); + expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); + expect(workspace.inputSha256).toBe( + createHash("sha256").update(input).digest("hex"), + ); + return { workspace, input }; +}; + /** One slice of a synthetic universal file: CPU type, offset and size. */ interface Layout { readonly cpu: number; @@ -43,6 +65,12 @@ interface Layout { readonly alignmentExponent?: number; } +const multiSliceLayout: readonly Layout[] = [ + { cpu: 0x01000007, offset: 4096, size: 8480, alignmentExponent: 12 }, + { cpu: 0x0100000c, offset: 16384, size: 33464 }, + { cpu: 0x00000007, offset: 65536, size: 33464 }, +]; + /** * Write a universal file whose slices span more than one 64 KiB read, each * filled with its own byte so bytes outside the selected slice are visible. @@ -51,9 +79,6 @@ const largeUniversalTarget = async ( layout: readonly Layout[], hostArchitecture: "arm64" | "x64", ) => { - const directory = await createTestTempDirectory("rea-ida-thin-large-"); - roots.push(directory); - const path = join(directory, "universal"); const total = Math.max(...layout.map(({ offset, size }) => offset + size)); const bytes = Buffer.alloc(total); bytes.writeUInt32BE(0xcafebabe, 0); @@ -67,37 +92,27 @@ const largeUniversalTarget = async ( bytes.fill(0x11 * (index + 1), offset, offset + size); thinMach(0xfeedfacf, cpu).copy(bytes, offset); }); - await writeFile(path, bytes); - const resolved = await parseBinaryTarget(path, { hostArchitecture }); - if (!resolved.ok) throw resolved.error; - if (resolved.value.format !== "mach-o-universal") - throw new Error("the universal fixture did not resolve as a FAT container"); - return { bytes, target: resolved.value }; + return await resolveUniversalTarget(bytes, hostArchitecture); }; describe("IDA workspace input thinning", () => { + it("admits the multi-read FAT fixture on every host platform", async () => { + const { target } = await largeUniversalTarget(multiSliceLayout, "arm64"); + expect(target.universalSlice).toMatchObject({ offset: 16384, size: 33464 }); + }); + it.skipIf(process.platform === "win32")( "thins the selected arm64 slice into the private headless input", async () => { const { bytes, target } = await universalTarget("arm64"); - const workspace = await IdaWorkspace.allocate(target); - roots.push(workspace.directory); - await workspace.copyInput(); - - const input = await readFile(workspace.inputPath); - const { offset, size } = target.universalSlice; - expect(offset).toBeGreaterThan(0); - expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); - expect(input.length).toBe(size); + const { workspace, input } = await copySelectedSlice({ bytes, target }); + expect(target.universalSlice.offset).toBeGreaterThan(0); expect(input.readUInt32BE(0)).toBe(0xfeedfacf); expect(input.readUInt32BE(4)).toBe(0x0100000c); expect(parseExecutableHeader(input, "arm64")).toMatchObject({ ok: true, value: { format: "mach-o", architecture: "arm64" }, }); - expect(createHash("sha256").update(input).digest("hex")).toBe( - workspace.inputSha256, - ); await workspace.remove(); }, ); @@ -106,13 +121,7 @@ describe("IDA workspace input thinning", () => { "thins the x86_64 slice when the host is intel", async () => { const { bytes, target } = await universalTarget("x64"); - const workspace = await IdaWorkspace.allocate(target); - roots.push(workspace.directory); - await workspace.copyInput(); - - const input = await readFile(workspace.inputPath); - const { offset, size } = target.universalSlice; - expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); + const { workspace, input } = await copySelectedSlice({ bytes, target }); expect(input.readUInt32BE(4)).toBe(0x01000007); await workspace.remove(); }, @@ -122,31 +131,12 @@ describe("IDA workspace input thinning", () => { "copies exactly the selected slice when later slices follow it across read boundaries", async () => { const { bytes, target } = await largeUniversalTarget( - [ - { - cpu: 0x01000007, - offset: 4096, - size: 8480, - alignmentExponent: 12, - }, - { cpu: 0x0100000c, offset: 16384, size: 33464 }, - { cpu: 0x00000007, offset: 65536, size: 33464 }, - ], + multiSliceLayout, "arm64", ); - const workspace = await IdaWorkspace.allocate(target); - roots.push(workspace.directory); - await workspace.copyInput(); - - const input = await readFile(workspace.inputPath); - const { offset, size } = target.universalSlice; - expect(size, "the arm64 slice is selected").toBe(33464); - expect(input.length, "no byte after the slice is copied").toBe(size); - expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); - expect(workspace.inputSha256).toBe( - createHash("sha256") - .update(bytes.subarray(offset, offset + size)) - .digest("hex"), + const { workspace } = await copySelectedSlice({ bytes, target }); + expect(target.universalSlice.size, "the arm64 slice is selected").toBe( + 33464, ); await workspace.remove(); }, @@ -162,15 +152,10 @@ describe("IDA workspace input thinning", () => { ], "x64", ); - const workspace = await IdaWorkspace.allocate(target); - roots.push(workspace.directory); - await workspace.copyInput(); - - const input = await readFile(workspace.inputPath); - const { offset, size } = target.universalSlice; - expect(size, "the x86_64 slice is selected").toBe(37456); - expect(input.length, "no byte after the slice is copied").toBe(size); - expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); + const { workspace } = await copySelectedSlice({ bytes, target }); + expect(target.universalSlice.size, "the x86_64 slice is selected").toBe( + 37456, + ); await workspace.remove(); }, ); diff --git a/tests/boundary/browser/cdpBrowserProvider.storage_completeness.test.ts b/tests/boundary/browser/cdpBrowserProvider.storage_completeness.test.ts new file mode 100644 index 0000000000..b1c5d7495f --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.storage_completeness.test.ts @@ -0,0 +1,355 @@ +import { expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; +import type { WebPageInspection } from "../../../src/domain/browserObservationSchemas.js"; +import { compareWebCaptures } from "../../../src/domain/webCaptureDiff.js"; +import { compareWebCapturesInputSchema } from "../../../src/domain/webCaptureDiffSchemas.js"; +import { + startFakeCdpBrowser, + type FakeOptions, +} from "../../fixtures/fakeCdpBrowser.js"; +import { trackBrowser } from "./cdpBrowserProvider.support.js"; + +const inspect = async (options: FakeOptions, includeFingerprints = true) => { + const browser = await startFakeCdpBrowser(options); + trackBrowser(browser); + const input = inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + include_storage_keys: true, + include_storage_fingerprints: includeFingerprints, + }); + const capture = async () => { + const result = await new CdpBrowserProvider().inspectPage(input); + if (!result.ok) throw result.error; + return result.value; + }; + return { capture }; +}; + +const compareStorage = (before: WebPageInspection, after: WebPageInspection) => + compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: before }, + after: { inspection: after }, + }), + ).dimensions.storage; + +it.each([ + ["CacheStorage.requestCacheNames", {}], + ["CacheStorage.requestCacheNames", { caches: null }], + ["CacheStorage.requestCacheNames", { caches: [null] }], + [ + "CacheStorage.requestCacheNames", + { caches: [{ cacheName: "assets-v1", cacheId: "secret-id" }, null] }, + ], + ["CacheStorage.requestCacheNames", { caches: [{ cacheName: "assets-v1" }] }], + ["IndexedDB.requestDatabaseNames", { databaseNames: [null] }], + ["DOMStorage.getDOMStorageItems", { entries: [null] }], +] as const)( + "preserves unknown inventory coverage from %s: %j", + async (method, reply) => { + let malformed = false; + const { capture } = await inspect({ + commandResult: (command) => + malformed && command.method === method ? reply : undefined, + }); + const before = await capture(); + malformed = true; + const after = await capture(); + expect(after.storage.fingerprints_complete).toBe(false); + expect(after.completeness.unavailable_sections).toContain("storage_keys"); + expect(after.completeness.excluded).toContainEqual({ + section: "storage_keys", + reason: "invalid_protocol_value", + count: null, + }); + expect(compareStorage(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + }, +); + +it("preserves unavailable storage-key coverage without requesting fingerprints", async () => { + const { capture } = await inspect( + { unsupportedMethods: ["CacheStorage.requestCacheNames"] }, + false, + ); + const captured = await capture(); + expect(captured.completeness.excluded).toContainEqual({ + section: "storage_keys", + reason: "provider_unavailable", + count: null, + }); + expect(captured.storage.local_storage_keys).toEqual(["public-key"]); +}); + +const validKey = { type: "string", value: "row-1" }; + +it.each(["unavailable", "malformed"] as const)( + "compares shared complete values with %s cache inventory", + async (inventory) => { + let entries: unknown[][] = [["shared-key", "before"]]; + const { capture } = await inspect({ + ...(inventory === "unavailable" + ? { unsupportedMethods: ["CacheStorage.requestCacheNames"] } + : {}), + commandResult: (command) => { + if (command.method === "CacheStorage.requestCacheNames") + return { caches: [null] }; + if (command.method === "DOMStorage.getDOMStorageItems") + return { entries }; + return undefined; + }, + }); + const before = await capture(); + expect(before.completeness.unavailable_sections).toContain("storage_keys"); + expect(before.storage.fingerprints_complete).toBe(false); + expect(compareStorage(before, await capture())).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + + entries = [["shared-key", "after"]]; + const changed = await capture(); + expect(changed.storage.usage_bytes).toBe(before.storage.usage_bytes); + expect(changed.storage.quota_bytes).toBe(before.storage.quota_bytes); + for (const [left, right] of [ + [before, changed], + [changed, before], + ] as const) + expect(compareStorage(left, right)).toMatchObject({ + status: "changed", + total_changes: 2, + changes: [ + expect.objectContaining({ change: "modified" }), + expect.objectContaining({ change: "modified" }), + ], + }); + + const incompleteValues = structuredClone(changed); + for (const fingerprint of incompleteValues.storage.content_fingerprints) + if (["local_storage", "session_storage"].includes(fingerprint.scope)) + fingerprint.complete = false; + expect(compareStorage(before, incompleteValues)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + + // Missing or invalid peer values establish neither removal nor addition. + for (const partial of [[], [["shared-key", null]]]) { + entries = partial; + const incomplete = await capture(); + for (const [left, right] of [ + [before, incomplete], + [incomplete, before], + ] as const) + expect(compareStorage(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + } + }, +); + +it.each(["key", "primaryKey"])( + "requires IndexedDB %s identity evidence", + async (field) => { + const { capture } = await inspect({ + commandResult: (command) => + command.method === "IndexedDB.requestData" + ? { + objectStoreDataEntries: [ + { + key: validKey, + primaryKey: validKey, + value: validKey, + [field]: {}, + }, + ], + hasMore: false, + } + : undefined, + }); + const captured = await capture(); + expect(captured.storage.fingerprints_complete).toBe(false); + expect( + captured.storage.content_fingerprints.find( + ({ scope }) => scope === "indexed_db_record", + )?.complete, + ).toBe(false); + }, +); + +it("does not fingerprint a filtered partial object-store schema as complete", async () => { + let malformed = false; + const store = { + name: "records", + keyPath: { type: "string", string: "id" }, + autoIncrement: false, + indexes: [], + }; + const { capture } = await inspect({ + commandResult: (command) => + command.method === "IndexedDB.requestDatabase" + ? { + databaseWithObjectStores: { + name: "app-db", + version: 1, + objectStores: [ + store, + malformed ? null : { ...store, name: "other" }, + ], + }, + } + : undefined, + }); + const before = await capture(); + malformed = true; + const after = await capture(); + expect( + after.storage.content_fingerprints.find( + ({ scope }) => scope === "indexed_db_schema", + )?.complete, + ).toBe(false); + expect(compareStorage(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); +}); + +const incompleteRemoteObjects = [ + undefined, + null, + "raw", + {}, + { type: "string" }, + { type: "string", value: 1 }, + { type: "boolean", value: "false" }, + { type: "number" }, + { type: "number", unserializableValue: "invalid" }, + { type: "number", unserializableValue: ["NaN"] }, + { type: "function", description: "function f() {}" }, + { type: "symbol", description: "Symbol(x)" }, + { type: "object", subtype: "null" }, + { type: "bigint", unserializableValue: "broken" }, +] as const; + +const completeRemoteObjects = [ + { type: "string", value: "" }, + { type: "boolean", value: false }, + { type: "number", value: 0 }, + { type: "undefined" }, + { type: "number", unserializableValue: "NaN" }, + { type: "number", unserializableValue: "-0" }, + { type: "bigint", unserializableValue: "-42n" }, + { type: "object", subtype: "null", value: null }, + { type: "object", value: { nested: [false, null, 0] } }, +] as const; + +it.each([ + ...incompleteRemoteObjects.map((value) => [value, false] as const), + ...completeRemoteObjects.map((value) => [value, true] as const), +])( + "validates IndexedDB RemoteObject completeness: %j => %s", + async (value, complete) => { + const { capture } = await inspect({ + commandResult: (command) => + command.method === "IndexedDB.requestData" + ? { + objectStoreDataEntries: [ + { key: validKey, primaryKey: validKey, value }, + ], + hasMore: false, + } + : undefined, + }); + const before = await capture(); + const after = await capture(); + const records = before.storage.content_fingerprints.filter( + ({ scope }) => scope === "indexed_db_record", + ); + expect(records).toHaveLength(1); + expect(records[0]?.complete).toBe(complete); + expect(before.storage.fingerprints_complete).toBe(complete); + expect(compareStorage(before, after)).toMatchObject({ + status: complete ? "unchanged" : "unknown", + total_changes: 0, + }); + }, +); + +it.each([ + [undefined, false], + [{}, false], + [{ type: "string" }, false], + [{ type: "array", array: [null] }, false], + [{ type: "null" }, true], + [{ type: "string", string: "id" }, true], + [{ type: "array", array: ["id", "name"] }, true], +] as const)( + "requires complete IndexedDB key paths: %j", + async (keyPath, complete) => { + const { capture } = await inspect({ + commandResult: (command) => + command.method === "IndexedDB.requestDatabase" + ? { + databaseWithObjectStores: { + name: "app-db", + version: 1, + objectStores: [ + { + name: "records", + keyPath, + autoIncrement: false, + indexes: [ + { + name: "by-id", + keyPath, + unique: false, + multiEntry: false, + }, + ], + }, + ], + }, + } + : undefined, + }); + const captured = await capture(); + expect( + captured.storage.content_fingerprints.find( + ({ scope }) => scope === "indexed_db_schema", + )?.complete, + ).toBe(complete); + expect(captured.storage.fingerprints_complete).toBe(complete); + }, +); + +it("accepts genuinely empty inventories as complete", async () => { + const { capture } = await inspect({ + commandResult: (command) => { + switch (command.method) { + case "CacheStorage.requestCacheNames": + return { caches: [] }; + case "IndexedDB.requestDatabaseNames": + return { databaseNames: [] }; + case "DOMStorage.getDOMStorageItems": + return { entries: [] }; + case "Network.getCookies": + return { cookies: [] }; + default: + return undefined; + } + }, + }); + const before = await capture(); + const after = await capture(); + expect(before.storage.fingerprints_complete).toBe(true); + expect(compareStorage(before, after).status).toBe("unchanged"); +});