mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(vom): actions refactor
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
parseKeySpec,
|
parseKeySpec,
|
||||||
resolveKeyDescriptor,
|
resolveKeyDescriptor,
|
||||||
} from "../interaction";
|
} from "../interaction";
|
||||||
|
import { captureSurfaceEnvironment } from "../surface-target";
|
||||||
|
|
||||||
function fakeAgentWindow(ids: number[]) {
|
function fakeAgentWindow(ids: number[]) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -54,6 +55,61 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
|
|||||||
return { cdp, tabsApi, sent };
|
return { cdp, tabsApi, sent };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function makeSurfaceClickFixture(onMouse?: (params: Record<string, unknown>) => void) {
|
||||||
|
const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const context = await manager.start("aa11");
|
||||||
|
context.refStore.set("e3", 99, {
|
||||||
|
tabId: 4,
|
||||||
|
kind: "surface",
|
||||||
|
visibleRect: { x: 10, y: 20, w: 100, h: 40 },
|
||||||
|
});
|
||||||
|
const fake = makeFakeCdp({
|
||||||
|
"Page.getFrameTree": () => ({
|
||||||
|
frameTree: {
|
||||||
|
frame: { id: "main", loaderId: "loader-1", url: "https://fixture.test/" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"Page.getLayoutMetrics": () => ({
|
||||||
|
cssLayoutViewport: { clientWidth: 800, clientHeight: 600, pageX: 0, pageY: 0 },
|
||||||
|
cssVisualViewport: {
|
||||||
|
clientWidth: 800,
|
||||||
|
clientHeight: 600,
|
||||||
|
pageX: 0,
|
||||||
|
pageY: 0,
|
||||||
|
scale: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
|
||||||
|
"Runtime.evaluate": () => ({
|
||||||
|
result: { value: { overlayHostPresent: false, overlayHostConnected: false } },
|
||||||
|
}),
|
||||||
|
"Input.dispatchMouseEvent": (params) => {
|
||||||
|
onMouse?.(params as Record<string, unknown>);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const environment = await captureSurfaceEnvironment(fake.cdp, 4, undefined);
|
||||||
|
if ("code" in environment) throw new Error(environment.message);
|
||||||
|
const entry = context.refStore.resolveEntry("e3");
|
||||||
|
if (!entry) throw new Error("missing Surface ref");
|
||||||
|
const capture = context.surfaceCaptures.create({
|
||||||
|
sessionId: "aa11",
|
||||||
|
tabId: 4,
|
||||||
|
navigationIdentity: environment.navigationIdentity,
|
||||||
|
surface: {
|
||||||
|
ref: "e3",
|
||||||
|
backendNodeId: 99,
|
||||||
|
observationGeneration: entry.generation,
|
||||||
|
},
|
||||||
|
topViewportRect: { x: 10, y: 20, w: 100, h: 40 },
|
||||||
|
imageWidth: 200,
|
||||||
|
imageHeight: 80,
|
||||||
|
viewportSignature: environment.viewportSignature,
|
||||||
|
frameProjectionSignature: environment.frameProjectionSignature,
|
||||||
|
});
|
||||||
|
return { manager, context, fake, capture };
|
||||||
|
}
|
||||||
|
|
||||||
describe("modifiersBitfield", () => {
|
describe("modifiersBitfield", () => {
|
||||||
it("matches CDP's expected bit layout", () => {
|
it("matches CDP's expected bit layout", () => {
|
||||||
expect(modifiersBitfield([])).toBe(0);
|
expect(modifiersBitfield([])).toBe(0);
|
||||||
@@ -190,6 +246,74 @@ describe("handleClick", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"left",
|
||||||
|
"middle",
|
||||||
|
"right",
|
||||||
|
] as const)("uses the common click executor for a %s-button Surface click", async (button) => {
|
||||||
|
const { manager, fake, capture } = await makeSurfaceClickFixture();
|
||||||
|
const result = await handleClick(
|
||||||
|
manager,
|
||||||
|
{
|
||||||
|
session_id: "aa11",
|
||||||
|
ref: "@e3",
|
||||||
|
capture_id: capture.id,
|
||||||
|
image_x: 50,
|
||||||
|
image_y: 20,
|
||||||
|
button,
|
||||||
|
click_count: 2,
|
||||||
|
modifiers: ["ctrl", "shift"],
|
||||||
|
},
|
||||||
|
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("code" in result) throw new Error(JSON.stringify(result));
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
used_ref: "e3",
|
||||||
|
capture_id: capture.id,
|
||||||
|
image_x: 50,
|
||||||
|
image_y: 20,
|
||||||
|
x: 35,
|
||||||
|
y: 30,
|
||||||
|
});
|
||||||
|
const mouse = fake.sent.filter((call) => call.method === "Input.dispatchMouseEvent");
|
||||||
|
expect(mouse).toHaveLength(3);
|
||||||
|
expect(mouse[1].params).toMatchObject({
|
||||||
|
type: "mousePressed",
|
||||||
|
button,
|
||||||
|
clickCount: 2,
|
||||||
|
modifiers: 2 | 8,
|
||||||
|
});
|
||||||
|
expect(mouse[2].params).toMatchObject({
|
||||||
|
type: "mouseReleased",
|
||||||
|
button,
|
||||||
|
clickCount: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes a Surface capture when the common click executor fails", async () => {
|
||||||
|
let mouseCalls = 0;
|
||||||
|
const { manager, fake, capture } = await makeSurfaceClickFixture((params) => {
|
||||||
|
mouseCalls += 1;
|
||||||
|
if (params.type === "mousePressed") throw new Error("input failed");
|
||||||
|
});
|
||||||
|
const params = {
|
||||||
|
session_id: "aa11",
|
||||||
|
ref: "@e3",
|
||||||
|
capture_id: capture.id,
|
||||||
|
image_x: 50,
|
||||||
|
image_y: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await handleClick(manager, params, { cdp: fake.cdp, tabsApi: fake.tabsApi }),
|
||||||
|
).toMatchObject({ code: "cdp_failed" });
|
||||||
|
expect(mouseCalls).toBe(2);
|
||||||
|
expect(
|
||||||
|
await handleClick(manager, params, { cdp: fake.cdp, tabsApi: fake.tabsApi }),
|
||||||
|
).toMatchObject({ data: { reason: "surface_capture_consumed" } });
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves frame refs in their CDP session and dispatches input in top coordinates", async () => {
|
it("resolves frame refs in their CDP session and dispatches input in top coordinates", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
const ctx = await sm.start("aa11");
|
const ctx = await sm.start("aa11");
|
||||||
|
|||||||
+61
-68
@@ -1,7 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { SessionManager } from "@/session-manager/manager";
|
import { SessionManager } from "@/session-manager/manager";
|
||||||
import type { CdpRunner } from "../shared";
|
import type { CdpRunner } from "../shared";
|
||||||
import { captureSurfaceEnvironment, handleSurfacePointClick } from "../surface-point-action";
|
import {
|
||||||
|
captureSurfaceEnvironment,
|
||||||
|
resolveSurfacePointerTarget,
|
||||||
|
SURFACE_POINTER_MOVE_SETTLE_MS,
|
||||||
|
} from "../surface-target";
|
||||||
|
|
||||||
function fakeAgentWindow() {
|
function fakeAgentWindow() {
|
||||||
return {
|
return {
|
||||||
@@ -15,7 +19,6 @@ function fakeBrowser() {
|
|||||||
let loaderId = "loader-1";
|
let loaderId = "loader-1";
|
||||||
let pageY = 0;
|
let pageY = 0;
|
||||||
let quad = [10, 20, 110, 20, 110, 60, 10, 60];
|
let quad = [10, 20, 110, 20, 110, 60, 10, 60];
|
||||||
let dispatchFails = false;
|
|
||||||
const sent: Array<{ method: string; params?: object }> = [];
|
const sent: Array<{ method: string; params?: object }> = [];
|
||||||
const cdp: CdpRunner = {
|
const cdp: CdpRunner = {
|
||||||
send: vi.fn(async (_tabId: number, method: string, params?: object) => {
|
send: vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||||
@@ -38,23 +41,12 @@ function fakeBrowser() {
|
|||||||
} as never;
|
} as never;
|
||||||
}
|
}
|
||||||
if (method === "DOM.getContentQuads") return { quads: [quad] } as never;
|
if (method === "DOM.getContentQuads") return { quads: [quad] } as never;
|
||||||
if (method === "Input.dispatchMouseEvent") {
|
|
||||||
if (dispatchFails) throw new Error("input failed");
|
|
||||||
return {} as never;
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected CDP call ${method}`);
|
throw new Error(`unexpected CDP call ${method}`);
|
||||||
}) as CdpRunner["send"],
|
}) as CdpRunner["send"],
|
||||||
trackSessionTab: vi.fn(),
|
trackSessionTab: vi.fn(),
|
||||||
};
|
};
|
||||||
const tabsApi = {
|
|
||||||
get: vi.fn(
|
|
||||||
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
|
|
||||||
),
|
|
||||||
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
cdp,
|
cdp,
|
||||||
tabsApi,
|
|
||||||
sent,
|
sent,
|
||||||
setLoaderId: (value: string) => {
|
setLoaderId: (value: string) => {
|
||||||
loaderId = value;
|
loaderId = value;
|
||||||
@@ -65,9 +57,6 @@ function fakeBrowser() {
|
|||||||
setQuad: (value: number[]) => {
|
setQuad: (value: number[]) => {
|
||||||
quad = value;
|
quad = value;
|
||||||
},
|
},
|
||||||
failDispatch: () => {
|
|
||||||
dispatchFails = true;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,33 +93,38 @@ async function setupCapture() {
|
|||||||
|
|
||||||
function pointParams(captureId: string) {
|
function pointParams(captureId: string) {
|
||||||
return {
|
return {
|
||||||
session_id: "aa11",
|
|
||||||
ref: "@e3",
|
ref: "@e3",
|
||||||
capture_id: captureId,
|
captureId,
|
||||||
image_x: 50,
|
imageX: 50,
|
||||||
image_y: 20,
|
imageY: 20,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Surface screenshot-bound point click", () => {
|
describe("Surface screenshot-bound pointer target", () => {
|
||||||
it("maps one fresh capture coordinate and dispatches exactly one trusted click", async () => {
|
it("maps one fresh capture coordinate without executing an action", async () => {
|
||||||
const { manager, browser, capture } = await setupCapture();
|
const { context, browser, capture } = await setupCapture();
|
||||||
const result = await handleSurfacePointClick(manager, pointParams(capture.id), browser);
|
const result = await resolveSurfacePointerTarget(
|
||||||
|
context,
|
||||||
|
{ tabId: 4 },
|
||||||
|
pointParams(capture.id),
|
||||||
|
{ cdp: browser.cdp },
|
||||||
|
);
|
||||||
|
|
||||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
if ("code" in result) throw new Error(JSON.stringify(result));
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
used_ref: "e3",
|
usedRef: "e3",
|
||||||
capture_id: capture.id,
|
point: { x: 35, y: 30 },
|
||||||
image_x: 50,
|
moveSettleMs: SURFACE_POINTER_MOVE_SETTLE_MS,
|
||||||
image_y: 20,
|
capture: { id: capture.id, imageX: 50, imageY: 20 },
|
||||||
x: 35,
|
|
||||||
y: 30,
|
|
||||||
});
|
});
|
||||||
expect(browser.sent.filter((call) => call.method === "Input.dispatchMouseEvent")).toHaveLength(
|
expect(browser.sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
||||||
3,
|
|
||||||
);
|
|
||||||
|
|
||||||
const repeated = await handleSurfacePointClick(manager, pointParams(capture.id), browser);
|
const repeated = await resolveSurfacePointerTarget(
|
||||||
|
context,
|
||||||
|
{ tabId: 4 },
|
||||||
|
pointParams(capture.id),
|
||||||
|
{ cdp: browser.cdp },
|
||||||
|
);
|
||||||
expect(repeated).toMatchObject({
|
expect(repeated).toMatchObject({
|
||||||
code: "permission_denied",
|
code: "permission_denied",
|
||||||
data: { reason: "surface_capture_consumed" },
|
data: { reason: "surface_capture_consumed" },
|
||||||
@@ -138,7 +132,7 @@ describe("Surface screenshot-bound point click", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a new observation generation before sending input", async () => {
|
it("rejects a new observation generation before sending input", async () => {
|
||||||
const { manager, context, browser, capture } = await setupCapture();
|
const { context, browser, capture } = await setupCapture();
|
||||||
context.refStore.replace([
|
context.refStore.replace([
|
||||||
[
|
[
|
||||||
"e3",
|
"e3",
|
||||||
@@ -151,7 +145,12 @@ describe("Surface screenshot-bound point click", () => {
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await handleSurfacePointClick(manager, pointParams(capture.id), browser);
|
const result = await resolveSurfacePointerTarget(
|
||||||
|
context,
|
||||||
|
{ tabId: 4 },
|
||||||
|
pointParams(capture.id),
|
||||||
|
{ cdp: browser.cdp },
|
||||||
|
);
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
code: "permission_denied",
|
code: "permission_denied",
|
||||||
data: { reason: "surface_capture_stale" },
|
data: { reason: "surface_capture_stale" },
|
||||||
@@ -166,9 +165,14 @@ describe("Surface screenshot-bound point click", () => {
|
|||||||
(browser: ReturnType<typeof fakeBrowser>) =>
|
(browser: ReturnType<typeof fakeBrowser>) =>
|
||||||
browser.setQuad([11, 20, 111, 20, 111, 60, 11, 60]),
|
browser.setQuad([11, 20, 111, 20, 111, 60, 11, 60]),
|
||||||
]) {
|
]) {
|
||||||
const { manager, browser, capture } = await setupCapture();
|
const { context, browser, capture } = await setupCapture();
|
||||||
mutate(browser);
|
mutate(browser);
|
||||||
const result = await handleSurfacePointClick(manager, pointParams(capture.id), browser);
|
const result = await resolveSurfacePointerTarget(
|
||||||
|
context,
|
||||||
|
{ tabId: 4 },
|
||||||
|
pointParams(capture.id),
|
||||||
|
{ cdp: browser.cdp },
|
||||||
|
);
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
code: "permission_denied",
|
code: "permission_denied",
|
||||||
data: { reason: "surface_capture_stale" },
|
data: { reason: "surface_capture_stale" },
|
||||||
@@ -177,33 +181,26 @@ describe("Surface screenshot-bound point click", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("consumes the capture when coordinates or input dispatch fail", async () => {
|
it("consumes the capture when coordinate validation fails", async () => {
|
||||||
const invalid = await setupCapture();
|
const invalid = await setupCapture();
|
||||||
const outside = await handleSurfacePointClick(
|
const outside = await resolveSurfacePointerTarget(
|
||||||
invalid.manager,
|
invalid.context,
|
||||||
{ ...pointParams(invalid.capture.id), image_x: 200 },
|
{ tabId: 4 },
|
||||||
invalid.browser,
|
{ ...pointParams(invalid.capture.id), imageX: 200 },
|
||||||
|
{ cdp: invalid.browser.cdp },
|
||||||
);
|
);
|
||||||
expect(outside).toMatchObject({
|
expect(outside).toMatchObject({
|
||||||
code: "invalid_params",
|
code: "invalid_params",
|
||||||
data: { reason: "surface_coordinate_invalid" },
|
data: { reason: "surface_coordinate_invalid" },
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await handleSurfacePointClick(
|
await resolveSurfacePointerTarget(
|
||||||
invalid.manager,
|
invalid.context,
|
||||||
|
{ tabId: 4 },
|
||||||
pointParams(invalid.capture.id),
|
pointParams(invalid.capture.id),
|
||||||
invalid.browser,
|
{ cdp: invalid.browser.cdp },
|
||||||
),
|
),
|
||||||
).toMatchObject({ data: { reason: "surface_capture_consumed" } });
|
).toMatchObject({ data: { reason: "surface_capture_consumed" } });
|
||||||
|
|
||||||
const failed = await setupCapture();
|
|
||||||
failed.browser.failDispatch();
|
|
||||||
expect(
|
|
||||||
await handleSurfacePointClick(failed.manager, pointParams(failed.capture.id), failed.browser),
|
|
||||||
).toMatchObject({ code: "cdp_failed" });
|
|
||||||
expect(
|
|
||||||
await handleSurfacePointClick(failed.manager, pointParams(failed.capture.id), failed.browser),
|
|
||||||
).toMatchObject({ data: { reason: "surface_capture_consumed" } });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps image coordinates through an OOPIF frame projection", async () => {
|
it("maps image coordinates through an OOPIF frame projection", async () => {
|
||||||
@@ -269,10 +266,6 @@ describe("Surface screenshot-bound point click", () => {
|
|||||||
getFrameGraph: vi.fn(async () => graph),
|
getFrameGraph: vi.fn(async () => graph),
|
||||||
trackSessionTab: vi.fn(),
|
trackSessionTab: vi.fn(),
|
||||||
};
|
};
|
||||||
const tabsApi = {
|
|
||||||
get: vi.fn(async () => ({ id: 4, windowId: 100, active: true }) as chrome.tabs.Tab),
|
|
||||||
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
|
|
||||||
};
|
|
||||||
const environment = await captureSurfaceEnvironment(cdp, 4, "child");
|
const environment = await captureSurfaceEnvironment(cdp, 4, "child");
|
||||||
if ("code" in environment) throw new Error(environment.message);
|
if ("code" in environment) throw new Error(environment.message);
|
||||||
const entry = context.refStore.resolveEntry("e3");
|
const entry = context.refStore.resolveEntry("e3");
|
||||||
@@ -294,20 +287,20 @@ describe("Surface screenshot-bound point click", () => {
|
|||||||
frameProjectionSignature: environment.frameProjectionSignature,
|
frameProjectionSignature: environment.frameProjectionSignature,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handleSurfacePointClick(
|
const result = await resolveSurfacePointerTarget(
|
||||||
manager,
|
context,
|
||||||
|
{ tabId: 4 },
|
||||||
{
|
{
|
||||||
session_id: "aa11",
|
|
||||||
ref: "@e3",
|
ref: "@e3",
|
||||||
capture_id: capture.id,
|
captureId: capture.id,
|
||||||
image_x: 100,
|
imageX: 100,
|
||||||
image_y: 40,
|
imageY: 40,
|
||||||
},
|
},
|
||||||
{ cdp, tabsApi },
|
{ cdp },
|
||||||
);
|
);
|
||||||
|
|
||||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
if ("code" in result) throw new Error(JSON.stringify(result));
|
||||||
expect(result).toMatchObject({ x: 170, y: 130 });
|
expect(result).toMatchObject({ point: { x: 170, y: 130 } });
|
||||||
expect(sent.filter((call) => call.method === "Input.dispatchMouseEvent")).toHaveLength(3);
|
expect(sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -44,7 +44,7 @@ import {
|
|||||||
resolveTargetTab,
|
resolveTargetTab,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||||
import { handleSurfacePointClick } from "./surface-point-action";
|
import { resolveSurfacePointerTarget } from "./surface-target";
|
||||||
|
|
||||||
export interface InteractionDeps {
|
export interface InteractionDeps {
|
||||||
cdp: CdpRunner;
|
cdp: CdpRunner;
|
||||||
@@ -221,6 +221,18 @@ async function resolveBackendNode(
|
|||||||
// tool.click
|
// tool.click
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ResolvedClickPointerTarget {
|
||||||
|
point: { x: number; y: number };
|
||||||
|
usedRef?: string;
|
||||||
|
usedSelector?: string;
|
||||||
|
moveSettleMs: number;
|
||||||
|
capture?: {
|
||||||
|
id: string;
|
||||||
|
imageX: number;
|
||||||
|
imageY: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleClick(
|
export async function handleClick(
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: ClickParams,
|
params: ClickParams,
|
||||||
@@ -228,9 +240,6 @@ export async function handleClick(
|
|||||||
): Promise<ClickResult | RpcError> {
|
): Promise<ClickResult | RpcError> {
|
||||||
const hasSurfacePointParams =
|
const hasSurfacePointParams =
|
||||||
params.capture_id !== undefined || params.image_x !== undefined || params.image_y !== undefined;
|
params.capture_id !== undefined || params.image_x !== undefined || params.image_y !== undefined;
|
||||||
if (hasSurfacePointParams) {
|
|
||||||
return handleSurfacePointClick(manager, params, deps);
|
|
||||||
}
|
|
||||||
const ctxOrErr = lookupSession(manager, params, "click");
|
const ctxOrErr = lookupSession(manager, params, "click");
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||||
const ctx = ctxOrErr;
|
const ctx = ctxOrErr;
|
||||||
@@ -242,39 +251,67 @@ export async function handleClick(
|
|||||||
if (denied) return denied;
|
if (denied) return denied;
|
||||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||||
|
|
||||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "click");
|
const button: MouseButton = params.button ?? "left";
|
||||||
if (isRpcError(node)) return node;
|
const clickCount = params.click_count ?? 1;
|
||||||
|
if (!Number.isSafeInteger(clickCount) || clickCount < 1) {
|
||||||
|
return { code: "invalid_params", message: "click_count must be a positive integer" };
|
||||||
|
}
|
||||||
|
const modifiers = modifiersBitfield(params.modifiers);
|
||||||
|
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "click aborted" };
|
return { code: "cancelled", message: "click aborted" };
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
const geometry = await resolveNodeGeometry(
|
const pointerTarget: ResolvedClickPointerTarget | RpcError = hasSurfacePointParams
|
||||||
deps.cdp,
|
? await resolveSurfacePointerTarget(
|
||||||
target.tabId,
|
ctx,
|
||||||
{
|
target,
|
||||||
target: node.cdpTarget,
|
{
|
||||||
backendNodeId: node.backendNodeId,
|
ref: params.ref,
|
||||||
...(node.frameId ? { frameId: node.frameId } : {}),
|
selector: params.selector,
|
||||||
},
|
captureId: params.capture_id,
|
||||||
{ scrollIntoView: true },
|
imageX: params.image_x,
|
||||||
);
|
imageY: params.image_y,
|
||||||
if (isRpcError(geometry)) return geometry;
|
},
|
||||||
const centre = geometry.actionPoint;
|
{
|
||||||
|
cdp: deps.cdp,
|
||||||
|
signal: deps.signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: await (async () => {
|
||||||
|
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "click");
|
||||||
|
if (isRpcError(node)) return node;
|
||||||
|
const geometry = await resolveNodeGeometry(
|
||||||
|
deps.cdp,
|
||||||
|
target.tabId,
|
||||||
|
{
|
||||||
|
target: node.cdpTarget,
|
||||||
|
backendNodeId: node.backendNodeId,
|
||||||
|
...(node.frameId ? { frameId: node.frameId } : {}),
|
||||||
|
},
|
||||||
|
{ scrollIntoView: true },
|
||||||
|
);
|
||||||
|
if (isRpcError(geometry)) return geometry;
|
||||||
|
return {
|
||||||
|
point: geometry.actionPoint,
|
||||||
|
usedRef: node.usedRef,
|
||||||
|
usedSelector: node.usedSelector,
|
||||||
|
moveSettleMs: 0,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
if (isRpcError(pointerTarget)) return pointerTarget;
|
||||||
|
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "click aborted" };
|
return { code: "cancelled", message: "click aborted" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const button: MouseButton = params.button ?? "left";
|
const overlayBlocking = await checkOverlayAtPoint(
|
||||||
const clickCount = params.click_count ?? 1;
|
deps.cdp,
|
||||||
if (clickCount < 1) {
|
target.tabId,
|
||||||
return { code: "invalid_params", message: "click_count must be greater than zero" };
|
pointerTarget.point.x,
|
||||||
}
|
pointerTarget.point.y,
|
||||||
const modifiers = modifiersBitfield(params.modifiers);
|
);
|
||||||
|
|
||||||
const overlayBlocking = await checkOverlayAtPoint(deps.cdp, target.tabId, centre.x, centre.y);
|
|
||||||
let automationBypassEnabled = false;
|
let automationBypassEnabled = false;
|
||||||
if (overlayBlocking && deps.bypassOverlay) {
|
if (overlayBlocking && deps.bypassOverlay) {
|
||||||
try {
|
try {
|
||||||
@@ -286,11 +323,12 @@ export async function handleClick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dispatch = await dispatchMouseClick(deps.cdp, target.tabId, centre, {
|
const dispatch = await dispatchMouseClick(deps.cdp, target.tabId, pointerTarget.point, {
|
||||||
button,
|
button,
|
||||||
clickCount,
|
clickCount,
|
||||||
modifiers,
|
modifiers,
|
||||||
signal: deps.signal,
|
signal: deps.signal,
|
||||||
|
moveSettleMs: pointerTarget.moveSettleMs,
|
||||||
});
|
});
|
||||||
if (dispatch === "cancelled") {
|
if (dispatch === "cancelled") {
|
||||||
return { code: "cancelled", message: "click aborted" };
|
return { code: "cancelled", message: "click aborted" };
|
||||||
@@ -312,10 +350,17 @@ export async function handleClick(
|
|||||||
|
|
||||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||||
tab_id: target.tabId,
|
tab_id: target.tabId,
|
||||||
used_ref: node.usedRef,
|
used_ref: pointerTarget.usedRef,
|
||||||
used_selector: node.usedSelector,
|
used_selector: pointerTarget.usedSelector,
|
||||||
x: centre.x,
|
x: pointerTarget.point.x,
|
||||||
y: centre.y,
|
y: pointerTarget.point.y,
|
||||||
|
...(pointerTarget.capture
|
||||||
|
? {
|
||||||
|
capture_id: pointerTarget.capture.id,
|
||||||
|
image_x: pointerTarget.capture.imageX,
|
||||||
|
image_y: pointerTarget.capture.imageY,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||||
import { surfaceVisibleRect } from "./surface-coordinate";
|
import { surfaceVisibleRect } from "./surface-coordinate";
|
||||||
import { captureSurfaceEnvironment } from "./surface-point-action";
|
import { captureSurfaceEnvironment } from "./surface-target";
|
||||||
import {
|
import {
|
||||||
type CapturedNode,
|
type CapturedNode,
|
||||||
type CapturedViewModel,
|
type CapturedViewModel,
|
||||||
|
|||||||
+55
-88
@@ -1,19 +1,9 @@
|
|||||||
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
|
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
|
||||||
import type { SessionManager } from "@/session-manager/manager";
|
import type { SessionContext } from "@/session-manager/manager";
|
||||||
import type { ClickParams, ClickResult, RpcError } from "@/transport/types";
|
import type { RpcError } from "@/transport/types";
|
||||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
|
||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
import { resolveNodeGeometry } from "./frame-geometry";
|
import { resolveNodeGeometry } from "./frame-geometry";
|
||||||
import { dispatchMouseClick } from "./mouse-input";
|
import { type CdpRunner, isRpcError, normaliseRef } from "./shared";
|
||||||
import {
|
|
||||||
type CdpRunner,
|
|
||||||
type ChromeTabsApi,
|
|
||||||
enforceAgentWindow,
|
|
||||||
isRpcError,
|
|
||||||
lookupSession,
|
|
||||||
normaliseRef,
|
|
||||||
resolveTargetTab,
|
|
||||||
} from "./shared";
|
|
||||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||||
import {
|
import {
|
||||||
mapImagePointToViewport,
|
mapImagePointToViewport,
|
||||||
@@ -28,16 +18,33 @@ export interface SurfaceCaptureEnvironment {
|
|||||||
frameProjectionSignature: string;
|
frameProjectionSignature: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurfacePointActionDeps {
|
export interface SurfaceTargetResolverDeps {
|
||||||
cdp: CdpRunner;
|
cdp: CdpRunner;
|
||||||
tabsApi: ChromeTabsApi;
|
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
|
}
|
||||||
|
|
||||||
|
export interface SurfacePointerTargetInput {
|
||||||
|
ref?: string;
|
||||||
|
selector?: string;
|
||||||
|
captureId?: string;
|
||||||
|
imageX?: number;
|
||||||
|
imageY?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSurfacePointerTarget {
|
||||||
|
point: { x: number; y: number };
|
||||||
|
usedRef: string;
|
||||||
|
moveSettleMs: number;
|
||||||
|
capture: {
|
||||||
|
id: string;
|
||||||
|
imageX: number;
|
||||||
|
imageY: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Canvas-style controls commonly update their pointer hit-test on the next
|
// Canvas-style controls commonly update their pointer hit-test on the next
|
||||||
// animation frame. Leave a scheduling boundary after moving and before pressing.
|
// animation frame. Leave a scheduling boundary after moving and before pressing.
|
||||||
const SURFACE_POINTER_MOVE_SETTLE_MS = 32;
|
export const SURFACE_POINTER_MOVE_SETTLE_MS = 32;
|
||||||
|
|
||||||
function stale(message: string): RpcError {
|
function stale(message: string): RpcError {
|
||||||
return rpcError("permission_denied", "surface_capture_stale", message);
|
return rpcError("permission_denied", "surface_capture_stale", message);
|
||||||
@@ -126,31 +133,22 @@ export async function captureSurfaceEnvironment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePointParams(params: ClickParams): RpcError | null {
|
function validatePointParams(input: SurfacePointerTargetInput): RpcError | null {
|
||||||
if (!params.capture_id || typeof params.capture_id !== "string") {
|
if (!input.captureId || typeof input.captureId !== "string") {
|
||||||
return { code: "invalid_params", message: "surface point click requires capture_id" };
|
return { code: "invalid_params", message: "surface point click requires capture_id" };
|
||||||
}
|
}
|
||||||
if (typeof params.image_x !== "number" || !Number.isFinite(params.image_x)) {
|
if (typeof input.imageX !== "number" || !Number.isFinite(input.imageX)) {
|
||||||
return { code: "invalid_params", message: "image_x must be a finite number" };
|
return { code: "invalid_params", message: "image_x must be a finite number" };
|
||||||
}
|
}
|
||||||
if (typeof params.image_y !== "number" || !Number.isFinite(params.image_y)) {
|
if (typeof input.imageY !== "number" || !Number.isFinite(input.imageY)) {
|
||||||
return { code: "invalid_params", message: "image_y must be a finite number" };
|
return { code: "invalid_params", message: "image_y must be a finite number" };
|
||||||
}
|
}
|
||||||
if (!params.ref || params.selector) {
|
if (!input.ref || input.selector) {
|
||||||
return {
|
return {
|
||||||
code: "invalid_params",
|
code: "invalid_params",
|
||||||
message: "surface point click requires one Surface ref and does not accept a selector",
|
message: "surface point click requires one Surface ref and does not accept a selector",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if ((params.button ?? "left") !== "left" || (params.click_count ?? 1) !== 1) {
|
|
||||||
return {
|
|
||||||
code: "invalid_params",
|
|
||||||
message: "surface point click currently supports one left click only",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (params.modifiers && params.modifiers.length > 0) {
|
|
||||||
return { code: "invalid_params", message: "surface point click does not accept modifiers" };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,27 +158,24 @@ function captureConsumeError(reason: "not_found" | "expired" | "consumed"): RpcE
|
|||||||
}
|
}
|
||||||
|
|
||||||
function aborted(signal: AbortSignal | undefined): RpcError | null {
|
function aborted(signal: AbortSignal | undefined): RpcError | null {
|
||||||
return signal?.aborted ? { code: "cancelled", message: "surface point click aborted" } : null;
|
return signal?.aborted
|
||||||
|
? { code: "cancelled", message: "surface target resolution aborted" }
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleSurfacePointClick(
|
/** Resolve one fresh Surface capture coordinate into a validated top-viewport pointer target. */
|
||||||
manager: SessionManager,
|
export async function resolveSurfacePointerTarget(
|
||||||
params: ClickParams,
|
ctx: SessionContext,
|
||||||
deps: SurfacePointActionDeps,
|
target: { tabId: number; url?: string },
|
||||||
): Promise<ClickResult | RpcError> {
|
input: SurfacePointerTargetInput,
|
||||||
const invalid = validatePointParams(params);
|
deps: SurfaceTargetResolverDeps,
|
||||||
|
): Promise<ResolvedSurfacePointerTarget | RpcError> {
|
||||||
|
const invalid = validatePointParams(input);
|
||||||
if (invalid) return invalid;
|
if (invalid) return invalid;
|
||||||
const ctxOrError = lookupSession(manager, params, "click");
|
|
||||||
if (isRpcError(ctxOrError)) return ctxOrError;
|
|
||||||
const ctx = ctxOrError;
|
|
||||||
const earlyAbort = aborted(deps.signal);
|
const earlyAbort = aborted(deps.signal);
|
||||||
if (earlyAbort) return earlyAbort;
|
if (earlyAbort) return earlyAbort;
|
||||||
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
|
|
||||||
if (isRpcError(target)) return target;
|
|
||||||
const denied = enforceAgentWindow(ctx, target, "click");
|
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const consumed = ctx.surfaceCaptures.consume(params.capture_id as string);
|
const consumed = ctx.surfaceCaptures.consume(input.captureId as string);
|
||||||
if (!consumed.ok) return captureConsumeError(consumed.reason);
|
if (!consumed.ok) return captureConsumeError(consumed.reason);
|
||||||
const capture = consumed.capture;
|
const capture = consumed.capture;
|
||||||
const postConsumeAbort = aborted(deps.signal);
|
const postConsumeAbort = aborted(deps.signal);
|
||||||
@@ -189,10 +184,10 @@ export async function handleSurfacePointClick(
|
|||||||
if (capture.sessionId !== ctx.sessionId || capture.tabId !== target.tabId) {
|
if (capture.sessionId !== ctx.sessionId || capture.tabId !== target.tabId) {
|
||||||
return stale("surface capture belongs to a different session or tab");
|
return stale("surface capture belongs to a different session or tab");
|
||||||
}
|
}
|
||||||
if (normaliseRef(params.ref as string) !== capture.surface.ref) {
|
if (normaliseRef(input.ref as string) !== capture.surface.ref) {
|
||||||
return stale("surface ref does not match the capture");
|
return stale("surface ref does not match the capture");
|
||||||
}
|
}
|
||||||
const node = resolveSnapshotRef(ctx, params.ref as string, target.tabId, "screenshot");
|
const node = resolveSnapshotRef(ctx, input.ref as string, target.tabId, "screenshot");
|
||||||
if (isRpcError(node)) return stale(node.message);
|
if (isRpcError(node)) return stale(node.message);
|
||||||
if (
|
if (
|
||||||
node.kind !== "surface" ||
|
node.kind !== "surface" ||
|
||||||
@@ -241,8 +236,8 @@ export async function handleSurfacePointClick(
|
|||||||
capture.topViewportRect,
|
capture.topViewportRect,
|
||||||
capture.imageWidth,
|
capture.imageWidth,
|
||||||
capture.imageHeight,
|
capture.imageHeight,
|
||||||
params.image_x as number,
|
input.imageX as number,
|
||||||
params.image_y as number,
|
input.imageY as number,
|
||||||
);
|
);
|
||||||
if (!point || !pointInRegion(point, geometry.topVisibleRegions)) {
|
if (!point || !pointInRegion(point, geometry.topVisibleRegions)) {
|
||||||
return rpcError(
|
return rpcError(
|
||||||
@@ -252,42 +247,14 @@ export async function handleSurfacePointClick(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
return {
|
||||||
let bypassEnabled = false;
|
point,
|
||||||
try {
|
usedRef: capture.surface.ref,
|
||||||
if (deps.bypassOverlay) {
|
moveSettleMs: SURFACE_POINTER_MOVE_SETTLE_MS,
|
||||||
await deps.bypassOverlay(target.tabId, true);
|
capture: {
|
||||||
bypassEnabled = true;
|
id: capture.id,
|
||||||
}
|
imageX: input.imageX as number,
|
||||||
const dispatch = await dispatchMouseClick(deps.cdp, target.tabId, point, {
|
imageY: input.imageY as number,
|
||||||
button: "left",
|
},
|
||||||
clickCount: 1,
|
};
|
||||||
modifiers: 0,
|
|
||||||
signal: deps.signal,
|
|
||||||
moveSettleMs: SURFACE_POINTER_MOVE_SETTLE_MS,
|
|
||||||
});
|
|
||||||
if (dispatch === "cancelled") {
|
|
||||||
return { code: "cancelled", message: "surface point click aborted" };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return { code: "cdp_failed", message: error instanceof Error ? error.message : String(error) };
|
|
||||||
} finally {
|
|
||||||
if (bypassEnabled && deps.bypassOverlay) {
|
|
||||||
try {
|
|
||||||
await deps.bypassOverlay(target.tabId, false);
|
|
||||||
} catch (error) {
|
|
||||||
console.debug("[bsk surface-point] overlay bypass disable failed", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
|
||||||
tab_id: target.tabId,
|
|
||||||
used_ref: capture.surface.ref,
|
|
||||||
x: point.x,
|
|
||||||
y: point.y,
|
|
||||||
capture_id: capture.id,
|
|
||||||
image_x: params.image_x,
|
|
||||||
image_y: params.image_y,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -581,9 +581,15 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
|
|||||||
type: "integer",
|
type: "integer",
|
||||||
description: "Number of consecutive presses (double-click = 2).",
|
description: "Number of consecutive presses (double-click = 2).",
|
||||||
},
|
},
|
||||||
|
modifiers: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", enum: ["alt", "ctrl", "meta", "shift"] },
|
||||||
|
description: "Keyboard modifiers held during the click.",
|
||||||
|
},
|
||||||
captureId: {
|
captureId: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Short-lived Surface capture id returned by browser_screenshot.",
|
description:
|
||||||
|
"Short-lived Surface capture id returned by browser_inspect action=screenshot.",
|
||||||
},
|
},
|
||||||
imageX: {
|
imageX: {
|
||||||
type: "number",
|
type: "number",
|
||||||
@@ -626,6 +632,9 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
|
|||||||
const cmdArgs = ["click", "--session", sessionId];
|
const cmdArgs = ["click", "--session", sessionId];
|
||||||
if (args.button !== undefined) cmdArgs.push("--button", args.button);
|
if (args.button !== undefined) cmdArgs.push("--button", args.button);
|
||||||
if (args.clickCount !== undefined) cmdArgs.push("--click-count", String(args.clickCount));
|
if (args.clickCount !== undefined) cmdArgs.push("--click-count", String(args.clickCount));
|
||||||
|
if (args.modifiers !== undefined && args.modifiers.length > 0) {
|
||||||
|
cmdArgs.push("--modifiers", args.modifiers.join(","));
|
||||||
|
}
|
||||||
if (args.captureId !== undefined) {
|
if (args.captureId !== undefined) {
|
||||||
cmdArgs.push(
|
cmdArgs.push(
|
||||||
"--capture",
|
"--capture",
|
||||||
|
|||||||
@@ -478,12 +478,12 @@ describe("interaction tools", () => {
|
|||||||
emulate: { tab_id: 7, cleared: false },
|
emulate: { tab_id: 7, cleared: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
it("interact.click maps target, button, and click count", async () => {
|
it("interact.click maps target, button, click count, and modifiers", async () => {
|
||||||
const { tools, calls } = setup(responses);
|
const { tools, calls } = setup(responses);
|
||||||
await startSession(tools);
|
await startSession(tools);
|
||||||
const click = tools.get("interact.click");
|
const click = tools.get("interact.click");
|
||||||
const value = (await click?.execute(
|
const value = (await click?.execute(
|
||||||
{ target: "@e1", button: "right", clickCount: 2 },
|
{ target: "@e1", button: "right", clickCount: 2, modifiers: ["ctrl", "shift"] },
|
||||||
makeExec(),
|
makeExec(),
|
||||||
)) as { session: string; x: number; y: number };
|
)) as { session: string; x: number; y: number };
|
||||||
expect(value).toMatchObject({ session: "s1", x: 10, y: 20 });
|
expect(value).toMatchObject({ session: "s1", x: 10, y: 20 });
|
||||||
@@ -495,6 +495,8 @@ describe("interaction tools", () => {
|
|||||||
"right",
|
"right",
|
||||||
"--click-count",
|
"--click-count",
|
||||||
"2",
|
"2",
|
||||||
|
"--modifiers",
|
||||||
|
"ctrl,shift",
|
||||||
"@e1",
|
"@e1",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -973,7 +975,7 @@ describe("inspect.screenshot", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await startSession(tools);
|
await startSession(tools);
|
||||||
const screenshot = tools.get("browser_screenshot");
|
const screenshot = tools.get("inspect.screenshot");
|
||||||
const value = await screenshot?.execute({ ref: "@e1" }, makeExec());
|
const value = await screenshot?.execute({ ref: "@e1" }, makeExec());
|
||||||
|
|
||||||
expect(value).toMatchObject({
|
expect(value).toMatchObject({
|
||||||
|
|||||||
Reference in New Issue
Block a user