aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-28 00:17:13 +0530
committerAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-08-02 19:12:46 +0530
commitbfba73fa2f1373abd8a5a704c9c833d6fce1b44f (patch)
treee684310863ef6d9aaa9a6bd3d4d3100d75ee9069
parent5cfa7ccc8f4082db111bc0186c5fa3d44b767ad3 (diff)
refactor(domains): share target resolution and CDP error mapping
The ref-vs-x/y branch turned out to exist in one tool, not four; it is now one helper with tests covering the half-specified-coordinate and stale-ref cases. cdpCall folds the repeated cdp_error mapping at 24 active-session call sites; target-scoped callOnTarget/callBrowser sites are left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-rw-r--r--src/domains/box.ts5
-rw-r--r--src/domains/cdp-call.ts14
-rw-r--r--src/domains/click.ts39
-rw-r--r--src/domains/dialog.ts7
-rw-r--r--src/domains/drag.ts8
-rw-r--r--src/domains/files.ts21
-rw-r--r--src/domains/form.ts5
-rw-r--r--src/domains/history.ts17
-rw-r--r--src/domains/keyboard.ts21
-rw-r--r--src/domains/navigate.ts5
-rw-r--r--src/domains/screenshot.ts7
-rw-r--r--src/domains/scroll.ts13
-rw-r--r--src/domains/snapshot.ts5
-rw-r--r--src/domains/target.ts35
-rw-r--r--src/domains/viewport.ts7
-rw-r--r--test/domains/target-test.ts70
16 files changed, 198 insertions, 81 deletions
diff --git a/src/domains/box.ts b/src/domains/box.ts
index e013f70..91f2f6a 100644
--- a/src/domains/box.ts
+++ b/src/domains/box.ts
@@ -1,6 +1,7 @@
import type { BrowserClient } from "../client";
import { type Result, err, ok } from "../util/result";
import type { ToolErr } from "../util/tool";
+import { cdpCall } from "./cdp-call";
export type Box = {
readonly x: number;
@@ -34,8 +35,8 @@ export const boxOf = async (
client: BrowserClient,
backendNodeId: number,
): Promise<Result<Box, ToolErr>> => {
- const r = await client.session().call("DOM.getBoxModel", { backendNodeId });
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ const r = await cdpCall(client, "DOM.getBoxModel", { backendNodeId });
+ if (!r.success) return r;
const model = r.data.model;
if (model.content.length < 8) {
return err({ kind: "invalid_state", message: "DOM.getBoxModel returned an incomplete content quad" });
diff --git a/src/domains/cdp-call.ts b/src/domains/cdp-call.ts
new file mode 100644
index 0000000..3316633
--- /dev/null
+++ b/src/domains/cdp-call.ts
@@ -0,0 +1,14 @@
+import type { BrowserClient } from "../client";
+import type { CdpMethod, ParamsOf, ResultOf } from "../cdp/commands";
+import { type Result, err } from "../util/result";
+import type { ToolErr } from "../util/tool";
+
+export const cdpCall = async <M extends CdpMethod>(
+ client: BrowserClient,
+ method: M,
+ params: ParamsOf<M>,
+): Promise<Result<ResultOf<M>, ToolErr>> => {
+ const r = await client.session().call(method, params);
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message, details: { method } });
+ return r;
+};
diff --git a/src/domains/click.ts b/src/domains/click.ts
index 81bd7ca..0215d9a 100644
--- a/src/domains/click.ts
+++ b/src/domains/click.ts
@@ -2,10 +2,12 @@ import { Type } from "typebox";
import { Text } from "@mariozechner/pi-tui";
import type { BrowserClient } from "../client";
import { Coords, MouseButton } from "../schemas/common";
-import { type Result, err, ok } from "../util/result";
+import { type Result, ok } from "../util/result";
import { defineBrowserTool, type ToolErr } from "../util/tool";
+import { cdpCall } from "./cdp-call";
import { captureWithCrosshair } from "./screenshot";
-import { interactiveDiff, resolveRefToBox } from "./ref-resolve";
+import { interactiveDiff } from "./ref-resolve";
+import { resolveTarget } from "./target";
const ClickArgs = Type.Object({
ref: Type.Optional(
@@ -37,18 +39,16 @@ const dispatchClick = async (
// Move the pointer to the target first. Some focus/hover handlers (and React
// synthetic events) only fire when a mousemove precedes the press, so without
// it a click can land without focusing the element.
- const moved = await client.session().call("Input.dispatchMouseEvent", {
- type: "mouseMoved", x, y,
- });
- if (!moved.success) return err({ kind: "cdp_error", message: moved.error.message });
- const pressed = await client.session().call("Input.dispatchMouseEvent", {
+ const moved = await cdpCall(client, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
+ if (!moved.success) return moved;
+ const pressed = await cdpCall(client, "Input.dispatchMouseEvent", {
type: "mousePressed", x, y, button, clickCount: count,
});
- if (!pressed.success) return err({ kind: "cdp_error", message: pressed.error.message });
- const released = await client.session().call("Input.dispatchMouseEvent", {
+ if (!pressed.success) return pressed;
+ const released = await cdpCall(client, "Input.dispatchMouseEvent", {
type: "mouseReleased", x, y, button, clickCount: count,
});
- if (!released.success) return err({ kind: "cdp_error", message: released.error.message });
+ if (!released.success) return released;
return ok(undefined);
};
@@ -73,26 +73,13 @@ export const clickTool = defineBrowserTool({
const button = args.button ?? "left";
const count = args.count ?? 1;
- // Resolve the click point: ref re-resolves to the element's CURRENT box
- // (so re-renders don't matter); otherwise use the literal x/y.
- let x: number;
- let y: number;
- if (args.ref !== undefined) {
- const box = await resolveRefToBox(client, args.ref);
- if (!box.success) return box;
- x = box.data.cx;
- y = box.data.cy;
- } else if (args.x !== undefined && args.y !== undefined) {
- x = args.x;
- y = args.y;
- } else {
- return err({ kind: "invalid_state", message: "Provide either `ref` or both `x` and `y`." });
- }
+ const resolved = await resolveTarget(client, args);
+ if (!resolved.success) return resolved;
+ const { x, y, label: target } = resolved.data;
const clicked = await dispatchClick(client, x, y, button, count);
if (!clicked.success) return clicked;
- const target = args.ref !== undefined ? `[${args.ref}] (${x}, ${y})` : `(${x}, ${y})`;
const diff = await interactiveDiff(client);
if (process.env["BH_DEBUG_CLICKS"]) {
const debug = await captureWithCrosshair(client, { x, y });
diff --git a/src/domains/dialog.ts b/src/domains/dialog.ts
index 7038c64..aeb26bb 100644
--- a/src/domains/dialog.ts
+++ b/src/domains/dialog.ts
@@ -1,6 +1,7 @@
import { Type } from "typebox";
-import { type Result, err, ok } from "../util/result";
+import { type Result, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
+import { cdpCall } from "./cdp-call";
const HandleDialogArgs = Type.Object({
accept: Type.Boolean({ description: "true = accept, false = dismiss" }),
@@ -20,11 +21,11 @@ export const handleDialogTool = defineBrowserTool({
parameters: HandleDialogArgs,
concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
- const r = await client.session().call("Page.handleJavaScriptDialog", {
+ const r = await cdpCall(client, "Page.handleJavaScriptDialog", {
accept: args.accept,
...(args.promptText !== undefined ? { promptText: args.promptText } : {}),
});
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ if (!r.success) return r;
return ok({
text: `Dialog ${args.accept ? "accepted" : "dismissed"}`,
details: { accept: args.accept },
diff --git a/src/domains/drag.ts b/src/domains/drag.ts
index 9ad2ada..49a5a58 100644
--- a/src/domains/drag.ts
+++ b/src/domains/drag.ts
@@ -1,6 +1,7 @@
import { Type } from "typebox";
-import { type Result, err, ok } from "../util/result";
+import { type Result, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
+import { cdpCall } from "./cdp-call";
const DragArgs = Type.Object({
startX: Type.Number(),
@@ -35,7 +36,6 @@ export const dragAndDropTool = defineBrowserTool({
dragOperationsMask: 1,
}
: { items: [], dragOperationsMask: 1 };
- const cdp = client.session();
type InputCall = readonly ["Input.dispatchMouseEvent" | "Input.dispatchDragEvent", Record<string, unknown>];
const calls: ReadonlyArray<InputCall> = [
["Input.dispatchMouseEvent", { type: "mousePressed", x: args.startX, y: args.startY, button: "left", clickCount: 1 }],
@@ -58,8 +58,8 @@ export const dragAndDropTool = defineBrowserTool({
["Input.dispatchMouseEvent", { type: "mouseReleased", x: args.endX, y: args.endY, button: "left", clickCount: 1 }],
];
for (const [method, params] of calls) {
- const r = await cdp.call(method, params);
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ const r = await cdpCall(client, method, params);
+ if (!r.success) return r;
}
return ok({
text: `Dragged from (${args.startX},${args.startY}) to (${args.endX},${args.endY})`,
diff --git a/src/domains/files.ts b/src/domains/files.ts
index 4e0195d..ebc5a83 100644
--- a/src/domains/files.ts
+++ b/src/domains/files.ts
@@ -6,6 +6,7 @@ import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { safeJs } from "../util/js-template";
import { pdfPath } from "../util/paths";
+import { cdpCall } from "./cdp-call";
import { resolveRefToBackendId } from "./ref-resolve";
const UploadArgs = Type.Object({
@@ -23,8 +24,8 @@ const tryRefUpload = async (
backendId: number,
filePath: string,
): Promise<Result<void, ToolErr>> => {
- const set = await client.session().call("DOM.setFileInputFiles", { files: [filePath], backendNodeId: backendId });
- if (!set.success) return err({ kind: "cdp_error", message: set.error.message });
+ const set = await cdpCall(client, "DOM.setFileInputFiles", { files: [filePath], backendNodeId: backendId });
+ if (!set.success) return set;
return ok(undefined);
};
@@ -45,14 +46,14 @@ const tryCdpUpload = async (
selector: string,
filePath: string,
): Promise<Result<void, ToolErr>> => {
- const doc = await client.session().call("DOM.getDocument", { depth: -1 });
- if (!doc.success) return err({ kind: "cdp_error", message: doc.error.message });
- const q = await client.session().call("DOM.querySelector", { nodeId: doc.data.root.nodeId, selector });
- if (!q.success) return err({ kind: "cdp_error", message: q.error.message });
+ const doc = await cdpCall(client, "DOM.getDocument", { depth: -1 });
+ if (!doc.success) return doc;
+ const q = await cdpCall(client, "DOM.querySelector", { nodeId: doc.data.root.nodeId, selector });
+ if (!q.success) return q;
const nodeId = q.data.nodeId;
if (!nodeId) return err({ kind: "invalid_state", message: `Selector matched 0 file inputs: ${selector}` });
- const set = await client.session().call("DOM.setFileInputFiles", { files: [filePath], nodeId });
- if (!set.success) return err({ kind: "cdp_error", message: set.error.message });
+ const set = await cdpCall(client, "DOM.setFileInputFiles", { files: [filePath], nodeId });
+ if (!set.success) return set;
const verify = await client.evaluateJs(safeJs`document.querySelector(${selector})?.files?.length || 0`);
if (!verify.success) return err({ kind: "cdp_error", message: verify.error.message });
if (Number(verify.data ?? 0) === 0) {
@@ -209,11 +210,11 @@ export const printToPdfTool = defineBrowserTool({
parameters: PrintPdfArgs,
concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
- const r = await client.session().call("Page.printToPDF", {
+ const r = await cdpCall(client, "Page.printToPDF", {
printBackground: true,
preferCSSPageSize: true,
});
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ if (!r.success) return r;
const path = args.outputPath ?? pdfPath(client.namespace);
await writeFile(path, Buffer.from(r.data.data, "base64"));
return ok({ text: `PDF saved: ${path}`, details: { path } });
diff --git a/src/domains/form.ts b/src/domains/form.ts
index 6e3285a..a1ae7f5 100644
--- a/src/domains/form.ts
+++ b/src/domains/form.ts
@@ -3,6 +3,7 @@ import type { BrowserClient } from "../client";
import { safeJs } from "../util/js-template";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
+import { cdpCall } from "./cdp-call";
import { interactiveDiff, resolveRefToObjectId } from "./ref-resolve";
const FillArgs = Type.Object({
@@ -33,13 +34,13 @@ const runOnElement = async (
if (opts.ref !== undefined) {
const objectId = await resolveRefToObjectId(client, opts.ref);
if (!objectId.success) return objectId;
- const r = await client.session().call("Runtime.callFunctionOn", {
+ const r = await cdpCall(client, "Runtime.callFunctionOn", {
objectId: objectId.data,
functionDeclaration: `function (arg) { ${opts.fnBody} }`,
arguments: [{ value: opts.arg }],
returnByValue: true,
});
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ if (!r.success) return r;
return ok(r.data.result.value);
}
if (opts.selector === undefined) {
diff --git a/src/domains/history.ts b/src/domains/history.ts
index fbab376..4b5fa56 100644
--- a/src/domains/history.ts
+++ b/src/domains/history.ts
@@ -2,13 +2,14 @@ import { Type } from "typebox";
import type { BrowserClient } from "../client";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
+import { cdpCall } from "./cdp-call";
type HistoryEntry = { readonly id: number; readonly url: string; readonly title: string };
type History = { readonly entries: ReadonlyArray<HistoryEntry>; readonly currentIndex: number };
const fetchHistory = async (client: BrowserClient): Promise<Result<History, ToolErr>> => {
- const r = await client.session().call("Page.getNavigationHistory");
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ const r = await cdpCall(client, "Page.getNavigationHistory", {});
+ if (!r.success) return r;
return ok(r.data);
};
@@ -26,8 +27,8 @@ export const goBackTool = defineBrowserTool({
if (h.data.currentIndex <= 0) return ok({ text: "Already at the beginning of history." });
const target = h.data.entries[h.data.currentIndex - 1];
if (!target) return err({ kind: "internal", message: "history entry missing at index" });
- const nav = await client.session().call("Page.navigateToHistoryEntry", { entryId: target.id });
- if (!nav.success) return err({ kind: "cdp_error", message: nav.error.message });
+ const nav = await cdpCall(client, "Page.navigateToHistoryEntry", { entryId: target.id });
+ if (!nav.success) return nav;
return ok({ text: `Navigated back to: ${target.url}`, details: { url: target.url } });
},
});
@@ -46,8 +47,8 @@ export const goForwardTool = defineBrowserTool({
if (h.data.currentIndex >= h.data.entries.length - 1) return ok({ text: "Already at the end of history." });
const target = h.data.entries[h.data.currentIndex + 1];
if (!target) return err({ kind: "internal", message: "history entry missing at index" });
- const nav = await client.session().call("Page.navigateToHistoryEntry", { entryId: target.id });
- if (!nav.success) return err({ kind: "cdp_error", message: nav.error.message });
+ const nav = await cdpCall(client, "Page.navigateToHistoryEntry", { entryId: target.id });
+ if (!nav.success) return nav;
return ok({ text: `Navigated forward to: ${target.url}`, details: { url: target.url } });
},
});
@@ -61,8 +62,8 @@ export const reloadTool = defineBrowserTool({
parameters: Type.Object({}),
concurrency: "serialized",
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
- const r = await client.session().call("Page.reload");
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ const r = await cdpCall(client, "Page.reload", {});
+ if (!r.success) return r;
const info = await client.pageInfo();
if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
if ("dialog" in info.data) {
diff --git a/src/domains/keyboard.ts b/src/domains/keyboard.ts
index 473f633..4ee8243 100644
--- a/src/domains/keyboard.ts
+++ b/src/domains/keyboard.ts
@@ -3,6 +3,7 @@ import { safeJs } from "../util/js-template";
import { virtualKeyCode } from "../util/keycodes";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
+import { cdpCall } from "./cdp-call";
import { resolveRefToObjectId } from "./ref-resolve";
const TypeArgs = Type.Object({
@@ -47,8 +48,8 @@ export const typeTool = defineBrowserTool({
});
}
}
- const r = await client.session().call("Input.insertText", { text: args.text });
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ const r = await cdpCall(client, "Input.insertText", { text: args.text });
+ if (!r.success) return r;
return ok({ text: `Typed: "${args.text}"` });
},
});
@@ -94,18 +95,18 @@ export const pressKeyTool = defineBrowserTool({
...(code ? { windowsVirtualKeyCode: code, nativeVirtualKeyCode: code } : {}),
...(isChar ? { text: k, unmodifiedText: k } : {}),
};
- const down = await client.session().call("Input.dispatchKeyEvent", downParams);
- if (!down.success) return err({ kind: "cdp_error", message: down.error.message });
+ const down = await cdpCall(client, "Input.dispatchKeyEvent", downParams);
+ if (!down.success) return down;
// For a printable character with no command modifier (Ctrl/Meta/Alt = 1|2|4),
// emit a `char` event so the page receives keypress/textInput and the
// character is actually inserted. Shift (8) is allowed (capitals/symbols).
const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
if (isChar && !hasCommandModifier) {
- const charEv = await client.session().call("Input.dispatchKeyEvent", { type: "char", text: k, key: k });
- if (!charEv.success) return err({ kind: "cdp_error", message: charEv.error.message });
+ const charEv = await cdpCall(client, "Input.dispatchKeyEvent", { type: "char", text: k, key: k });
+ if (!charEv.success) return charEv;
}
- const up = await client.session().call("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, modifiers });
- if (!up.success) return err({ kind: "cdp_error", message: up.error.message });
+ const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, modifiers });
+ if (!up.success) return up;
return ok({ text: `Pressed: ${k}${modifiers ? ` (modifiers=${modifiers})` : ""}` });
},
});
@@ -151,13 +152,13 @@ export const dispatchKeyTool = defineBrowserTool({
if (args.ref !== undefined) {
const objectId = await resolveRefToObjectId(client, args.ref);
if (!objectId.success) return objectId;
- const r = await client.session().call("Runtime.callFunctionOn", {
+ const r = await cdpCall(client, "Runtime.callFunctionOn", {
objectId: objectId.data,
functionDeclaration: `function (type, key, keyCode) { this.dispatchEvent(new KeyboardEvent(type, { key, keyCode, which: keyCode, bubbles: true, cancelable: true })); return 1; }`,
arguments: [{ value: eventType }, { value: args.key }, { value: code }],
returnByValue: true,
});
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ if (!r.success) return r;
return ok({
text: `Dispatched ${eventType}('${args.key}') on ${target}`,
details: { matched: 1, ref: args.ref },
diff --git a/src/domains/navigate.ts b/src/domains/navigate.ts
index ba86751..93c6a89 100644
--- a/src/domains/navigate.ts
+++ b/src/domains/navigate.ts
@@ -6,6 +6,7 @@ import { applyTruncation } from "../util/truncate";
import { safeJs } from "../util/js-template";
import { attachTo } from "../cdp/attach";
import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
+import { cdpCall } from "./cdp-call";
const NavigateArgs = Type.Object({
url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
@@ -39,8 +40,8 @@ export const navigateTool = defineBrowserTool({
if (!created.success) return err({ kind: "cdp_error", message: created.error.message });
outcome = { kind: "new_tab_created", targetId: created.data, reason: "no_tabs" };
} else {
- const nav = await client.session().call("Page.navigate", { url: args.url });
- if (!nav.success) return err({ kind: "cdp_error", message: nav.error.message });
+ const nav = await cdpCall(client, "Page.navigate", { url: args.url });
+ if (!nav.success) return nav;
const cur = client.current();
if (!cur) return err({ kind: "internal", message: "navigate succeeded but no current target tracked" });
outcome = { kind: "in_place", targetId: cur.targetId };
diff --git a/src/domains/screenshot.ts b/src/domains/screenshot.ts
index e3b3615..c6cc87b 100644
--- a/src/domains/screenshot.ts
+++ b/src/domains/screenshot.ts
@@ -3,11 +3,12 @@ import { writeFile, rename } from "node:fs/promises";
import { Type } from "typebox";
import { Image, type ImageTheme, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import type { BrowserClient } from "../client";
-import { type Result, err, ok } from "../util/result";
+import { type Result, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { screenshotPath } from "../util/paths";
import { loadSharp } from "../util/sharp-shim";
import { safeJs } from "../util/js-template";
+import { cdpCall } from "./cdp-call";
const ScreenshotArgs = Type.Object({
fullPage: Type.Optional(Type.Boolean({ default: false, description: "Capture beyond viewport" })),
@@ -29,12 +30,12 @@ const captureBase = async (
const format = args.format ?? "png";
const quality = args.quality ?? 80;
const path = screenshotPath(client.namespace, format);
- const r = await client.session().call("Page.captureScreenshot", {
+ const r = await cdpCall(client, "Page.captureScreenshot", {
format,
captureBeyondViewport: args.fullPage ?? false,
...(format === "jpeg" ? { quality } : {}),
});
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ if (!r.success) return r;
await writeFile(path, Buffer.from(r.data.data, "base64"));
return ok({ path, format });
};
diff --git a/src/domains/scroll.ts b/src/domains/scroll.ts
index f29e931..1819a48 100644
--- a/src/domains/scroll.ts
+++ b/src/domains/scroll.ts
@@ -1,6 +1,7 @@
import { Type } from "typebox";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
+import { cdpCall } from "./cdp-call";
const ScrollArgs = Type.Object({
x: Type.Optional(Type.Number({ description: "X coordinate where to scroll. Default: viewport center" })),
@@ -40,19 +41,19 @@ export const scrollTool = defineBrowserTool({
// The page target must be focused or Input events are dropped silently
// (mouseWheel hangs; synthesizeScrollGesture returns ok but no scroll).
// Page.bringToFront makes the target the active one in the browser.
- const front = await client.session().call("Page.bringToFront", {});
- if (!front.success) return err({ kind: "cdp_error", message: front.error.message });
+ const front = await cdpCall(client, "Page.bringToFront", {});
+ if (!front.success) return front;
// Establish compositor mouse position before mouseWheel; without this,
// CDP sometimes drops the wheel event with a timeout.
- const moved = await client.session().call("Input.dispatchMouseEvent", {
+ const moved = await cdpCall(client, "Input.dispatchMouseEvent", {
type: "mouseMoved", x: cx, y: cy, button: "none", buttons: 0,
});
- if (!moved.success) return err({ kind: "cdp_error", message: moved.error.message });
- const wheel = await client.session().call("Input.dispatchMouseEvent", {
+ if (!moved.success) return moved;
+ const wheel = await cdpCall(client, "Input.dispatchMouseEvent", {
type: "mouseWheel", x: cx, y: cy, deltaX: dx, deltaY: dy,
button: "none", buttons: 0, pointerType: "mouse",
});
- if (!wheel.success) return err({ kind: "cdp_error", message: wheel.error.message });
+ if (!wheel.success) return wheel;
return ok({
text: `Scrolled at (${cx}, ${cy}) by (${dx}, ${dy})`,
details: { x: cx, y: cy, deltaX: dx, deltaY: dy },
diff --git a/src/domains/snapshot.ts b/src/domains/snapshot.ts
index bfc475c..ec5c894 100644
--- a/src/domains/snapshot.ts
+++ b/src/domains/snapshot.ts
@@ -4,6 +4,7 @@ import { Container, Image, type ImageTheme, Markdown, Text, truncateToWidth, vis
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import type { CdpSession } from "../cdp/session";
import { type Box, boxOf } from "./box";
+import { cdpCall } from "./cdp-call";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
@@ -319,8 +320,8 @@ export const snapshotTool = defineBrowserTool({
const session = client.session();
// 1. AX tree
- const axRes = await session.call("Accessibility.getFullAXTree", {});
- if (!axRes.success) return err({ kind: "cdp_error", message: axRes.error.message });
+ const axRes = await cdpCall(client, "Accessibility.getFullAXTree", {});
+ if (!axRes.success) return axRes;
const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
// 2. Page url/title
diff --git a/src/domains/target.ts b/src/domains/target.ts
new file mode 100644
index 0000000..8a9ee07
--- /dev/null
+++ b/src/domains/target.ts
@@ -0,0 +1,35 @@
+import type { BrowserClient } from "../client";
+import { type Result, err, ok } from "../util/result";
+import type { ToolErr } from "../util/tool";
+import { resolveRefToBox } from "./ref-resolve";
+
+export type PointArgs = {
+ readonly ref?: string | undefined;
+ readonly x?: number | undefined;
+ readonly y?: number | undefined;
+};
+
+export type ResolvedTarget = {
+ readonly x: number;
+ readonly y: number;
+ readonly label: string;
+};
+
+export const resolveTarget = async (
+ client: BrowserClient,
+ args: PointArgs,
+): Promise<Result<ResolvedTarget, ToolErr>> => {
+ if (args.ref !== undefined) {
+ const box = await resolveRefToBox(client, args.ref);
+ if (!box.success) return box;
+ return ok({
+ x: box.data.cx,
+ y: box.data.cy,
+ label: `[${args.ref}] (${box.data.cx}, ${box.data.cy})`,
+ });
+ }
+ if (args.x !== undefined && args.y !== undefined) {
+ return ok({ x: args.x, y: args.y, label: `(${args.x}, ${args.y})` });
+ }
+ return err({ kind: "invalid_state", message: "Provide either `ref` or both `x` and `y`." });
+};
diff --git a/src/domains/viewport.ts b/src/domains/viewport.ts
index 9e9ac07..e4ba5d5 100644
--- a/src/domains/viewport.ts
+++ b/src/domains/viewport.ts
@@ -1,6 +1,7 @@
import { Type } from "typebox";
-import { type Result, err, ok } from "../util/result";
+import { type Result, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
+import { cdpCall } from "./cdp-call";
const ViewportArgs = Type.Object({
width: Type.Integer({ minimum: 100, maximum: 8000, description: "Viewport CSS pixel width" }),
@@ -22,13 +23,13 @@ export const viewportResizeTool = defineBrowserTool({
parameters: ViewportArgs,
concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
- const r = await client.session().call("Emulation.setDeviceMetricsOverride", {
+ const r = await cdpCall(client, "Emulation.setDeviceMetricsOverride", {
width: args.width,
height: args.height,
deviceScaleFactor: args.deviceScaleFactor ?? 1,
mobile: false,
});
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ if (!r.success) return r;
return ok({
text: `Viewport set to ${args.width}x${args.height} @${args.deviceScaleFactor ?? 1}x`,
details: {
diff --git a/test/domains/target-test.ts b/test/domains/target-test.ts
new file mode 100644
index 0000000..814c669
--- /dev/null
+++ b/test/domains/target-test.ts
@@ -0,0 +1,70 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { resolveTarget } from "../../src/domains/target";
+import type { BrowserClient } from "../../src/client";
+
+const noClient = {} as unknown as BrowserClient;
+
+const clientWithRef = (refs: Record<string, number>, boxes: Record<number, unknown>): BrowserClient => {
+ const session = {
+ resolveRef: (ref: string): number | undefined => refs[ref],
+ call: (_method: string, params: { readonly backendNodeId?: number }) => {
+ const model = params.backendNodeId === undefined ? undefined : boxes[params.backendNodeId];
+ return Promise.resolve(
+ model === undefined
+ ? { success: false as const, error: { kind: "protocol", message: "No node found" } }
+ : { success: true as const, data: { model } },
+ );
+ },
+ };
+ return { session: () => session } as unknown as BrowserClient;
+};
+
+describe("resolveTarget", () => {
+ test("uses literal x/y when no ref is given", async () => {
+ const r = await resolveTarget(noClient, { x: 10, y: 20 });
+ assert.equal(r.success, true);
+ if (r.success) {
+ assert.equal(r.data.x, 10);
+ assert.equal(r.data.y, 20);
+ assert.equal(r.data.label, "(10, 20)");
+ }
+ });
+
+ test("errors when neither ref nor a complete coordinate pair is given", async () => {
+ const r = await resolveTarget(noClient, {});
+ assert.equal(r.success, false);
+ if (!r.success) assert.equal(r.error.kind, "invalid_state");
+ });
+
+ test("a half-specified coordinate pair is an error, not a silent zero", async () => {
+ const r = await resolveTarget(noClient, { x: 10 });
+ assert.equal(r.success, false);
+ });
+
+ test("a half-specified y-only pair is an error too", async () => {
+ const r = await resolveTarget(noClient, { y: 20 });
+ assert.equal(r.success, false);
+ });
+
+ test("a ref resolves to the box centre and takes precedence over x/y", async () => {
+ const client = clientWithRef({ e7: 42 }, { 42: { content: [100, 200, 140, 200, 140, 260, 100, 260], width: 40, height: 60 } });
+ const r = await resolveTarget(client, { ref: "e7", x: 1, y: 2 });
+ assert.equal(r.success, true);
+ if (r.success) {
+ assert.equal(r.data.x, 120);
+ assert.equal(r.data.y, 230);
+ assert.equal(r.data.label, "[e7] (120, 230)");
+ }
+ });
+
+ test("an unknown ref fails as a stale ref rather than falling back to x/y", async () => {
+ const client = clientWithRef({}, {});
+ const r = await resolveTarget(client, { ref: "e9", x: 1, y: 2 });
+ assert.equal(r.success, false);
+ if (!r.success) {
+ assert.equal(r.error.kind, "invalid_state");
+ assert.match(r.error.message, /stale/);
+ }
+ });
+});