perf(observation): validate document identity against snapshot roots

This commit is contained in:
Ljy-0827
2026-09-09 16:09:09 +08:00
committed by drakezhang
parent 2d1e5c6d9d
commit b4006d5b85
6 changed files with 118 additions and 256 deletions
@@ -2,24 +2,6 @@ import { describe, expect, it } from "vitest";
import { buildFrameGraph, type CdpFrameTreeNode, type CdpFrameTreeSource } from "../frame-graph"; import { buildFrameGraph, type CdpFrameTreeNode, type CdpFrameTreeSource } from "../frame-graph";
describe("buildFrameGraph", () => { describe("buildFrameGraph", () => {
it("preserves the owning target's loader identity", () => {
const graph = buildFrameGraph([
{
target: { tabId: 4 },
tree: {
frame: { id: "root", loaderId: "root-loader" },
childFrames: [{ frame: { id: "child", loaderId: "stale-placeholder" } }],
},
},
{
target: { tabId: 4, sessionId: "child-session" },
tree: { frame: { id: "child", loaderId: "child-loader" } },
},
]);
expect(graph?.frames.find((frame) => frame.frameId === "root")?.loaderId).toBe("root-loader");
expect(graph?.frames.find((frame) => frame.frameId === "child")?.loaderId).toBe("child-loader");
});
it("keeps sibling frames distinct and routes nested OOPIFs to their child sessions", () => { it("keeps sibling frames distinct and routes nested OOPIFs to their child sessions", () => {
const graph = buildFrameGraph([ const graph = buildFrameGraph([
{ {
@@ -8,7 +8,6 @@ export interface CdpFrame {
parentFrameId?: string; parentFrameId?: string;
ownerBackendNodeId?: number; ownerBackendNodeId?: number;
url?: string; url?: string;
loaderId?: string;
target: CdpTarget; target: CdpTarget;
} }
@@ -22,7 +21,6 @@ export interface CdpFrameTreeNode {
id: string; id: string;
parentId?: string; parentId?: string;
url?: string; url?: string;
loaderId?: string;
}; };
childFrames?: CdpFrameTreeNode[]; childFrames?: CdpFrameTreeNode[];
} }
@@ -50,7 +48,6 @@ function mergeFrame(
frameId: node.frame.id, frameId: node.frame.id,
...(parentFrameId ? { parentFrameId } : {}), ...(parentFrameId ? { parentFrameId } : {}),
...(url ? { url } : {}), ...(url ? { url } : {}),
...(node.frame.loaderId ? { loaderId: node.frame.loaderId } : {}),
target, target,
}); });
} }
@@ -11,10 +11,7 @@ import { REQUESTED_STYLES, type SnapshotReply } from "../snapshot";
function fixture( function fixture(
options: { options: {
frames?: CdpFrame[]; frames?: CdpFrame[];
after?: Record< after?: Record<string, { element?: number; missing?: boolean; unreadable?: boolean }>;
string,
{ loader?: string; element?: number; missing?: boolean; unreadable?: boolean }
>;
fail?: string; fail?: string;
missingIdentity?: boolean; missingIdentity?: boolean;
omitDocument?: string; omitDocument?: string;
@@ -23,26 +20,23 @@ function fixture(
} = {}, } = {},
) { ) {
const frames = options.frames ?? [ const frames = options.frames ?? [
{ frameId: "main", loaderId: "main-loader", target: { tabId: 4 } }, { frameId: "main", target: { tabId: 4 } },
{ {
frameId: "same", frameId: "same",
parentFrameId: "main", parentFrameId: "main",
ownerBackendNodeId: 3, ownerBackendNodeId: 3,
loaderId: "same-loader",
target: { tabId: 4 }, target: { tabId: 4 },
}, },
{ {
frameId: "nested", frameId: "nested",
parentFrameId: "same", parentFrameId: "same",
ownerBackendNodeId: 13, ownerBackendNodeId: 13,
loaderId: "nested-loader",
target: { tabId: 4 }, target: { tabId: 4 },
}, },
{ {
frameId: "remote", frameId: "remote",
parentFrameId: "main", parentFrameId: "main",
ownerBackendNodeId: 4, ownerBackendNodeId: 4,
loaderId: "remote-loader",
target: { tabId: 4, sessionId: "remote" }, target: { tabId: 4, sessionId: "remote" },
}, },
]; ];
@@ -50,7 +44,6 @@ function fixture(
frames.map((frame, i) => [frame.frameId, frame.target.sessionId ? 1 : i * 10 + 1]), frames.map((frame, i) => [frame.frameId, frame.target.sessionId ? 1 : i * 10 + 1]),
); );
const contexts = new Map(frames.map((frame, i) => [i + 1, frame.frameId])); const contexts = new Map(frames.map((frame, i) => [i + 1, frame.frameId]));
const reads = new Map<string, number>();
const logs: Array<{ target: CdpTarget; method: string; params: Record<string, unknown> }> = []; const logs: Array<{ target: CdpTarget; method: string; params: Record<string, unknown> }> = [];
let snapshots = 0; let snapshots = 0;
const targetCount = new Set(frames.map((frame) => cdpTargetKey(frame.target))).size; const targetCount = new Set(frames.map((frame) => cdpTargetKey(frame.target))).size;
@@ -65,47 +58,23 @@ function fixture(
result = { result = {
executionContextId: frames.findIndex((frame) => frame.frameId === args.frameId) + 1, executionContextId: frames.findIndex((frame) => frame.frameId === args.frameId) + 1,
}; };
if (method === "Runtime.evaluate") if (method === "Runtime.evaluate" && args.contextId) {
result = { result: { objectId: contexts.get(Number(args.contextId)) } }; const id = contexts.get(Number(args.contextId))!;
if (method === "DOM.describeNode") { const change = options.after?.[id];
const id = String(args.objectId);
const count = reads.get(id) ?? 0;
reads.set(id, count + 1);
result = result =
options.missingIdentity || (count > 0 && options.after?.[id]?.unreadable) options.missingIdentity || change?.unreadable
? {} ? {}
: { : {
node: { result: {
backendNodeId: deepSerializedValue: change?.missing
count > 0 ? { type: "null" }
? (options.after?.[id]?.element ?? elements.get(id)) : {
: elements.get(id), type: "node",
value: { backendNodeId: change?.element ?? elements.get(id) },
},
}, },
}; };
} }
if (method === "Page.getFrameTree") {
const own = frames.filter(
(frame) =>
cdpTargetKey(frame.target) === cdpTargetKey(target) &&
!options.after?.[frame.frameId]?.missing,
);
const trees = new Map(
own.map((frame) => [
frame.frameId,
{
frame: {
id: frame.frameId,
loaderId: options.after?.[frame.frameId]?.loader ?? frame.loaderId,
},
childFrames: [] as unknown[],
},
]),
);
for (const frame of own)
if (frame.parentFrameId && trees.has(frame.parentFrameId))
trees.get(frame.parentFrameId)!.childFrames.push(trees.get(frame.frameId));
result = { frameTree: trees.get(own[0]?.frameId) };
}
if (method === "Page.getLayoutMetrics") if (method === "Page.getLayoutMetrics")
result = { result = {
visualViewport: { clientWidth: 1000 }, visualViewport: { clientWidth: 1000 },
@@ -203,7 +172,11 @@ function fixture(
); );
const cdp: CdpRunner = { const cdp: CdpRunner = {
getAttachmentId: () => getAttachmentId: () =>
options.attachmentChanged && snapshots === targetCount ? "new-attachment" : "attachment", options.missingIdentity
? undefined
: options.attachmentChanged && snapshots === targetCount
? "new-attachment"
: "attachment",
getFrameGraph: async () => ({ rootFrameId: frames[0].frameId, frames }), getFrameGraph: async () => ({ rootFrameId: frames[0].frameId, frames }),
send: (tabId, method, params) => send: (tabId, method, params) =>
(send as NonNullable<CdpRunner["sendToTarget"]>)({ tabId }, method, params), (send as NonNullable<CdpRunner["sendToTarget"]>)({ tabId }, method, params),
@@ -249,8 +222,8 @@ describe("captureObservationFacts", () => {
expect(logs.filter((call) => call.method === "Accessibility.enable")).toHaveLength(2); expect(logs.filter((call) => call.method === "Accessibility.enable")).toHaveLength(2);
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(4); expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(4);
expect(facts.documents.every((doc) => doc.identity)).toBe(true); expect(facts.documents.every((doc) => doc.identity)).toBe(true);
expect(logs.filter((call) => call.method === "DOM.describeNode")).toHaveLength(8); expect(logs.filter((call) => call.method === "DOM.describeNode")).toHaveLength(0);
expect(logs.filter((call) => call.method === "Runtime.releaseObject")).toHaveLength(8); expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(4);
const main = facts.documents.find((doc) => doc.frame.frameId === "main")!; const main = facts.documents.find((doc) => doc.frame.frameId === "main")!;
const remote = facts.documents.find((doc) => doc.frame.frameId === "remote")!; const remote = facts.documents.find((doc) => doc.frame.frameId === "remote")!;
expect(main.index.nodes.get(2)).not.toBe(remote.index.nodes.get(2)); expect(main.index.nodes.get(2)).not.toBe(remote.index.nodes.get(2));
@@ -262,7 +235,6 @@ describe("captureObservationFacts", () => {
}); });
it.each([ it.each([
{ loader: "new-loader" },
{ element: 99 }, { element: 99 },
{ missing: true }, { missing: true },
])("isolates changed child identity %j and dependent descendants", async (change) => { ])("isolates changed child identity %j and dependent descendants", async (change) => {
@@ -299,31 +271,20 @@ describe("captureObservationFacts", () => {
} }
}); });
it("keeps newly discovered snapshot documents unverified without reading a synthetic before identity", async () => { it("validates newly discovered snapshot documents without a pre-snapshot identity read", async () => {
const { cdp, logs } = fixture(); const { cdp, logs } = fixture();
const originalGraph = await cdp.getFrameGraph!(4); const graph = await cdp.getFrameGraph!(4);
cdp.getFrameGraph = async () => ({ cdp.getFrameGraph = async () => ({ ...graph, frames: graph.frames.slice(0, 1) });
...originalGraph,
frames: originalGraph.frames.slice(0, 1),
});
const facts = await captureObservationFacts(cdp, 4); const facts = await captureObservationFacts(cdp, 4);
expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "same", "nested"]); expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "same", "nested"]);
expect(facts.documents[0].identity).toBeDefined(); expect(facts.documents.every((doc) => doc.identity)).toBe(true);
for (const doc of facts.documents.slice(1)) { expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(3);
expect(doc.identity).toBeUndefined(); expect(logs.findIndex((call) => call.method === "Page.createIsolatedWorld")).toBeGreaterThan(
expect(facts.issues).toContainEqual( logs.findIndex((call) => call.method === "Accessibility.getFullAXTree"),
expect.objectContaining({ frameId: doc.frame.frameId, reason: "identity-unverified" }), );
);
}
expect(
logs.filter((call) => call.method === "DOM.describeNode").map((call) => call.params.objectId),
).toEqual(["main", "main"]);
}); });
it.each([ it("rejects a snapshot root that no longer matches the current frame root", async () => {
"frame-id",
"snapshot-element",
])("rejects root replacement evidenced by %s even if the later graph is stale", async (mode) => {
const { cdp } = fixture(); const { cdp } = fixture();
const original = cdp.sendToTarget!; const original = cdp.sendToTarget!;
cdp.sendToTarget = async (target, method, params) => { cdp.sendToTarget = async (target, method, params) => {
@@ -332,8 +293,7 @@ describe("captureObservationFacts", () => {
const snapshot = result as { const snapshot = result as {
documents: { frameId: string; nodes: { backendNodeId: number[] } }[]; documents: { frameId: string; nodes: { backendNodeId: number[] } }[];
}; };
if (mode === "frame-id") snapshot.documents[0].frameId = "replacement"; snapshot.documents[0].nodes.backendNodeId[2] = 999;
else snapshot.documents[0].nodes.backendNodeId[2] = 999;
} }
return result as never; return result as never;
}; };
@@ -347,17 +307,20 @@ describe("captureObservationFacts", () => {
const original = cdp.sendToTarget!; const original = cdp.sendToTarget!;
let identityCleanup = 0; let identityCleanup = 0;
cdp.sendToTarget = async (target, method, params) => { cdp.sendToTarget = async (target, method, params) => {
if (method === "Page.getFrameTree") { if (method === "Page.createIsolatedWorld") {
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength( expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(
4, 4,
); );
} }
const result = await original(target, method, params); const result = await original(target, method, params);
if ( if (
method === "Runtime.releaseObject" && method === "Runtime.releaseObjectGroup" &&
(params as { objectId?: string })?.objectId === "remote" String((params as { objectGroup?: string })?.objectGroup).startsWith(
"bsk-document-identity-",
) &&
target.sessionId === "remote"
) { ) {
if (++identityCleanup === 2) controller.abort(); if (++identityCleanup === 1) controller.abort();
} }
return result as never; return result as never;
}; };
@@ -365,15 +328,14 @@ describe("captureObservationFacts", () => {
await expect(captureObservationFacts(cdp, 4, controller.signal)).rejects.toMatchObject({ await expect(captureObservationFacts(cdp, 4, controller.signal)).rejects.toMatchObject({
name: "AbortError", name: "AbortError",
}); });
expect(identityCleanup).toBe(2); expect(identityCleanup).toBe(1);
}); });
it.each([ it.each([
1, 10, 100, 1, 10, 100,
])("bounds identity reads for %i same-target documents across both phases", async (count) => { ])("bounds identity reads for %i same-target documents after snapshot and AX collection", async (count) => {
const frames: CdpFrame[] = Array.from({ length: count }, (_, i) => ({ const frames: CdpFrame[] = Array.from({ length: count }, (_, i) => ({
frameId: `frame-${i}`, frameId: `frame-${i}`,
loaderId: `loader-${i}`,
target: { tabId: 4 }, target: { tabId: 4 },
...(i ? { parentFrameId: "frame-0", ownerBackendNodeId: 90000 + i } : {}), ...(i ? { parentFrameId: "frame-0", ownerBackendNodeId: 90000 + i } : {}),
})); }));
@@ -389,16 +351,20 @@ describe("captureObservationFacts", () => {
} }
if (method === "DOMSnapshot.captureSnapshot") { if (method === "DOMSnapshot.captureSnapshot") {
expect(active).toBe(0); expect(active).toBe(0);
expect(released).toBe(count); expect(released).toBe(0);
} }
if (method === "Page.getFrameTree") { if (method === "Page.createIsolatedWorld") {
expect(active).toBe(0);
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength( expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(
count, count,
); );
} }
const result = await original(target, method, params); const result = await original(target, method, params);
if (method === "Runtime.releaseObject") { if (
method === "Runtime.releaseObjectGroup" &&
String((params as { objectGroup?: string })?.objectGroup).startsWith(
"bsk-document-identity-",
)
) {
active--; active--;
released++; released++;
} }
@@ -409,25 +375,20 @@ describe("captureObservationFacts", () => {
expect(facts.documents.every((doc) => doc.identity)).toBe(true); expect(facts.documents.every((doc) => doc.identity)).toBe(true);
expect(peak).toBe(Math.min(count, 4)); expect(peak).toBe(Math.min(count, 4));
expect(active).toBe(0); expect(active).toBe(0);
expect(released).toBe(count * 2); expect(released).toBe(count);
for (const method of [ for (const method of [
"Page.createIsolatedWorld", "Page.createIsolatedWorld",
"Runtime.evaluate", "Runtime.evaluate",
"DOM.describeNode", "Runtime.releaseObjectGroup",
"Runtime.releaseObject",
]) ])
expect(logs.filter((call) => call.method === method)).toHaveLength(count * 2); expect(logs.filter((call) => call.method === method)).toHaveLength(count);
expect(logs.filter((call) => call.method === "DOMSnapshot.captureSnapshot")).toHaveLength(1); expect(logs.filter((call) => call.method === "DOMSnapshot.captureSnapshot")).toHaveLength(1);
expect(logs.filter((call) => call.method === "Page.getFrameTree")).toHaveLength(1); expect(logs.filter((call) => call.method === "Page.getFrameTree")).toHaveLength(0);
}); });
it.each([ it("joins identity cleanup on cancellation without starting queued frames", async () => {
"before",
"after",
])("joins identity cleanup on %s cancellation without starting queued frames", async (phase) => {
const frames: CdpFrame[] = Array.from({ length: 5 }, (_, i) => ({ const frames: CdpFrame[] = Array.from({ length: 5 }, (_, i) => ({
frameId: `frame-${i}`, frameId: `frame-${i}`,
loaderId: `loader-${i}`,
target: { tabId: 4 }, target: { tabId: 4 },
...(i ? { parentFrameId: "frame-0", ownerBackendNodeId: 90000 + i } : {}), ...(i ? { parentFrameId: "frame-0", ownerBackendNodeId: 90000 + i } : {}),
})); }));
@@ -443,11 +404,14 @@ describe("captureObservationFacts", () => {
ready = resolve; ready = resolve;
}); });
let held = 0; let held = 0;
let after = false;
cdp.sendToTarget = async (target, method, params) => { cdp.sendToTarget = async (target, method, params) => {
if (method === "Page.getFrameTree") after = true;
const result = await original(target, method, params); const result = await original(target, method, params);
if (method === "Runtime.releaseObject" && after === (phase === "after")) { if (
method === "Runtime.releaseObjectGroup" &&
String((params as { objectGroup?: string })?.objectGroup).startsWith(
"bsk-document-identity-",
)
) {
if (++held === 4) ready(); if (++held === 4) ready();
await gate; await gate;
} }
@@ -466,9 +430,7 @@ describe("captureObservationFacts", () => {
release(); release();
await rejected; await rejected;
expect(held).toBe(4); expect(held).toBe(4);
expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength( expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(4);
phase === "after" ? 9 : 4,
);
}); });
it.each([ it.each([
@@ -1206,7 +1168,7 @@ describe("sibling frame measurement scheduling", () => {
return original(tabId, method, params); return original(tabId, method, params);
}; };
const captured = await captureObservationFacts(fixture.cdp, 4); const captured = await captureObservationFacts(fixture.cdp, 4);
expect(captured.issues).toEqual([]); expect(captured.issues.filter((issue) => issue.stage === "geometry")).toEqual([]);
expect(captured.documents.map((doc) => doc.frame.frameId)).toEqual( expect(captured.documents.map((doc) => doc.frame.frameId)).toEqual(
Array.from({ length: 8 }, (_, i) => `frame-${i}`), Array.from({ length: 8 }, (_, i) => `frame-${i}`),
); );
@@ -1,19 +1,17 @@
import { import {
buildFrameGraph,
type CdpFrame, type CdpFrame,
type CdpFrameGraph, type CdpFrameGraph,
type CdpFrameTreeNode,
type CdpTarget, type CdpTarget,
cdpTargetKey, cdpTargetKey,
} from "@/browser-driver/frame-graph"; } from "@/browser-driver/frame-graph";
import { cssViewport, GeometryContext } from "../geometry/frame-context"; import { cssViewport, GeometryContext } from "../geometry/frame-context";
import { type CdpRunner, cdpRunnerForTarget, sendToCdpTarget } from "../shared"; import { type CdpRunner, cdpRunnerForTarget } from "../shared";
import { collectOverlayExcludedBackendIds } from "./capture"; import { collectOverlayExcludedBackendIds } from "./capture";
import { import {
isAbortError as isCaptureAbort, isAbortError as isCaptureAbort,
throwIfAborted as throwCaptureAborted, throwIfAborted as throwCaptureAborted,
} from "./capture-abort"; } from "./capture-abort";
import { readDocumentIdentity, sameDocument } from "./document-identity"; import { verifyDocumentIdentity } from "./document-identity";
import type { CapturedSceneInput, DocumentIdentity } from "./facts"; import type { CapturedSceneInput, DocumentIdentity } from "./facts";
import { buildDocumentIndex, type CaptureIssue, type ObservationFacts } from "./facts"; import { buildDocumentIndex, type CaptureIssue, type ObservationFacts } from "./facts";
import { enrichFormControlStates } from "./form-capture"; import { enrichFormControlStates } from "./form-capture";
@@ -29,7 +27,7 @@ import { describeSnapshotFrames, REQUESTED_STYLES, type SnapshotReply } from "./
interface TargetBatch<T extends FrameOwnedAxNode> { interface TargetBatch<T extends FrameOwnedAxNode> {
target: CdpTarget; target: CdpTarget;
frames: CdpFrame[]; frames: CdpFrame[];
before: Map<string, DocumentIdentity>; snapshotAttachmentId?: string;
documents: NormalizedFrameDocument[]; documents: NormalizedFrameDocument[];
ax: FrameAxBatch<T>[]; ax: FrameAxBatch<T>[];
fallbackExcluded: Set<number>; fallbackExcluded: Set<number>;
@@ -92,7 +90,6 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
group = { group = {
target: frame.target, target: frame.target,
frames: [], frames: [],
before: new Map(),
documents: [], documents: [],
ax: [], ax: [],
fallbackExcluded: new Set(), fallbackExcluded: new Set(),
@@ -109,7 +106,6 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
groups.set(cdpTargetKey({ tabId }), { groups.set(cdpTargetKey({ tabId }), {
target: { tabId }, target: { tabId },
frames: [], frames: [],
before: new Map(),
documents: [], documents: [],
ax: [], ax: [],
fallbackExcluded: new Set(), fallbackExcluded: new Set(),
@@ -117,16 +113,6 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
const batches = [...groups.values()]; const batches = [...groups.values()];
const unavailable = (batch: TargetBatch<T>, stage: CaptureIssue["stage"], frameId?: string) => const unavailable = (batch: TargetBatch<T>, stage: CaptureIssue["stage"], frameId?: string) =>
issues.push({ target: batch.target, frameId, stage, reason: "capture-unavailable" }); issues.push({ target: batch.target, frameId, stage, reason: "capture-unavailable" });
// All before reads finish before any target starts collecting. The bound is
// shared across frames, including frames in the same target.
await collectTasks(
batches.flatMap((batch) => batch.frames.map((frame) => ({ batch, frame }))),
async ({ batch, frame }) => {
const identity = await readDocumentIdentity(cdp, frame, signal);
if (identity) batch.before.set(frame.frameId, identity);
},
signal,
);
let firstFailure: unknown; let firstFailure: unknown;
const axReady = new Set<TargetBatch<T>>(); const axReady = new Set<TargetBatch<T>>();
await collectTasks( await collectTasks(
@@ -137,6 +123,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
throwCaptureAborted(signal); throwCaptureAborted(signal);
await scoped.send(tabId, "DOMSnapshot.enable", {}); await scoped.send(tabId, "DOMSnapshot.enable", {});
throwCaptureAborted(signal); throwCaptureAborted(signal);
batch.snapshotAttachmentId = cdp.getAttachmentId?.(tabId);
const snapshot = await scoped.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", { const snapshot = await scoped.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
computedStyles: REQUESTED_STYLES, computedStyles: REQUESTED_STYLES,
includePaintOrder: true, includePaintOrder: true,
@@ -264,44 +251,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
rootBatch?.rootFrameId ?? graph?.rootFrameId ?? rootBatch?.frames[0]?.frameId ?? "root"; rootBatch?.rootFrameId ?? graph?.rootFrameId ?? rootBatch?.frames[0]?.frameId ?? "root";
const ownershipInvalid = new Set(invalid); const ownershipInvalid = new Set(invalid);
const identities = new Map<string, DocumentIdentity>(); const identities = new Map<string, DocumentIdentity>();
const rootBefore = graph && rootBatch?.before.get(graph.rootFrameId);
if (rootBefore && rootFrameId !== rootBefore.frameId) {
invalid.add(rootFrameId);
issues.push({
target: rootBefore.target,
frameId: rootFrameId,
stage: "identity",
reason: "document-changed",
});
}
const selected = new Map(currentFrames.map((frame) => [frame.frameId, frame])); const selected = new Map(currentFrames.map((frame) => [frame.frameId, frame]));
const freshFrames = new Map<string, Map<string, CdpFrame>>();
await collectTasks(
batches.filter((batch) => batch.before.size > 0),
async (batch) => {
try {
throwCaptureAborted(signal);
const reply = await sendToCdpTarget<{ frameTree?: CdpFrameTreeNode }>(
cdp,
batch.target,
"Page.getFrameTree",
{},
);
const tree = reply.frameTree
? buildFrameGraph([{ target: batch.target, tree: reply.frameTree }])
: null;
if (tree)
freshFrames.set(
cdpTargetKey(batch.target),
new Map(tree.frames.map((frame) => [frame.frameId, frame])),
);
} catch (error) {
throwCaptureAborted(signal);
if (isCaptureAbort(error)) throw error;
}
},
signal,
);
const checks = batches.flatMap((batch) => { const checks = batches.flatMap((batch) => {
const docs = new Map(batch.documents.map((doc) => [doc.frame.frameId, doc])); const docs = new Map(batch.documents.map((doc) => [doc.frame.frameId, doc]));
return batch.frames return batch.frames
@@ -315,43 +265,33 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
await collectTasks( await collectTasks(
checks, checks,
async ({ batch, frame, doc }) => { async ({ batch, frame, doc }) => {
const fresh = freshFrames.get(cdpTargetKey(batch.target));
throwCaptureAborted(signal); throwCaptureAborted(signal);
const before = batch.before.get(frame.frameId); if (!batch.snapshotAttachmentId || doc?.documentElementBackendNodeId === undefined) {
const current = fresh?.get(frame.frameId);
const after =
before && current ? await readDocumentIdentity(cdp, current, signal) : undefined;
const snapshotMismatch =
before &&
doc?.documentElementBackendNodeId !== undefined &&
before.documentElementBackendNodeId !== doc.documentElementBackendNodeId;
const changed =
before &&
(snapshotMismatch ||
(fresh && !current) ||
(after && !sameDocument(before, after)) ||
cdp.getAttachmentId?.(tabId) !== before.attachmentId);
if (changed || (before && !after)) {
invalid.add(frame.frameId);
issues.push({
target: frame.target,
frameId: frame.frameId,
stage: "identity",
reason: changed ? "document-changed" : "identity-unavailable",
});
} else if (
before &&
after &&
(!doc || doc.documentElementBackendNodeId === before.documentElementBackendNodeId)
) {
identities.set(frame.frameId, after);
} else {
issues.push({ issues.push({
target: frame.target, target: frame.target,
frameId: frame.frameId, frameId: frame.frameId,
stage: "identity", stage: "identity",
reason: "identity-unverified", reason: "identity-unverified",
}); });
return;
}
const identity: DocumentIdentity = {
attachmentId: batch.snapshotAttachmentId,
target: frame.target,
frameId: frame.frameId,
documentElementBackendNodeId: doc.documentElementBackendNodeId,
};
const verified = await verifyDocumentIdentity(cdp, identity, signal);
const status = cdp.getAttachmentId?.(tabId) === identity.attachmentId ? verified : "changed";
if (status === "current") identities.set(frame.frameId, identity);
else {
invalid.add(frame.frameId);
issues.push({
target: frame.target,
frameId: frame.frameId,
stage: "identity",
reason: status === "changed" ? "document-changed" : "identity-unavailable",
});
} }
}, },
signal, signal,
@@ -1,71 +1,53 @@
import type { CdpFrame } from "@/browser-driver/frame-graph";
import type { CdpRunner } from "../shared"; import type { CdpRunner } from "../shared";
import { sendToCdpTarget } from "../shared"; import { sendToCdpTarget } from "../shared";
import { import { isAbortError, throwIfAborted } from "./capture-abort";
isAbortError as isCaptureAbort,
throwIfAborted as throwCaptureAborted,
} from "./capture-abort";
import type { DocumentIdentity } from "./facts"; import type { DocumentIdentity } from "./facts";
/** Fresh, frame-scoped identity read. An isolated world supplies the owning /** Compare the snapshot root with the current root in that exact frame.
* document without querying each node or installing a page observer. */ * Deep serialization supplies the backend ID without a separate describeNode. */
export async function readDocumentIdentity( export async function verifyDocumentIdentity(
cdp: CdpRunner, cdp: CdpRunner,
frame: CdpFrame, identity: DocumentIdentity,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<DocumentIdentity | undefined> { ): Promise<"current" | "changed" | "unavailable"> {
const attachmentId = cdp.getAttachmentId?.(frame.target.tabId); const attached = () => cdp.getAttachmentId?.(identity.target.tabId) === identity.attachmentId;
if (!attachmentId || !frame.loaderId) return undefined; throwIfAborted(signal);
let objectId: string | undefined; if (!attached()) return "changed";
const objectGroup = `bsk-document-identity-${crypto.randomUUID()}`;
const send = <T>(method: string, params: object) => { const send = <T>(method: string, params: object) => {
throwCaptureAborted(signal); throwIfAborted(signal);
return sendToCdpTarget<T>(cdp, frame.target, method, params); return sendToCdpTarget<T>(cdp, identity.target, method, params);
}; };
try { try {
const world = await send<{ executionContextId: number }>("Page.createIsolatedWorld", { const world = await send<{ executionContextId: number }>("Page.createIsolatedWorld", {
frameId: frame.frameId, frameId: identity.frameId,
worldName: "bsk-document-identity", worldName: "bsk-document-identity",
}); });
const element = await send<{ result?: { objectId?: string } }>("Runtime.evaluate", { const reply = await send<{
result?: { deepSerializedValue?: { type: string; value?: { backendNodeId?: number } } };
}>("Runtime.evaluate", {
expression: "document.documentElement", expression: "document.documentElement",
contextId: world.executionContextId, contextId: world.executionContextId,
objectGroup,
serializationOptions: {
serialization: "deep",
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
},
}); });
objectId = element.result?.objectId; if (!attached()) return "changed";
if (!objectId) return undefined; const root = reply.result?.deepSerializedValue;
const reply = await send<{ node?: { backendNodeId?: number } }>("DOM.describeNode", { if (root?.type === "null") return "changed";
objectId, const id = root?.type === "node" ? root.value?.backendNodeId : undefined;
depth: 0, if (id === undefined) return "unavailable";
}); return id === identity.documentElementBackendNodeId ? "current" : "changed";
const backendNodeId = reply.node?.backendNodeId;
if (!backendNodeId || cdp.getAttachmentId?.(frame.target.tabId) !== attachmentId)
return undefined;
return {
attachmentId,
target: frame.target,
frameId: frame.frameId,
loaderId: frame.loaderId,
documentElementBackendNodeId: backendNodeId,
};
} catch (error) { } catch (error) {
throwCaptureAborted(signal); throwIfAborted(signal);
if (isCaptureAbort(error)) throw error; if (isAbortError(error)) throw error;
return undefined; return attached() ? "unavailable" : "changed";
} finally { } finally {
if (objectId) await sendToCdpTarget(cdp, identity.target, "Runtime.releaseObjectGroup", {
await sendToCdpTarget(cdp, frame.target, "Runtime.releaseObject", { objectId }).catch( objectGroup,
() => {}, }).catch(() => {});
); throwIfAborted(signal);
throwCaptureAborted(signal);
} }
} }
export function sameDocument(a: DocumentIdentity, b: DocumentIdentity): boolean {
return (
a.attachmentId === b.attachmentId &&
a.target.tabId === b.target.tabId &&
a.target.sessionId === b.target.sessionId &&
a.frameId === b.frameId &&
a.loaderId === b.loaderId &&
a.documentElementBackendNodeId === b.documentElementBackendNodeId
);
}
-1
View File
@@ -101,7 +101,6 @@ export interface DocumentIdentity {
attachmentId: string; attachmentId: string;
target: CdpTarget; target: CdpTarget;
frameId: string; frameId: string;
loaderId: string;
documentElementBackendNodeId: number; documentElementBackendNodeId: number;
} }