Compare commits

...
Author SHA1 Message Date
Ljy-0827 86415440e3 feat(vom): actions refactor 2026-09-08 11:26:34 +08:00
Ljy-0827 8e428f4660 fix(vom): canvas bug fix 2026-09-08 11:15:53 +08:00
Ljy-0827 000b374d0c feat(vom): canvas position calculation and projection 2026-09-08 11:15:53 +08:00
36 changed files with 1843 additions and 93 deletions
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { type SurfaceCaptureInput, SurfaceCaptureStore } from "../surface-capture-store";
function input(): SurfaceCaptureInput {
return {
sessionId: "aa11",
tabId: 7,
navigationIdentity: "navigation",
surface: {
ref: "e3",
frameId: "main",
backendNodeId: 99,
observationGeneration: 2,
},
topViewportRect: { x: 10, y: 20, w: 100, h: 50 },
imageWidth: 200,
imageHeight: 100,
viewportSignature: "viewport",
frameProjectionSignature: "frame",
};
}
describe("SurfaceCaptureStore", () => {
it("creates metadata-only, short-lived captures and consumes them once", () => {
let now = 1_000;
const store = new SurfaceCaptureStore({
now: () => now,
ttlMs: 500,
createId: () => "sc_test",
});
const capture = store.create(input());
expect(capture).toMatchObject({
id: "sc_test",
createdAt: 1_000,
expiresAt: 1_500,
consumed: false,
});
expect(capture).not.toHaveProperty("imageBase64");
expect(store.consume("sc_test")).toMatchObject({ ok: true });
expect(store.consume("sc_test")).toEqual({ ok: false, reason: "consumed" });
now = 1_600;
expect(store.size()).toBe(0);
});
it("rejects expired and unknown captures", () => {
let now = 1_000;
const store = new SurfaceCaptureStore({
now: () => now,
ttlMs: 10,
createId: () => "sc_expired",
});
store.create(input());
now = 1_010;
expect(store.consume("sc_expired")).toEqual({ ok: false, reason: "expired" });
expect(store.consume("sc_missing")).toEqual({ ok: false, reason: "not_found" });
});
it("clears every capture on session cleanup", () => {
const store = new SurfaceCaptureStore({ createId: () => "sc_clear" });
store.create(input());
store.clear();
expect(store.size()).toBe(0);
});
});
@@ -1,10 +1,12 @@
import { AGENT_WINDOW_HOME, type AgentWindowApi, chromeAgentWindowApi } from "./agent-window"; import { AGENT_WINDOW_HOME, type AgentWindowApi, chromeAgentWindowApi } from "./agent-window";
import { RefStore } from "./ref-store"; import { RefStore } from "./ref-store";
import { SurfaceCaptureStore } from "./surface-capture-store";
export interface SessionContext { export interface SessionContext {
sessionId: string; sessionId: string;
agentWindowId: number; agentWindowId: number;
refStore: RefStore; refStore: RefStore;
surfaceCaptures: SurfaceCaptureStore;
borrowedTabs: Map<number, BorrowedTab>; borrowedTabs: Map<number, BorrowedTab>;
/** /**
* Tabs explicitly claimed by the agent because it created them. This * Tabs explicitly claimed by the agent because it created them. This
@@ -210,6 +212,7 @@ export class SessionManager {
sessionId, sessionId,
agentWindowId: windowId, agentWindowId: windowId,
refStore: new RefStore(), refStore: new RefStore(),
surfaceCaptures: new SurfaceCaptureStore({ now: this.now }),
borrowedTabs: new Map(), borrowedTabs: new Map(),
// The home tab is the session's first explicit claim. Every other // The home tab is the session's first explicit claim. Every other
// tab remains free until `tab_create` or `tab_borrow` identifies it // tab remains free until `tab_create` or `tab_borrow` identifies it
@@ -0,0 +1,100 @@
import type { Rect } from "@browser-skill/vom";
export const SURFACE_CAPTURE_TTL_MS = 30_000;
export interface SurfaceCapture {
id: string;
sessionId: string;
tabId: number;
navigationIdentity: string;
surface: {
ref: string;
frameId?: string;
backendNodeId: number;
observationGeneration: number;
};
topViewportRect: Rect;
imageWidth: number;
imageHeight: number;
viewportSignature: string;
frameProjectionSignature: string;
createdAt: number;
expiresAt: number;
consumed: boolean;
}
export type SurfaceCaptureInput = Omit<
SurfaceCapture,
"id" | "createdAt" | "expiresAt" | "consumed"
>;
export type SurfaceCaptureConsumeResult =
| { ok: true; capture: SurfaceCapture }
| { ok: false; reason: "not_found" | "expired" | "consumed" };
export interface SurfaceCaptureStoreOptions {
now?: () => number;
ttlMs?: number;
createId?: () => string;
}
function randomCaptureId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(12));
return `sc_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}
/** Per-session, metadata-only store for short-lived screenshot coordinate transactions. */
export class SurfaceCaptureStore {
private readonly captures = new Map<string, SurfaceCapture>();
private readonly now: () => number;
private readonly ttlMs: number;
private readonly createId: () => string;
constructor(options: SurfaceCaptureStoreOptions = {}) {
this.now = options.now ?? Date.now;
this.ttlMs = options.ttlMs ?? SURFACE_CAPTURE_TTL_MS;
this.createId = options.createId ?? randomCaptureId;
}
create(input: SurfaceCaptureInput): SurfaceCapture {
this.purgeExpired();
const createdAt = this.now();
const capture: SurfaceCapture = {
...input,
id: this.createId(),
createdAt,
expiresAt: createdAt + this.ttlMs,
consumed: false,
};
this.captures.set(capture.id, capture);
return capture;
}
consume(id: string): SurfaceCaptureConsumeResult {
const capture = this.captures.get(id);
if (!capture) return { ok: false, reason: "not_found" };
if (capture.expiresAt <= this.now()) {
this.captures.delete(id);
return { ok: false, reason: "expired" };
}
if (capture.consumed) return { ok: false, reason: "consumed" };
capture.consumed = true;
return { ok: true, capture };
}
clear(): void {
this.captures.clear();
}
size(): number {
this.purgeExpired();
return this.captures.size;
}
private purgeExpired(): void {
const now = this.now();
for (const [id, capture] of this.captures) {
if (capture.expiresAt <= now) this.captures.delete(id);
}
}
}
@@ -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");
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from "vitest";
import { dispatchMouseClick } from "../mouse-input";
import type { CdpRunner } from "../shared";
function fakeCdp(onSend?: (method: string, params: Record<string, unknown>) => void): {
cdp: CdpRunner;
events: Record<string, unknown>[];
} {
const events: Record<string, unknown>[] = [];
return {
events,
cdp: {
send: vi.fn(async (_tabId, method, params) => {
const event = params as Record<string, unknown>;
events.push(event);
onSend?.(method, event);
return {} as never;
}),
},
};
}
describe("dispatchMouseClick", () => {
it("emits one move, press, and release sequence", async () => {
const { cdp, events } = fakeCdp();
expect(
await dispatchMouseClick(
cdp,
4,
{ x: 12, y: 34 },
{
button: "left",
clickCount: 1,
modifiers: 0,
moveSettleMs: 0,
},
),
).toBe("completed");
expect(events).toEqual([
{
type: "mouseMoved",
x: 12,
y: 34,
modifiers: 0,
},
{
type: "mousePressed",
x: 12,
y: 34,
button: "left",
clickCount: 1,
modifiers: 0,
},
{
type: "mouseReleased",
x: 12,
y: 34,
button: "left",
clickCount: 1,
modifiers: 0,
},
]);
});
it("releases a pressed button when cancellation arrives", async () => {
const controller = new AbortController();
const { cdp, events } = fakeCdp((_method, event) => {
if (event.type === "mousePressed") controller.abort();
});
expect(
await dispatchMouseClick(
cdp,
4,
{ x: 1, y: 2 },
{
button: "left",
clickCount: 1,
modifiers: 0,
signal: controller.signal,
moveSettleMs: 0,
},
),
).toBe("cancelled");
expect(events.at(-1)).toMatchObject({ type: "mouseReleased" });
});
it("settles pointer-move hit testing before pressing", async () => {
vi.useFakeTimers();
try {
const { cdp, events } = fakeCdp();
const click = dispatchMouseClick(
cdp,
4,
{ x: 1, y: 2 },
{
button: "left",
clickCount: 1,
modifiers: 0,
moveSettleMs: 32,
},
);
await vi.advanceTimersByTimeAsync(31);
expect(events.map((event) => event.type)).toEqual(["mouseMoved"]);
await vi.advanceTimersByTimeAsync(1);
await click;
expect(events.map((event) => event.type)).toEqual([
"mouseMoved",
"mousePressed",
"mouseReleased",
]);
} finally {
vi.useRealTimers();
}
});
});
@@ -75,6 +75,9 @@ function makeFakeCdp(handlers: Record<string, (params?: object) => unknown>) {
if (!handler && method === "Page.getLayoutMetrics") { if (!handler && method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } }; return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
} }
if (!handler && method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "main", loaderId: "loader-1", url: "https://test/" } } };
}
if (!handler) throw new Error(`unexpected CDP call ${method}`); if (!handler) throw new Error(`unexpected CDP call ${method}`);
return handler(params); return handler(params);
}); });
@@ -357,6 +360,11 @@ describe("handleScreenshot", () => {
}; };
expect(clip.clip).toMatchObject({ width: 4096, height: 4096 }); expect(clip.clip).toMatchObject({ width: 4096, height: 4096 });
expect(clip.clip?.scale).toBeCloseTo(Math.sqrt(4_000_000 / (4096 * 4096))); expect(clip.clip?.scale).toBeCloseTo(Math.sqrt(4_000_000 / (4096 * 4096)));
expect(res.capture).toMatchObject({
surface_ref: "@e5",
coordinate_space: "capture-image-pixel",
});
expect(ctx.surfaceCaptures.size()).toBe(1);
}); });
it("crops a visual surface screenshot to its observation-time visible region", async () => { it("crops a visual surface screenshot to its observation-time visible region", async () => {
@@ -392,6 +400,7 @@ describe("handleScreenshot", () => {
}; };
expect(clip.clip).toMatchObject({ x: 25, y: 30, width: 50, height: 40 }); expect(clip.clip).toMatchObject({ x: 25, y: 30, width: 50, height: 40 });
expect(res).toMatchObject({ width: 100, height: 80 }); expect(res).toMatchObject({ width: 100, height: 80 });
expect(res.capture?.id).toMatch(/^sc_/);
}); });
it("routes OOPIF surface geometry through its child session and captures top coordinates", async () => { it("routes OOPIF surface geometry through its child session and captures top coordinates", async () => {
@@ -278,12 +278,23 @@ describe("handleSessionStop with auto-return", () => {
expect(aw.remove).not.toHaveBeenCalled(); expect(aw.remove).not.toHaveBeenCalled();
}); });
it("clears the RefStore before window teardown", async () => { it("clears refs and Surface captures before window teardown", async () => {
const aw = fakeAgentWindow([100]); const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw }); const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11"); const ctx = await sm.start("aa11");
// Insert a fake ref so we can verify clear() ran. // Insert a fake ref so we can verify clear() ran.
ctx.refStore.set("e1", 123, { tabId: 7 }); ctx.refStore.set("e1", 123, { tabId: 7 });
ctx.surfaceCaptures.create({
sessionId: "aa11",
tabId: 7,
navigationIdentity: "navigation",
surface: { ref: "e1", backendNodeId: 123, observationGeneration: 0 },
topViewportRect: { x: 0, y: 0, w: 100, h: 50 },
imageWidth: 100,
imageHeight: 50,
viewportSignature: "viewport",
frameProjectionSignature: "frame",
});
const state: FakeState = { const state: FakeState = {
tabs: new Map(), tabs: new Map(),
windowsClosed: new Set(), windowsClosed: new Set(),
@@ -292,6 +303,7 @@ describe("handleSessionStop with auto-return", () => {
const { tabs, windows } = makeApis(state); const { tabs, windows } = makeApis(state);
await handleSessionStop(sm, { session_id: "aa11" }, { tabManagement: { tabs, windows } }); await handleSessionStop(sm, { session_id: "aa11" }, { tabManagement: { tabs, windows } });
expect(ctx.refStore.isEmpty()).toBe(true); expect(ctx.refStore.isEmpty()).toBe(true);
expect(ctx.surfaceCaptures.size()).toBe(0);
}); });
it.each([ it.each([
@@ -26,12 +26,14 @@ describe("lookupSnapshotRef", () => {
refKey: "e3", refKey: "e3",
kind: "dom", kind: "dom",
capabilities: ["interact", "screenshot"], capabilities: ["interact", "screenshot"],
generation: 0,
}); });
expect(lookupSnapshotRef(ctx, "e3", 4)).toEqual({ expect(lookupSnapshotRef(ctx, "e3", 4)).toEqual({
backendNodeId: 1234, backendNodeId: 1234,
refKey: "e3", refKey: "e3",
kind: "dom", kind: "dom",
capabilities: ["interact", "screenshot"], capabilities: ["interact", "screenshot"],
generation: 0,
}); });
}); });
@@ -68,6 +70,7 @@ describe("resolveSnapshotRef", () => {
cdpSessionId: "child-session", cdpSessionId: "child-session",
kind: "dom" as const, kind: "dom" as const,
capabilities: ["interact", "screenshot"] as const, capabilities: ["interact", "screenshot"] as const,
generation: 0,
}; };
expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual(expected); expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual(expected); expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
@@ -115,6 +118,7 @@ describe("resolveSnapshotRef", () => {
refKey: "e3", refKey: "e3",
kind: "dom", kind: "dom",
capabilities: ["interact", "screenshot"], capabilities: ["interact", "screenshot"],
generation: 0,
}); });
}); });
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
mapImagePointToViewport,
pointInRegion,
sameRect,
surfaceVisibleRect,
} from "../surface-coordinate";
describe("surface coordinate mapping", () => {
it("maps capture image pixels through the actual screenshot dimensions", () => {
expect(mapImagePointToViewport({ x: 100, y: 50, w: 400, h: 200 }, 800, 400, 200, 100)).toEqual({
x: 200,
y: 100,
});
});
it("rejects invalid image coordinates and dimensions", () => {
const rect = { x: 0, y: 0, w: 100, h: 50 };
expect(mapImagePointToViewport(rect, 100, 50, -1, 0)).toBeNull();
expect(mapImagePointToViewport(rect, 100, 50, 100, 0)).toBeNull();
expect(mapImagePointToViewport(rect, 100, 50, 0, 50)).toBeNull();
expect(mapImagePointToViewport(rect, 100, 50, Number.NaN, 0)).toBeNull();
expect(mapImagePointToViewport(rect, 100, 50, Number.POSITIVE_INFINITY, 0)).toBeNull();
expect(mapImagePointToViewport(rect, 0, 50, 0, 0)).toBeNull();
});
it("intersects live and observed visible regions and compares them strictly", () => {
const clipped = surfaceVisibleRect(
{ x: 0, y: 0, width: 200, height: 100 },
{ x: 25, y: 30, w: 50, h: 40 },
);
expect(clipped).toEqual({ x: 25, y: 30, w: 50, h: 40 });
expect(sameRect(clipped as NonNullable<typeof clipped>, { x: 25, y: 30, w: 50, h: 40 })).toBe(
true,
);
expect(sameRect(clipped as NonNullable<typeof clipped>, { x: 26, y: 30, w: 50, h: 40 })).toBe(
false,
);
});
it("checks the projected visible region rather than only its bounding box", () => {
const triangle = [
[
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 0, y: 100 },
],
];
expect(pointInRegion({ x: 20, y: 20 }, triangle)).toBe(true);
expect(pointInRegion({ x: 90, y: 90 }, triangle)).toBe(false);
});
});
@@ -0,0 +1,306 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "../shared";
import {
captureSurfaceEnvironment,
resolveSurfacePointerTarget,
SURFACE_POINTER_MOVE_SETTLE_MS,
} from "../surface-target";
function fakeAgentWindow() {
return {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
function fakeBrowser() {
let loaderId = "loader-1";
let pageY = 0;
let quad = [10, 20, 110, 20, 110, 60, 10, 60];
const sent: Array<{ method: string; params?: object }> = [];
const cdp: CdpRunner = {
send: vi.fn(async (_tabId: number, method: string, params?: object) => {
sent.push({ method, params });
if (method === "Page.getFrameTree") {
return {
frameTree: { frame: { id: "main", loaderId, url: "https://fixture.test/" } },
} as never;
}
if (method === "Page.getLayoutMetrics") {
return {
cssLayoutViewport: { clientWidth: 800, clientHeight: 600, pageX: 0, pageY },
cssVisualViewport: {
clientWidth: 800,
clientHeight: 600,
pageX: 0,
pageY,
scale: 1,
},
} as never;
}
if (method === "DOM.getContentQuads") return { quads: [quad] } as never;
throw new Error(`unexpected CDP call ${method}`);
}) as CdpRunner["send"],
trackSessionTab: vi.fn(),
};
return {
cdp,
sent,
setLoaderId: (value: string) => {
loaderId = value;
},
setPageY: (value: number) => {
pageY = value;
},
setQuad: (value: number[]) => {
quad = value;
},
};
}
async function setupCapture() {
const manager = new SessionManager({ agentWindow: fakeAgentWindow() });
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 browser = fakeBrowser();
const environment = await captureSurfaceEnvironment(browser.cdp, 4, undefined);
if ("code" in environment) throw new Error(environment.message);
const entry = context.refStore.resolveEntry("e3");
if (!entry) throw new Error("missing 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, browser, capture };
}
function pointParams(captureId: string) {
return {
ref: "@e3",
captureId,
imageX: 50,
imageY: 20,
};
}
describe("Surface screenshot-bound pointer target", () => {
it("maps one fresh capture coordinate without executing an action", async () => {
const { context, browser, capture } = await setupCapture();
const result = await resolveSurfacePointerTarget(
context,
{ tabId: 4 },
pointParams(capture.id),
{ cdp: browser.cdp },
);
if ("code" in result) throw new Error(JSON.stringify(result));
expect(result).toMatchObject({
usedRef: "e3",
point: { x: 35, y: 30 },
moveSettleMs: SURFACE_POINTER_MOVE_SETTLE_MS,
capture: { id: capture.id, imageX: 50, imageY: 20 },
});
expect(browser.sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
const repeated = await resolveSurfacePointerTarget(
context,
{ tabId: 4 },
pointParams(capture.id),
{ cdp: browser.cdp },
);
expect(repeated).toMatchObject({
code: "permission_denied",
data: { reason: "surface_capture_consumed" },
});
});
it("rejects a new observation generation before sending input", async () => {
const { context, browser, capture } = await setupCapture();
context.refStore.replace([
[
"e3",
{
backendNodeId: 99,
tabId: 4,
kind: "surface" as const,
visibleRect: { x: 10, y: 20, w: 100, h: 40 },
},
],
]);
const result = await resolveSurfacePointerTarget(
context,
{ tabId: 4 },
pointParams(capture.id),
{ cdp: browser.cdp },
);
expect(result).toMatchObject({
code: "permission_denied",
data: { reason: "surface_capture_stale" },
});
expect(browser.sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
});
it("rejects navigation, scroll, and visible geometry changes", async () => {
for (const mutate of [
(browser: ReturnType<typeof fakeBrowser>) => browser.setLoaderId("loader-2"),
(browser: ReturnType<typeof fakeBrowser>) => browser.setPageY(20),
(browser: ReturnType<typeof fakeBrowser>) =>
browser.setQuad([11, 20, 111, 20, 111, 60, 11, 60]),
]) {
const { context, browser, capture } = await setupCapture();
mutate(browser);
const result = await resolveSurfacePointerTarget(
context,
{ tabId: 4 },
pointParams(capture.id),
{ cdp: browser.cdp },
);
expect(result).toMatchObject({
code: "permission_denied",
data: { reason: "surface_capture_stale" },
});
expect(browser.sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
}
});
it("consumes the capture when coordinate validation fails", async () => {
const invalid = await setupCapture();
const outside = await resolveSurfacePointerTarget(
invalid.context,
{ tabId: 4 },
{ ...pointParams(invalid.capture.id), imageX: 200 },
{ cdp: invalid.browser.cdp },
);
expect(outside).toMatchObject({
code: "invalid_params",
data: { reason: "surface_coordinate_invalid" },
});
expect(
await resolveSurfacePointerTarget(
invalid.context,
{ tabId: 4 },
pointParams(invalid.capture.id),
{ cdp: invalid.browser.cdp },
),
).toMatchObject({ data: { reason: "surface_capture_consumed" } });
});
it("maps image coordinates through an OOPIF frame projection", async () => {
const manager = new SessionManager({ agentWindow: fakeAgentWindow() });
const context = await manager.start("aa11");
context.refStore.set("e3", 99, {
tabId: 4,
frameId: "child",
cdpSessionId: "child-session",
kind: "surface",
visibleRect: { x: 120, y: 110, w: 100, h: 40 },
});
const graph = {
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 77,
target: { tabId: 4, sessionId: "child-session" },
},
],
};
const sent: Array<{ method: string; params?: object }> = [];
const cdp: CdpRunner = {
send: vi.fn(async (_tabId: number, method: string, params?: object) => {
sent.push({ method, params });
if (method === "Page.getFrameTree") {
return {
frameTree: {
frame: { id: "main", loaderId: "loader-1", url: "https://fixture.test/" },
},
} as never;
}
if (method === "Page.getLayoutMetrics") {
return {
cssLayoutViewport: { clientWidth: 800, clientHeight: 600, pageX: 0, pageY: 0 },
cssVisualViewport: {
clientWidth: 800,
clientHeight: 600,
pageX: 0,
pageY: 0,
scale: 1,
},
} as never;
}
if (method === "DOM.getBoxModel") {
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } } as never;
}
if (method === "Input.dispatchMouseEvent") return {} as never;
throw new Error(`unexpected root CDP call ${method}`);
}) as CdpRunner["send"],
sendToTarget: vi.fn(async (_target, method) => {
if (method === "DOM.getContentQuads") {
return { quads: [[20, 10, 120, 10, 120, 50, 20, 50]] } as never;
}
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } } as never;
}
throw new Error(`unexpected child CDP call ${method}`);
}) as CdpRunner["sendToTarget"],
getFrameGraph: vi.fn(async () => graph),
trackSessionTab: vi.fn(),
};
const environment = await captureSurfaceEnvironment(cdp, 4, "child");
if ("code" in environment) throw new Error(environment.message);
const entry = context.refStore.resolveEntry("e3");
if (!entry) throw new Error("missing ref");
const capture = context.surfaceCaptures.create({
sessionId: "aa11",
tabId: 4,
navigationIdentity: environment.navigationIdentity,
surface: {
ref: "e3",
frameId: "child",
backendNodeId: 99,
observationGeneration: entry.generation,
},
topViewportRect: { x: 120, y: 110, w: 100, h: 40 },
imageWidth: 200,
imageHeight: 80,
viewportSignature: environment.viewportSignature,
frameProjectionSignature: environment.frameProjectionSignature,
});
const result = await resolveSurfacePointerTarget(
context,
{ tabId: 4 },
{
ref: "@e3",
captureId: capture.id,
imageX: 100,
imageY: 40,
},
{ cdp },
);
if ("code" in result) throw new Error(JSON.stringify(result));
expect(result).toMatchObject({ point: { x: 170, y: 130 } });
expect(sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
});
});
+79 -48
View File
@@ -32,6 +32,7 @@ import { attachDialogs, markDialogCursor } from "./dialogs";
import { backendNodeToObject } from "./element-geometry"; import { backendNodeToObject } from "./element-geometry";
import { rpcError } from "./errors"; import { rpcError } from "./errors";
import { resolveNodeGeometry, scrollElementAndFramesIntoView } from "./frame-geometry"; import { resolveNodeGeometry, scrollElementAndFramesIntoView } from "./frame-geometry";
import { dispatchMouseClick } from "./mouse-input";
import { import {
type CdpRunner, type CdpRunner,
type ChromeTabsApi, type ChromeTabsApi,
@@ -44,6 +45,7 @@ import {
resolveTargetTab, resolveTargetTab,
} from "./shared"; } from "./shared";
import { resolveSnapshotRef } from "./snapshot-ref"; import { resolveSnapshotRef } from "./snapshot-ref";
import { resolveSurfacePointerTarget } from "./surface-target";
export interface InteractionDeps { export interface InteractionDeps {
cdp: CdpRunner; cdp: CdpRunner;
@@ -242,11 +244,25 @@ export async function resolveActionTarget(
// 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,
deps: InteractionDeps = getDefaultDeps(), deps: InteractionDeps = getDefaultDeps(),
): Promise<ClickResult | RpcError> { ): Promise<ClickResult | RpcError> {
const hasSurfacePointParams =
params.capture_id !== undefined || params.image_x !== undefined || params.image_y !== undefined;
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;
@@ -256,6 +272,23 @@ export async function handleClick(
if (isRpcError(target)) return target; if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "click"); const denied = enforceAgentWindow(ctx, target, "click");
if (denied) return denied; if (denied) return denied;
if (hasSurfacePointParams) {
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const pointerTarget = await resolveSurfacePointerTarget(
ctx,
target,
{
ref: params.ref,
selector: params.selector,
captureId: params.capture_id,
imageX: params.image_x,
imageY: params.image_y,
},
{ cdp: deps.cdp, signal: deps.signal },
);
if (isRpcError(pointerTarget)) return pointerTarget;
return executeResolvedClick(ctx, target, pointerTarget, params, deps, dialogCursor);
}
const resolved = await resolveActionTarget(deps.cdp, ctx, target, params, "click"); const resolved = await resolveActionTarget(deps.cdp, ctx, target, params, "click");
if (isRpcError(resolved)) return resolved; if (isRpcError(resolved)) return resolved;
return clickResolvedTarget(ctx, resolved, params, deps); return clickResolvedTarget(ctx, resolved, params, deps);
@@ -269,11 +302,6 @@ export async function clickResolvedTarget(
): Promise<ClickResult | RpcError> { ): Promise<ClickResult | RpcError> {
const { tab: target } = resolved; const { tab: target } = resolved;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId); const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
if (throwIfAborted(deps.signal)) {
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 geometry = await resolveNodeGeometry(
deps.cdp, deps.cdp,
@@ -286,20 +314,47 @@ export async function clickResolvedTarget(
{ scrollIntoView: true }, { scrollIntoView: true },
); );
if (isRpcError(geometry)) return geometry; if (isRpcError(geometry)) return geometry;
const centre = geometry.actionPoint; return executeResolvedClick(
ctx,
target,
{
point: geometry.actionPoint,
usedRef: resolved.usedRef,
usedSelector: resolved.usedSelector,
moveSettleMs: 0,
},
params,
deps,
dialogCursor,
);
}
async function executeResolvedClick(
ctx: SessionContext,
target: ResolvedTargetTab,
pointerTarget: ResolvedClickPointerTarget,
params: Pick<ClickParams, "button" | "click_count" | "modifiers">,
deps: InteractionDeps,
dialogCursor: number,
): Promise<ClickResult | RpcError> {
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 button: MouseButton = params.button ?? "left";
const clickCount = params.click_count ?? 1; const clickCount = params.click_count ?? 1;
if (clickCount < 1) { if (!Number.isSafeInteger(clickCount) || clickCount < 1) {
return { code: "invalid_params", message: "click_count must be greater than zero" }; return { code: "invalid_params", message: "click_count must be a positive integer" };
} }
const modifiers = modifiersBitfield(params.modifiers); const modifiers = modifiersBitfield(params.modifiers);
const overlayBlocking = await checkOverlayAtPoint(deps.cdp, target.tabId, centre.x, centre.y); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const overlayBlocking = await checkOverlayAtPoint(
deps.cdp,
target.tabId,
pointerTarget.point.x,
pointerTarget.point.y,
);
let automationBypassEnabled = false; let automationBypassEnabled = false;
if (overlayBlocking && deps.bypassOverlay) { if (overlayBlocking && deps.bypassOverlay) {
try { try {
@@ -311,47 +366,16 @@ export async function clickResolvedTarget(
} }
try { try {
// Move first so hover state activates, then press → release. const dispatch = await dispatchMouseClick(deps.cdp, target.tabId, pointerTarget.point, {
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: centre.x,
y: centre.y,
modifiers,
});
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "click aborted" };
}
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
type: "mousePressed",
x: centre.x,
y: centre.y,
button, button,
clickCount, clickCount,
modifiers, modifiers,
signal: deps.signal,
moveSettleMs: pointerTarget.moveSettleMs,
}); });
if (throwIfAborted(deps.signal)) { if (dispatch === "cancelled") {
try {
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
type: "mouseReleased",
x: centre.x,
y: centre.y,
button,
clickCount,
modifiers,
});
} catch (err) {
console.debug("[bsk interaction] best-effort mouseReleased after abort failed", err);
}
return { code: "cancelled", message: "click aborted" }; return { code: "cancelled", message: "click aborted" };
} }
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
type: "mouseReleased",
x: centre.x,
y: centre.y,
button,
clickCount,
modifiers,
});
} catch (err) { } catch (err) {
return { return {
code: "cdp_failed", code: "cdp_failed",
@@ -369,10 +393,17 @@ export async function clickResolvedTarget(
return attachDialogs(deps.cdp, target.tabId, dialogCursor, { return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId, tab_id: target.tabId,
used_ref: resolved.usedRef, used_ref: pointerTarget.usedRef,
used_selector: resolved.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,
}
: {}),
}); });
} }
+96
View File
@@ -0,0 +1,96 @@
import type { MouseButton } from "@/transport/types";
import type { Point } from "./geometry";
import type { CdpRunner } from "./shared";
interface MouseClickDispatchOptions {
button: MouseButton;
clickCount: number;
modifiers: number;
signal?: AbortSignal;
moveSettleMs?: number;
}
type MouseClickDispatchResult = "completed" | "cancelled";
function waitForSettle(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
if (ms <= 0) return Promise.resolve(signal?.aborted !== true);
if (signal?.aborted) return Promise.resolve(false);
return new Promise((resolve) => {
let settled = false;
const finish = (completed: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
resolve(completed);
};
const timer = setTimeout(() => finish(true), ms);
const onAbort = () => finish(false);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Dispatch one coherent CDP mouse click in top-viewport CSS coordinates.
*
* Keeping the sequence here prevents DOM and Surface interaction paths from
* duplicating cancellation and best-effort release behavior. Callers that
* target asynchronously hit-tested content may request a settle interval.
*/
export async function dispatchMouseClick(
cdp: CdpRunner,
tabId: number,
point: Point,
options: MouseClickDispatchOptions,
): Promise<MouseClickDispatchResult> {
const { button, clickCount, modifiers, signal, moveSettleMs = 0 } = options;
let pressed = false;
const release = () =>
cdp.send(tabId, "Input.dispatchMouseEvent", {
type: "mouseReleased",
x: point.x,
y: point.y,
button,
clickCount,
modifiers,
});
try {
await cdp.send(tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
modifiers,
});
if (!(await waitForSettle(moveSettleMs, signal))) return "cancelled";
await cdp.send(tabId, "Input.dispatchMouseEvent", {
type: "mousePressed",
x: point.x,
y: point.y,
button,
clickCount,
modifiers,
});
pressed = true;
if (signal?.aborted) {
await release();
pressed = false;
return "cancelled";
}
await release();
pressed = false;
return "completed";
} catch (error) {
if (pressed) {
try {
await release();
} catch (releaseError) {
console.debug("[bsk mouse-input] best-effort mouse release failed", releaseError);
}
}
throw error;
}
}
+53 -20
View File
@@ -48,6 +48,8 @@ import {
type ToolEffect, type ToolEffect,
} from "./shared"; } from "./shared";
import { resolveSnapshotRef } from "./snapshot-ref"; import { resolveSnapshotRef } from "./snapshot-ref";
import { surfaceVisibleRect } from "./surface-coordinate";
import { captureSurfaceEnvironment } from "./surface-target";
import { import {
type CapturedNode, type CapturedNode,
type CapturedSurfaceProbe, type CapturedSurfaceProbe,
@@ -197,7 +199,9 @@ async function captureElementScreenshot(
visualSurface = false, visualSurface = false,
observedVisibleRect?: Rect, observedVisibleRect?: Rect,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<{ image_base64: string; width: number; height: number } | RpcError> { ): Promise<
{ image_base64: string; width: number; height: number; topViewportRect: Rect } | RpcError
> {
if (signal?.aborted) return cancelled("screenshot"); if (signal?.aborted) return cancelled("screenshot");
const geometry = await resolveNodeGeometry( const geometry = await resolveNodeGeometry(
cdp, cdp,
@@ -207,24 +211,19 @@ async function captureElementScreenshot(
); );
if (isRpcError(geometry)) return geometry; if (isRpcError(geometry)) return geometry;
if (signal?.aborted) return cancelled("screenshot"); if (signal?.aborted) return cancelled("screenshot");
const liveRect = geometry.topBounds; const topViewportRect = surfaceVisibleRect(
const rect = geometry.topBounds,
visualSurface && observedVisibleRect visualSurface ? observedVisibleRect : undefined,
? (() => { );
const x = Math.max(liveRect.x, observedVisibleRect.x); if (!topViewportRect) {
const y = Math.max(liveRect.y, observedVisibleRect.y); return { code: "permission_denied", message: "surface is no longer visible" };
const right = Math.min( }
liveRect.x + liveRect.width, const rect = {
observedVisibleRect.x + observedVisibleRect.w, x: topViewportRect.x,
); y: topViewportRect.y,
const bottom = Math.min( width: topViewportRect.w,
liveRect.y + liveRect.height, height: topViewportRect.h,
observedVisibleRect.y + observedVisibleRect.h, };
);
return right > x && bottom > y ? { x, y, width: right - x, height: bottom - y } : null;
})()
: liveRect;
if (!rect) return { code: "permission_denied", message: "surface is no longer visible" };
const scale = visualSurface const scale = visualSurface
? Math.min( ? Math.min(
1, 1,
@@ -254,7 +253,7 @@ async function captureElementScreenshot(
if (!dims) { if (!dims) {
return { code: "cdp_failed", message: "Page.captureScreenshot returned invalid PNG data" }; return { code: "cdp_failed", message: "Page.captureScreenshot returned invalid PNG data" };
} }
return { image_base64, width: dims.width, height: dims.height }; return { image_base64, width: dims.width, height: dims.height, topViewportRect };
} catch (err) { } catch (err) {
return { return {
code: "cdp_failed", code: "cdp_failed",
@@ -377,12 +376,46 @@ export async function handleScreenshot(
); );
if (isRpcError(captured)) return captured; if (isRpcError(captured)) return captured;
if (signal?.aborted) return cancelled("screenshot"); if (signal?.aborted) return cancelled("screenshot");
const capture =
node.kind === "surface"
? await (async () => {
const environment = await captureSurfaceEnvironment(cdp, target.tabId, node.frameId);
if (isRpcError(environment)) return environment;
return ctx.surfaceCaptures.create({
sessionId: ctx.sessionId,
tabId: target.tabId,
navigationIdentity: environment.navigationIdentity,
surface: {
ref: node.refKey,
...(node.frameId ? { frameId: node.frameId } : {}),
backendNodeId: node.backendNodeId,
observationGeneration: node.generation,
},
topViewportRect: captured.topViewportRect,
imageWidth: captured.width,
imageHeight: captured.height,
viewportSignature: environment.viewportSignature,
frameProjectionSignature: environment.frameProjectionSignature,
});
})()
: null;
if (capture && isRpcError(capture)) return capture;
return withShotDialogs({ return withShotDialogs({
image_base64: captured.image_base64, image_base64: captured.image_base64,
width: captured.width, width: captured.width,
height: captured.height, height: captured.height,
format: "png", format: "png",
tab_id: target.tabId, tab_id: target.tabId,
...(capture
? {
capture: {
id: capture.id,
surface_ref: `@${capture.surface.ref}`,
coordinate_space: "capture-image-pixel" as const,
expires_at: capture.expiresAt,
},
}
: {}),
}); });
} }
+1
View File
@@ -271,6 +271,7 @@ export async function handleSessionStop(
// Step 2: clear the per-session RefStore (review M6/M7 parity). // Step 2: clear the per-session RefStore (review M6/M7 parity).
ctx.refStore.clear(); ctx.refStore.clear();
ctx.surfaceCaptures.clear();
clearRecordingForSession(params.session_id); clearRecordingForSession(params.session_id);
+2
View File
@@ -16,6 +16,7 @@ export interface SnapshotRefLookup {
visibleRect?: Rect; visibleRect?: Rect;
kind: RefTargetKind; kind: RefTargetKind;
capabilities: RefCapability[]; capabilities: RefCapability[];
generation: number;
} }
function refEntryForTab(ctx: SessionContext, refKey: string, tabId: number) { function refEntryForTab(ctx: SessionContext, refKey: string, tabId: number) {
@@ -44,6 +45,7 @@ export function lookupSnapshotRef(
...(entry.visibleRect ? { visibleRect: entry.visibleRect } : {}), ...(entry.visibleRect ? { visibleRect: entry.visibleRect } : {}),
kind: entry.kind, kind: entry.kind,
capabilities: entry.capabilities, capabilities: entry.capabilities,
generation: entry.generation,
}; };
} }
@@ -0,0 +1,88 @@
import type { Rect } from "@browser-skill/vom";
import type { Point, Region, ViewportRect } from "./geometry";
const RECT_EPSILON = 0.01;
export function surfaceVisibleRect(
liveRect: ViewportRect,
observedRect: Rect | undefined,
): Rect | null {
const observed = observedRect ?? {
x: liveRect.x,
y: liveRect.y,
w: liveRect.width,
h: liveRect.height,
};
const x = Math.max(liveRect.x, observed.x);
const y = Math.max(liveRect.y, observed.y);
const right = Math.min(liveRect.x + liveRect.width, observed.x + observed.w);
const bottom = Math.min(liveRect.y + liveRect.height, observed.y + observed.h);
return right > x && bottom > y ? { x, y, w: right - x, h: bottom - y } : null;
}
export function sameRect(a: Rect, b: Rect): boolean {
return (
Math.abs(a.x - b.x) <= RECT_EPSILON &&
Math.abs(a.y - b.y) <= RECT_EPSILON &&
Math.abs(a.w - b.w) <= RECT_EPSILON &&
Math.abs(a.h - b.h) <= RECT_EPSILON
);
}
export function mapImagePointToViewport(
rect: Rect,
imageWidth: number,
imageHeight: number,
imageX: number,
imageY: number,
): Point | null {
if (
!Number.isFinite(imageX) ||
!Number.isFinite(imageY) ||
!Number.isSafeInteger(imageWidth) ||
!Number.isSafeInteger(imageHeight) ||
imageWidth <= 0 ||
imageHeight <= 0 ||
imageX < 0 ||
imageY < 0 ||
imageX >= imageWidth ||
imageY >= imageHeight ||
!Number.isFinite(rect.x) ||
!Number.isFinite(rect.y) ||
!Number.isFinite(rect.w) ||
!Number.isFinite(rect.h) ||
rect.w <= 0 ||
rect.h <= 0
) {
return null;
}
return {
x: rect.x + (imageX / imageWidth) * rect.w,
y: rect.y + (imageY / imageHeight) * rect.h,
};
}
function pointInPolygon(point: Point, polygon: Point[]): boolean {
for (let index = 0; index < polygon.length; index += 1) {
const a = polygon[index];
const b = polygon[(index + 1) % polygon.length];
const cross = (point.x - a.x) * (b.y - a.y) - (point.y - a.y) * (b.x - a.x);
const withinX = point.x >= Math.min(a.x, b.x) - 1e-9 && point.x <= Math.max(a.x, b.x) + 1e-9;
const withinY = point.y >= Math.min(a.y, b.y) - 1e-9 && point.y <= Math.max(a.y, b.y) + 1e-9;
if (Math.abs(cross) <= 1e-9 && withinX && withinY) return true;
}
let inside = false;
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
const a = polygon[index];
const b = polygon[previous];
const intersects =
a.y > point.y !== b.y > point.y &&
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x;
if (intersects) inside = !inside;
}
return inside;
}
export function pointInRegion(point: Point, region: Region): boolean {
return region.some((polygon) => polygon.length >= 3 && pointInPolygon(point, polygon));
}
+260
View File
@@ -0,0 +1,260 @@
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
import type { SessionContext } from "@/session-manager/manager";
import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
import { resolveNodeGeometry } from "./frame-geometry";
import { type CdpRunner, isRpcError, normaliseRef } from "./shared";
import { resolveSnapshotRef } from "./snapshot-ref";
import {
mapImagePointToViewport,
pointInRegion,
sameRect,
surfaceVisibleRect,
} from "./surface-coordinate";
export interface SurfaceCaptureEnvironment {
navigationIdentity: string;
viewportSignature: string;
frameProjectionSignature: string;
}
export interface SurfaceTargetResolverDeps {
cdp: CdpRunner;
signal?: AbortSignal;
}
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
// animation frame. Leave a scheduling boundary after moving and before pressing.
export const SURFACE_POINTER_MOVE_SETTLE_MS = 32;
function stale(message: string): RpcError {
return rpcError("permission_denied", "surface_capture_stale", message);
}
function finiteObject(value: unknown): unknown {
if (Array.isArray(value)) return value.map(finiteObject);
if (typeof value !== "object" || value === null) return value;
return Object.fromEntries(
Object.entries(value)
.filter(
([, entry]) => entry !== undefined && (typeof entry !== "number" || Number.isFinite(entry)),
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, entry]) => [key, finiteObject(entry)]),
);
}
function framePathSignature(graph: CdpFrameGraph, frameId: string | undefined): string | null {
if (!frameId) return "top";
const frames = new Map(graph.frames.map((frame) => [frame.frameId, frame]));
const path: Array<{
frameId: string;
parentFrameId?: string;
ownerBackendNodeId?: number;
targetSessionId?: string;
}> = [];
const seen = new Set<string>();
let current = frames.get(frameId);
while (current) {
if (seen.has(current.frameId)) return null;
seen.add(current.frameId);
path.push({
frameId: current.frameId,
...(current.parentFrameId ? { parentFrameId: current.parentFrameId } : {}),
...(current.ownerBackendNodeId !== undefined
? { ownerBackendNodeId: current.ownerBackendNodeId }
: {}),
...(current.target.sessionId ? { targetSessionId: current.target.sessionId } : {}),
});
if (!current.parentFrameId) return JSON.stringify(path);
const parent = frames.get(current.parentFrameId);
if (!parent) return null;
current = parent;
}
return null;
}
export async function captureSurfaceEnvironment(
cdp: CdpRunner,
tabId: number,
frameId: string | undefined,
): Promise<SurfaceCaptureEnvironment | RpcError> {
try {
const [tree, metrics, graph] = await Promise.all([
cdp.send<{
frameTree?: { frame?: { id?: string; loaderId?: string; url?: string } };
}>(tabId, "Page.getFrameTree", {}),
cdp.send<Record<string, unknown>>(tabId, "Page.getLayoutMetrics", {}),
frameId && cdp.getFrameGraph ? cdp.getFrameGraph(tabId) : Promise.resolve(null),
]);
const root = tree.frameTree?.frame;
if (!root?.id || !root.loaderId) {
return { code: "cdp_failed", message: "could not identify the current navigation" };
}
const frameProjectionSignature = frameId
? graph
? framePathSignature(graph, frameId)
: null
: "top";
if (!frameProjectionSignature) {
return { code: "cdp_failed", message: `could not identify frame projection for ${frameId}` };
}
return {
navigationIdentity: JSON.stringify({ id: root.id, loaderId: root.loaderId, url: root.url }),
viewportSignature: JSON.stringify(
finiteObject({
cssLayoutViewport: metrics.cssLayoutViewport,
cssVisualViewport: metrics.cssVisualViewport,
}),
),
frameProjectionSignature,
};
} catch (error) {
return { code: "cdp_failed", message: error instanceof Error ? error.message : String(error) };
}
}
function validatePointParams(input: SurfacePointerTargetInput): RpcError | null {
if (!input.captureId || typeof input.captureId !== "string") {
return { code: "invalid_params", message: "surface point click requires capture_id" };
}
if (typeof input.imageX !== "number" || !Number.isFinite(input.imageX)) {
return { code: "invalid_params", message: "image_x must be a finite number" };
}
if (typeof input.imageY !== "number" || !Number.isFinite(input.imageY)) {
return { code: "invalid_params", message: "image_y must be a finite number" };
}
if (!input.ref || input.selector) {
return {
code: "invalid_params",
message: "surface point click requires one Surface ref and does not accept a selector",
};
}
return null;
}
function captureConsumeError(reason: "not_found" | "expired" | "consumed"): RpcError {
const code = reason === "not_found" ? "not_found" : "permission_denied";
return rpcError(code, `surface_capture_${reason}`, `surface capture is ${reason}`);
}
function aborted(signal: AbortSignal | undefined): RpcError | null {
return signal?.aborted
? { code: "cancelled", message: "surface target resolution aborted" }
: null;
}
/** Resolve one fresh Surface capture coordinate into a validated top-viewport pointer target. */
export async function resolveSurfacePointerTarget(
ctx: SessionContext,
target: { tabId: number; url?: string },
input: SurfacePointerTargetInput,
deps: SurfaceTargetResolverDeps,
): Promise<ResolvedSurfacePointerTarget | RpcError> {
const invalid = validatePointParams(input);
if (invalid) return invalid;
const earlyAbort = aborted(deps.signal);
if (earlyAbort) return earlyAbort;
const consumed = ctx.surfaceCaptures.consume(input.captureId as string);
if (!consumed.ok) return captureConsumeError(consumed.reason);
const capture = consumed.capture;
const postConsumeAbort = aborted(deps.signal);
if (postConsumeAbort) return postConsumeAbort;
if (capture.sessionId !== ctx.sessionId || capture.tabId !== target.tabId) {
return stale("surface capture belongs to a different session or tab");
}
if (normaliseRef(input.ref as string) !== capture.surface.ref) {
return stale("surface ref does not match the capture");
}
const node = resolveSnapshotRef(ctx, input.ref as string, target.tabId, "screenshot");
if (isRpcError(node)) return stale(node.message);
if (
node.kind !== "surface" ||
node.backendNodeId !== capture.surface.backendNodeId ||
node.frameId !== capture.surface.frameId ||
node.generation !== capture.surface.observationGeneration
) {
return stale("surface observation generation or node identity changed");
}
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
try {
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
} catch (error) {
return { code: "cdp_failed", message: error instanceof Error ? error.message : String(error) };
}
const environment = await captureSurfaceEnvironment(deps.cdp, target.tabId, node.frameId);
if (isRpcError(environment)) return environment;
if (
environment.navigationIdentity !== capture.navigationIdentity ||
environment.viewportSignature !== capture.viewportSignature ||
environment.frameProjectionSignature !== capture.frameProjectionSignature
) {
return stale("navigation, viewport, zoom, scroll, or frame projection changed");
}
const geometry = await resolveNodeGeometry(
deps.cdp,
target.tabId,
{
target: {
tabId: target.tabId,
...(node.cdpSessionId ? { sessionId: node.cdpSessionId } : {}),
},
backendNodeId: node.backendNodeId,
...(node.frameId ? { frameId: node.frameId } : {}),
},
{ scrollIntoView: false },
);
if (isRpcError(geometry)) return stale(geometry.message);
const currentRect = surfaceVisibleRect(geometry.topBounds, node.visibleRect);
if (!currentRect || !sameRect(currentRect, capture.topViewportRect)) {
return stale("surface visible region changed");
}
const point = mapImagePointToViewport(
capture.topViewportRect,
capture.imageWidth,
capture.imageHeight,
input.imageX as number,
input.imageY as number,
);
if (!point || !pointInRegion(point, geometry.topVisibleRegions)) {
return rpcError(
"invalid_params",
"surface_coordinate_invalid",
"image coordinate is outside the captured Surface",
);
}
return {
point,
usedRef: capture.surface.ref,
moveSettleMs: SURFACE_POINTER_MOVE_SETTLE_MS,
capture: {
id: capture.id,
imageX: input.imageX as number,
imageY: input.imageY as number,
},
};
}
+19
View File
@@ -49,6 +49,11 @@ export type RpcErrorReason =
| "download_capture_failed" | "download_capture_failed"
| "transfer_outcome_unknown" | "transfer_outcome_unknown"
| "transfer_timeout" | "transfer_timeout"
| "surface_capture_not_found"
| "surface_capture_expired"
| "surface_capture_consumed"
| "surface_capture_stale"
| "surface_coordinate_invalid"
| "cleanup_failed"; | "cleanup_failed";
export type TransferEffectState = "none" | "committed" | "unknown"; export type TransferEffectState = "none" | "committed" | "unknown";
@@ -320,9 +325,17 @@ export interface ScreenshotResult {
height: number; height: number;
format: string; format: string;
tab_id: number; tab_id: number;
capture?: SurfaceCaptureInfo;
dialogs?: JavaScriptDialogInfo[]; dialogs?: JavaScriptDialogInfo[];
} }
export interface SurfaceCaptureInfo {
id: string;
surface_ref: string;
coordinate_space: "capture-image-pixel";
expires_at: number;
}
export interface SnapshotParams { export interface SnapshotParams {
session_id: string; session_id: string;
tab_id?: number; tab_id?: number;
@@ -441,6 +454,9 @@ export interface ClickParams {
click_count?: number; click_count?: number;
modifiers?: KeyModifier[]; modifiers?: KeyModifier[];
timeout_ms?: number; timeout_ms?: number;
capture_id?: string;
image_x?: number;
image_y?: number;
} }
export interface ClickResult { export interface ClickResult {
@@ -449,6 +465,9 @@ export interface ClickResult {
used_selector?: string; used_selector?: string;
x: number; x: number;
y: number; y: number;
capture_id?: string;
image_x?: number;
image_y?: number;
dialogs?: JavaScriptDialogInfo[]; dialogs?: JavaScriptDialogInfo[];
} }
@@ -77,6 +77,14 @@ Expected Surface labels:
The cross-origin frame uses `http://localhost:4173` while the parent uses `http://127.0.0.1:4173`. With Chromium site isolation this exercises the OOPIF path without an external service. The cross-origin frame uses `http://localhost:4173` while the parent uses `http://127.0.0.1:4173`. With Chromium site isolation this exercises the OOPIF path without an external service.
Each frame Canvas also exposes a `pointer status` live region. A successful
screenshot-bound point click must report
`down button=0 buttons=1 hoverReady=true; click received` and turn the Canvas
yellow. The hover flag is set on the animation frame after `pointermove`, so the
fixture verifies both child-document delivery and the scheduling boundary
required before `pointerdown`, rather than merely validating projected
coordinates or a successful CDP response.
### `viewport.html` ### `viewport.html`
Run with a `900x700` Agent Window. Run with a `900x700` Agent Window.
@@ -8,6 +8,7 @@
body { padding: 12px; background: #fdfefe; } body { padding: 12px; background: #fdfefe; }
canvas { display: block; width: 220px; height: 90px; background: #d5f5e3; border: 2px solid #1e8449; } canvas { display: block; width: 220px; height: 90px; background: #d5f5e3; border: 2px solid #1e8449; }
iframe { width: 280px; height: 150px; margin-top: 10px; border: 2px solid #85929e; } iframe { width: 280px; height: 150px; margin-top: 10px; border: 2px solid #85929e; }
#pointer-status { margin: 8px 0 0; min-height: 20px; }
</style> </style>
</head> </head>
<body> <body>
@@ -16,11 +17,34 @@
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const canvas = document.querySelector("#frame-canvas"); const canvas = document.querySelector("#frame-canvas");
if (params.get("canvas") === "0") canvas.remove(); if (params.get("canvas") === "0") canvas.remove();
else canvas.setAttribute("aria-label", params.get("label") || "fixture-frame-default"); else {
canvas.setAttribute("aria-label", params.get("label") || "fixture-frame-default");
if (params.get("interactive") === "1") {
const status = document.createElement("p");
status.id = "pointer-status";
status.setAttribute("aria-live", "polite");
status.textContent = "pointer status: idle";
canvas.insertAdjacentElement("afterend", status);
let hoverReady = false;
canvas.addEventListener("pointermove", () => {
hoverReady = false;
requestAnimationFrame(() => {
hoverReady = true;
});
});
canvas.addEventListener("pointerdown", (event) => {
status.textContent = `pointer status: down button=${event.button} buttons=${event.buttons} hoverReady=${hoverReady}`;
if (event.buttons === 1 && hoverReady) canvas.style.background = "#f9e79f";
});
canvas.addEventListener("click", () => {
status.textContent += "; click received";
});
}
}
if (params.get("nested") === "1") { if (params.get("nested") === "1") {
const iframe = document.createElement("iframe"); const iframe = document.createElement("iframe");
iframe.title = "nested fixture frame"; iframe.title = "nested fixture frame";
iframe.src = "frame.html?label=fixture-nested-frame"; iframe.src = "frame.html?label=fixture-nested-frame&interactive=1";
document.body.append(iframe); document.body.append(iframe);
} }
</script> </script>
@@ -14,8 +14,8 @@
<body> <body>
<h1>Frame matrix</h1> <h1>Frame matrix</h1>
<div class="frames"> <div class="frames">
<iframe title="same-origin fixture" src="frame.html?label=fixture-same-origin-frame"></iframe> <iframe title="same-origin fixture" src="frame.html?label=fixture-same-origin-frame&interactive=1"></iframe>
<iframe title="cross-origin fixture" src="http://localhost:4173/frame.html?label=fixture-cross-origin-frame"></iframe> <iframe title="cross-origin fixture" src="http://localhost:4173/frame.html?label=fixture-cross-origin-frame&interactive=1"></iframe>
<iframe title="nested fixture" src="frame.html?canvas=0&nested=1"></iframe> <iframe title="nested fixture" src="frame.html?canvas=0&nested=1"></iframe>
</div> </div>
</body> </body>
+19 -6
View File
@@ -76,12 +76,24 @@ is cheaper and more precise.
When `bsk observe` renders When `bsk observe` renders
`@eN surface ... [visual-only; requires=image-understanding; ...]`, the ref identifies rendered `@eN surface ... [visual-only; requires=image-understanding; ...]`, the ref identifies rendered
canvas content that is not represented by the text observation, not an interactable DOM control. canvas content that is not represented by the text observation, not an interactable DOM control.
Keep using any reliable DOM/AX text and controls that appear alongside it. If you can actually Keep using any reliable DOM/AX text and controls that appear alongside it. If you lack multimodal or
inspect image output, use `bsk screenshot --ref @eN --session <id>` to obtain the visible crop. If image-reading capability, do not take a screenshot and pretend to know its contents, and never guess
you lack multimodal or image-reading capability, do not take a screenshot and pretend to know its coordinates; tell the user that they need to switch to a model with image-understanding capability.
contents, and never guess coordinates; tell the user that they need to switch to a model with
image-understanding capability. Do not pass a visual surface ref to click, fill, hover, or select; If you can actually inspect image output, use `bsk screenshot --ref @eN --session <id>` to obtain
those commands intentionally reject screenshot-only refs. the visible crop and its short-lived, single-use Surface capture id. Only when the task requires a
point action and the image provides a clear target, bind that exact screenshot to the click command:
```bash
bsk click @eN --capture <capture-id> --image-x <px> --image-y <px> --session <id>
```
Coordinates are pixels in the returned capture image, not viewport CSS coordinates. Re-observing,
navigating, scrolling, resizing, zooming, changing Frame projection, expiry, or reusing the capture
makes the action fail; take a new Surface screenshot instead of adjusting or retrying coordinates.
A point click does not reveal Canvas semantics and must not be followed by an assumed fill/press
workflow. Without all three capture arguments, visual Surface refs remain screenshot-only and
click/fill/hover/select reject them.
Escalate page reading only as needed: Escalate page reading only as needed:
@@ -135,6 +147,7 @@ Required flags that are easy to get wrong:
bsk fill <ref> --value <text> bsk select <ref> --value <option-value> bsk fill <ref> --value <text> bsk select <ref> --value <option-value>
bsk screenshot --out <path> bsk emulate --device <preset-id> bsk screenshot --out <path> bsk emulate --device <preset-id>
bsk upload <ref> --file <path> bsk download <ref> --out <path> bsk upload <ref> --file <path> bsk download <ref> --out <path>
bsk click <surface-ref> --capture <id> --image-x <px> --image-y <px>
``` ```
`select` matches an option's `value` attribute, not its visible label. Device preset ids are `select` matches an option's `value` attribute, not its visible label. Device preset ids are
+31
View File
@@ -136,6 +136,18 @@ pub struct ClickArgs {
#[arg(long, default_value = "30s", value_parser = parse_timeout_ms)] #[arg(long, default_value = "30s", value_parser = parse_timeout_ms)]
pub timeout: u32, pub timeout: u32,
/// Capture id returned by `bsk screenshot --ref <surface>`.
#[arg(long)]
pub capture: Option<String>,
/// X coordinate in capture-image pixels. Requires `--capture` and `--image-y`.
#[arg(long = "image-x", allow_hyphen_values = true)]
pub image_x: Option<f64>,
/// Y coordinate in capture-image pixels. Requires `--capture` and `--image-x`.
#[arg(long = "image-y", allow_hyphen_values = true)]
pub image_y: Option<f64>,
} }
pub fn dispatch_click(args: ClickArgs, format: Format) -> Result<(), CliError> { pub fn dispatch_click(args: ClickArgs, format: Format) -> Result<(), CliError> {
@@ -147,6 +159,14 @@ pub fn dispatch_click(args: ClickArgs, format: Format) -> Result<(), CliError> {
)?; )?;
let modifiers = parse_modifiers(&args.modifiers) let modifiers = parse_modifiers(&args.modifiers)
.map_err(|e| CliError::Local(anyhow::anyhow!("--modifiers: {e}")))?; .map_err(|e| CliError::Local(anyhow::anyhow!("--modifiers: {e}")))?;
let point_arg_count = usize::from(args.capture.is_some())
+ usize::from(args.image_x.is_some())
+ usize::from(args.image_y.is_some());
if point_arg_count != 0 && point_arg_count != 3 {
return Err(CliError::Local(anyhow::anyhow!(
"--capture, --image-x, and --image-y must be given together"
)));
}
let params = ClickParams { let params = ClickParams {
session_id: args.session.clone(), session_id: args.session.clone(),
ref_, ref_,
@@ -160,6 +180,9 @@ pub fn dispatch_click(args: ClickArgs, format: Format) -> Result<(), CliError> {
Some(modifiers) Some(modifiers)
}, },
timeout_ms: Some(args.timeout), timeout_ms: Some(args.timeout),
capture_id: args.capture.clone(),
image_x: args.image_x,
image_y: args.image_y,
}; };
let reply: ClickResult = call( let reply: ClickResult = call(
info.sock_path, info.sock_path,
@@ -183,6 +206,14 @@ pub fn dispatch_click(args: ClickArgs, format: Format) -> Result<(), CliError> {
"click ok tab={} target={target} at=({}, {})", "click ok tab={} target={target} at=({}, {})",
reply.tab_id, reply.x, reply.y reply.tab_id, reply.x, reply.y
); );
if let Some(capture_id) = &reply.capture_id {
println!(
"capture={} image=({}, {}) coordinate_space=capture-image-pixel",
capture_id,
reply.image_x.unwrap_or_default(),
reply.image_y.unwrap_or_default()
);
}
print_dialog_summaries(&reply.dialogs); print_dialog_summaries(&reply.dialogs);
} }
} }
+61 -2
View File
@@ -65,6 +65,11 @@ pub mod reason {
pub const DOWNLOAD_CAPTURE_FAILED: &str = "download_capture_failed"; pub const DOWNLOAD_CAPTURE_FAILED: &str = "download_capture_failed";
pub const TRANSFER_OUTCOME_UNKNOWN: &str = "transfer_outcome_unknown"; pub const TRANSFER_OUTCOME_UNKNOWN: &str = "transfer_outcome_unknown";
pub const TRANSFER_TIMEOUT: &str = "transfer_timeout"; pub const TRANSFER_TIMEOUT: &str = "transfer_timeout";
pub const SURFACE_CAPTURE_NOT_FOUND: &str = "surface_capture_not_found";
pub const SURFACE_CAPTURE_EXPIRED: &str = "surface_capture_expired";
pub const SURFACE_CAPTURE_CONSUMED: &str = "surface_capture_consumed";
pub const SURFACE_CAPTURE_STALE: &str = "surface_capture_stale";
pub const SURFACE_COORDINATE_INVALID: &str = "surface_coordinate_invalid";
pub const SESSION_BUSY: &str = crate::rpc_reason::SESSION_BUSY; pub const SESSION_BUSY: &str = crate::rpc_reason::SESSION_BUSY;
pub const RECORD_START_PAGE_UNREACHABLE: &str = "record_start_page_unreachable"; pub const RECORD_START_PAGE_UNREACHABLE: &str = "record_start_page_unreachable";
} }
@@ -303,7 +308,7 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
(ErrorCode::PermissionDenied, reason::REF_CAPABILITY_DENIED) => RenderInfo { (ErrorCode::PermissionDenied, reason::REF_CAPABILITY_DENIED) => RenderInfo {
summary: "snapshot ref does not support this operation", summary: "snapshot ref does not support this operation",
hint: Some( hint: Some(
"visual surface refs are screenshot-only; inspect `bsk screenshot --ref <ref> --session <id>` with image-understanding capability", "visual Surface refs require image understanding; inspect `bsk screenshot --ref <ref> --session <id>`, then bind its capture id and image coordinates to `bsk click`",
), ),
exit_code: base.exit_code, exit_code: base.exit_code,
}, },
@@ -349,6 +354,41 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
), ),
exit_code: base.exit_code, exit_code: base.exit_code,
}, },
(ErrorCode::NotFound, reason::SURFACE_CAPTURE_NOT_FOUND) => RenderInfo {
summary: "surface capture transaction was not found",
hint: Some(
"take a fresh surface screenshot and use its capture id with coordinates from that image",
),
exit_code: base.exit_code,
},
(ErrorCode::PermissionDenied, reason::SURFACE_CAPTURE_EXPIRED) => RenderInfo {
summary: "surface capture transaction has expired",
hint: Some(
"take a fresh surface screenshot, then click promptly with that capture id and image coordinates",
),
exit_code: base.exit_code,
},
(ErrorCode::PermissionDenied, reason::SURFACE_CAPTURE_CONSUMED) => RenderInfo {
summary: "surface capture transaction was already used",
hint: Some(
"each capture id is single-use; take a fresh surface screenshot before another point action",
),
exit_code: base.exit_code,
},
(ErrorCode::PermissionDenied, reason::SURFACE_CAPTURE_STALE) => RenderInfo {
summary: "page state changed since the surface screenshot",
hint: Some(
"take a fresh surface screenshot and choose coordinates from the new image before retrying",
),
exit_code: base.exit_code,
},
(ErrorCode::InvalidParams, reason::SURFACE_COORDINATE_INVALID) => RenderInfo {
summary: "surface image coordinate is invalid",
hint: Some(
"use finite pixel coordinates inside the captured image; its top-left corner is (0, 0)",
),
exit_code: base.exit_code,
},
( (
ErrorCode::CdpFailed | ErrorCode::Timeout | ErrorCode::InvalidParams, ErrorCode::CdpFailed | ErrorCode::Timeout | ErrorCode::InvalidParams,
reason::RECORD_START_PAGE_UNREACHABLE, reason::RECORD_START_PAGE_UNREACHABLE,
@@ -578,7 +618,7 @@ mod tests {
let info = info_for_error(ErrorCode::PermissionDenied, Some(&data)); let info = info_for_error(ErrorCode::PermissionDenied, Some(&data));
assert_eq!(info.summary, "snapshot ref does not support this operation"); assert_eq!(info.summary, "snapshot ref does not support this operation");
assert!( assert!(
info.hint.unwrap().contains("screenshot-only"), info.hint.unwrap().contains("capture id"),
"expected visual-surface-specific hint" "expected visual-surface-specific hint"
); );
assert_eq!(info.exit_code, 1); assert_eq!(info.exit_code, 1);
@@ -684,6 +724,25 @@ mod tests {
assert!(info.summary.contains("timed out after browser dispatch")); assert!(info.summary.contains("timed out after browser dispatch"));
} }
#[test]
fn surface_capture_stale_requests_a_fresh_screenshot() {
let data = serde_json::json!({ "reason": reason::SURFACE_CAPTURE_STALE });
let info = info_for_error(ErrorCode::PermissionDenied, Some(&data));
assert_eq!(
info.summary,
"page state changed since the surface screenshot"
);
assert!(info.hint.unwrap().contains("fresh surface screenshot"));
}
#[test]
fn surface_coordinate_invalid_explains_image_pixel_bounds() {
let data = serde_json::json!({ "reason": reason::SURFACE_COORDINATE_INVALID });
let info = info_for_error(ErrorCode::InvalidParams, Some(&data));
assert_eq!(info.summary, "surface image coordinate is invalid");
assert!(info.hint.unwrap().contains("top-left corner is (0, 0)"));
}
#[test] #[test]
fn ref_not_found_overrides_not_found_copy() { fn ref_not_found_overrides_not_found_copy() {
let data = serde_json::json!({ "reason": reason::REF_NOT_FOUND }); let data = serde_json::json!({ "reason": reason::REF_NOT_FOUND });
+11 -1
View File
@@ -59,7 +59,7 @@ fn run(sock: PathBuf, args: ScreenshotArgs, format: Format) -> Result<(), CliErr
.map_err(CliError::Local)?; .map_err(CliError::Local)?;
match format { match format {
Format::Json => { Format::Json => {
let json = serde_json::json!({ let mut json = serde_json::json!({
"tab_id": reply.tab_id, "tab_id": reply.tab_id,
"width": reply.width, "width": reply.width,
"height": reply.height, "height": reply.height,
@@ -67,6 +67,10 @@ fn run(sock: PathBuf, args: ScreenshotArgs, format: Format) -> Result<(), CliErr
"path": out_path.to_string_lossy(), "path": out_path.to_string_lossy(),
"byte_size": bytes.len(), "byte_size": bytes.len(),
}); });
if let Some(capture) = &reply.capture {
json["capture"] = serde_json::to_value(capture)
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?;
}
println!( println!(
"{}", "{}",
serde_json::to_string_pretty(&json) serde_json::to_string_pretty(&json)
@@ -75,6 +79,12 @@ fn run(sock: PathBuf, args: ScreenshotArgs, format: Format) -> Result<(), CliErr
} }
Format::Human => { Format::Human => {
println!("{}", out_path.display()); println!("{}", out_path.display());
if let Some(capture) = &reply.capture {
println!(
"capture={} surface={} coordinate_space={} expires_at={}",
capture.id, capture.surface_ref, capture.coordinate_space, capture.expires_at
);
}
print_dialog_summaries(&reply.dialogs); print_dialog_summaries(&reply.dialogs);
} }
} }
+23
View File
@@ -237,6 +237,29 @@ fn parses_click_count_alias() {
assert_eq!(args.click_count, 2); assert_eq!(args.click_count, 2);
} }
#[test]
fn parses_surface_capture_image_coordinates() {
let cli = parse(&[
"bsk",
"click",
"@e1",
"--session",
"s1",
"--capture",
"sc_test",
"--image-x",
"160.5",
"--image-y",
"-1",
]);
let Command::Click(args) = cli.command else {
panic!("expected click command");
};
assert_eq!(args.capture.as_deref(), Some("sc_test"));
assert_eq!(args.image_x, Some(160.5));
assert_eq!(args.image_y, Some(-1.0));
}
#[test] #[test]
fn parses_hover_with_settle() { fn parses_hover_with_settle() {
let cli = parse(&[ let cli = parse(&[
+2
View File
@@ -238,6 +238,7 @@ async fn screenshot_returns_image_base64_with_dimensions() {
height: 600, height: 600,
format: "png".into(), format: "png".into(),
tab_id: 7, tab_id: 7,
capture: None,
dialogs: vec![], dialogs: vec![],
}) })
.unwrap(), .unwrap(),
@@ -286,6 +287,7 @@ async fn screenshot_forwards_ref_to_extension() {
height: 60, height: 60,
format: "png".into(), format: "png".into(),
tab_id: 7, tab_id: 7,
capture: None,
dialogs: vec![], dialogs: vec![],
}) })
.unwrap(), .unwrap(),
+9
View File
@@ -354,6 +354,9 @@ async fn click_round_trips_ref_and_modifiers() {
used_selector: None, used_selector: None,
x: 12.5, x: 12.5,
y: 34.0, y: 34.0,
capture_id: None,
image_x: None,
image_y: None,
dialogs: vec![], dialogs: vec![],
}) })
.unwrap(), .unwrap(),
@@ -373,6 +376,9 @@ async fn click_round_trips_ref_and_modifiers() {
click_count: Some(1), click_count: Some(1),
modifiers: Some(vec![KeyModifier::Ctrl, KeyModifier::Shift]), modifiers: Some(vec![KeyModifier::Ctrl, KeyModifier::Shift]),
timeout_ms: Some(5_000), timeout_ms: Some(5_000),
capture_id: None,
image_x: None,
image_y: None,
}, },
) )
.await .await
@@ -610,6 +616,9 @@ async fn m7_tools_propagate_extension_errors() {
click_count: None, click_count: None,
modifiers: None, modifiers: None,
timeout_ms: Some(5_000), timeout_ms: Some(5_000),
capture_id: None,
image_x: None,
image_y: None,
}, },
) )
.await .await
@@ -16,6 +16,13 @@
} }
] ]
}, },
"capture_id": {
"description": "Short-lived capture id returned by a visual Surface screenshot.",
"type": [
"string",
"null"
]
},
"click_count": { "click_count": {
"description": "Number of consecutive mouse presses (double-click = 2).", "description": "Number of consecutive mouse presses (double-click = 2).",
"type": [ "type": [
@@ -25,6 +32,22 @@
"format": "uint32", "format": "uint32",
"minimum": 1.0 "minimum": 1.0
}, },
"image_x": {
"description": "X coordinate in the capture image's pixel coordinate space.",
"type": [
"number",
"null"
],
"format": "double"
},
"image_y": {
"description": "Y coordinate in the capture image's pixel coordinate space.",
"type": [
"number",
"null"
],
"format": "double"
},
"modifiers": { "modifiers": {
"type": [ "type": [
"array", "array",
@@ -8,6 +8,12 @@
"y" "y"
], ],
"properties": { "properties": {
"capture_id": {
"type": [
"string",
"null"
]
},
"dialogs": { "dialogs": {
"description": "Native JS dialogs observed and auto-handled during this call.", "description": "Native JS dialogs observed and auto-handled during this call.",
"type": "array", "type": "array",
@@ -15,6 +21,20 @@
"$ref": "#/definitions/JavaScriptDialogInfo" "$ref": "#/definitions/JavaScriptDialogInfo"
} }
}, },
"image_x": {
"type": [
"number",
"null"
],
"format": "double"
},
"image_y": {
"type": [
"number",
"null"
],
"format": "double"
},
"tab_id": { "tab_id": {
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"
@@ -8,6 +8,16 @@
"tab_id" "tab_id"
], ],
"properties": { "properties": {
"capture": {
"anyOf": [
{
"$ref": "#/definitions/SurfaceCaptureInfo"
},
{
"type": "null"
}
]
},
"dialogs": { "dialogs": {
"type": "array", "type": "array",
"items": { "items": {
@@ -109,6 +119,31 @@
"prompt", "prompt",
"beforeunload" "beforeunload"
] ]
},
"SurfaceCaptureInfo": {
"type": "object",
"required": [
"coordinate_space",
"expires_at",
"id",
"surface_ref"
],
"properties": {
"coordinate_space": {
"type": "string"
},
"expires_at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
},
"id": {
"type": "string"
},
"surface_ref": {
"type": "string"
}
}
} }
} }
} }
@@ -67,6 +67,15 @@ pub struct ClickParams {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))] #[schemars(range(min = 1))]
pub timeout_ms: Option<u32>, pub timeout_ms: Option<u32>,
/// Short-lived capture id returned by a visual Surface screenshot.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capture_id: Option<String>,
/// X coordinate in the capture image's pixel coordinate space.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_x: Option<f64>,
/// Y coordinate in the capture image's pixel coordinate space.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_y: Option<f64>,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -80,6 +89,12 @@ pub struct ClickResult {
/// agents can correlate with a follow-up `tool.screenshot`. /// agents can correlate with a follow-up `tool.screenshot`.
pub x: f64, pub x: f64,
pub y: f64, pub y: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capture_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_x: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_y: Option<f64>,
/// Native JS dialogs observed and auto-handled during this call. /// Native JS dialogs observed and auto-handled during this call.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>, pub dialogs: Vec<JavaScriptDialogInfo>,
@@ -288,6 +303,9 @@ mod tests {
click_count: Some(1), click_count: Some(1),
modifiers: Some(vec![KeyModifier::Ctrl]), modifiers: Some(vec![KeyModifier::Ctrl]),
timeout_ms: Some(5_000), timeout_ms: Some(5_000),
capture_id: Some("sc_test".into()),
image_x: Some(160.0),
image_y: Some(90.0),
}; };
let v = serde_json::to_value(&p).unwrap(); let v = serde_json::to_value(&p).unwrap();
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3")); assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
@@ -233,10 +233,20 @@ pub struct ScreenshotResult {
/// Always `"png"` in v0.1; reserved for future JPEG / WebP support. /// Always `"png"` in v0.1; reserved for future JPEG / WebP support.
pub format: String, pub format: String,
pub tab_id: i64, pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capture: Option<SurfaceCaptureInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>, pub dialogs: Vec<JavaScriptDialogInfo>,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SurfaceCaptureInfo {
pub id: String,
pub surface_ref: String,
pub coordinate_space: String,
pub expires_at: u64,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -318,6 +328,12 @@ mod tests {
height: 600, height: 600,
format: "png".into(), format: "png".into(),
tab_id: 7, tab_id: 7,
capture: Some(SurfaceCaptureInfo {
id: "sc_test".into(),
surface_ref: "@e3".into(),
coordinate_space: "capture-image-pixel".into(),
expires_at: 1_700_000_030_000,
}),
dialogs: vec![], dialogs: vec![],
}; };
let v = serde_json::to_value(&r).unwrap(); let v = serde_json::to_value(&r).unwrap();
+60 -3
View File
@@ -563,7 +563,8 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
name: "interact.click", name: "interact.click",
description: description:
"Click an element in the session's active tab. Target is a snapshot ref (@e3) from the " + "Click an element in the session's active tab. Target is a snapshot ref (@e3) from the " +
"last browser_inspect observation, or a CSS selector.", "last browser_inspect observation, or a CSS selector. A visual Surface requires the " +
"captureId and image coordinates returned by browser_inspect action=screenshot.",
parameters: { parameters: {
target: { target: {
type: "string", type: "string",
@@ -580,6 +581,24 @@ 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: {
type: "string",
description:
"Short-lived Surface capture id returned by browser_inspect action=screenshot.",
},
imageX: {
type: "number",
description: "X coordinate in capture-image pixels; requires captureId and imageY.",
},
imageY: {
type: "number",
description: "Y coordinate in capture-image pixels; requires captureId and imageX.",
},
}, },
output: { output: {
schema: { schema: {
@@ -590,6 +609,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
tabId: { type: "integer", required: true }, tabId: { type: "integer", required: true },
x: { type: "number", required: true }, x: { type: "number", required: true },
y: { type: "number", required: true }, y: { type: "number", required: true },
captureId: { type: "string" },
}, },
}, },
render: (_args, value) => [ render: (_args, value) => [
@@ -601,17 +621,44 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
}, },
async execute(args, exec) { async execute(args, exec) {
if (args.target.trim().length === 0) throw new Error("target must be a non-empty string"); if (args.target.trim().length === 0) throw new Error("target must be a non-empty string");
const pointArgCount =
Number(args.captureId !== undefined) +
Number(args.imageX !== undefined) +
Number(args.imageY !== undefined);
if (pointArgCount !== 0 && pointArgCount !== 3) {
throw new Error("captureId, imageX, and imageY must be given together");
}
const sessionId = registry.resolve(args.session, "browser_interact(action=click)"); const sessionId = registry.resolve(args.session, "browser_interact(action=click)");
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) {
cmdArgs.push(
"--capture",
args.captureId,
"--image-x",
String(args.imageX),
"--image-y",
String(args.imageY),
);
}
cmdArgs.push(args.target); cmdArgs.push(args.target);
const reply = (await runBsk(deps, exec, cmdArgs, "click", sessionId)) as { const reply = (await runBsk(deps, exec, cmdArgs, "click", sessionId)) as {
tab_id: number; tab_id: number;
x: number; x: number;
y: number; y: number;
capture_id?: string;
};
return {
session: sessionId,
tabId: reply.tab_id,
x: reply.x,
y: reply.y,
...(reply.capture_id !== undefined ? { captureId: reply.capture_id } : {}),
}; };
return { session: sessionId, tabId: reply.tab_id, x: reply.x, y: reply.y };
}, },
presentCall: (args) => ({ presentCall: (args) => ({
card: "terminal", card: "terminal",
@@ -780,6 +827,8 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
width: { type: "integer", required: true }, width: { type: "integer", required: true },
height: { type: "integer", required: true }, height: { type: "integer", required: true },
byteSize: { type: "integer", required: true }, byteSize: { type: "integer", required: true },
captureId: { type: "string" },
captureExpiresAt: { type: "integer" },
image: { image: {
type: "object", type: "object",
additionalProperties: false, additionalProperties: false,
@@ -803,7 +852,11 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
value.image !== undefined value.image !== undefined
? `[session ${value.session}] screenshot of tab ${value.tabId} (${value.width}x${value.height}px)` ? `[session ${value.session}] screenshot of tab ${value.tabId} (${value.width}x${value.height}px)`
: `[session ${value.session}] screenshot saved to ${value.path} (${value.width}x${value.height}px, ${value.byteSize} bytes) — this deployment cannot inline images; read the file to view it`; : `[session ${value.session}] screenshot saved to ${value.path} (${value.width}x${value.height}px, ${value.byteSize} bytes) — this deployment cannot inline images; read the file to view it`;
const blocks: ContentBlock[] = [{ type: "text", text }]; const captureText =
value.captureId !== undefined
? ` Surface capture ${value.captureId} uses capture-image-pixel coordinates and expires at ${value.captureExpiresAt}.`
: "";
const blocks: ContentBlock[] = [{ type: "text", text: text + captureText }];
if (value.image !== undefined) { if (value.image !== undefined) {
blocks.push({ blocks.push({
type: "image", type: "image",
@@ -842,6 +895,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
height: number; height: number;
path: string; path: string;
byte_size: number; byte_size: number;
capture?: { id: string; expires_at: number };
}; };
writtenPath = reply.path; writtenPath = reply.path;
const data = await readFile(reply.path); const data = await readFile(reply.path);
@@ -855,6 +909,9 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
width: reply.width, width: reply.width,
height: reply.height, height: reply.height,
byteSize: reply.byte_size, byteSize: reply.byte_size,
...(reply.capture !== undefined
? { captureId: reply.capture.id, captureExpiresAt: reply.capture.expires_at }
: {}),
...(ref !== undefined ...(ref !== undefined
? { ? {
image: { image: {
@@ -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,43 @@ describe("interaction tools", () => {
"right", "right",
"--click-count", "--click-count",
"2", "2",
"--modifiers",
"ctrl,shift",
"@e1",
]);
});
it("interact.click forwards a complete Surface capture coordinate transaction", async () => {
const { tools, calls } = setup({
...responses,
click: {
tab_id: 7,
used_ref: "e1",
x: 110,
y: 70,
capture_id: "sc_test",
image_x: 200,
image_y: 100,
},
});
await startSession(tools);
const click = tools.get("interact.click");
const value = await click?.execute(
{ target: "@e1", captureId: "sc_test", imageX: 200, imageY: 100 },
makeExec(),
);
expect(value).toMatchObject({ captureId: "sc_test", x: 110, y: 70 });
expect(calls[1].args).toEqual([
"click",
"--session",
"s1",
"--capture",
"sc_test",
"--image-x",
"200",
"--image-y",
"100",
"@e1", "@e1",
]); ]);
}); });
@@ -923,6 +960,32 @@ describe("inspect.screenshot", () => {
expect(rendered?.every((block) => block.type === "text")).toBe(true); expect(rendered?.every((block) => block.type === "text")).toBe(true);
}); });
it("returns Surface capture metadata alongside the screenshot", async () => {
const path = pngFile();
const { tools } = setup({
"session start": START_REPLY("s1"),
screenshot: {
tab_id: 7,
width: 800,
height: 600,
format: "png",
path,
byte_size: 8,
capture: { id: "sc_test", expires_at: 1700000030000 },
},
});
await startSession(tools);
const screenshot = tools.get("inspect.screenshot");
const value = await screenshot?.execute({ ref: "@e1" }, makeExec());
expect(value).toMatchObject({
captureId: "sc_test",
captureExpiresAt: 1700000030000,
});
const rendered = screenshot?.output.render({ session: "s1" }, value as never);
expect(rendered?.[0]).toMatchObject({ type: "text", text: expect.stringContaining("sc_test") });
});
it("commits the image through the attachment store on an image-capable route", async () => { it("commits the image through the attachment store on an image-capable route", async () => {
const path = pngFile(); const path = pngFile();
const services = { const services = {
+19 -6
View File
@@ -76,12 +76,24 @@ is cheaper and more precise.
When `bsk observe` renders When `bsk observe` renders
`@eN surface ... [visual-only; requires=image-understanding; ...]`, the ref identifies rendered `@eN surface ... [visual-only; requires=image-understanding; ...]`, the ref identifies rendered
canvas content that is not represented by the text observation, not an interactable DOM control. canvas content that is not represented by the text observation, not an interactable DOM control.
Keep using any reliable DOM/AX text and controls that appear alongside it. If you can actually Keep using any reliable DOM/AX text and controls that appear alongside it. If you lack multimodal or
inspect image output, use `bsk screenshot --ref @eN --session <id>` to obtain the visible crop. If image-reading capability, do not take a screenshot and pretend to know its contents, and never guess
you lack multimodal or image-reading capability, do not take a screenshot and pretend to know its coordinates; tell the user that they need to switch to a model with image-understanding capability.
contents, and never guess coordinates; tell the user that they need to switch to a model with
image-understanding capability. Do not pass a visual surface ref to click, fill, hover, or select; If you can actually inspect image output, use `bsk screenshot --ref @eN --session <id>` to obtain
those commands intentionally reject screenshot-only refs. the visible crop and its short-lived, single-use Surface capture id. Only when the task requires a
point action and the image provides a clear target, bind that exact screenshot to the click command:
```bash
bsk click @eN --capture <capture-id> --image-x <px> --image-y <px> --session <id>
```
Coordinates are pixels in the returned capture image, not viewport CSS coordinates. Re-observing,
navigating, scrolling, resizing, zooming, changing Frame projection, expiry, or reusing the capture
makes the action fail; take a new Surface screenshot instead of adjusting or retrying coordinates.
A point click does not reveal Canvas semantics and must not be followed by an assumed fill/press
workflow. Without all three capture arguments, visual Surface refs remain screenshot-only and
click/fill/hover/select reject them.
Escalate page reading only as needed: Escalate page reading only as needed:
@@ -135,6 +147,7 @@ Required flags that are easy to get wrong:
bsk fill <ref> --value <text> bsk select <ref> --value <option-value> bsk fill <ref> --value <text> bsk select <ref> --value <option-value>
bsk screenshot --out <path> bsk emulate --device <preset-id> bsk screenshot --out <path> bsk emulate --device <preset-id>
bsk upload <ref> --file <path> bsk download <ref> --out <path> bsk upload <ref> --file <path> bsk download <ref> --out <path>
bsk click <surface-ref> --capture <id> --image-x <px> --image-y <px>
``` ```
`select` matches an option's `value` attribute, not its visible label. Device preset ids are `select` matches an option's `value` attribute, not its visible label. Device preset ids are