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"); });