From 486b56724c4eaba50719177bcb9eae84d6907297 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:52:42 +0800 Subject: [PATCH] fix(browser): compare only observed HTTP response facts --- src/domain/webCaptureDiff.ts | 49 +++++++++-- tests/boundary/browser/webCaptureDiff.test.ts | 86 +++++++++++++++++++ 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index 768f73c0bb..72c351f31e 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -249,10 +249,37 @@ const networkIdentity = ( ): string => `net_${digestCanonicalValue({ method: request.method, url: request.url, resource_type: request.resource_type })}`; +const networkResponseFields = [ + "status", + "mime_type", + "encoded_data_length", +] as const; +type NetworkResponseField = (typeof networkResponseFields)[number]; + +const sharedNetworkResponseFields = ( + before: WebPageInspection, + after: WebPageInspection, +): ReadonlyMap> => { + const shared = new Map>(); + for (const inspection of [before, after]) + for (const request of inspection.network.requests) { + const identity = networkIdentity(request); + const fields = shared.get(identity) ?? new Set(networkResponseFields); + for (const field of networkResponseFields) + if (request[field] === null) fields.delete(field); + shared.set(identity, fields); + } + return shared; +}; + const networkDimension = ( before: WebPageInspection, after: WebPageInspection, ): Dimension => { + const responseFields = sharedNetworkResponseFields(before, after); + const responseFieldsComplete = [...responseFields.values()].every( + (fields) => fields.size === networkResponseFields.length, + ); const selected = bodyShapesSelected(before) && bodyShapesSelected(after); const webSocketShapes = webSocketShapesSelected(before) && webSocketShapesSelected(after); @@ -313,14 +340,14 @@ const networkDimension = ( afterWebSockets, webSocketShapes, ); - return compareDimension( + const compared = compareDimension( new Map([ - ...networkMap(before, sources), + ...networkMap(before, sources, responseFields), ...webSocketMetadataMap(beforeWebSockets), ...beforeWebSocketPayload, ]), new Map([ - ...networkMap(after, sources), + ...networkMap(after, sources, responseFields), ...webSocketMetadataMap(afterWebSockets), ...afterWebSocketPayload, ]), @@ -336,21 +363,31 @@ const networkDimension = ( webSocketShapesComplete, "Network capture or selected HTTP/WebSocket payload-shape coverage is attach-window limited, unavailable, or incomplete.", ); + return compared.status === "unchanged" && !responseFieldsComplete + ? { + ...compared, + status: "unknown", + reason: + "HTTP response status, MIME type, or byte-count evidence is incomplete.", + } + : compared; }; const networkMap = ( inspection: WebPageInspection, sources: ReadonlyMap, + responseFields: ReadonlyMap>, ): ReadonlyMap => { const grouped = new Map(); for (const request of inspection.network.requests) { const identity = networkIdentity(request); const shapes = sources.get(identity); const values = grouped.get(identity) ?? []; + const response: Record = {}; + for (const field of responseFields.get(identity) ?? []) + response[field] = request[field]; values.push({ - status: request.status, - mime_type: request.mime_type, - encoded_data_length: request.encoded_data_length, + ...response, redirects: (request.redirects ?? []).map( ({ request_timestamp: _requestTimestamp, diff --git a/tests/boundary/browser/webCaptureDiff.test.ts b/tests/boundary/browser/webCaptureDiff.test.ts index 7a3698d778..5c2ce87854 100644 --- a/tests/boundary/browser/webCaptureDiff.test.ts +++ b/tests/boundary/browser/webCaptureDiff.test.ts @@ -446,6 +446,92 @@ describe("web capture WebSocket payload byte coverage", () => { }); describe("web capture diff", () => { + it("treats unavailable HTTP response facts as unknown while retaining observed changes", 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 request = before.network.requests[0]; + if (request === undefined) throw new Error("Missing network request"); + request.status = null; + request.mime_type = null; + request.encoded_data_length = null; + markSectionsComplete(before, [ + "network_requests", + "websocket_connections", + "websocket_frames", + "json_body_shapes", + ]); + for (const item of before.network.requests) + item.body_shapes.status = "not_approved"; + + const after = structuredClone(before); + const responseObserved = after.network.requests[0]; + if (responseObserved === undefined) + throw new Error("Missing network request"); + responseObserved.status = 200; + responseObserved.mime_type = "application/json"; + responseObserved.encoded_data_length = 100; + expect(compareNetwork(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + expect(compareNetwork(after, before)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + expect(compareNetwork(before, before).status).toBe("unknown"); + + for (const field of [ + "status", + "mime_type", + "encoded_data_length", + ] as const) { + const partial = structuredClone(after); + partial.network.requests[0]![field] = null; + expect(compareNetwork(partial, after).status).toBe("unknown"); + expect(compareNetwork(after, partial).status).toBe("unknown"); + } + + const inventoryBefore = structuredClone(before); + inventoryBefore.network.requests = []; + expect(compareNetwork(inventoryBefore, before)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [expect.objectContaining({ change: "added" })], + }); + + const statusChanged = structuredClone(after); + statusChanged.network.requests[0]!.status = 500; + expect(compareNetwork(after, statusChanged)).toMatchObject({ + status: "changed", + changes: [expect.objectContaining({ change: "modified" })], + }); + after.network.requests[0]!.encoded_data_length = null; + expect(compareNetwork(after, statusChanged).status).toBe("changed"); + + const repeated = structuredClone(statusChanged); + const duplicate = structuredClone(repeated.network.requests[0]!); + duplicate.status = 200; + duplicate.mime_type = "text/plain"; + repeated.network.requests.push(duplicate); + const reassociated = structuredClone(repeated); + reassociated.network.requests[0]!.mime_type = "text/plain"; + reassociated.network.requests.at(-1)!.mime_type = "application/json"; + expect(compareNetwork(repeated, reassociated).status).toBe("changed"); + repeated.network.requests[0]!.status = null; + expect(compareNetwork(repeated, reassociated).status).toBe("unknown"); + }); + it("ignores transient request IDs and capture-approval state", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser);