mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-10-02 07:34:35 +08:00
Merge pull request #209 from Tencent/feat/typed-visual-refs
feat(vom): add typed visual refs and target identity validation | 支持类型化视觉 ref 与目标身份验证
This commit is contained in:
@@ -23,11 +23,12 @@ describe("RefStore", () => {
|
||||
s.set("@e1", 1);
|
||||
s.set("@e2", 2);
|
||||
s.replace([
|
||||
["e10", { backendNodeId: 10, tabId: 1 }],
|
||||
["e10", { backendNodeId: 10, tabId: 1, name: "Save" }],
|
||||
["@e11", { backendNodeId: 11, tabId: 1 }],
|
||||
]);
|
||||
expect(s.resolve("@e1")).toBeNull();
|
||||
expect(s.resolve("@e10")).toBe(10);
|
||||
expect(s.resolveEntry("e10")).toMatchObject({ kind: "dom", name: "Save" });
|
||||
expect(s.resolve("@e10", { tabId: 1 })).toBe(10);
|
||||
expect(s.resolve("@e10", { tabId: 2 })).toBeNull();
|
||||
expect(s.resolve("@e11")).toBe(11);
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
/**
|
||||
* Per-session map from `@e<N>` snapshot refs to a CDP node address.
|
||||
*
|
||||
* Each fresh `tool.snapshot` resets the store: M6 will call
|
||||
* `replace(...)` with the new ref → node address pairs. A node address
|
||||
* includes the owning flat CDP session/frame when the element lives in
|
||||
* an OOPIF; tools resolve live geometry from that identity at call time.
|
||||
*
|
||||
* Refs are session-scoped (§7): looking up a ref in the wrong session
|
||||
* returns `null`, never silently leaks. Storing values in different
|
||||
* sessions is fine; they live in independent `RefStore` instances
|
||||
* inside the `SessionContext`.
|
||||
*/
|
||||
import type { VisualCandidate } from "@/tools/vom/visual-discovery";
|
||||
|
||||
/** Session-local refs describe the latest observation. Reusing eN does not identify
|
||||
* which observation a caller read; generation is internal bookkeeping only. */
|
||||
export type BackendNodeId = number;
|
||||
|
||||
export interface RefEntry {
|
||||
export interface DomRefEntry {
|
||||
readonly kind: "dom";
|
||||
name?: string;
|
||||
backendNodeId: BackendNodeId;
|
||||
tabId: number | null;
|
||||
@@ -22,7 +14,15 @@ export interface RefEntry {
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface VisualRefInput {
|
||||
readonly kind: "visual-region";
|
||||
readonly candidate: VisualCandidate;
|
||||
}
|
||||
|
||||
export type RefEntry = DomRefEntry | (VisualRefInput & { readonly generation: number });
|
||||
|
||||
export type RefInput =
|
||||
| VisualRefInput
|
||||
| BackendNodeId
|
||||
| {
|
||||
name?: string;
|
||||
@@ -33,7 +33,7 @@ export type RefInput =
|
||||
};
|
||||
|
||||
export class RefStore {
|
||||
private readonly map = new Map<string, RefEntry>();
|
||||
private map = new Map<string, RefEntry>();
|
||||
private generation = 0;
|
||||
|
||||
size(): number {
|
||||
@@ -46,7 +46,7 @@ export class RefStore {
|
||||
|
||||
resolve(ref: string, opts: { tabId?: number } = {}): BackendNodeId | null {
|
||||
const entry = this.map.get(normaliseRef(ref));
|
||||
if (!entry) return null;
|
||||
if (!entry || entry.kind !== "dom") return null;
|
||||
if (opts.tabId !== undefined && entry.tabId !== opts.tabId) return null;
|
||||
return entry.backendNodeId;
|
||||
}
|
||||
@@ -60,9 +60,11 @@ export class RefStore {
|
||||
* Used after every fresh `tool.snapshot`.
|
||||
*/
|
||||
replace(entries: Iterable<readonly [string, RefInput]>): void {
|
||||
this.map.clear();
|
||||
this.generation += 1;
|
||||
for (const [ref, input] of entries) this.map.set(normaliseRef(ref), this.entry(input));
|
||||
const generation = this.generation + 1;
|
||||
const next = new Map<string, RefEntry>();
|
||||
for (const [ref, input] of entries) next.set(normaliseRef(ref), this.entry(input, generation));
|
||||
this.map = next;
|
||||
this.generation = generation;
|
||||
}
|
||||
|
||||
set(
|
||||
@@ -75,6 +77,7 @@ export class RefStore {
|
||||
} = {},
|
||||
): void {
|
||||
this.map.set(normaliseRef(ref), {
|
||||
kind: "dom",
|
||||
backendNodeId: id,
|
||||
tabId: opts.tabId ?? null,
|
||||
...(opts.frameId ? { frameId: opts.frameId } : {}),
|
||||
@@ -91,21 +94,38 @@ export class RefStore {
|
||||
return this.map.entries();
|
||||
}
|
||||
|
||||
private entry(input: RefInput): RefEntry {
|
||||
private entry(input: RefInput, generation: number): RefEntry {
|
||||
if (typeof input !== "number" && "kind" in input) {
|
||||
const { document } = input.candidate;
|
||||
if (
|
||||
!document?.attachmentId ||
|
||||
!document.frameId ||
|
||||
!Number.isSafeInteger(document.target?.tabId) ||
|
||||
!Number.isSafeInteger(document.documentElementBackendNodeId) ||
|
||||
document.documentElementBackendNodeId <= 0 ||
|
||||
!Number.isSafeInteger(input.candidate.backendNodeId) ||
|
||||
input.candidate.backendNodeId <= 0
|
||||
)
|
||||
throw new TypeError("visual ref requires a verified DOM identity and anchor");
|
||||
// Preserve the read-only evidence and shared clipping chain; do not clone ancestors per ref.
|
||||
return { kind: "visual-region", candidate: input.candidate, generation };
|
||||
}
|
||||
if (typeof input === "number") {
|
||||
return {
|
||||
kind: "dom",
|
||||
backendNodeId: input,
|
||||
tabId: null,
|
||||
generation: this.generation,
|
||||
generation,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "dom",
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
backendNodeId: input.backendNodeId,
|
||||
tabId: input.tabId,
|
||||
...(input.frameId ? { frameId: input.frameId } : {}),
|
||||
...(input.cdpSessionId ? { cdpSessionId: input.cdpSessionId } : {}),
|
||||
generation: this.generation,
|
||||
generation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +607,7 @@ describe("handleRequestHelp", () => {
|
||||
agentWindowId: 99,
|
||||
refStore: {
|
||||
resolveEntry: () => ({
|
||||
kind: "dom",
|
||||
backendNodeId: 42,
|
||||
tabId: 5,
|
||||
frameId: "child",
|
||||
|
||||
@@ -3813,7 +3813,9 @@ describe("handleSnapshot", () => {
|
||||
|
||||
if ("code" in result) throw new Error(`unexpected error: ${JSON.stringify(result)}`);
|
||||
expect(result.text).toContain('@e1 button "Frame action"');
|
||||
const frameRef = [...ctx.refStore.entries()].find(([, entry]) => entry.backendNodeId === 22);
|
||||
const frameRef = [...ctx.refStore.entries()].find(
|
||||
([, entry]) => entry.kind === "dom" && entry.backendNodeId === 22,
|
||||
);
|
||||
expect(frameRef?.[1]).toMatchObject({
|
||||
tabId: 4,
|
||||
frameId: "child",
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import { RefStore, type VisualRefInput } from "@/session-manager/ref-store";
|
||||
import { handleDownload } from "../download";
|
||||
import { handleRequestHelp, resetHelpLifecycleForTests } from "../human-loop";
|
||||
import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "../interaction";
|
||||
import { handleGetHtml, handleScreenshot } from "../observation";
|
||||
import type { CdpRunner } from "../shared";
|
||||
import { lookupRefTarget, lookupSnapshotRef, resolveSnapshotRef } from "../snapshot-ref";
|
||||
import { handleUpload } from "../upload";
|
||||
|
||||
function visual(): VisualRefInput {
|
||||
return {
|
||||
kind: "visual-region",
|
||||
candidate: {
|
||||
document: {
|
||||
attachmentId: "a",
|
||||
target: { tabId: 4, sessionId: "child" },
|
||||
frameId: "frame",
|
||||
documentElementBackendNodeId: 1,
|
||||
},
|
||||
backendNodeId: 42,
|
||||
parentBackendNodeId: 1,
|
||||
region: {
|
||||
status: "available",
|
||||
borderBox: { x: 0, y: 0, width: 100, height: 50 },
|
||||
crop: { x: 0, y: 0, width: 100, height: 50 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const manager = new SessionManager({
|
||||
agentWindow: {
|
||||
create: async () => 100,
|
||||
remove: async () => {},
|
||||
ensureActiveTab: async () => 4,
|
||||
},
|
||||
});
|
||||
const ctx = await manager.start("test");
|
||||
ctx.refStore.replace([
|
||||
["e1", visual()],
|
||||
["e2", { backendNodeId: 43, tabId: 4 }],
|
||||
]);
|
||||
const send = vi.fn(async () => {
|
||||
throw new Error("unexpected target operation");
|
||||
});
|
||||
const cdp = { send, sendToTarget: send, trackSessionTab: vi.fn() } as unknown as CdpRunner;
|
||||
const tab = { id: 4, windowId: 100, active: true, url: "https://example.com" } as chrome.tabs.Tab;
|
||||
const tabsApi = { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) };
|
||||
return { manager, ctx, send, cdp, tabsApi };
|
||||
}
|
||||
|
||||
describe("typed visual refs", () => {
|
||||
afterEach(() => resetHelpLifecycleForTests());
|
||||
|
||||
it("keeps candidate evidence and cannot resolve a visual anchor as a bare DOM node", () => {
|
||||
const store = new RefStore();
|
||||
const input = visual();
|
||||
store.replace([
|
||||
["@e1", input],
|
||||
["e2", { backendNodeId: 43, tabId: 4 }],
|
||||
]);
|
||||
const entry = store.resolveEntry("e1");
|
||||
expect(entry?.kind).toBe("visual-region");
|
||||
if (entry?.kind !== "visual-region") throw new Error("missing visual ref");
|
||||
expect(entry.candidate).toBe(input.candidate);
|
||||
expect(entry.generation).toBe(1);
|
||||
expect(store.resolve("@e1")).toBeNull();
|
||||
expect(store.resolve("e2")).toBe(43);
|
||||
// The latest observation replaces both target kinds; eN strings carry no generation.
|
||||
store.replace([["e1", { backendNodeId: 99, tabId: 4 }]]);
|
||||
expect(store.resolve("e1")).toBe(99);
|
||||
expect(store.resolveEntry("e1")).toMatchObject({ kind: "dom", generation: 2 });
|
||||
expect(store.resolveEntry("e2")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing visual identity without partially publishing a replacement", () => {
|
||||
const store = new RefStore();
|
||||
store.set("e1", 10, { tabId: 4 });
|
||||
const invalid = visual();
|
||||
invalid.candidate.document.attachmentId = "";
|
||||
expect(() =>
|
||||
store.replace([
|
||||
["e2", 20],
|
||||
["e3", invalid],
|
||||
]),
|
||||
).toThrow(TypeError);
|
||||
expect(store.resolveEntry("e1")).toMatchObject({ generation: 0, backendNodeId: 10 });
|
||||
expect(store.size()).toBe(1);
|
||||
store.replace([["e1", visual()]]);
|
||||
expect(store.resolveEntry("e1")?.generation).toBe(1);
|
||||
});
|
||||
|
||||
it("isolates tabs/sessions and distinguishes unsupported targets from missing refs", async () => {
|
||||
const { ctx } = await setup();
|
||||
expect(lookupRefTarget(ctx, "@e1", 4)?.kind).toBe("visual-region");
|
||||
expect(lookupRefTarget(ctx, "e1", 5)).toBeNull();
|
||||
expect(lookupSnapshotRef(ctx, "e1", 4)).toBeNull();
|
||||
expect(resolveSnapshotRef(ctx, "e1", 4)).toMatchObject({
|
||||
code: "unsupported",
|
||||
data: { reason: "ref_kind_unsupported" },
|
||||
});
|
||||
expect(resolveSnapshotRef(ctx, "e1", 5)).toMatchObject({
|
||||
code: "not_found",
|
||||
data: { reason: "ref_not_found" },
|
||||
});
|
||||
expect(lookupRefTarget({ ...ctx, refStore: new RefStore() }, "e1", 4)).toBeNull();
|
||||
expect(lookupSnapshotRef(ctx, "e2", 4)).toMatchObject({ backendNodeId: 43 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"click",
|
||||
"fill",
|
||||
"hover",
|
||||
"press",
|
||||
"select",
|
||||
"get_html",
|
||||
"screenshot",
|
||||
])("rejects visual refs in %s before target effects", async (tool) => {
|
||||
const { manager, send, cdp, tabsApi } = await setup();
|
||||
const params = { session_id: "test", tab_id: 4, ref: "@e1" };
|
||||
const deps = { cdp, tabsApi };
|
||||
const captureVisibleTab = vi.fn();
|
||||
let result: unknown;
|
||||
switch (tool) {
|
||||
case "click":
|
||||
result = await handleClick(manager, params, deps);
|
||||
break;
|
||||
case "fill":
|
||||
result = await handleFill(manager, { ...params, value: "hello" }, deps);
|
||||
break;
|
||||
case "hover":
|
||||
result = await handleHover(manager, params, deps);
|
||||
break;
|
||||
case "press":
|
||||
result = await handlePress(manager, { ...params, key: "Enter" }, deps);
|
||||
break;
|
||||
case "select":
|
||||
result = await handleSelect(manager, { ...params, values: ["one"] }, deps);
|
||||
break;
|
||||
case "get_html":
|
||||
result = await handleGetHtml(manager, params, deps);
|
||||
break;
|
||||
case "screenshot":
|
||||
result = await handleScreenshot(manager, params, {
|
||||
...deps,
|
||||
captureApi: { ...tabsApi, captureVisibleTab },
|
||||
});
|
||||
break;
|
||||
}
|
||||
expect(result).toMatchObject({ code: "unsupported", data: { reason: "ref_kind_unsupported" } });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(captureVisibleTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"input",
|
||||
"drop",
|
||||
"download",
|
||||
] as const)("rejects visual refs before %s file-transfer setup", async (mode) => {
|
||||
const { manager, send, cdp, tabsApi } = await setup();
|
||||
const params = { session_id: "test", tab_id: 4, ref: "e1" };
|
||||
const result =
|
||||
mode === "download"
|
||||
? await handleDownload(
|
||||
manager,
|
||||
{ ...params, browser_relative_dir: "test" },
|
||||
{ cdp, tabsApi },
|
||||
)
|
||||
: await handleUpload(
|
||||
manager,
|
||||
{
|
||||
...params,
|
||||
mode,
|
||||
files: [{ transfer_id: "test", name: "test.txt", staged_path: "/unused/test.txt" }],
|
||||
},
|
||||
{ cdp, tabsApi },
|
||||
);
|
||||
expect(result).toMatchObject({ code: "unsupported", data: { reason: "ref_kind_unsupported" } });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not scroll or highlight a visual anchor in human help", async () => {
|
||||
const { manager, cdp, tabsApi, send } = await setup();
|
||||
const sendToTab = vi.fn(async (_tab: number, _message: unknown) => ({
|
||||
type: "bsk-help-response",
|
||||
outcome: "continued",
|
||||
}));
|
||||
const result = await handleRequestHelp(
|
||||
manager,
|
||||
{ session_id: "test", tab_id: 4, prompt: "help", targets: [{ ref: "@e1" }] },
|
||||
{
|
||||
cdp,
|
||||
tabsApi,
|
||||
sendToTab,
|
||||
windows: { update: vi.fn(async () => ({}) as never) },
|
||||
activateTab: vi.fn(async () => {}),
|
||||
notifications: null,
|
||||
autoAttachLifecycle: false,
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({ resolved_targets: [{ ref: "@e1", matched: false }] });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(sendToTab.mock.calls[0][1]).toMatchObject({ rects: [], selectors: [] });
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,8 @@ export async function auditContext(
|
||||
operation_id: params._audit_id,
|
||||
tab_id: tab.id,
|
||||
...(url ? { url } : {}),
|
||||
...(ref?.tabId === tab.id && ref.name ? { target: ref.name.slice(0, 160) } : {}),
|
||||
...(ref?.kind === "dom" && ref.tabId === tab.id && ref.name
|
||||
? { target: ref.name.slice(0, 160) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
normaliseRef as sharedNormaliseRef,
|
||||
type ToolEffect,
|
||||
} from "./shared";
|
||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||
import { lookupRefTarget, resolveSnapshotRef } from "./snapshot-ref";
|
||||
import { type CapturedNode, type CapturedSurfaceProbe, probeHoverSurfaces } from "./vom/capture";
|
||||
import { captureObservationFacts, semanticCapture } from "./vom/capture-coordinator";
|
||||
import type { FrameDocument as CapturedFrameDocument } from "./vom/frame-document";
|
||||
@@ -308,6 +308,12 @@ export async function handleScreenshot(
|
||||
if (!deps.cdp) {
|
||||
return { code: "cdp_failed", message: "screenshot ref capture requires CDP" };
|
||||
}
|
||||
if (lookupRefTarget(ctx, ref, target.tabId)?.kind === "visual-region")
|
||||
return rpcError(
|
||||
"unsupported",
|
||||
"ref_kind_unsupported",
|
||||
"visual-region screenshot execution is not available in this build",
|
||||
);
|
||||
const node = resolveSnapshotRef(ctx, ref, target.tabId);
|
||||
if (isRpcError(node)) return node;
|
||||
if (signal?.aborted) return cancelled("screenshot");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// `ref_not_found` errors for hard-failure tool paths.
|
||||
|
||||
import type { SessionContext } from "@/session-manager/manager";
|
||||
import { normaliseRef } from "@/session-manager/ref-store";
|
||||
import { normaliseRef, type RefEntry } from "@/session-manager/ref-store";
|
||||
import type { RpcError } from "@/transport/types";
|
||||
import { rpcError } from "./errors";
|
||||
|
||||
@@ -14,14 +14,22 @@ export interface SnapshotRefLookup {
|
||||
cdpSessionId?: string;
|
||||
}
|
||||
|
||||
function refEntryForTab(ctx: SessionContext, refKey: string, tabId: number) {
|
||||
/** Typed lookup never turns a visual anchor into a DOM operation target. */
|
||||
export function lookupRefTarget(
|
||||
ctx: SessionContext,
|
||||
refKey: string,
|
||||
tabId: number,
|
||||
): RefEntry | null {
|
||||
const entry = ctx.refStore.resolveEntry(refKey);
|
||||
return entry?.tabId === tabId ? entry : null;
|
||||
if (!entry) return null;
|
||||
const ownerTabId =
|
||||
entry.kind === "visual-region" ? entry.candidate.document.target.tabId : entry.tabId;
|
||||
return ownerTabId === tabId ? entry : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft lookup: returns `null` when the ref is unknown or bound to a
|
||||
* different tab. Used by paths that report `matched: false` instead of
|
||||
* different tab, or is a visual region. Used by paths that report `matched: false` instead of
|
||||
* emitting an RPC error (e.g. `tool.request_help`).
|
||||
*/
|
||||
export function lookupSnapshotRef(
|
||||
@@ -30,8 +38,8 @@ export function lookupSnapshotRef(
|
||||
tabId: number,
|
||||
): SnapshotRefLookup | null {
|
||||
const refKey = normaliseRef(ref);
|
||||
const entry = refEntryForTab(ctx, refKey, tabId);
|
||||
if (!entry) return null;
|
||||
const entry = lookupRefTarget(ctx, refKey, tabId);
|
||||
if (!entry || entry.kind !== "dom") return null;
|
||||
return {
|
||||
backendNodeId: entry.backendNodeId,
|
||||
refKey,
|
||||
@@ -50,6 +58,13 @@ export function resolveSnapshotRef(
|
||||
ref: string,
|
||||
tabId: number,
|
||||
): SnapshotRefLookup | RpcError {
|
||||
const entry = lookupRefTarget(ctx, ref, tabId);
|
||||
if (entry?.kind === "visual-region")
|
||||
return rpcError(
|
||||
"unsupported",
|
||||
"ref_kind_unsupported",
|
||||
`ref ${ref} is a visual region, not a DOM operation target`,
|
||||
);
|
||||
const looked = lookupSnapshotRef(ctx, ref, tabId);
|
||||
if (looked === null) {
|
||||
return rpcError(
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CdpRunner } from "../../shared";
|
||||
import { verifyDocumentIdentity, verifyVisualTargetIdentity } from "../document-identity";
|
||||
import type { VisualCandidate } from "../visual-discovery";
|
||||
|
||||
const candidate: VisualCandidate = {
|
||||
document: {
|
||||
attachmentId: "attached",
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
frameId: "child-frame",
|
||||
documentElementBackendNodeId: 10,
|
||||
},
|
||||
backendNodeId: 20,
|
||||
parentBackendNodeId: 10,
|
||||
region: {
|
||||
status: "available",
|
||||
borderBox: { x: 0, y: 0, width: 10, height: 10 },
|
||||
crop: { x: 0, y: 0, width: 10, height: 10 },
|
||||
},
|
||||
};
|
||||
|
||||
function fixture(
|
||||
options: {
|
||||
connected?: boolean;
|
||||
sameDocument?: boolean;
|
||||
root?: number;
|
||||
missing?: boolean;
|
||||
fail?: string;
|
||||
abortAt?: string;
|
||||
detachAt?: string;
|
||||
} = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
let attachment = "attached";
|
||||
const send = vi.fn(
|
||||
async (_tab: number, _session: string, method: string, params: Record<string, unknown>) => {
|
||||
if (options.abortAt === method) controller.abort();
|
||||
if (options.detachAt === method) attachment = "new-attachment";
|
||||
if (options.fail === method) throw new Error("CDP failed");
|
||||
if (method === "Page.createIsolatedWorld") return { executionContextId: 7 };
|
||||
if (method === "DOM.resolveNode")
|
||||
return options.missing ? {} : { object: { objectId: "anchor" } };
|
||||
if (method === "Runtime.evaluate" || method === "Runtime.callFunctionOn") {
|
||||
let root: unknown = {};
|
||||
if (method === "Runtime.callFunctionOn") {
|
||||
const document = { documentElement: {} };
|
||||
// Exercise the actual predicate, including connection and owner checks.
|
||||
root = new Function("document", `return (${params.functionDeclaration}).call(this)`).call(
|
||||
{
|
||||
isConnected: options.connected ?? true,
|
||||
ownerDocument: options.sameDocument === false ? {} : document,
|
||||
},
|
||||
document,
|
||||
);
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
deepSerializedValue:
|
||||
root === null
|
||||
? { type: "null" }
|
||||
: { type: "node", value: { backendNodeId: options.root ?? 10 } },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "Runtime.releaseObjectGroup") return {};
|
||||
throw new Error(`unexpected ${method}`);
|
||||
},
|
||||
);
|
||||
const cdp = {
|
||||
send: vi.fn(),
|
||||
sendToTarget: (
|
||||
target: { tabId: number; sessionId?: string },
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
) => send(target.tabId, target.sessionId!, method, params),
|
||||
getAttachmentId: () => attachment,
|
||||
} as unknown as CdpRunner;
|
||||
return { cdp, send, controller };
|
||||
}
|
||||
|
||||
describe("visual target identity", () => {
|
||||
it("uses the exact frame and target with bounded local reads and releases objects", async () => {
|
||||
const { cdp, send } = fixture();
|
||||
expect(await verifyVisualTargetIdentity(cdp, candidate)).toBe("current");
|
||||
expect(send.mock.calls.map((c) => c[2])).toEqual([
|
||||
"Page.createIsolatedWorld",
|
||||
"DOM.resolveNode",
|
||||
"Runtime.callFunctionOn",
|
||||
"Runtime.releaseObjectGroup",
|
||||
]);
|
||||
expect(
|
||||
send.mock.calls.every(([tab, session]) => tab === 4 && session === "child-session"),
|
||||
).toBe(true);
|
||||
expect(send.mock.calls[0][3]).toMatchObject({ frameId: "child-frame" });
|
||||
expect(send.mock.calls[1][3]).toMatchObject({ backendNodeId: 20, executionContextId: 7 });
|
||||
expect(send.mock.calls[2][3]).toMatchObject({ objectId: "anchor" });
|
||||
expect(send.mock.calls[3][3].objectGroup).toBe(send.mock.calls[1][3].objectGroup);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ connected: false },
|
||||
{ sameDocument: false },
|
||||
{ root: 11 },
|
||||
])("rejects detached/adopted/replaced DOM: %j", async (options) => {
|
||||
const { cdp } = fixture(options);
|
||||
expect(await verifyVisualTargetIdentity(cdp, candidate)).toBe("changed");
|
||||
});
|
||||
|
||||
it("does not add anchor reads to existing capture identity verification", async () => {
|
||||
const { cdp, send } = fixture();
|
||||
expect(await verifyDocumentIdentity(cdp, candidate.document)).toBe("current");
|
||||
expect(send.mock.calls.map((c) => c[2])).toEqual([
|
||||
"Page.createIsolatedWorld",
|
||||
"Runtime.evaluate",
|
||||
"Runtime.releaseObjectGroup",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Page.createIsolatedWorld",
|
||||
"DOM.resolveNode",
|
||||
"Runtime.callFunctionOn",
|
||||
])("fails closed and cleans up when %s fails", async (fail) => {
|
||||
const { cdp, send } = fixture({ fail });
|
||||
expect(await verifyVisualTargetIdentity(cdp, candidate)).toBe("unavailable");
|
||||
expect(send.mock.calls.at(-1)?.[2]).toBe("Runtime.releaseObjectGroup");
|
||||
});
|
||||
|
||||
it("rejects attachment changes before and during verification", async () => {
|
||||
const first = fixture();
|
||||
expect(
|
||||
await verifyVisualTargetIdentity({ ...first.cdp, getAttachmentId: () => "other" }, candidate),
|
||||
).toBe("changed");
|
||||
expect(first.send).not.toHaveBeenCalled();
|
||||
const second = fixture({ detachAt: "Runtime.callFunctionOn" });
|
||||
expect(await verifyVisualTargetIdentity(second.cdp, candidate)).toBe("changed");
|
||||
const missing = fixture({ missing: true });
|
||||
expect(await verifyVisualTargetIdentity(missing.cdp, candidate)).toBe("unavailable");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"DOM.resolveNode",
|
||||
"Runtime.callFunctionOn",
|
||||
])("propagates cancellation at %s after releasing the object group", async (abortAt) => {
|
||||
const { cdp, send, controller } = fixture({ abortAt });
|
||||
await expect(
|
||||
verifyVisualTargetIdentity(cdp, candidate, controller.signal),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(send.mock.calls.at(-1)?.[2]).toBe("Runtime.releaseObjectGroup");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { CdpRunner } from "../shared";
|
||||
import { sendToCdpTarget } from "../shared";
|
||||
import { isAbortError, throwIfAborted } from "./capture-abort";
|
||||
import type { DocumentIdentity } from "./facts";
|
||||
import type { VisualCandidate } from "./visual-discovery";
|
||||
|
||||
/** Compare the snapshot root with the current root in that exact frame.
|
||||
* Deep serialization supplies the backend ID without a separate describeNode. */
|
||||
@@ -9,6 +10,24 @@ export async function verifyDocumentIdentity(
|
||||
cdp: CdpRunner,
|
||||
identity: DocumentIdentity,
|
||||
signal?: AbortSignal,
|
||||
): Promise<"current" | "changed" | "unavailable"> {
|
||||
return verifyIdentity(cdp, identity, signal);
|
||||
}
|
||||
|
||||
/** Verify the visual anchor in its original frame; geometry is checked by screenshot execution. */
|
||||
export async function verifyVisualTargetIdentity(
|
||||
cdp: CdpRunner,
|
||||
candidate: VisualCandidate,
|
||||
signal?: AbortSignal,
|
||||
): Promise<"current" | "changed" | "unavailable"> {
|
||||
return verifyIdentity(cdp, candidate.document, signal, candidate.backendNodeId);
|
||||
}
|
||||
|
||||
async function verifyIdentity(
|
||||
cdp: CdpRunner,
|
||||
identity: DocumentIdentity,
|
||||
signal?: AbortSignal,
|
||||
anchorBackendNodeId?: number,
|
||||
): Promise<"current" | "changed" | "unavailable"> {
|
||||
const attached = () => cdp.getAttachmentId?.(identity.target.tabId) === identity.attachmentId;
|
||||
throwIfAborted(signal);
|
||||
@@ -23,17 +42,36 @@ export async function verifyDocumentIdentity(
|
||||
frameId: identity.frameId,
|
||||
worldName: "bsk-document-identity",
|
||||
});
|
||||
const reply = await send<{
|
||||
const serializationOptions = {
|
||||
serialization: "deep",
|
||||
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||
};
|
||||
type RootReply = {
|
||||
result?: { deepSerializedValue?: { type: string; value?: { backendNodeId?: number } } };
|
||||
}>("Runtime.evaluate", {
|
||||
expression: "document.documentElement",
|
||||
contextId: world.executionContextId,
|
||||
objectGroup,
|
||||
serializationOptions: {
|
||||
serialization: "deep",
|
||||
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||
},
|
||||
});
|
||||
};
|
||||
let reply: RootReply;
|
||||
if (anchorBackendNodeId === undefined) {
|
||||
reply = await send<RootReply>("Runtime.evaluate", {
|
||||
expression: "document.documentElement",
|
||||
contextId: world.executionContextId,
|
||||
objectGroup,
|
||||
serializationOptions,
|
||||
});
|
||||
} else {
|
||||
const anchor = await send<{ object?: { objectId?: string } }>("DOM.resolveNode", {
|
||||
backendNodeId: anchorBackendNodeId,
|
||||
executionContextId: world.executionContextId,
|
||||
objectGroup,
|
||||
});
|
||||
if (!anchor.object?.objectId) return "unavailable";
|
||||
reply = await send<RootReply>("Runtime.callFunctionOn", {
|
||||
objectId: anchor.object.objectId,
|
||||
functionDeclaration:
|
||||
"function() { return this.isConnected && this.ownerDocument === document ? document.documentElement : null; }",
|
||||
objectGroup,
|
||||
serializationOptions,
|
||||
});
|
||||
}
|
||||
if (!attached()) return "changed";
|
||||
const root = reply.result?.deepSerializedValue;
|
||||
if (root?.type === "null") return "changed";
|
||||
|
||||
@@ -24,6 +24,7 @@ export type RpcErrorReason =
|
||||
| "agent_window_scope"
|
||||
| "element_not_visible"
|
||||
| "ref_not_found"
|
||||
| "ref_kind_unsupported"
|
||||
| "selector_not_found"
|
||||
| "target_not_fillable"
|
||||
| "fill_value_invalid"
|
||||
|
||||
@@ -40,6 +40,7 @@ use bsk_protocol::ErrorCode;
|
||||
pub mod reason {
|
||||
pub const AGENT_WINDOW_SCOPE: &str = "agent_window_scope";
|
||||
pub const ELEMENT_NOT_VISIBLE: &str = "element_not_visible";
|
||||
pub const REF_KIND_UNSUPPORTED: &str = "ref_kind_unsupported";
|
||||
pub const REF_NOT_FOUND: &str = "ref_not_found";
|
||||
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
|
||||
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
|
||||
@@ -278,6 +279,13 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(ErrorCode::Unsupported, reason::REF_KIND_UNSUPPORTED) => RenderInfo {
|
||||
summary: "this tool does not support the ref target type",
|
||||
hint: Some(
|
||||
"use a DOM ref for DOM operations; visual-region refs require an available visual screenshot path",
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(ErrorCode::NotFound, reason::REF_NOT_FOUND) => RenderInfo {
|
||||
summary: "snapshot ref was not found for this tab",
|
||||
hint: Some("rerun `bsk snapshot` for the current tab and use one of the returned refs"),
|
||||
@@ -695,6 +703,15 @@ mod tests {
|
||||
assert!(info.summary.contains("timed out after browser dispatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_ref_type_error_has_specific_guidance() {
|
||||
let data = serde_json::json!({ "reason": reason::REF_KIND_UNSUPPORTED });
|
||||
let info = info_for_error(ErrorCode::Unsupported, Some(&data));
|
||||
assert!(info.summary.contains("ref target type"));
|
||||
assert!(info.hint.unwrap().contains("DOM ref"));
|
||||
assert_eq!(info.exit_code, info_for(ErrorCode::Unsupported).exit_code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_not_found_overrides_not_found_copy() {
|
||||
let data = serde_json::json!({ "reason": reason::REF_NOT_FOUND });
|
||||
|
||||
Reference in New Issue
Block a user