Files
rea/tests/boundary/browser/cdpJsonShapeComparison.test.ts
T
morluto d1dcebce2c fix(evidence): preserve literal keys and response body ownership
Retain exact empty and computed literal keys in JavaScript observations. Bind CDP response bodies to request occurrences, account for late responses, and validate the target after final progress callbacks.
2026-10-12 16:17:29 +08:00

421 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 : "{}",
},
initiator: { type: "script" },
},
});
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),
},
});
const candidate = events.responseBodyRequests()[0];
expect(candidate?.requestId).toBe("request-1");
expect(candidate).toBeDefined();
events.ingestResponseBody(candidate!, {
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: [],
});
};