Files
rea/tests/boundary/browser/cdpResourceCompleteness.test.ts
morluto 27311f88d4 fix(browser): preserve capture uncertainty and frame ancestry
Parse required resource rows and retain valid siblings. Missing collections and malformed child subtrees keep unknown omission counts. Compare complete frame forests without capture-local IDs, and never infer aggregate DOM/metadata changes from omissions.

Co-authored-by: ShuaixinHuang <1836656514@qq.com>
2026-10-14 15:20:08 +08:00

323 lines
11 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
import type { WebPageInspection } from "../../../src/domain/browserObservationSchemas.js";
import { compareWebCaptures } from "../../../src/domain/webCaptureDiff.js";
import { compareWebCapturesInputSchema } from "../../../src/domain/webCaptureDiffSchemas.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
import { runCliJson } from "../../fixtures/cliJsonProcess.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { trackBrowser } from "./cdpBrowserProvider.support.js";
type ResourceMode =
| "valid"
| "missing"
| "null"
| "wrong-type"
| "invalid-entry"
| "child-missing"
| "child-invalid-entry"
| "child-wrong-type"
| "empty";
const startBrowser = async () => {
const state = {
mode: "valid" as ResourceMode,
peerFields: undefined as Record<string, unknown> | undefined,
};
const browser = await startFakeCdpBrowser({
commandResult(command, origin) {
const malformedChildren =
state.mode === "child-invalid-entry" ||
state.mode === "child-wrong-type";
if (command.method === "Page.getFrameTree" && malformedChildren)
return {
frameTree: {
frame: { id: "frame-main", url: origin + "/app" },
childFrames:
state.mode === "child-wrong-type"
? {}
: [
{
frame: {
id: "child",
parentId: "frame-main",
url: origin + "/child",
},
},
null,
],
},
};
if (command.method !== "Page.getResourceTree") return undefined;
const resource = (name: string) => ({
url: origin + "/" + name,
type: "Script",
mimeType: "text/javascript",
contentSize: 128,
...(name === "peer.js" ? state.peerFields : undefined),
});
let resources: unknown = [resource("keep.js"), resource("peer.js")];
if (state.mode === "missing") resources = undefined;
if (state.mode === "null") resources = null;
if (state.mode === "wrong-type") resources = {};
if (state.mode === "invalid-entry")
resources = [resource("keep.js"), null];
if (state.mode === "empty") resources = [];
const malformedChildFrames =
state.mode === "child-wrong-type"
? {}
: state.mode === "child-invalid-entry"
? [
{
frame: {
id: "child",
parentId: "frame-main",
url: origin + "/child",
},
resources: [resource("child.js")],
},
null,
]
: undefined;
return {
frameTree: {
frame: { id: "frame-main", url: origin + "/app" },
resources,
...(malformedChildFrames === undefined
? {}
: { childFrames: malformedChildFrames }),
...(state.mode === "child-missing"
? {
childFrames: [
{
frame: {
id: "child",
parentId: "frame-main",
url: origin + "/child",
},
},
],
}
: {}),
},
};
},
});
trackBrowser(browser);
const inspect = async () => {
const result = await new CdpBrowserProvider().inspectPage(
inspectWebPageInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
}),
);
if (!result.ok) throw result.error;
return result.value;
};
return { state, inspect, origin: browser.allowedOrigin };
};
const compareResources = (
before: WebPageInspection,
after: WebPageInspection,
) =>
compareWebCaptures(
compareWebCapturesInputSchema.parse({
before: { inspection: before },
after: { inspection: after },
}),
).dimensions.resources;
describe("resource inventory omissions through CDP", () => {
it.each([
{ fields: { type: undefined }, retained: false },
{ fields: { type: null }, retained: false },
{ fields: { type: 17 }, retained: false },
{ fields: { type: "" }, retained: false },
{ fields: { mimeType: undefined }, retained: false },
{ fields: { mimeType: null }, retained: false },
{ fields: { mimeType: 17 }, retained: false },
{ fields: { contentSize: -1 }, retained: true },
{ fields: { contentSize: null }, retained: true },
{ fields: { contentSize: "128" }, retained: true },
])(
"keeps malformed resource fields uncertain: $fields",
async ({ fields, retained }) => {
const { state, inspect, origin } = await startBrowser();
const before = await inspect();
state.peerFields = fields;
const after = await inspect();
for (const [left, right] of [
[before, after],
[after, before],
[after, after],
] as const)
expect(compareResources(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
expect(after.completeness.excluded).toContainEqual({
section: "resources",
reason: "invalid_protocol_value",
count: 1,
});
expect(after.resources.map(({ url }) => url).sort()).toEqual(
retained
? [origin + "/keep.js", origin + "/peer.js"]
: [origin + "/keep.js"],
);
if (retained)
expect(
after.resources.find(({ url }) => url.endsWith("/peer.js")),
).toMatchObject({
type: "Script",
mime_type: "text/javascript",
content_size: null,
});
},
);
it.each([
{
fields: { type: "Other", mimeType: "", contentSize: undefined },
size: null,
},
{ fields: { contentSize: 0 }, size: 0 },
{ fields: { contentSize: 1.5 }, size: 1.5 },
])(
"accepts valid optional and empty fields: $fields",
async ({ fields, size }) => {
const { state, inspect } = await startBrowser();
state.peerFields = fields;
const capture = await inspect();
expect(capture.resources).toHaveLength(2);
expect(
capture.resources.find(({ url }) => url.endsWith("/peer.js"))
?.content_size,
).toBe(size);
expect(capture.completeness.unavailable_sections).not.toContain(
"resources",
);
expect(compareResources(capture, capture).status).toBe("unchanged");
},
);
it.each([
"missing",
"null",
"wrong-type",
"invalid-entry",
"child-missing",
"child-invalid-entry",
"child-wrong-type",
] as const)(
"does not claim a resource removal or equality for %s",
async (mode) => {
const { state, inspect, origin } = await startBrowser();
const before = await inspect();
expect(before.resources).toHaveLength(2);
expect(before.completeness.unavailable_sections).not.toContain(
"resources",
);
state.mode = mode;
const after = await inspect();
for (const [left, right] of [
[before, after],
[after, before],
[after, after],
] as const)
expect(compareResources(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
expect(after.completeness.unavailable_sections).toContain("resources");
expect(after.completeness.excluded).toContainEqual({
section: "resources",
reason: "invalid_protocol_value",
count: mode === "invalid-entry" ? 1 : null,
});
if (mode === "child-invalid-entry" || mode === "child-wrong-type") {
expect(after.completeness.excluded).toContainEqual({
section: "frames",
reason: "invalid_protocol_value",
count: null,
});
expect(after.frames.map(({ frame_id }) => frame_id)).toContain(
"frame-main",
);
if (mode === "child-invalid-entry")
expect(after.frames.map(({ frame_id }) => frame_id)).toContain(
"child",
);
}
const expected =
mode === "invalid-entry"
? [origin + "/keep.js"]
: mode === "child-invalid-entry"
? [origin + "/child.js", origin + "/keep.js", origin + "/peer.js"]
: mode === "child-missing"
? [origin + "/keep.js", origin + "/peer.js"]
: mode === "child-wrong-type"
? [origin + "/keep.js", origin + "/peer.js"]
: [];
expect(after.resources.map(({ url }) => url).sort()).toEqual(expected);
},
);
it("distinguishes a complete empty inventory from an unavailable collection", async () => {
const { state, inspect } = await startBrowser();
const before = await inspect();
state.mode = "empty";
const after = await inspect();
expect(after.resources).toEqual([]);
expect(after.completeness.unavailable_sections).not.toContain("resources");
expect(compareResources(before, after)).toMatchObject({
status: "changed",
total_changes: 2,
changes: [{ change: "removed" }, { change: "removed" }],
});
expect(compareResources(after, after).status).toBe("unchanged");
});
});
describe("resource uncertainty in public CLI Evidence", () => {
it("preserves partial membership while complete inventories still prove removal", async () => {
const { state, inspect } = await startBrowser();
const root = await createTestTempDirectory("rea-resource-comparison-");
const before = join(root, "before.json");
const after = join(root, "after.json");
await writeFile(before, JSON.stringify({ inspection: await inspect() }));
for (const { mode, fields } of [
{ mode: "invalid-entry", fields: undefined },
{ mode: "valid", fields: { type: undefined } },
{ mode: "valid", fields: { contentSize: -1 } },
{ mode: "empty", fields: undefined },
] as const) {
state.mode = mode;
state.peerFields = fields;
await writeFile(after, JSON.stringify({ inspection: await inspect() }));
expect(
await runCliJson(["compare-web-captures", before, after, "--json"]),
).toMatchObject({
operation: "compare_web_captures",
normalized_result: {
dimensions: {
resources: {
status: mode === "empty" ? "changed" : "unknown",
total_changes: mode === "empty" ? 2 : 0,
},
},
},
});
}
});
});