Files
rea/tests/boundary/browser/cdpJsonShapeComparison.test.ts
morluto db4e360846 fix(browser)!: preserve WebSocket connections and comparable payload coverage
Retain source URLs, empty connections, and ordered frame streams. Compare same-URL streams without relying on transient request IDs, and compare selected shapes and byte counts only through common known coverage. Malformed binary frames retain known metadata with an unknown byte count.

BREAKING CHANGE: inspect_web_page network.websocket_events is replaced by network.websocket_connections, whose records contain request_id, url, and events. Frame payload_bytes is nullable when decoding cannot establish its size. No legacy alias is retained.

Validation: 78 domain/browser/MCP focused tests, check:fast, docs:check, and real Chrome 154 CDP capture passed. Real capture retained six connections and twelve frames with source URLs and selected shapes. Full browser verifier was interrupted during an unrelated slow scenario stage; its assertions were exercised by the bounded capture proof.
2026-10-11 09:43:25 +08:00

418 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { compareWebCaptureEvidence } from "../../../src/application/BrowserObservationService.js";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { CdpCaptureEvents } from "../../../src/browser/CdpCaptureEvents.js";
import { browserCaptureComparisonInputSchema } from "../../../src/domain/browserCaptureComparison.js";
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
import {
webPageInspectionSchema,
type WebPageInspection,
} from "../../../src/domain/browserObservationSchemas.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { webCaptureDiffSchema } from "../../../src/domain/webCaptureDiffSchemas.js";
const ORIGIN = "https://example.test";
const CAPTURE_TIME = "2026-01-01T00:00:00.000Z";
type BodySource = "request" | "response" | "base64 response";
const equivalentBodies = [
{
name: "root keys",
before: '{"é":true,"e\u0301":null}',
after: '{"e\u0301":null,"é":true}',
},
{
name: "nested null and array values",
before: '{"outer":{"é":null,"e\u0301":[1,null]}}',
after: '{"outer":{"e\u0301":[1,null],"é":null}}',
},
{
name: "object arrays",
before: '[{"é":1,"e\u0301":null},{"é":2,"e\u0301":[]}]',
after: '[{"e\u0301":null,"é":1},{"e\u0301":[],"é":2}]',
},
{
name: "escaped pointer names",
before: '{"é/~":1,"e\u0301/~":true}',
after: '{"e\u0301/~":true,"é/~":1}',
},
];
it("captures all repeated array rows and empty arrays without retaining payload values", async () => {
const count = 6_000;
const body = JSON.stringify(
Array.from({ length: count }, (_, index) => ({
value: index % 2 === 0 ? index : "payload-value-excluded",
nested: index % 2 === 0 ? [] : [true, null],
empty: [],
"*": { "": "payload-value-excluded" },
})),
);
const before = captureBody("base64 response", body);
const shape = before.network.requests[0]?.body_shapes.response;
const element = { kind: "array-element" };
const property = (name: string) => ({ kind: "property", name });
expect(shape).toMatchObject({
root_type: "array",
node_count: count * 7 + 1,
max_depth_observed: 3,
properties: [
{ path: [element], types: ["object"], observations: count },
{
path: [element, property("*")],
types: ["object"],
observations: count,
},
{
path: [element, property("*"), property("")],
types: ["string"],
observations: count,
},
{
path: [element, property("empty")],
types: ["array"],
observations: count,
},
{
path: [element, property("nested")],
types: ["array"],
observations: count,
},
{
path: [element, property("nested"), element],
types: ["boolean", "null"],
observations: count,
},
{
path: [element, property("value")],
types: ["number", "string"],
observations: count,
},
],
});
expect(JSON.stringify(shape)).not.toContain("payload-value-excluded");
const after = captureBody("base64 response", body);
expect(
(await compareCaptures(before, after)).dimensions.network,
).toMatchObject({
status: "unknown",
total_changes: 0,
});
});
describe.each<BodySource>(["request", "response", "base64 response"])(
"CDP %s JSON shape comparison",
(source) => {
it.each(equivalentBodies)(
"does not report a network modification for reordered $name",
async ({ before: beforeBody, after: afterBody }) => {
const [before, after] = comparableCaptures(
source,
beforeBody,
afterBody,
);
const result = await compareCaptures(before, after);
expect(result.dimensions.network).toEqual({
status: "unknown",
total_changes: 0,
changes: [],
reason: expect.any(String),
});
expect(result.overall_status).toBe("unknown");
expect(before.network).toEqual(after.network);
},
);
it.each([
{ name: "scalar array types", before: "[123]", after: '["a"]' },
{ name: "mixed array types", before: '[1,"a"]', after: "[1,true]" },
{ name: "nested arrays", before: "[[1],null]", after: '[["a"],null]' },
{
name: "literal star properties",
before: '{"items":[{"*":1},[true]]}',
after: '{"items":[{"*":true},[1]]}',
},
])("compares $name without retaining values", async ({ before, after }) => {
const length = Math.max(
Buffer.byteLength(before),
Buffer.byteLength(after),
);
const captures = comparableCaptures(
source,
before + " ".repeat(length - Buffer.byteLength(before)),
after + " ".repeat(length - Buffer.byteLength(after)),
);
const result = await compareCaptures(...captures);
expect(result.dimensions.network).toMatchObject({
status: "changed",
total_changes: 1,
});
});
it.each([
{ name: "a type change", after: '{"é":null,"e\u0301":null}' },
{ name: "a removed key", after: '{"e\u0301":null}' },
{
name: "swapped key/type associations",
after: '{"é":null,"e\u0301":true}',
},
])("still reports $name", async ({ after: changedBody }) => {
const beforeBody = '{"é":true,"e\u0301":null}';
// JSON whitespace equalizes wire length even for the removed-key control.
const afterBody =
changedBody +
" ".repeat(
Buffer.byteLength(beforeBody) - Buffer.byteLength(changedBody),
);
const [before, after] = comparableCaptures(source, beforeBody, afterBody);
const result = await compareCaptures(before, after);
expect(result.overall_status).toBe("changed");
expect(result.dimensions.network).toEqual({
status: "changed",
total_changes: 1,
changes: [
{ identity: expect.stringMatching(/^net_/u), change: "modified" },
],
reason: null,
});
});
},
);
describe("selected JSON body-shape coverage", () => {
it.each(["unavailable", "partial", "truncated"] as const)(
"keeps %s coverage unknown without reporting an availability change",
async (coverage) => {
const before = captureBody("request", "[123]");
const after = structuredClone(before);
for (const capture of [before, after])
capture.completeness.attach_limited_sections = [];
const request = after.network.requests[0];
if (request === undefined) throw new Error("Expected request");
if (coverage === "truncated")
after.completeness.truncated_sections.push("json_body_shapes");
else {
request.body_shapes.status = coverage;
request.body_shapes.response = null;
if (coverage === "unavailable") request.body_shapes.request = null;
after.completeness.unavailable_sections.push("json_body_shapes");
}
expect(
(await compareCaptures(before, after)).dimensions.network,
).toMatchObject({ status: "unknown", total_changes: 0 });
expect(
(await compareCaptures(after, after)).dimensions.network.status,
).toBe("unknown");
request.status = 503;
expect(
(await compareCaptures(before, after)).dimensions.network.status,
).toBe("changed");
},
);
it("ignores shapes when either capture did not request them", async () => {
const before = captureBody("request", "[123]");
const after = structuredClone(before);
for (const capture of [before, after])
capture.completeness.attach_limited_sections = [];
after.completeness.excluded.push({
section: "json_body_shapes",
reason: "not_approved",
count: null,
});
after.completeness.policy_filtered_sections.push("json_body_shapes");
for (const request of after.network.requests)
request.body_shapes = {
status: "not_approved",
request: null,
response: null,
};
expect(
(await compareCaptures(before, after)).dimensions.network,
).toMatchObject({ status: "unchanged", total_changes: 0 });
expect(
(await compareCaptures(after, after)).dimensions.network.status,
).toBe("unchanged");
});
});
const comparableCaptures = (
source: BodySource,
beforeBody: string,
afterBody: string,
): readonly [WebPageInspection, WebPageInspection] => {
expect(Buffer.byteLength(beforeBody)).toBe(Buffer.byteLength(afterBody));
const before = captureBody(source, beforeBody);
const after = captureBody(source, afterBody);
const withoutShapes = (capture: WebPageInspection) => ({
...capture,
network: {
...capture.network,
requests: capture.network.requests.map(
({ body_shapes: _shapes, ...request }) => request,
),
},
});
// Prove no status, length, metadata, or other observed field confounds the diff.
expect(withoutShapes(before)).toEqual(withoutShapes(after));
for (const capture of [before, after]) {
expect(capture.network.requests).toHaveLength(1);
expect(capture.network.prior_activity_available).toBe(false);
expect(capture.completeness.attach_limited_sections).toContain(
"network_requests",
);
const request = capture.network.requests[0];
expect(request).not.toHaveProperty("postData");
expect(request).not.toHaveProperty("body");
expect(request).not.toHaveProperty("body_hash");
expect(request?.body_shapes.status).toBe("included");
expect(
request?.body_shapes[source === "request" ? "request" : "response"],
).not.toBeNull();
}
return [before, after];
};
const compareCaptures = async (
before: WebPageInspection,
after: WebPageInspection,
) => {
const result = await compareWebCaptureEvidence(
new CdpBrowserProvider(),
browserCaptureComparisonInputSchema.parse({
before: { inspection: before },
after: { inspection: after },
}),
);
if (!result.ok) throw result.error;
const evidence = parseEvidence(JSON.parse(JSON.stringify(result.value)));
expect(evidence.operation).toBe("compare_web_captures");
return webCaptureDiffSchema.parse(evidence.normalized_result);
};
const captureBody = (source: BodySource, body: string): WebPageInspection => {
const events = new CdpCaptureEvents(
inspectWebPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
allowed_origins: [ORIGIN],
target_id: "page-1",
observation_ms: 0,
include_json_body_shapes: true,
}),
new Set([ORIGIN]),
);
events.beginAuthorizedFrame("main");
events.ingest({
method: "Network.requestWillBeSent",
params: {
requestId: "request-1",
type: "Fetch",
request: {
url: `${ORIGIN}/api`,
method: "POST",
headers: { "Content-Type": "application/json" },
postData: source === "request" ? body : "{}",
},
},
});
const responseBody = source === "request" ? "{}" : body;
events.ingest({
method: "Network.responseReceived",
params: {
requestId: "request-1",
response: {
url: `${ORIGIN}/api`,
status: 200,
mimeType: "application/json",
headers: {},
},
},
});
events.ingest({
method: "Network.loadingFinished",
params: {
requestId: "request-1",
encodedDataLength: Buffer.byteLength(responseBody),
},
});
expect(events.responseBodyRequestIds()).toEqual(["request-1"]);
events.ingestResponseBody("request-1", {
body:
source === "base64 response"
? Buffer.from(responseBody).toString("base64")
: responseBody,
base64Encoded: source === "base64 response",
});
// Match CdpPageCapture's normalized projection, retaining actual event records
// and attach-limited coverage; unrelated page sections are empty fixtures.
return webPageInspectionSchema.parse({
browser: {
product: "fixture",
protocol_version: "1.3",
revision: "fixture",
user_agent: "fixture",
js_version: "fixture",
},
target: {
target_id: "page-1",
type: "page",
title: "fixture",
url: ORIGIN,
origin: ORIGIN,
attached: true,
},
capture_window: {
started_at: CAPTURE_TIME,
ended_at: CAPTURE_TIME,
observation_ms: 0,
},
completeness: events.completeness.snapshot(),
frames: [],
dom: { total_nodes: 0, nodes: [] },
accessibility: {
total_nodes: 0,
text_capture: {
status: "not_approved",
retained_bytes: 0,
excluded_fields: 0,
},
nodes: [],
},
scripts: { total: 0, items: [] },
resources: [],
network: {
requests: [...events.network.values()],
websocket_connections: [...events.webSocketConnectionsById.values()],
coverage_started_at: CAPTURE_TIME,
prior_activity_available: false,
},
console: {
events: events.console,
coverage_started_at: CAPTURE_TIME,
prior_activity_available: false,
},
workers: [],
metadata: {
responses: events.responseMetadata,
dom_urls: [],
agent_hints: events.agentHints,
excluded_dom_urls: 0,
headers_allowlisted: true,
},
storage: {
origin: ORIGIN,
usage_bytes: null,
quota_bytes: null,
local_storage_keys: [],
session_storage_keys: [],
indexed_db_names: [],
cache_names: [],
values_redacted: true,
},
limitations: [],
});
};