Merge pull request #700 from kaoru0822-kitauji/fix/json-shape-collation

fix(comparison): break JSON shape collation ties
This commit is contained in:
Kaoru0822 2026-10-06 01:10:26 -07:00 committed by GitHub
commit 217e8cf59f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 358 additions and 1 deletions

View File

@ -39,6 +39,85 @@ describe("inferJsonShape", () => {
expect(inferJsonShape("not-json")).toBeNull();
});
it.each([
["é", "e\u0301", "e\u0341"],
["é", "e\u0341", "e\u0301"],
["e\u0301", "é", "e\u0341"],
["e\u0301", "e\u0341", "é"],
["e\u0341", "é", "e\u0301"],
["e\u0341", "e\u0301", "é"],
])("orders distinct collation-equal paths from %j, %j, %j", (...names) => {
for (const name of names) expect(name.localeCompare("é")).toBe(0);
const values: Readonly<Record<string, unknown>> = {
é: 1,
"e\u0301": "value",
"e\u0341": null,
};
const shape = inferJsonShape(
JSON.stringify(
Object.fromEntries(names.map((name) => [name, values[name]])),
),
);
expect(shape).toEqual({
root_type: "object",
node_count: 4,
max_depth_observed: 1,
properties: [
{ path: "/e\u0301", types: ["string"], observations: 1 },
{ path: "/e\u0341", types: ["null"], observations: 1 },
{ path: "/é", types: ["number"], observations: 1 },
],
});
});
it.each([false, true])(
"preserves nested types, counts, depth and pointer names with reversed keys: %s",
(reverse) => {
const object = (number: number, value: unknown) => {
const entries = [
["é/~", number],
["e\u0301/~", value],
];
return Object.fromEntries(reverse ? entries.reverse() : entries);
};
const shape = inferJsonShape(
JSON.stringify({ nested: [object(1, null), object(2, [null])] }),
);
expect(shape).toEqual({
root_type: "object",
node_count: 9,
max_depth_observed: 4,
properties: [
{ path: "/nested", types: ["array"], observations: 1 },
{
path: "/nested/*/e\u0301~1~0",
types: ["array", "null"],
observations: 2,
},
{ path: "/nested/*/é~1~0", types: ["number"], observations: 2 },
],
});
},
);
it("preserves the existing locale order for paths that do not tie", () => {
const names = ["z", "é", "A", "_", "a"];
const expected = names
.map((name) => `/${name}`)
.sort((left, right) => left.localeCompare(right));
expect(new Set(expected).size).toBe(names.length);
for (const [index, left] of expected.entries())
for (const right of expected.slice(index + 1))
expect(left.localeCompare(right)).not.toBe(0);
const shape = inferJsonShape(
JSON.stringify(Object.fromEntries(names.map((name) => [name, true]))),
);
expect(shape?.properties.map(({ path }) => path)).toEqual(expected);
});
it("retains every parsed property beyond the former shape-node limit", () => {
const content = Object.fromEntries(
Array.from({ length: 5_001 }, (_, index) => [`field_${index}`, index]),

View File

@ -1,5 +1,7 @@
import { z } from "zod";
import { compareCodePoints } from "./canonicalOrdering.js";
const jsonValueTypeSchema = z.enum([
"object",
"array",
@ -88,7 +90,11 @@ export const inferJsonShape = (text: string): JsonShape | null => {
types: [...value.types].sort(),
observations: value.observations,
}))
.sort((left, right) => left.path.localeCompare(right.path)),
.sort(
(left, right) =>
left.path.localeCompare(right.path) ||
compareCodePoints(left.path, right.path),
),
});
};

View File

@ -0,0 +1,272 @@
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,
webPageInspectionSchema,
type WebPageInspection,
} from "../../../src/domain/browserObservation.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { webCaptureDiffSchema } from "../../../src/domain/webCaptureDiff.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}',
},
];
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: "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,
});
});
},
);
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_events: events.websockets,
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: [],
});
};