Merge pull request #203 from Tencent/fix/capture-document-consistency

fix(observation): validate DOM identity across capture phases | 校验跨采集阶段的 DOM 身份一致性
This commit is contained in:
Hannah Liu
2026-09-09 16:26:24 +08:00
committed by GitHub
11 changed files with 447 additions and 10 deletions
@@ -29,6 +29,24 @@ function fakeApi() {
}
describe("ChromiumCdp", () => {
it("changes attachment identity only across real debugger attachments", async () => {
const { api, onEvent, onDetach } = fakeApi();
const cdp = new ChromiumCdp(api);
expect(cdp.getAttachmentId(4)).toBeUndefined();
await cdp.ensureAttached(4);
const first = cdp.getAttachmentId(4);
expect(first).toBeTruthy();
await cdp.ensureAttached(4);
onEvent.fire({ tabId: 4 }, "Target.attachedToTarget", { sessionId: "child" });
expect(cdp.getAttachmentId(4)).toBe(first);
onDetach.fire({ tabId: 4 }, "target_closed");
expect(cdp.getAttachmentId(4)).toBeUndefined();
await cdp.ensureAttached(4);
expect(cdp.getAttachmentId(4)).not.toBe(first);
await cdp.detach(4);
expect(cdp.getAttachmentId(4)).toBeUndefined();
});
it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
const { api, onEvent } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
@@ -164,6 +164,7 @@ async function settleBeforeDeadline(promises: Promise<void>[], deadline: number)
export class ChromiumCdp {
private readonly api: CdpDebuggerApi;
private readonly attachedTabs = new Set<number>();
private readonly attachmentIds = new Map<number, string>();
private readonly attachInFlight = new Map<number, Promise<void>>();
private readonly detachInFlight = new Map<number, Promise<void>>();
private readonly tabOwners = new Map<number, Set<string>>();
@@ -198,6 +199,11 @@ export class ChromiumCdp {
this.bindFrameTargetHandler();
}
/** Identity of this actual debugger attachment, independent of frame topology. */
getAttachmentId(tabId: number): string | undefined {
return this.attachmentIds.get(tabId);
}
/** Attach to `tabId` if we haven't already in this driver. */
async ensureAttached(tabId: number): Promise<void> {
// Returning a tab clears the cache before Chrome finishes detaching.
@@ -217,6 +223,7 @@ export class ChromiumCdp {
await this.enableConsoleDomains(tabId);
await this.enableNetworkDomainBestEffort(tabId);
this.attachedTabs.add(tabId);
this.attachmentIds.set(tabId, crypto.randomUUID());
await this.enableFrameDiscovery({ tabId }).catch((err) => {
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
});
@@ -444,6 +451,7 @@ export class ChromiumCdp {
this.attachInFlight.delete(tabId);
if (!this.attachedTabs.has(tabId)) return;
this.attachedTabs.delete(tabId);
this.attachmentIds.delete(tabId);
this.clearDialogState(tabId);
this.clearConsoleState(tabId);
this.clearNetworkState(tabId);
@@ -502,6 +510,7 @@ export class ChromiumCdp {
this.attachInFlight.clear();
this.tabOwners.clear();
this.attachedTabs.clear();
this.attachmentIds.clear();
this.dialogBuffers.clear();
this.dialogSequences.clear();
this.consoleBuffers.clear();
@@ -848,6 +857,7 @@ export class ChromiumCdp {
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
if (typeof source.tabId === "number") {
this.attachedTabs.delete(source.tabId);
this.attachmentIds.delete(source.tabId);
this.attachInFlight.delete(source.tabId);
this.tabOwners.delete(source.tabId);
this.clearDialogState(source.tabId);
@@ -3695,7 +3695,8 @@ describe("handleSnapshot", () => {
const result = await captureVomObservation(makeFrameAwareDeps().cdp, 4, "https://example.com", {
maxTokens,
});
expect(result.text.match(/@warning/g)).toHaveLength(1);
expect(result.text.match(/@warning/g)).toHaveLength(2);
expect(result.text).toContain("document identity unverified");
expect(result.text).toContain("geometry incomplete");
expect(result.text.startsWith("@vom 1\n")).toBe(true);
if (maxTokens === undefined) {
@@ -3722,7 +3723,8 @@ describe("handleSnapshot", () => {
for (const maxTokens of [undefined, 100, 0]) {
const result = await captureVomObservation(cdp, 4, "https://example.com", { maxTokens });
expect(result.text).toContain(`@warning observation incomplete: some ${stage} data`);
expect(result.text.match(/@warning/g)).toHaveLength(1);
expect(result.text.match(/@warning/g)).toHaveLength(2);
expect(result.text).toContain("document identity unverified");
expect(result.text.startsWith("@vom 1\n")).toBe(true);
if (maxTokens === undefined) {
expect(result.frames.map((frame) => frame.frameId)).toEqual(["main", "child"]);
@@ -3770,7 +3772,8 @@ describe("handleSnapshot", () => {
4,
"https://example.com",
);
expect(result.text).not.toContain("@warning");
expect(result.text).not.toContain("iframe geometry incomplete");
expect(result.text).toContain("document identity unverified");
const node = result.matchNodes.find((item) => item.backendNodeId === 22);
if (ownerGeometry === "clipped") expect(node?.rect).toBeNull();
else expect(node?.rect).toEqual({ x: 120, y: 130, w: 120, h: 40 });
@@ -130,6 +130,7 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
return send(method, params, sessionFor(target));
},
getFrameGraph: async () => graph,
getAttachmentId: () => rootSession,
};
const oracles = new Map<string, Oracle>();
for (const frame of frames) {
+12
View File
@@ -938,6 +938,18 @@ export async function captureVomObservation(
notices.push(
`@warning observation incomplete: some ${incompleteStages.join(", ")} data is unavailable or omitted.`,
);
if (
facts.issues.some(
(issue) => issue.stage === "identity" && issue.reason !== "identity-unverified",
)
)
notices.push(
"@warning observation incomplete: some documents were omitted because their identity changed or could not be revalidated.",
);
if (facts.issues.some((issue) => issue.reason === "identity-unverified"))
notices.push(
"@warning document identity unverified: some retained documents could not be checked for changes during capture.",
);
const captureNotice = notices.join("\n");
const rendered = renderVom(decoratedScene, {
maxDepth: options.maxDepth,
+1
View File
@@ -55,6 +55,7 @@ export interface CdpRunner {
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
detach?(tabId: number): Promise<void>;
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
getAttachmentId?(tabId: number): string | undefined;
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
trackSessionTab?(sessionId: string, tabId: number): void;
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
@@ -2,13 +2,22 @@ import { describe, expect, it, vi } from "vitest";
import type { CdpFrame, CdpTarget } from "@/browser-driver/frame-graph";
import { cdpTargetKey } from "@/browser-driver/frame-graph";
import { OVERLAY_HOST_MARKER_ATTR } from "@/lib/overlay-bridge";
import { captureVomObservation } from "../../observation";
import type { CdpRunner } from "../../shared";
import { captureObservationFacts, semanticCapture } from "../capture-coordinator";
import { buildSemanticGraph } from "../semantic-graph/build";
import { REQUESTED_STYLES, type SnapshotReply } from "../snapshot";
function fixture(
options: { frames?: CdpFrame[]; fail?: string; omitDocument?: string; overlay?: string } = {},
options: {
frames?: CdpFrame[];
after?: Record<string, { element?: number; missing?: boolean; unreadable?: boolean }>;
fail?: string;
missingIdentity?: boolean;
omitDocument?: string;
attachmentChanged?: boolean;
overlay?: string;
} = {},
) {
const frames = options.frames ?? [
{ frameId: "main", target: { tabId: 4 } },
@@ -34,7 +43,10 @@ function fixture(
const elements = new Map(
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 logs: Array<{ target: CdpTarget; method: string; params: Record<string, unknown> }> = [];
let snapshots = 0;
const targetCount = new Set(frames.map((frame) => cdpTargetKey(frame.target))).size;
const send = vi.fn(
async <T>(target: CdpTarget, method: string, params: object = {}): Promise<T> => {
logs.push({ target, method, params: params as Record<string, unknown> });
@@ -42,6 +54,27 @@ function fixture(
if (options.fail === `${target.sessionId ?? "main"}:${method}`)
throw new Error("fixture failure");
let result: unknown = {};
if (method === "Page.createIsolatedWorld")
result = {
executionContextId: frames.findIndex((frame) => frame.frameId === args.frameId) + 1,
};
if (method === "Runtime.evaluate" && args.contextId) {
const id = contexts.get(Number(args.contextId))!;
const change = options.after?.[id];
result =
options.missingIdentity || change?.unreadable
? {}
: {
result: {
deepSerializedValue: change?.missing
? { type: "null" }
: {
type: "node",
value: { backendNodeId: change?.element ?? elements.get(id) },
},
},
};
}
if (method === "Page.getLayoutMetrics")
result = {
visualViewport: { clientWidth: 1000 },
@@ -49,6 +82,7 @@ function fixture(
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
};
if (method === "DOMSnapshot.captureSnapshot") {
snapshots++;
expect((params as { computedStyles: unknown }).computedStyles).toEqual(REQUESTED_STYLES);
result = {
strings: [
@@ -137,6 +171,12 @@ function fixture(
},
);
const cdp: CdpRunner = {
getAttachmentId: () =>
options.missingIdentity
? undefined
: options.attachmentChanged && snapshots === targetCount
? "new-attachment"
: "attachment",
getFrameGraph: async () => ({ rootFrameId: frames[0].frameId, frames }),
send: (tabId, method, params) =>
(send as NonNullable<CdpRunner["sendToTarget"]>)({ tabId }, method, params),
@@ -181,6 +221,9 @@ describe("captureObservationFacts", () => {
expect(logs.filter((call) => call.method === "DOMSnapshot.captureSnapshot")).toHaveLength(2);
expect(logs.filter((call) => call.method === "Accessibility.enable")).toHaveLength(2);
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(4);
expect(facts.documents.every((doc) => doc.identity)).toBe(true);
expect(logs.filter((call) => call.method === "DOM.describeNode")).toHaveLength(0);
expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(4);
const main = facts.documents.find((doc) => doc.frame.frameId === "main")!;
const remote = facts.documents.find((doc) => doc.frame.frameId === "remote")!;
expect(main.index.nodes.get(2)).not.toBe(remote.index.nodes.get(2));
@@ -191,6 +234,219 @@ describe("captureObservationFacts", () => {
expect(facts.finishedAt).toBeGreaterThanOrEqual(facts.startedAt);
});
it.each([
{ element: 99 },
{ missing: true },
])("isolates changed child identity %j and dependent descendants", async (change) => {
const { cdp } = fixture({ after: { same: change } });
const facts = await captureObservationFacts(cdp, 4);
expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "remote"]);
expect(facts.issues).toContainEqual(
expect.objectContaining({ frameId: "same", reason: "document-changed" }),
);
});
it.each([
{ after: { main: { element: 99 } } },
{ attachmentChanged: true },
])("rejects a root replacement or reattachment %j", async (options) => {
await expect(captureObservationFacts(fixture(options).cdp, 4)).rejects.toThrow(
"document identity",
);
});
it.each([
"main",
"same",
])("does not publish a previously identified %s document after identity reads fail", async (frameId) => {
const { cdp } = fixture({ after: { [frameId]: { unreadable: true } } });
if (frameId === "main")
await expect(captureObservationFacts(cdp, 4)).rejects.toThrow("document identity");
else {
const facts = await captureObservationFacts(cdp, 4);
expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "remote"]);
expect(facts.issues).toContainEqual(
expect.objectContaining({ frameId, reason: "identity-unavailable" }),
);
}
});
it("validates newly discovered snapshot documents without a pre-snapshot identity read", async () => {
const { cdp, logs } = fixture();
const graph = await cdp.getFrameGraph!(4);
cdp.getFrameGraph = async () => ({ ...graph, frames: graph.frames.slice(0, 1) });
const facts = await captureObservationFacts(cdp, 4);
expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "same", "nested"]);
expect(facts.documents.every((doc) => doc.identity)).toBe(true);
expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(3);
expect(logs.findIndex((call) => call.method === "Page.createIsolatedWorld")).toBeGreaterThan(
logs.findIndex((call) => call.method === "Accessibility.getFullAXTree"),
);
});
it("rejects a snapshot root that no longer matches the current frame root", async () => {
const { cdp } = fixture();
const original = cdp.sendToTarget!;
cdp.sendToTarget = async (target, method, params) => {
const result = await original(target, method, params);
if (method === "DOMSnapshot.captureSnapshot" && !target.sessionId) {
const snapshot = result as {
documents: { frameId: string; nodes: { backendNodeId: number[] } }[];
};
snapshot.documents[0].nodes.backendNodeId[2] = 999;
}
return result as never;
};
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
await expect(captureObservationFacts(cdp, 4)).rejects.toThrow("document identity");
});
it("starts after-identity reads only after every target finishes and propagates cancellation during identity cleanup", async () => {
const { cdp, logs } = fixture();
const controller = new AbortController();
const original = cdp.sendToTarget!;
let identityCleanup = 0;
cdp.sendToTarget = async (target, method, params) => {
if (method === "Page.createIsolatedWorld") {
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(
4,
);
}
const result = await original(target, method, params);
if (
method === "Runtime.releaseObjectGroup" &&
String((params as { objectGroup?: string })?.objectGroup).startsWith(
"bsk-document-identity-",
) &&
target.sessionId === "remote"
) {
if (++identityCleanup === 1) controller.abort();
}
return result as never;
};
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
await expect(captureObservationFacts(cdp, 4, controller.signal)).rejects.toMatchObject({
name: "AbortError",
});
expect(identityCleanup).toBe(1);
});
it.each([
1, 10, 100,
])("bounds identity reads for %i same-target documents after snapshot and AX collection", async (count) => {
const frames: CdpFrame[] = Array.from({ length: count }, (_, i) => ({
frameId: `frame-${i}`,
target: { tabId: 4 },
...(i ? { parentFrameId: "frame-0", ownerBackendNodeId: 90000 + i } : {}),
}));
const { cdp, logs } = fixture({ frames });
const original = cdp.sendToTarget!;
let active = 0;
let peak = 0;
let released = 0;
cdp.sendToTarget = async (target, method, params) => {
if (method === "Page.createIsolatedWorld") {
peak = Math.max(peak, ++active);
await new Promise((resolve) => setTimeout(resolve, 1));
}
if (method === "DOMSnapshot.captureSnapshot") {
expect(active).toBe(0);
expect(released).toBe(0);
}
if (method === "Page.createIsolatedWorld") {
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(
count,
);
}
const result = await original(target, method, params);
if (
method === "Runtime.releaseObjectGroup" &&
String((params as { objectGroup?: string })?.objectGroup).startsWith(
"bsk-document-identity-",
)
) {
active--;
released++;
}
return result as never;
};
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
const facts = await captureObservationFacts(cdp, 4);
expect(facts.documents.every((doc) => doc.identity)).toBe(true);
expect(peak).toBe(Math.min(count, 4));
expect(active).toBe(0);
expect(released).toBe(count);
for (const method of [
"Page.createIsolatedWorld",
"Runtime.evaluate",
"Runtime.releaseObjectGroup",
])
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 === "Page.getFrameTree")).toHaveLength(0);
});
it("joins identity cleanup on cancellation without starting queued frames", async () => {
const frames: CdpFrame[] = Array.from({ length: 5 }, (_, i) => ({
frameId: `frame-${i}`,
target: { tabId: 4 },
...(i ? { parentFrameId: "frame-0", ownerBackendNodeId: 90000 + i } : {}),
}));
const { cdp, logs } = fixture({ frames });
const original = cdp.sendToTarget!;
const controller = new AbortController();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let ready!: () => void;
const blocked = new Promise<void>((resolve) => {
ready = resolve;
});
let held = 0;
cdp.sendToTarget = async (target, method, params) => {
const result = await original(target, method, params);
if (
method === "Runtime.releaseObjectGroup" &&
String((params as { objectGroup?: string })?.objectGroup).startsWith(
"bsk-document-identity-",
)
) {
if (++held === 4) ready();
await gate;
}
return result as never;
};
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
let settled = false;
const pending = captureObservationFacts(cdp, 4, controller.signal).finally(() => {
settled = true;
});
const rejected = expect(pending).rejects.toMatchObject({ name: "AbortError" });
await blocked;
controller.abort();
await Promise.resolve();
expect(settled).toBe(false);
release();
await rejected;
expect(held).toBe(4);
expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(4);
});
it.each([
[{ after: { same: { element: 99 } } }, "omitted", false],
[{ after: { same: { unreadable: true } } }, "omitted", false],
[{ missingIdentity: true }, "document identity unverified", true],
[{}, "", true],
] as const)("publishes identity integrity without silently losing content %j", async (options, notice, retained) => {
const { cdp } = fixture(options);
const result = await captureVomObservation(cdp, 4, "https://example.com");
expect(result.frames.some((frame) => frame.frameId === "same")).toBe(retained);
if (notice) expect(result.text).toContain(notice);
else expect(result.text).not.toContain("identity");
if (!retained) expect(result.text).not.toContain("document identity unverified");
});
it("does not retry failed or missing documents and retains valid AX-only semantics", async () => {
const { cdp, logs } = fixture({
fail: "remote:DOMSnapshot.captureSnapshot",
@@ -209,6 +465,12 @@ describe("captureObservationFacts", () => {
);
});
it("keeps missing identity explicit without manufacturing a verified document", async () => {
const facts = await captureObservationFacts(fixture({ missingIdentity: true }).cdp, 4);
expect(facts.documents.every((doc) => !doc.identity)).toBe(true);
expect(facts.issues.filter((issue) => issue.reason === "identity-unverified")).toHaveLength(4);
});
it("excludes overlay AX in its own document without excluding equal IDs elsewhere", async () => {
const facts = await captureObservationFacts(fixture({ overlay: "remote" }).cdp, 4);
const { documents } = semanticCapture(facts);
@@ -906,7 +1168,7 @@ describe("sibling frame measurement scheduling", () => {
return original(tabId, method, params);
};
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(
Array.from({ length: 8 }, (_, i) => `frame-${i}`),
);
@@ -11,7 +11,8 @@ import {
isAbortError as isCaptureAbort,
throwIfAborted as throwCaptureAborted,
} from "./capture-abort";
import type { CapturedSceneInput } from "./facts";
import { verifyDocumentIdentity } from "./document-identity";
import type { CapturedSceneInput, DocumentIdentity } from "./facts";
import { buildDocumentIndex, type CaptureIssue, type ObservationFacts } from "./facts";
import { enrichFormControlStates } from "./form-capture";
import {
@@ -26,6 +27,7 @@ import { describeSnapshotFrames, REQUESTED_STYLES, type SnapshotReply } from "./
interface TargetBatch<T extends FrameOwnedAxNode> {
target: CdpTarget;
frames: CdpFrame[];
snapshotAttachmentId?: string;
documents: NormalizedFrameDocument[];
ax: FrameAxBatch<T>[];
fallbackExcluded: Set<number>;
@@ -121,6 +123,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
throwCaptureAborted(signal);
await scoped.send(tabId, "DOMSnapshot.enable", {});
throwCaptureAborted(signal);
batch.snapshotAttachmentId = cdp.getAttachmentId?.(tabId);
const snapshot = await scoped.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
computedStyles: REQUESTED_STYLES,
includePaintOrder: true,
@@ -246,6 +249,53 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
const rootBatch = batches.find((batch) => !batch.target.sessionId);
const rootFrameId =
rootBatch?.rootFrameId ?? graph?.rootFrameId ?? rootBatch?.frames[0]?.frameId ?? "root";
const ownershipInvalid = new Set(invalid);
const identities = new Map<string, DocumentIdentity>();
const selected = new Map(currentFrames.map((frame) => [frame.frameId, frame]));
const checks = batches.flatMap((batch) => {
const docs = new Map(batch.documents.map((doc) => [doc.frame.frameId, doc]));
return batch.frames
.filter(
(frame) =>
!invalid.has(frame.frameId) &&
cdpTargetKey(selected.get(frame.frameId)!.target) === cdpTargetKey(batch.target),
)
.map((frame) => ({ batch, frame, doc: docs.get(frame.frameId) }));
});
await collectTasks(
checks,
async ({ batch, frame, doc }) => {
throwCaptureAborted(signal);
if (!batch.snapshotAttachmentId || doc?.documentElementBackendNodeId === undefined) {
issues.push({
target: frame.target,
frameId: frame.frameId,
stage: "identity",
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,
);
const children = new Map<string, string[]>();
for (const frame of currentFrames) {
if (!frame.parentFrameId) continue;
@@ -263,7 +313,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
}
}
for (const frame of currentFrames) {
if (invalid.has(frame.frameId))
if (ownershipInvalid.has(frame.frameId))
issues.push({
target: frame.target,
frameId: frame.frameId,
@@ -271,7 +321,12 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
reason: "frame-ownership-unresolved",
});
}
if (invalid.has(rootFrameId)) throw new Error("observation root document ownership is ambiguous");
if (invalid.has(rootFrameId))
throw new Error(
ownershipInvalid.has(rootFrameId)
? "observation root document ownership is ambiguous"
: "observation document identity changed or could not be verified; observe again",
);
const frames = currentFrames.filter((frame) => !invalid.has(frame.frameId));
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
const belongs = (frame: CdpFrame) => {
@@ -319,6 +374,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
} = document;
facts.push({
frame,
identity: identities.get(document.frameId),
index: fallback?.size ? { ...index, excludedBackendNodeIds: fallback } : index,
domNodes,
axNodes,
@@ -0,0 +1,53 @@
import type { CdpRunner } from "../shared";
import { sendToCdpTarget } from "../shared";
import { isAbortError, throwIfAborted } from "./capture-abort";
import type { DocumentIdentity } from "./facts";
/** Compare the snapshot root with the current root in that exact frame.
* Deep serialization supplies the backend ID without a separate describeNode. */
export async function verifyDocumentIdentity(
cdp: CdpRunner,
identity: DocumentIdentity,
signal?: AbortSignal,
): Promise<"current" | "changed" | "unavailable"> {
const attached = () => cdp.getAttachmentId?.(identity.target.tabId) === identity.attachmentId;
throwIfAborted(signal);
if (!attached()) return "changed";
const objectGroup = `bsk-document-identity-${crypto.randomUUID()}`;
const send = <T>(method: string, params: object) => {
throwIfAborted(signal);
return sendToCdpTarget<T>(cdp, identity.target, method, params);
};
try {
const world = await send<{ executionContextId: number }>("Page.createIsolatedWorld", {
frameId: identity.frameId,
worldName: "bsk-document-identity",
});
const reply = await send<{
result?: { deepSerializedValue?: { type: string; value?: { backendNodeId?: number } } };
}>("Runtime.evaluate", {
expression: "document.documentElement",
contextId: world.executionContextId,
objectGroup,
serializationOptions: {
serialization: "deep",
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
},
});
if (!attached()) return "changed";
const root = reply.result?.deepSerializedValue;
if (root?.type === "null") return "changed";
const id = root?.type === "node" ? root.value?.backendNodeId : undefined;
if (id === undefined) return "unavailable";
return id === identity.documentElementBackendNodeId ? "current" : "changed";
} catch (error) {
throwIfAborted(signal);
if (isAbortError(error)) throw error;
return attached() ? "unavailable" : "changed";
} finally {
await sendToCdpTarget(cdp, identity.target, "Runtime.releaseObjectGroup", {
objectGroup,
}).catch(() => {});
throwIfAborted(signal);
}
}
+16 -2
View File
@@ -97,6 +97,13 @@ export async function buildDocumentIndex<T extends DecodedNode>(
return { nodes, excludedBackendNodeIds };
}
export interface DocumentIdentity {
attachmentId: string;
target: CdpTarget;
frameId: string;
documentElementBackendNodeId: number;
}
/** Narrow input shared by the existing semantic scene/hover consumers. */
export interface CapturedSceneInput {
nodes: CapturedNode[];
@@ -110,12 +117,19 @@ export interface CaptureIssue {
projectionIssue?: FrameProjectionIssue;
target: CdpTarget;
frameId?: string;
stage: "dom" | "ax" | "ownership" | "geometry" | "forms";
reason: "capture-unavailable" | "frame-ownership-unresolved" | "geometry-unavailable";
stage: "dom" | "ax" | "identity" | "ownership" | "geometry" | "forms";
reason:
| "document-changed"
| "identity-unavailable"
| "identity-unverified"
| "capture-unavailable"
| "frame-ownership-unresolved"
| "geometry-unavailable";
}
export interface DocumentFacts<T extends FrameOwnedAxNode> {
readonly frame: CdpFrame;
readonly identity?: DocumentIdentity;
readonly index: DocumentIndex;
readonly domNodes: CapturedNode[];
readonly axNodes: T[];
@@ -38,6 +38,7 @@ export interface FrameContext {
export interface NormalizedDocument {
nodes: NodeFacts[];
index: DocumentIndex;
documentElementBackendNodeId?: number;
}
/** Interpret one document using a supplied projection. No live reads or frame scheduling. */
@@ -98,6 +99,12 @@ export async function normalizeDocument(
(node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId),
),
index,
documentElementBackendNodeId: nodes.find(
(node) =>
node.nodeType === 1 &&
node.parentBackendNodeId !== null &&
index.nodes.get(node.parentBackendNodeId)?.nodeType === 9,
)?.backendNodeId,
};
}