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>
267 lines
9.1 KiB
TypeScript
267 lines
9.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
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 { trackBrowser } from "./cdpBrowserProvider.support.js";
|
|
import { runCliJson } from "../../fixtures/cliJsonProcess.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
type SnapshotMode =
|
|
| "valid"
|
|
| "omitted-node"
|
|
| "changed-node"
|
|
| "missing-nodes"
|
|
| "null-nodes"
|
|
| "missing-documents"
|
|
| "invalid-documents"
|
|
| "empty-documents"
|
|
| "invalid-document"
|
|
| "mixed-documents"
|
|
| "missing-strings";
|
|
|
|
const snapshot = (origin: string, mode: SnapshotMode) => {
|
|
const document = {
|
|
documentURL: 0,
|
|
nodes: {
|
|
nodeType: [9, 1],
|
|
nodeName: [1, mode === "changed-node" ? 6 : 3],
|
|
nodeValue: [2, 2],
|
|
parentIndex: [-1, 0],
|
|
attributes: [[], mode === "omitted-node" ? null : [4, 5]],
|
|
},
|
|
};
|
|
let documents: unknown = [document];
|
|
if (mode === "missing-nodes") documents = [{ documentURL: 0 }];
|
|
if (mode === "null-nodes") documents = [{ documentURL: 0, nodes: null }];
|
|
if (mode === "missing-documents") documents = undefined;
|
|
if (mode === "invalid-documents") documents = {};
|
|
if (mode === "empty-documents") documents = [];
|
|
if (mode === "invalid-document") documents = [null];
|
|
if (mode === "mixed-documents") documents = [document, null];
|
|
return {
|
|
strings:
|
|
mode === "missing-strings"
|
|
? undefined
|
|
: [origin + "/app", "#document", "", "A", "href", "/target", "SPAN"],
|
|
documents,
|
|
};
|
|
};
|
|
|
|
const startBrowser = async () => {
|
|
const state = {
|
|
mode: "valid" as SnapshotMode,
|
|
nested: false,
|
|
renamed: false,
|
|
parentOverride: undefined as { value: unknown } | undefined,
|
|
};
|
|
const browser = await startFakeCdpBrowser({
|
|
commandResult(command, origin) {
|
|
if (command.method === "DOMSnapshot.captureSnapshot")
|
|
return snapshot(origin, state.mode);
|
|
if (command.method !== "Page.getFrameTree") return undefined;
|
|
const id = (name: string) => (state.renamed ? "new-" + name : name);
|
|
const child = {
|
|
frame: {
|
|
id: id("b"),
|
|
parentId:
|
|
state.parentOverride === undefined
|
|
? id(state.nested ? "a" : "main")
|
|
: state.parentOverride.value,
|
|
loaderId: "loader-b",
|
|
url: origin + "/b",
|
|
},
|
|
};
|
|
const parent = {
|
|
frame: {
|
|
id: id("a"),
|
|
parentId: id("main"),
|
|
loaderId: "loader-a",
|
|
url: origin + "/a",
|
|
},
|
|
childFrames: state.nested ? [child] : [],
|
|
};
|
|
return {
|
|
frameTree: {
|
|
frame: {
|
|
id: id("main"),
|
|
loaderId: "loader-main",
|
|
url: origin + "/app",
|
|
},
|
|
childFrames: state.nested ? [parent] : [parent, 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 };
|
|
};
|
|
|
|
const compare = (before: WebPageInspection, after: WebPageInspection) =>
|
|
compareWebCaptures(
|
|
compareWebCapturesInputSchema.parse({
|
|
before: { inspection: before },
|
|
after: { inspection: after },
|
|
}),
|
|
);
|
|
|
|
describe("DOM protocol omissions at the production capture boundary", () => {
|
|
it.each([
|
|
"missing-nodes",
|
|
"null-nodes",
|
|
"missing-documents",
|
|
"invalid-documents",
|
|
"empty-documents",
|
|
"invalid-document",
|
|
"mixed-documents",
|
|
"missing-strings",
|
|
] as const)("retains unknown coverage for %s", async (mode) => {
|
|
const { state, inspect } = await startBrowser();
|
|
state.mode = mode;
|
|
const capture = await inspect();
|
|
for (const section of ["dom", "metadata"])
|
|
expect(capture.completeness.unavailable_sections).toContain(section);
|
|
if (mode === "mixed-documents") {
|
|
expect(capture.dom.nodes.map(({ node_name }) => node_name)).toEqual([
|
|
"#document",
|
|
"A",
|
|
]);
|
|
expect(capture.metadata.dom_urls).toHaveLength(1);
|
|
}
|
|
const comparison = compare(capture, capture);
|
|
expect(comparison.dimensions.dom_structure.status).toBe("unknown");
|
|
expect(comparison.dimensions.metadata.status).toBe("unknown");
|
|
});
|
|
|
|
it("does not turn a rejected node and its URL into a page change", async () => {
|
|
const { state, inspect } = await startBrowser();
|
|
const valid = await inspect();
|
|
state.mode = "omitted-node";
|
|
const partial = await inspect();
|
|
expect(partial.dom.total_nodes).toBe(2);
|
|
expect(partial.dom.nodes).toHaveLength(1);
|
|
for (const [before, after] of [
|
|
[valid, partial],
|
|
[partial, valid],
|
|
]) {
|
|
if (before === undefined || after === undefined)
|
|
throw new Error("Missing pair");
|
|
const comparison = compare(before, after);
|
|
expect(comparison.dimensions.dom_structure.status).toBe("unknown");
|
|
expect(comparison.dimensions.metadata.status).toBe("unknown");
|
|
expect(comparison.overall_status).toBe("unknown");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("complete frame and DOM comparison", () => {
|
|
it.each([undefined, null, 17, ""])(
|
|
"keeps missing or invalid child parent evidence unknown (%#)",
|
|
async (parent) => {
|
|
const { state, inspect } = await startBrowser();
|
|
const before = await inspect();
|
|
state.parentOverride = { value: parent };
|
|
const after = await inspect();
|
|
expect(after.frames).toHaveLength(3);
|
|
expect(after.completeness.unavailable_sections).toContain("frames");
|
|
expect(compare(before, after).dimensions.dom_structure.status).toBe(
|
|
"unknown",
|
|
);
|
|
expect(compare(after, after).dimensions.dom_structure.status).toBe(
|
|
"unknown",
|
|
);
|
|
},
|
|
);
|
|
it("retains a complete observed node change", async () => {
|
|
const { state, inspect } = await startBrowser();
|
|
const before = await inspect();
|
|
expect(compare(before, before).dimensions.dom_structure.status).toBe(
|
|
"unchanged",
|
|
);
|
|
state.mode = "changed-node";
|
|
expect(
|
|
compare(before, await inspect()).dimensions.dom_structure.status,
|
|
).toBe("changed");
|
|
});
|
|
|
|
it("compares frame ancestry independently of volatile IDs", async () => {
|
|
const { state, inspect } = await startBrowser();
|
|
const before = await inspect();
|
|
state.renamed = true;
|
|
expect(
|
|
compare(before, await inspect()).dimensions.dom_structure.status,
|
|
).toBe("unchanged");
|
|
state.nested = true;
|
|
const nested = await inspect();
|
|
expect(nested.frames[2]?.parent_frame_id).toBe("new-a");
|
|
expect(compare(before, nested).dimensions.dom_structure.status).toBe(
|
|
"changed",
|
|
);
|
|
});
|
|
|
|
it("retains proven frame changes when DOM node evidence is incomplete", async () => {
|
|
const { state, inspect } = await startBrowser();
|
|
const before = await inspect();
|
|
state.mode = "omitted-node";
|
|
state.nested = true;
|
|
const comparison = compare(before, await inspect());
|
|
expect(comparison.dimensions.dom_structure.status).toBe("changed");
|
|
expect(comparison.dimensions.metadata.status).toBe("unknown");
|
|
});
|
|
});
|
|
|
|
describe("DOM evidence through the public CLI", () => {
|
|
it("preserves omissions and independently proven frame changes", async () => {
|
|
const { state, inspect } = await startBrowser();
|
|
const root = await createTestTempDirectory("rea-dom-comparison-");
|
|
const before = join(root, "before.json");
|
|
const after = join(root, "after.json");
|
|
await writeFile(before, JSON.stringify({ inspection: await inspect() }));
|
|
state.mode = "omitted-node";
|
|
for (const nested of [false, true]) {
|
|
state.nested = nested;
|
|
await writeFile(after, JSON.stringify({ inspection: await inspect() }));
|
|
expect(
|
|
await runCliJson(["compare-web-captures", before, after, "--json"]),
|
|
).toMatchObject({
|
|
operation: "compare_web_captures",
|
|
normalized_result: {
|
|
overall_status: nested ? "changed" : "unknown",
|
|
dimensions: {
|
|
dom_structure: { status: nested ? "changed" : "unknown" },
|
|
metadata: { status: "unknown" },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
state.mode = "missing-nodes";
|
|
await writeFile(after, JSON.stringify({ inspection: await inspect() }));
|
|
expect(
|
|
await runCliJson(["compare-web-captures", after, after, "--json"]),
|
|
).toMatchObject({
|
|
operation: "compare_web_captures",
|
|
normalized_result: {
|
|
overall_status: "unknown",
|
|
dimensions: { dom_structure: { status: "unknown" } },
|
|
},
|
|
});
|
|
});
|
|
});
|