mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(observation): validate document consistency during capture
This commit is contained in:
@@ -29,6 +29,24 @@ function fakeApi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ChromiumCdp", () => {
|
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 () => {
|
it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
|
||||||
const { api, onEvent } = fakeApi();
|
const { api, onEvent } = fakeApi();
|
||||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ 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([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ async function settleBeforeDeadline(promises: Promise<void>[], deadline: number)
|
|||||||
export class ChromiumCdp {
|
export class ChromiumCdp {
|
||||||
private readonly api: CdpDebuggerApi;
|
private readonly api: CdpDebuggerApi;
|
||||||
private readonly attachedTabs = new Set<number>();
|
private readonly attachedTabs = new Set<number>();
|
||||||
|
private readonly attachmentIds = new Map<number, string>();
|
||||||
private readonly attachInFlight = new Map<number, Promise<void>>();
|
private readonly attachInFlight = new Map<number, Promise<void>>();
|
||||||
private readonly detachInFlight = new Map<number, Promise<void>>();
|
private readonly detachInFlight = new Map<number, Promise<void>>();
|
||||||
private readonly tabOwners = new Map<number, Set<string>>();
|
private readonly tabOwners = new Map<number, Set<string>>();
|
||||||
@@ -198,6 +199,11 @@ export class ChromiumCdp {
|
|||||||
this.bindFrameTargetHandler();
|
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. */
|
/** Attach to `tabId` if we haven't already in this driver. */
|
||||||
async ensureAttached(tabId: number): Promise<void> {
|
async ensureAttached(tabId: number): Promise<void> {
|
||||||
// Returning a tab clears the cache before Chrome finishes detaching.
|
// Returning a tab clears the cache before Chrome finishes detaching.
|
||||||
@@ -217,6 +223,7 @@ export class ChromiumCdp {
|
|||||||
await this.enableConsoleDomains(tabId);
|
await this.enableConsoleDomains(tabId);
|
||||||
await this.enableNetworkDomainBestEffort(tabId);
|
await this.enableNetworkDomainBestEffort(tabId);
|
||||||
this.attachedTabs.add(tabId);
|
this.attachedTabs.add(tabId);
|
||||||
|
this.attachmentIds.set(tabId, crypto.randomUUID());
|
||||||
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
||||||
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
||||||
});
|
});
|
||||||
@@ -444,6 +451,7 @@ export class ChromiumCdp {
|
|||||||
this.attachInFlight.delete(tabId);
|
this.attachInFlight.delete(tabId);
|
||||||
if (!this.attachedTabs.has(tabId)) return;
|
if (!this.attachedTabs.has(tabId)) return;
|
||||||
this.attachedTabs.delete(tabId);
|
this.attachedTabs.delete(tabId);
|
||||||
|
this.attachmentIds.delete(tabId);
|
||||||
this.clearDialogState(tabId);
|
this.clearDialogState(tabId);
|
||||||
this.clearConsoleState(tabId);
|
this.clearConsoleState(tabId);
|
||||||
this.clearNetworkState(tabId);
|
this.clearNetworkState(tabId);
|
||||||
@@ -502,6 +510,7 @@ export class ChromiumCdp {
|
|||||||
this.attachInFlight.clear();
|
this.attachInFlight.clear();
|
||||||
this.tabOwners.clear();
|
this.tabOwners.clear();
|
||||||
this.attachedTabs.clear();
|
this.attachedTabs.clear();
|
||||||
|
this.attachmentIds.clear();
|
||||||
this.dialogBuffers.clear();
|
this.dialogBuffers.clear();
|
||||||
this.dialogSequences.clear();
|
this.dialogSequences.clear();
|
||||||
this.consoleBuffers.clear();
|
this.consoleBuffers.clear();
|
||||||
@@ -848,6 +857,7 @@ export class ChromiumCdp {
|
|||||||
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
||||||
if (typeof source.tabId === "number") {
|
if (typeof source.tabId === "number") {
|
||||||
this.attachedTabs.delete(source.tabId);
|
this.attachedTabs.delete(source.tabId);
|
||||||
|
this.attachmentIds.delete(source.tabId);
|
||||||
this.attachInFlight.delete(source.tabId);
|
this.attachInFlight.delete(source.tabId);
|
||||||
this.tabOwners.delete(source.tabId);
|
this.tabOwners.delete(source.tabId);
|
||||||
this.clearDialogState(source.tabId);
|
this.clearDialogState(source.tabId);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface CdpFrame {
|
|||||||
parentFrameId?: string;
|
parentFrameId?: string;
|
||||||
ownerBackendNodeId?: number;
|
ownerBackendNodeId?: number;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
loaderId?: string;
|
||||||
target: CdpTarget;
|
target: CdpTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export interface CdpFrameTreeNode {
|
|||||||
id: string;
|
id: string;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
loaderId?: string;
|
||||||
};
|
};
|
||||||
childFrames?: CdpFrameTreeNode[];
|
childFrames?: CdpFrameTreeNode[];
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,7 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface CdpRunner {
|
|||||||
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
||||||
detach?(tabId: number): Promise<void>;
|
detach?(tabId: number): Promise<void>;
|
||||||
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
||||||
|
getAttachmentId?(tabId: number): string | undefined;
|
||||||
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
||||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||||
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
|
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { CdpFrame } from "@/browser-driver/frame-graph";
|
||||||
|
import type { CdpRunner } from "../shared";
|
||||||
|
import { sendToCdpTarget } from "../shared";
|
||||||
|
import {
|
||||||
|
isAbortError as isCaptureAbort,
|
||||||
|
throwIfAborted as throwCaptureAborted,
|
||||||
|
} from "./capture-abort";
|
||||||
|
import type { DocumentIdentity } from "./facts";
|
||||||
|
|
||||||
|
/** Fresh, frame-scoped identity read. An isolated world supplies the owning
|
||||||
|
* document without querying each node or installing a page observer. */
|
||||||
|
export async function readDocumentIdentity(
|
||||||
|
cdp: CdpRunner,
|
||||||
|
frame: CdpFrame,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<DocumentIdentity | undefined> {
|
||||||
|
const attachmentId = cdp.getAttachmentId?.(frame.target.tabId);
|
||||||
|
if (!attachmentId || !frame.loaderId) return undefined;
|
||||||
|
let objectId: string | undefined;
|
||||||
|
const send = <T>(method: string, params: object) => {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
return sendToCdpTarget<T>(cdp, frame.target, method, params);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const world = await send<{ executionContextId: number }>("Page.createIsolatedWorld", {
|
||||||
|
frameId: frame.frameId,
|
||||||
|
worldName: "bsk-document-identity",
|
||||||
|
});
|
||||||
|
const element = await send<{ result?: { objectId?: string } }>("Runtime.evaluate", {
|
||||||
|
expression: "document.documentElement",
|
||||||
|
contextId: world.executionContextId,
|
||||||
|
});
|
||||||
|
objectId = element.result?.objectId;
|
||||||
|
if (!objectId) return undefined;
|
||||||
|
const reply = await send<{ node?: { backendNodeId?: number } }>("DOM.describeNode", {
|
||||||
|
objectId,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
if (objectId)
|
||||||
|
await sendToCdpTarget(cdp, frame.target, "Runtime.releaseObject", { objectId }).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,3 +1,4 @@
|
|||||||
|
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
import { isOverlayHostNode } from "@/lib/overlay-bridge";
|
import { isOverlayHostNode } from "@/lib/overlay-bridge";
|
||||||
import { createCaptureCheckpoint } from "./capture-abort";
|
import { createCaptureCheckpoint } from "./capture-abort";
|
||||||
import type { CapturedNode } from "./capture-types";
|
import type { CapturedNode } from "./capture-types";
|
||||||
@@ -92,3 +93,11 @@ export async function buildDocumentIndex<T extends DecodedNode>(
|
|||||||
}
|
}
|
||||||
return { nodes, excludedBackendNodeIds };
|
return { nodes, excludedBackendNodeIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocumentIdentity {
|
||||||
|
attachmentId: string;
|
||||||
|
target: CdpTarget;
|
||||||
|
frameId: string;
|
||||||
|
loaderId: string;
|
||||||
|
documentElementBackendNodeId: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface FrameContext {
|
|||||||
export interface NormalizedDocument {
|
export interface NormalizedDocument {
|
||||||
nodes: NodeFacts[];
|
nodes: NodeFacts[];
|
||||||
index: DocumentIndex;
|
index: DocumentIndex;
|
||||||
|
documentElementBackendNodeId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Interpret one document using a supplied projection. No live reads or frame scheduling. */
|
/** Interpret one document using a supplied projection. No live reads or frame scheduling. */
|
||||||
@@ -69,5 +70,11 @@ export async function normalizeDocument(
|
|||||||
(node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId),
|
(node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId),
|
||||||
),
|
),
|
||||||
index,
|
index,
|
||||||
|
documentElementBackendNodeId: nodes.find(
|
||||||
|
(node) =>
|
||||||
|
node.nodeType === 1 &&
|
||||||
|
node.parentBackendNodeId !== null &&
|
||||||
|
index.nodes.get(node.parentBackendNodeId)?.nodeType === 9,
|
||||||
|
)?.backendNodeId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user