fix(browser): compare only observed HTTP response facts

This commit is contained in:
morluto
2026-10-11 23:52:42 +08:00
parent 9d8e42bdd6
commit 486b56724c
2 changed files with 129 additions and 6 deletions
+43 -6
View File
@@ -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<string, ReadonlySet<NetworkResponseField>> => {
const shared = new Map<string, Set<NetworkResponseField>>();
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<string, BodyShapeSources>,
responseFields: ReadonlyMap<string, ReadonlySet<NetworkResponseField>>,
): ReadonlyMap<string, string> => {
const grouped = new Map<string, unknown[]>();
for (const request of inspection.network.requests) {
const identity = networkIdentity(request);
const shapes = sources.get(identity);
const values = grouped.get(identity) ?? [];
const response: Record<string, unknown> = {};
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,
@@ -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);