diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-28 00:17:13 +0530 |
|---|---|---|
| committer | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-08-02 19:12:46 +0530 |
| commit | bfba73fa2f1373abd8a5a704c9c833d6fce1b44f (patch) | |
| tree | e684310863ef6d9aaa9a6bd3d4d3100d75ee9069 | |
| parent | 5cfa7ccc8f4082db111bc0186c5fa3d44b767ad3 (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.ts | 5 | ||||
| -rw-r--r-- | src/domains/cdp-call.ts | 14 | ||||
| -rw-r--r-- | src/domains/click.ts | 39 | ||||
| -rw-r--r-- | src/domains/dialog.ts | 7 | ||||
| -rw-r--r-- | src/domains/drag.ts | 8 | ||||
| -rw-r--r-- | src/domains/files.ts | 21 | ||||
| -rw-r--r-- | src/domains/form.ts | 5 | ||||
| -rw-r--r-- | src/domains/history.ts | 17 | ||||
| -rw-r--r-- | src/domains/keyboard.ts | 21 | ||||
| -rw-r--r-- | src/domains/navigate.ts | 5 | ||||
| -rw-r--r-- | src/domains/screenshot.ts | 7 | ||||
| -rw-r--r-- | src/domains/scroll.ts | 13 | ||||
| -rw-r--r-- | src/domains/snapshot.ts | 5 | ||||
| -rw-r--r-- | src/domains/target.ts | 35 | ||||
| -rw-r--r-- | src/domains/viewport.ts | 7 | ||||
| -rw-r--r-- | test/domains/target-test.ts | 70 |
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/); + } + }); +}); |
