Files
rea/src/domain/webCaptureDiff.network.test.ts
T

757 lines
24 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
passiveNetworkCapture,
withPassiveDroppedEvents,
} from "../../tests/fixtures/passiveNetworkCapture.js";
import {
browserCaptureComparisonInputSchema,
compareBrowserCaptures,
} from "./browserCaptureComparison.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import { webCaptureDiffSchema } from "./webCaptureDiffSchemas.js";
const compare = (before: WebPageInspection, after: WebPageInspection) =>
webCaptureDiffSchema.parse(
compareBrowserCaptures(
browserCaptureComparisonInputSchema.parse({
before: { inspection: before },
after: { inspection: after },
}),
),
).dimensions.network;
const unselectedShapes = (capture: WebPageInspection): WebPageInspection => {
capture.completeness.excluded.push({
section: "json_body_shapes",
reason: "not_approved",
count: null,
});
for (const request of capture.network.requests)
request.body_shapes.status = "not_approved";
return capture;
};
const redirectCapture = (resourceType: string | null): WebPageInspection => {
const capture = unselectedShapes(passiveNetworkCapture());
capture.network.requests[0]!.redirects = [
{
url: "https://app.example.test/start",
response_url: "https://app.example.test/start",
method: "GET",
resource_type: resourceType,
status: 302,
mime_type: "text/html",
encoded_data_length: 20,
request_timestamp: 1,
redirect_event_timestamp: 2,
},
];
return capture;
};
describe("nullable redirect resource types", () => {
it.each([true, false])(
"retains uncertainty when a redirect type is omitted (reversed=%s)",
(reversed) => {
const before = redirectCapture("Document");
const after = structuredClone(before);
after.network.requests[0]!.redirects![0]!.resource_type = null;
const [left, right] = reversed
? ([after, before] as const)
: ([before, after] as const);
expect(compare(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
},
);
it.each(["Document", "Fetch"])(
"keeps a known redirect type %s comparable",
(resourceType) => {
const before = redirectCapture("Document");
const after = structuredClone(before);
after.network.requests[0]!.redirects![0]!.resource_type = resourceType;
expect(compare(before, after)).toMatchObject({
status: resourceType === "Document" ? "unchanged" : "changed",
total_changes: resourceType === "Document" ? 0 : 1,
});
},
);
it("retains proven HTTP membership with incomplete redirect metadata", () => {
const before = unselectedShapes(passiveNetworkCapture([]));
const after = redirectCapture(null);
expect(compare(before, after)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change: "added" }],
});
});
});
describe("passive network inventory changes", () => {
it.each(["websocket_connections", "websocket_frames"] as const)(
"does not infer duplicate WebSocket inventory changes from dropped %s",
(kind) => {
const before = unselectedShapes(passiveNetworkCapture());
before.network.websocket_connections = [
{
request_id: "ws-one",
url: "wss://app.example.test/live",
events: [],
},
];
const after = structuredClone(before);
after.network.websocket_connections.push({
request_id: "ws-two",
url: "wss://app.example.test/live",
events: [],
});
const limited = withPassiveDroppedEvents(before, { [kind]: 1 });
for (const [left, right] of [
[limited, after],
[after, limited],
] as const)
expect(compare(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
expect(compare(before, after)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change: "modified" }],
});
},
);
it.each(["dropped", "truncated", "unavailable"] as const)(
"does not infer repeated-request count changes from %s HTTP inventory",
(limitation) => {
const after = unselectedShapes(passiveNetworkCapture(["/a", "/a"]));
let before = unselectedShapes(passiveNetworkCapture());
if (limitation === "dropped")
before = withPassiveDroppedEvents(before, { network_requests: 1 });
else if (limitation === "truncated")
before.completeness.truncated_sections.push("network_requests");
else before.completeness.unavailable_sections.push("network_requests");
for (const [left, right] of [
[before, after],
[after, before],
] as const)
expect(compare(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
},
);
it.each(["unavailable", "partial", "truncated"] as const)(
"retains complete request additions/removals with %s selected shapes",
(coverage) => {
const before = passiveNetworkCapture(["/a"]);
const after = passiveNetworkCapture(["/a", "/b"]);
for (const capture of [before, after]) {
if (coverage === "truncated")
capture.completeness.truncated_sections.push("json_body_shapes");
else capture.completeness.unavailable_sections.push("json_body_shapes");
for (const request of capture.network.requests) {
const shape = {
root_type: "object" as const,
node_count: 1,
max_depth_observed: 0,
properties: [],
};
if (coverage !== "unavailable") request.body_shapes.request = shape;
if (coverage === "truncated") request.body_shapes.response = shape;
request.body_shapes.status =
coverage === "truncated" ? "included" : coverage;
}
}
for (const [left, right, change] of [
[before, after, "added"],
[after, before, "removed"],
] as const)
expect(compare(left, right)).toEqual({
status: "changed",
total_changes: 1,
changes: [{ identity: expect.stringMatching(/^net_/u), change }],
reason: null,
});
expect(compare(after, after)).toMatchObject({
status: "unknown",
total_changes: 0,
});
},
);
it("keeps request absence proof independent of WebSocket inventory and response metadata", () => {
const before = passiveNetworkCapture([]);
const after = passiveNetworkCapture(["/added"]);
for (const capture of [before, after]) {
capture.completeness.attach_limited_sections.push(
"websocket_connections",
"websocket_frames",
);
for (const request of capture.network.requests) {
request.status = null;
request.mime_type = null;
request.encoded_data_length = null;
}
}
for (const [left, right, change] of [
[before, after, "added"],
[after, before, "removed"],
] as const)
expect(compare(left, right)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change }],
});
expect(compare(after, after)).toMatchObject({
status: "unknown",
total_changes: 0,
});
});
});
describe("unknown WebSocket opcode evidence", () => {
const captureWithWebSocketFrames = (
events: WebPageInspection["network"]["websocket_connections"][number]["events"],
): WebPageInspection => {
const capture = unselectedShapes(passiveNetworkCapture([]));
capture.network.websocket_connections.push({
request_id: "capture-local-socket-id",
url: "wss://app.example.test/live",
events: structuredClone(events),
});
return capture;
};
it("does not compare an unknown opcode as a real continuation frame", () => {
const malformed = captureWithWebSocketFrames([
{
direction: "received",
opcode: null,
payload_bytes: null,
payload_shape: null,
},
]);
const observedZero = captureWithWebSocketFrames([
{
direction: "received",
opcode: 0,
payload_bytes: 0,
payload_shape: { format: "binary", json_shape: null },
},
]);
for (const [before, after] of [
[malformed, observedZero],
[observedZero, malformed],
] as const)
expect(compare(before, after)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
});
it("still compares known payload facts in a stream with another unknown frame", () => {
const before = captureWithWebSocketFrames([
{
direction: "received",
opcode: null,
payload_bytes: null,
payload_shape: null,
},
{
direction: "sent",
opcode: 1,
payload_bytes: 3,
payload_shape: { format: "text", json_shape: null },
},
]);
const after = structuredClone(before);
after.network.websocket_connections[0]!.events[1]!.payload_bytes = 4;
expect(compare(before, after)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change: "modified" }],
});
});
});
describe("passive network coverage uncertainty", () => {
it.each([
"attach_limited_sections",
"unavailable_sections",
"truncated_sections",
"policy_filtered_sections",
] as const)(
"does not infer request absence when its inventory is %s",
(section) => {
const before = passiveNetworkCapture();
const after = passiveNetworkCapture(["/a", "/b"]);
before.completeness[section].push("network_requests");
for (const [left, right] of [
[before, after],
[after, before],
])
expect(compare(left!, right!)).toMatchObject({
status: "unknown",
total_changes: 0,
});
after.network.requests[0]!.status = 503;
expect(compare(before, after)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change: "modified" }],
});
},
);
it("does not promote unavailable shapes to a modification and preserves unselected-shape behavior", () => {
const before = passiveNetworkCapture();
const after = structuredClone(before);
after.network.requests[0]!.body_shapes = {
status: "included",
request: {
root_type: "object",
node_count: 1,
max_depth_observed: 0,
properties: [],
},
response: {
root_type: "string",
node_count: 1,
max_depth_observed: 0,
properties: [],
},
};
expect(compare(before, after)).toMatchObject({
status: "unknown",
total_changes: 0,
});
before.completeness.excluded.push({
section: "json_body_shapes",
reason: "not_approved",
count: null,
});
expect(compare(before, after)).toMatchObject({
status: "unchanged",
total_changes: 0,
});
});
});
describe("independent WebSocket inventory evidence", () => {
it("does not use complete HTTP inventory to infer an unobserved WebSocket connection", () => {
const before = passiveNetworkCapture();
const after = structuredClone(before);
after.network.websocket_connections.push({
request_id: "ws",
url: "wss://app.example.test/live",
events: [],
});
before.completeness.attach_limited_sections.push("websocket_connections");
expect(compare(before, after)).toMatchObject({
status: "unknown",
total_changes: 0,
});
expect(compare(after, before)).toMatchObject({
status: "unknown",
total_changes: 0,
});
after.network.requests.push({
...passiveNetworkCapture(["/b"]).network.requests[0]!,
request_id: "request-b",
});
expect(compare(before, after)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ identity: expect.stringMatching(/^net_/u), change: "added" }],
});
});
it("retains complete WebSocket membership changes when HTTP inventory is limited", () => {
const before = passiveNetworkCapture();
before.completeness.attach_limited_sections.push("network_requests");
const after = structuredClone(before);
after.network.websocket_connections.push({
request_id: "ws",
url: "wss://app.example.test/live",
events: [],
});
for (const [left, right, change] of [
[before, after, "added"],
[after, before, "removed"],
] as const)
expect(compare(left, right)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ identity: expect.stringMatching(/^ws_/u), change }],
});
});
it("does not turn missing WebSocket payload facts into membership changes", () => {
const before = passiveNetworkCapture();
before.network.websocket_connections.push({
request_id: "ws",
url: "wss://app.example.test/live",
events: [
{
direction: "sent",
opcode: 1,
payload_bytes: 4,
payload_shape: { format: "text", json_shape: null },
},
],
});
const after = structuredClone(before);
const event = after.network.websocket_connections[0]!.events[0]!;
event.payload_bytes = null;
event.payload_shape = null;
expect(compare(before, after)).toMatchObject({
status: "unknown",
total_changes: 0,
});
after.network.requests.push({
...passiveNetworkCapture(["/b"]).network.requests[0]!,
request_id: "request-b",
});
expect(compare(before, after)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ identity: expect.stringMatching(/^net_/u), change: "added" }],
});
});
});
describe("repeated HTTP request identity uncertainty", () => {
it.each([true, false])(
"keeps repeated-request regrouping unknown when one type is missing (shapes selected=%s)",
(selected) => {
const before = passiveNetworkCapture(["/a", "/a"]);
const after = structuredClone(before);
after.network.requests[1]!.resource_type = null;
if (!selected)
for (const capture of [before, after]) unselectedShapes(capture);
for (const [left, right] of [
[before, after],
[after, before],
] as const)
expect(compare(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
},
);
it("retains a known repeated-request count change", () => {
const before = unselectedShapes(passiveNetworkCapture(["/a", "/a"]));
const after = unselectedShapes(passiveNetworkCapture());
for (const [left, right] of [
[before, after],
[after, before],
] as const)
expect(compare(left, right)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change: "modified" }],
});
});
it("does not assign observed content to an ambiguous type bucket", () => {
const before = unselectedShapes(passiveNetworkCapture(["/a", "/a"]));
before.network.requests[1]!.resource_type = null;
before.network.requests[1]!.status = 503;
const after = structuredClone(before);
after.network.requests[0]!.status = 503;
after.network.requests[1]!.status = 200;
for (const [left, right] of [
[before, after],
[after, before],
] as const)
expect(compare(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
});
});
describe("nullable HTTP request identities", () => {
it.each([true, false])(
"keeps known/unknown resource-type matches uncertain (shapes selected=%s)",
(selected) => {
const before = passiveNetworkCapture();
const after = structuredClone(before);
after.network.requests[0]!.resource_type = null;
if (!selected)
for (const capture of [before, after]) unselectedShapes(capture);
for (const [left, right] of [
[before, after],
[after, before],
] as const)
expect(compare(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
reason: expect.stringContaining("request identity"),
});
},
);
it("does not prove equality from two unknown request types", () => {
const capture = unselectedShapes(passiveNetworkCapture());
capture.network.requests[0]!.resource_type = null;
expect(compare(capture, structuredClone(capture))).toMatchObject({
status: "unknown",
total_changes: 0,
reason: expect.stringContaining("request identity"),
});
});
it("does not assign an unknown type to one of multiple same-method/URL candidates", () => {
const before = unselectedShapes(passiveNetworkCapture(["/a", "/a"]));
before.network.requests[1]!.resource_type = "Fetch";
const after = structuredClone(before);
after.network.requests[1]!.resource_type = null;
for (const [left, right] of [
[before, after],
[after, before],
] as const)
expect(compare(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
});
});
it.each([
["/b", "GET"],
["/a", "POST"],
])(
"retains a distinct %s %s request beside ambiguous identities",
(path, method) => {
const before = unselectedShapes(passiveNetworkCapture());
const after = unselectedShapes(passiveNetworkCapture(["/a", path]));
after.network.requests[0]!.resource_type = null;
after.network.requests[1]!.method = method;
for (const [left, right, change] of [
[before, after, "added"],
[after, before, "removed"],
] as const)
expect(compare(left, right)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ identity: expect.stringMatching(/^net_/u), change }],
});
},
);
it("uses a complete empty peer inventory to prove absence despite an unknown type", () => {
const empty = unselectedShapes(passiveNetworkCapture([]));
const observed = unselectedShapes(passiveNetworkCapture());
observed.network.requests[0]!.resource_type = null;
for (const [left, right, change] of [
[empty, observed, "added"],
[observed, empty, "removed"],
] as const)
expect(compare(left, right)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change }],
});
expect(compare(empty, empty)).toMatchObject({
status: "unchanged",
total_changes: 0,
});
});
it("retains definite type changes and independently observed metadata changes", () => {
const before = unselectedShapes(passiveNetworkCapture());
const changedType = structuredClone(before);
changedType.network.requests[0]!.resource_type = "Fetch";
expect(compare(before, changedType)).toMatchObject({
status: "changed",
total_changes: 2,
changes: expect.arrayContaining([
expect.objectContaining({ change: "added" }),
expect.objectContaining({ change: "removed" }),
]),
});
before.network.requests.push({
...passiveNetworkCapture(["/b"]).network.requests[0]!,
resource_type: null,
});
const changedStatus = structuredClone(before);
changedStatus.network.requests[0]!.status = 503;
expect(compare(before, changedStatus)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change: "modified" }],
});
});
});
describe("passive dropped-event inventory coverage", () => {
it("does not infer request absence from a capture with independently counted drops", () => {
const before = withPassiveDroppedEvents(passiveNetworkCapture(), {
network_requests: 1,
});
const after = passiveNetworkCapture(["/a", "/b"]);
expect(before.completeness).toMatchObject({
status: "truncated",
truncated_sections: [],
unavailable_sections: [],
attach_limited_sections: [],
policy_filtered_sections: [],
dropped_events: { network_requests: 1, total: 1 },
});
for (const [left, right] of [
[before, after],
[after, before],
] as const)
expect(compare(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
});
const changed = structuredClone(before);
changed.network.requests[0]!.status = 503;
expect(compare(before, changed)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
// Once one complete inventory excludes the peer's observed response,
// omissions cannot explain that response difference.
changed.completeness.dropped_events.network_requests = 0;
changed.completeness.dropped_events.total = 0;
expect(compare(before, changed)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change: "modified" }],
});
});
it.each([
"network_requests",
"websocket_connections",
"websocket_frames",
] as const)(
"cannot prove network equality after dropped %s even when shapes are unselected",
(kind) => {
const before = withPassiveDroppedEvents(
unselectedShapes(passiveNetworkCapture()),
{ [kind]: 1 },
);
expect(before.completeness.truncated_sections).toEqual([]);
expect(compare(before, structuredClone(before))).toMatchObject({
status: "unknown",
total_changes: 0,
});
},
);
it.each(["websocket_connections", "websocket_frames"] as const)(
"keeps %s drops separate from HTTP inventory",
(kind) => {
const before = withPassiveDroppedEvents(passiveNetworkCapture(), {
[kind]: 1,
});
const after = structuredClone(before);
after.network.websocket_connections.push({
request_id: "ws",
url: "wss://app.example.test/live",
events: [],
});
for (const [left, right] of [
[before, after],
[after, before],
] as const)
expect(compare(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
});
after.network.requests.push({
...passiveNetworkCapture(["/b"]).network.requests[0]!,
request_id: "request-b",
});
for (const [left, right, change] of [
[before, after, "added"],
[after, before, "removed"],
] as const)
expect(compare(left, right)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ identity: expect.stringMatching(/^net_/u), change }],
});
},
);
it("retains complete WebSocket membership despite HTTP drops", () => {
const before = withPassiveDroppedEvents(passiveNetworkCapture(), {
network_requests: 1,
});
const after = structuredClone(before);
after.network.websocket_connections.push({
request_id: "ws",
url: "wss://app.example.test/live",
events: [],
});
expect(compare(before, after)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ identity: expect.stringMatching(/^ws_/u), change: "added" }],
});
expect(compare(after, before)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [
{ identity: expect.stringMatching(/^ws_/u), change: "removed" },
],
});
});
it.each([
"console_events",
"scripts",
"timeline_events",
"webmcp_tools",
] as const)(
"does not use the total or unrelated %s drops as HTTP absence limits",
(kind) => {
const before = withPassiveDroppedEvents(
unselectedShapes(passiveNetworkCapture()),
{ [kind]: 2 },
);
expect(before.completeness).toMatchObject({
status: "truncated",
dropped_events: { total: 2 },
});
expect(compare(before, structuredClone(before))).toMatchObject({
status: "unchanged",
total_changes: 0,
});
const after = unselectedShapes(passiveNetworkCapture(["/a", "/b"]));
expect(compare(before, after)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change: "added" }],
});
expect(compare(after, before)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change: "removed" }],
});
},
);
});