aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-27 23:26:50 +0530
committerAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-08-02 19:12:46 +0530
commit9f2f75dd9281153888776e5d39d4926655c15f2a (patch)
tree89c23c8ad333b5096d39b661b142eaad544cf004
parent931576443dcb990761cb7e68e354465145f9292d (diff)
refactor(domains): drop CDP casts, share the box-model unwrap
ref-resolve.ts and snapshot.ts had the same box-model unwrap, both using data.model! to work around an untyped response. Extracted to domains/box.ts; the typed schema makes the assertions unnecessary. Both derived the centre identically (quad corner + half the model width/height), so centreOf keeps that derivation; the shared zero-size rejection moved into boxOf, and snapshot keeps its own off-viewport (x/y < 0) filter. evalInIsolatedTab now goes through evaluateJson with the tab's own sessionId and a caller-supplied guard; both callers evaluate expressions ending in JSON.stringify, so they pass isJsonText rather than a check that checks nothing. Runtime.evaluate/callFunctionOn result casts in form.ts, forms.ts and keyboard.ts are replaced by named guards over the in-page result shapes. Runtime.callFunctionOn's params schema gained arguments/returnByValue/ awaitPromise/userGesture: four domain call sites legitimately pass them and typebox's additionalProperties:true does not widen the static type, so the params type was rejecting valid CDP fields. Params schemas are type-only (only results are compiled), so this is a compile-time widening with no runtime effect. tsc is green again for both tsconfigs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-rw-r--r--src/cdp/commands.ts18
-rw-r--r--src/domains/box.ts47
-rw-r--r--src/domains/dialog.ts7
-rw-r--r--src/domains/drag.ts5
-rw-r--r--src/domains/files.ts11
-rw-r--r--src/domains/form.ts45
-rw-r--r--src/domains/forms.ts30
-rw-r--r--src/domains/history.ts2
-rw-r--r--src/domains/isolated-tab.ts30
-rw-r--r--src/domains/keyboard.ts11
-rw-r--r--src/domains/network.ts2
-rw-r--r--src/domains/readpage/read-page.ts5
-rw-r--r--src/domains/ref-resolve.ts26
-rw-r--r--src/domains/screenshot.ts12
-rw-r--r--src/domains/search/web-search.ts5
-rw-r--r--src/domains/snapshot.ts35
-rw-r--r--src/domains/tabs.ts9
-rw-r--r--test/domains/box-test.ts20
18 files changed, 212 insertions, 108 deletions
diff --git a/src/cdp/commands.ts b/src/cdp/commands.ts
index d7faa34..95c8be2 100644
--- a/src/cdp/commands.ts
+++ b/src/cdp/commands.ts
@@ -36,6 +36,15 @@ const RemoteObject = Type.Object(
{ additionalProperties: true },
);
+const CallArgument = Type.Object(
+ {
+ value: Type.Optional(Type.Unknown()),
+ objectId: Type.Optional(Type.String()),
+ unserializableValue: Type.Optional(Type.String()),
+ },
+ { additionalProperties: true },
+);
+
const ExceptionDetails = Type.Object(
{ text: Type.String(), exception: Type.Optional(RemoteObject) },
{ additionalProperties: true },
@@ -142,7 +151,14 @@ export const COMMANDS = {
),
"Runtime.callFunctionOn": cmd(
Type.Object(
- { functionDeclaration: Type.String(), objectId: Type.Optional(Type.String()) },
+ {
+ functionDeclaration: Type.String(),
+ objectId: Type.Optional(Type.String()),
+ arguments: Type.Optional(Type.Array(CallArgument)),
+ returnByValue: Type.Optional(Type.Boolean()),
+ awaitPromise: Type.Optional(Type.Boolean()),
+ userGesture: Type.Optional(Type.Boolean()),
+ },
{ additionalProperties: true },
),
Type.Object({ result: RemoteObject, exceptionDetails: Type.Optional(ExceptionDetails) }),
diff --git a/src/domains/box.ts b/src/domains/box.ts
new file mode 100644
index 0000000..7b12e82
--- /dev/null
+++ b/src/domains/box.ts
@@ -0,0 +1,47 @@
+import type { BrowserClient } from "../client";
+import { type Result, err, ok } from "../util/result";
+import type { ToolErr } from "../util/tool";
+
+export type Box = {
+ readonly x: number;
+ readonly y: number;
+ readonly width: number;
+ readonly height: number;
+ readonly cx: number;
+ readonly cy: number;
+};
+
+type BoxModel = {
+ readonly content: ReadonlyArray<number>;
+ readonly width: number;
+ readonly height: number;
+};
+
+export const centreOf = (model: BoxModel): Box => {
+ const x = model.content[0] ?? 0;
+ const y = model.content[1] ?? 0;
+ return {
+ x: Math.round(x),
+ y: Math.round(y),
+ width: Math.round(model.width),
+ height: Math.round(model.height),
+ cx: Math.round(x + model.width / 2),
+ cy: Math.round(y + model.height / 2),
+ };
+};
+
+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 model = r.data.model;
+ if (model.content.length < 8) {
+ return err({ kind: "invalid_state", message: "DOM.getBoxModel returned an incomplete content quad" });
+ }
+ if (model.width <= 0 || model.height <= 0) {
+ return err({ kind: "invalid_state", message: "element has a zero-size box and cannot be clicked" });
+ }
+ return ok(centreOf(model));
+};
diff --git a/src/domains/dialog.ts b/src/domains/dialog.ts
index a5872a1..c30e70c 100644
--- a/src/domains/dialog.ts
+++ b/src/domains/dialog.ts
@@ -19,9 +19,10 @@ export const handleDialogTool = defineBrowserTool({
],
parameters: HandleDialogArgs,
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
- const params: Record<string, unknown> = { accept: args.accept };
- if (args.promptText !== undefined) params["promptText"] = args.promptText;
- const r = await client.session().call("Page.handleJavaScriptDialog", params);
+ const r = await client.session().call("Page.handleJavaScriptDialog", {
+ accept: args.accept,
+ ...(args.promptText !== undefined ? { promptText: args.promptText } : {}),
+ });
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
return ok({
text: `Dialog ${args.accept ? "accepted" : "dismissed"}`,
diff --git a/src/domains/drag.ts b/src/domains/drag.ts
index bc8e789..9075fd0 100644
--- a/src/domains/drag.ts
+++ b/src/domains/drag.ts
@@ -35,11 +35,12 @@ export const dragAndDropTool = defineBrowserTool({
}
: { items: [], dragOperationsMask: 1 };
const cdp = client.session();
- const calls: ReadonlyArray<readonly [string, Record<string, unknown>]> = [
+ 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 }],
["Input.dispatchDragEvent", { type: "dragEnter", x: args.startX, y: args.startY, data, modifiers: 0 }],
// 5 interpolated dragOver steps from start → end so the page sees a smooth drag.
- ...Array.from({ length: 5 }, (_, i): readonly [string, Record<string, unknown>] => {
+ ...Array.from({ length: 5 }, (_, i): InputCall => {
const t = (i + 1) / 5;
return [
"Input.dispatchDragEvent",
diff --git a/src/domains/files.ts b/src/domains/files.ts
index d68d236..cf9393e 100644
--- a/src/domains/files.ts
+++ b/src/domains/files.ts
@@ -47,12 +47,9 @@ const tryCdpUpload = async (
): 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 });
- // CDP boundary cast: DOM.getDocument returns { root: { nodeId: number } }
- const root = (doc.data as { root: { nodeId: number } }).root;
- const q = await client.session().call("DOM.querySelector", { nodeId: root.nodeId, selector });
+ 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 });
- // CDP boundary cast: DOM.querySelector returns { nodeId: number }
- const nodeId = (q.data as { nodeId: number }).nodeId;
+ 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 });
@@ -214,10 +211,8 @@ export const printToPdfTool = defineBrowserTool({
preferCSSPageSize: true,
});
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
- // CDP boundary cast: Page.printToPDF returns { data: string } (base64-encoded PDF)
- const data = (r.data as { data: string }).data;
const path = args.outputPath ?? pdfPath(client.namespace);
- await writeFile(path, Buffer.from(data, "base64"));
+ 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 063cfaf..95516c5 100644
--- a/src/domains/form.ts
+++ b/src/domains/form.ts
@@ -40,7 +40,7 @@ const runOnElement = async (
returnByValue: true,
});
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
- return ok((r.data as { result?: { value?: unknown } }).result?.value);
+ return ok(r.data.result.value);
}
if (opts.selector === undefined) {
return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
@@ -69,6 +69,18 @@ type FillResult =
| { status: "not_found" }
| { status: "not_fillable"; tag: string };
+const isFillResult = (v: unknown): v is FillResult => {
+ if (typeof v !== "object" || v === null || !("status" in v)) return false;
+ if (v.status === "not_found") return true;
+ if (v.status === "not_fillable") return "tag" in v && typeof v.tag === "string";
+ if (v.status === "ok") {
+ return "tag" in v && typeof v.tag === "string"
+ && "kind" in v && typeof v.kind === "string"
+ && "value" in v && typeof v.value === "string";
+ }
+ return false;
+};
+
// Element-scoped fill logic. `this` is the target element, `arg` is the value.
// Write via the native prototype value setter so React's _valueTracker
// registers the change — assigning el.value directly does NOT, which is why
@@ -127,7 +139,7 @@ export const fillTool = defineBrowserTool({
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: FILL_FN, arg: args.value });
if (!r.success) return r;
- const res = r.data as FillResult | undefined;
+ const res = isFillResult(r.data) ? r.data : undefined;
const target = args.ref ?? args.selector ?? "";
if (res === undefined || res.status === "not_found") {
return err({
@@ -160,6 +172,13 @@ const FocusArgs = Type.Object({
type FocusResult = { status: "ok"; tag: string } | { status: "not_found" } | { status: "not_focusable"; tag: string };
+const isFocusResult = (v: unknown): v is FocusResult => {
+ if (typeof v !== "object" || v === null || !("status" in v)) return false;
+ if (v.status === "not_found") return true;
+ if (v.status === "ok" || v.status === "not_focusable") return "tag" in v && typeof v.tag === "string";
+ return false;
+};
+
const FOCUS_FN = `
const el = this;
if (typeof el.focus !== "function") return { status: "not_focusable", tag: el.tagName };
@@ -182,7 +201,7 @@ export const focusTool = defineBrowserTool({
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: FOCUS_FN, arg: "" });
if (!r.success) return r;
- const res = r.data as FocusResult | undefined;
+ const res = isFocusResult(r.data) ? r.data : undefined;
const target = args.ref ?? args.selector ?? "";
if (res === undefined || res.status === "not_found") {
return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
@@ -210,6 +229,24 @@ type SelectResult =
| { status: "not_select"; tag: string }
| { status: "no_match"; options: ReadonlyArray<{ value: string; label: string }> };
+const isSelectOption = (v: unknown): v is { value: string; label: string } =>
+ typeof v === "object" && v !== null
+ && "value" in v && typeof v.value === "string"
+ && "label" in v && typeof v.label === "string";
+
+const isSelectResult = (v: unknown): v is SelectResult => {
+ if (typeof v !== "object" || v === null || !("status" in v)) return false;
+ if (v.status === "not_found") return true;
+ if (v.status === "not_select") return "tag" in v && typeof v.tag === "string";
+ if (v.status === "ok") {
+ return "value" in v && typeof v.value === "string" && "label" in v && typeof v.label === "string";
+ }
+ if (v.status === "no_match") {
+ return "options" in v && Array.isArray(v.options) && v.options.every(isSelectOption);
+ }
+ return false;
+};
+
// `this` is the <select>; `arg` is { wv, wl, wi } (null = not provided).
const SELECT_FN = `
const el = this;
@@ -248,7 +285,7 @@ export const selectOptionTool = defineBrowserTool({
const arg = { wv: args.value ?? null, wl: args.label ?? null, wi: args.index ?? null };
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: SELECT_FN, arg });
if (!r.success) return r;
- const res = r.data as SelectResult | undefined;
+ const res = isSelectResult(r.data) ? r.data : undefined;
const target = args.ref ?? args.selector ?? "";
if (res === undefined || res.status === "not_found") {
return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
diff --git a/src/domains/forms.ts b/src/domains/forms.ts
index 1d83ba6..e38af9a 100644
--- a/src/domains/forms.ts
+++ b/src/domains/forms.ts
@@ -128,6 +128,23 @@ type PageResult = {
readonly options?: ReadonlyArray<{ value: string; text: string }>;
};
+const isOption = (v: unknown): v is { readonly value: string; readonly text: string } =>
+ typeof v === "object" && v !== null
+ && "value" in v && typeof v.value === "string"
+ && "text" in v && typeof v.text === "string";
+
+const isPageResult = (v: unknown): v is PageResult => {
+ if (typeof v !== "object" || v === null) return false;
+ if (!("ok" in v) || typeof v.ok !== "boolean") return false;
+ if ("reason" in v && typeof v.reason !== "string") return false;
+ if ("kind" in v && typeof v.kind !== "string") return false;
+ if ("text" in v && typeof v.text !== "string") return false;
+ if ("checked" in v && typeof v.checked !== "boolean") return false;
+ if ("changed" in v && typeof v.changed !== "boolean") return false;
+ if ("options" in v && !(Array.isArray(v.options) && v.options.every(isOption))) return false;
+ return true;
+};
+
/**
* Resolve a ref (CDP backendDOMNodeId) to a live JS handle and invoke a
* page-side function on it. DOM + Runtime are enabled on every session
@@ -149,7 +166,7 @@ const resolveAndCall = async (
details: { ref },
});
}
- const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
+ const objectId = resolved.data.object.objectId;
if (objectId === undefined) {
return err({
kind: "invalid_state",
@@ -167,15 +184,14 @@ const resolveAndCall = async (
awaitPromise: true,
});
if (!called.success) return err({ kind: "cdp_error", message: called.error.message, details: { ref } });
- const data = called.data as { result?: { value?: unknown }; exceptionDetails?: unknown };
- if (data.exceptionDetails !== undefined) {
- return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(data.exceptionDetails)}`, details: { ref } });
+ if (called.data.exceptionDetails !== undefined) {
+ return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(called.data.exceptionDetails)}`, details: { ref } });
}
- const value = data.result?.value;
- if (typeof value !== "object" || value === null) {
+ const value = called.data.result.value;
+ if (!isPageResult(value)) {
return err({ kind: "internal", message: "page function returned no result object", details: { ref } });
}
- return ok(value as PageResult);
+ return ok(value);
} finally {
// Best-effort: release the remote handle so it can be GC'd. Failure is
// harmless (the page may have navigated), so we ignore the result.
diff --git a/src/domains/history.ts b/src/domains/history.ts
index b556056..96036ba 100644
--- a/src/domains/history.ts
+++ b/src/domains/history.ts
@@ -9,7 +9,7 @@ type History = { readonly entries: ReadonlyArray<HistoryEntry>; readonly current
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 });
- return ok(r.data as History);
+ return ok(r.data);
};
export const goBackTool = defineBrowserTool({
diff --git a/src/domains/isolated-tab.ts b/src/domains/isolated-tab.ts
index 7969c6b..d0f3def 100644
--- a/src/domains/isolated-tab.ts
+++ b/src/domains/isolated-tab.ts
@@ -14,6 +14,7 @@ import type { BrowserClient } from "../client";
import { type Result, err, ok } from "../util/result";
import { safeJs } from "../util/js-template";
import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
+import { evaluateJson } from "../cdp/evaluate";
import type { ToolErr } from "../util/tool";
/** A tab attached by its own sessionId, ready to drive via callOnTarget. */
@@ -51,7 +52,7 @@ export const openIsolatedTab = async (client: BrowserClient): Promise<Result<Iso
await client.closeTab(targetId);
return err(toToolErr(attached.error.message));
}
- const { sessionId } = attached.data as { sessionId: string };
+ const { sessionId } = attached.data;
const enabled = await client.session().callOnTarget("Page.enable", {}, sessionId);
if (!enabled.success) {
@@ -91,27 +92,20 @@ export const waitForIsolatedLoad = async (
return err(toToolErr(`page did not finish loading in ${Math.round(timeoutMs / 1000)}s`, "timeout"));
};
-/**
- * Evaluate an expression in the isolated tab and return the raw value. Bounds
- * the eval with a timeout so a blocking dialog cannot hang the tool.
- */
-export const evalInIsolatedTab = async (
+export const isJsonText = (v: unknown): v is string => typeof v === "string";
+
+export const evalInIsolatedTab = async <T>(
client: BrowserClient,
tab: IsolatedTab,
expression: string,
-): Promise<Result<unknown, ToolErr>> => {
- const r = await client.session().callOnTarget(
- "Runtime.evaluate",
- { expression, returnByValue: true, awaitPromise: true },
- tab.sessionId,
- { timeoutMs: EVAL_TIMEOUT_MS },
- );
+ check: (v: unknown) => v is T,
+): Promise<Result<T, ToolErr>> => {
+ const r = await evaluateJson(client.session(), expression, check, {
+ sessionId: tab.sessionId,
+ timeoutMs: EVAL_TIMEOUT_MS,
+ });
if (!r.success) return err(toToolErr(r.error.message, r.error.kind === "timeout" ? "timeout" : "cdp_error"));
- const data = r.data as { result?: { value?: unknown }; exceptionDetails?: unknown };
- if (data.exceptionDetails) {
- return err(toToolErr(`page evaluation failed: ${JSON.stringify(data.exceptionDetails)}`));
- }
- return ok(data.result?.value);
+ return ok(r.data);
};
/** Close an isolated tab and drop its ownership. Best-effort; never throws. */
diff --git a/src/domains/keyboard.ts b/src/domains/keyboard.ts
index 24b7833..21681c5 100644
--- a/src/domains/keyboard.ts
+++ b/src/domains/keyboard.ts
@@ -9,6 +9,13 @@ const TypeArgs = Type.Object({
text: Type.String({ description: "Text to type" }),
});
+type FocusProbe = { readonly ok: boolean; readonly tag: string | null };
+
+const isFocusProbe = (v: unknown): v is FocusProbe =>
+ typeof v === "object" && v !== null
+ && "ok" in v && typeof v.ok === "boolean"
+ && "tag" in v && (typeof v.tag === "string" || v.tag === null);
+
export const typeTool = defineBrowserTool({
name: "browser_type",
label: "Browser Type",
@@ -32,8 +39,8 @@ export const typeTool = defineBrowserTool({
return { ok, tag: el ? el.tagName : null };
})()
`);
- if (focused.success) {
- const f = focused.data as { ok: boolean; tag: string | null };
+ if (focused.success && isFocusProbe(focused.data)) {
+ const f = focused.data;
if (!f.ok) {
return err({
kind: "invalid_state",
diff --git a/src/domains/network.ts b/src/domains/network.ts
index f13b1e7..ca46664 100644
--- a/src/domains/network.ts
+++ b/src/domains/network.ts
@@ -202,7 +202,7 @@ export const networkRequestsTool = defineBrowserTool({
r.body = null;
return;
}
- const b = bodyRes.data as { body: string; base64Encoded?: boolean };
+ const b = bodyRes.data;
let body = b.base64Encoded ? Buffer.from(b.body, "base64").toString("utf8") : b.body;
if (body.length > PER_BODY_CAP) body = body.slice(0, PER_BODY_CAP) + "…";
r.body = body;
diff --git a/src/domains/readpage/read-page.ts b/src/domains/readpage/read-page.ts
index bc1a2b1..c21b264 100644
--- a/src/domains/readpage/read-page.ts
+++ b/src/domains/readpage/read-page.ts
@@ -5,6 +5,7 @@ import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
import {
closeIsolatedTab,
evalInIsolatedTab,
+ isJsonText,
type IsolatedTab,
navigateIsolatedTab,
openIsolatedTab,
@@ -73,7 +74,7 @@ const readOpenedUrl = async (
if (!navigated.success) return err(navigated.error);
const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal);
if (!loaded.success) return err(loaded.error);
- const captured = await evalInIsolatedTab(client, tab, buildPageCaptureExpr());
+ const captured = await evalInIsolatedTab(client, tab, buildPageCaptureExpr(), isJsonText);
if (!captured.success) return err(captured.error);
return captureToResult(captured.data);
} finally {
@@ -90,7 +91,7 @@ const readOwnedTab = async (
}
const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
if (!attached.success) return err({ kind: "cdp_error", message: attached.error.message });
- const sessionId = (attached.data as { sessionId: string }).sessionId;
+ const sessionId = attached.data.sessionId;
const captured = await client.evaluateJs(buildPageCaptureExpr(), sessionId);
if (!captured.success) return err({ kind: "cdp_error", message: captured.error.message });
return captureToResult(captured.data);
diff --git a/src/domains/ref-resolve.ts b/src/domains/ref-resolve.ts
index 3f636df..de83521 100644
--- a/src/domains/ref-resolve.ts
+++ b/src/domains/ref-resolve.ts
@@ -1,10 +1,9 @@
import type { BrowserClient } from "../client";
import { type Result, err, ok } from "../util/result";
import type { ToolErr } from "../util/tool";
+import { type Box, boxOf } from "./box";
import { buildTree, collectInteractiveTargets, type RawAxNode, type SlimNode } from "./snapshot";
-type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
-
const staleErr = (ref: string): ToolErr => ({
kind: "invalid_state",
message: `Ref ${ref} is unknown or stale — re-run browser_snapshot to get fresh refs.`,
@@ -29,7 +28,7 @@ export const resolveRefToObjectId = async (
// a stale ref rather than a raw CDP error so the agent knows to re-snapshot.
return err(staleErr(ref));
}
- const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
+ const objectId = resolved.data.object.objectId;
if (!objectId) return err(staleErr(ref));
return ok(objectId);
};
@@ -52,24 +51,9 @@ export const resolveRefToBox = async (
): Promise<Result<Box, ToolErr>> => {
const backendId = client.session().resolveRef(ref);
if (backendId === undefined) return err(staleErr(ref));
- const r = await client.session().call("DOM.getBoxModel", { backendNodeId: backendId });
- if (!r.success) return err(staleErr(ref));
- const data = r.data as { model?: { content?: ReadonlyArray<number>; width?: number; height?: number } };
- const content = data.model?.content;
- if (!content || content.length < 8) return err(staleErr(ref));
- const x = content[0]!;
- const y = content[1]!;
- const width = data.model!.width ?? 0;
- const height = data.model!.height ?? 0;
- if (width <= 0 || height <= 0) return err(staleErr(ref));
- return ok({
- x: Math.round(x),
- y: Math.round(y),
- width: Math.round(width),
- height: Math.round(height),
- cx: Math.round(x + width / 2),
- cy: Math.round(y + height / 2),
- });
+ const box = await boxOf(client, backendId);
+ if (!box.success) return err(staleErr(ref));
+ return ok(box.data);
};
const sigOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}|${n.value ?? ""}|${n.state ?? ""}`;
diff --git a/src/domains/screenshot.ts b/src/domains/screenshot.ts
index 9d6313b..a30d336 100644
--- a/src/domains/screenshot.ts
+++ b/src/domains/screenshot.ts
@@ -29,13 +29,13 @@ const captureBase = async (
const format = args.format ?? "png";
const quality = args.quality ?? 80;
const path = screenshotPath(client.namespace, format);
- const params: Record<string, unknown> = { format, captureBeyondViewport: args.fullPage ?? false };
- if (format === "jpeg") params["quality"] = quality;
- const r = await client.session().call("Page.captureScreenshot", params);
+ const r = await client.session().call("Page.captureScreenshot", {
+ format,
+ captureBeyondViewport: args.fullPage ?? false,
+ ...(format === "jpeg" ? { quality } : {}),
+ });
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
- // Page.captureScreenshot returns { data: base64 }
- const data = (r.data as { data: string }).data;
- await writeFile(path, Buffer.from(data, "base64"));
+ await writeFile(path, Buffer.from(r.data.data, "base64"));
return ok({ path, format });
};
diff --git a/src/domains/search/web-search.ts b/src/domains/search/web-search.ts
index 41a0f36..8908e29 100644
--- a/src/domains/search/web-search.ts
+++ b/src/domains/search/web-search.ts
@@ -5,6 +5,7 @@ import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
import {
closeIsolatedTab,
evalInIsolatedTab,
+ isJsonText,
navigateIsolatedTab,
openIsolatedTab,
waitForIsolatedLoad,
@@ -64,10 +65,10 @@ export const webSearchTool = defineBrowserTool({
const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal);
if (!loaded.success) return err(loaded.error);
- const extracted = await evalInIsolatedTab(client, tab, buildSerpExtractionExpr());
+ const extracted = await evalInIsolatedTab(client, tab, buildSerpExtractionExpr(), isJsonText);
if (!extracted.success) return err(extracted.error);
- const parsedJson = typeof extracted.data === "string" ? safeParse(extracted.data) : extracted.data;
+ const parsedJson = safeParse(extracted.data);
if (!isSerpExtraction(parsedJson)) {
return err({ kind: "internal", message: "SERP extraction returned an unexpected shape" });
}
diff --git a/src/domains/snapshot.ts b/src/domains/snapshot.ts
index 8665e0b..e1c66d3 100644
--- a/src/domains/snapshot.ts
+++ b/src/domains/snapshot.ts
@@ -3,6 +3,7 @@ import { Type } from "typebox";
import { Container, Image, type ImageTheme, Markdown, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import type { CdpSession } from "../cdp/session";
+import { type Box, boxOf } from "./box";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
@@ -41,8 +42,6 @@ export type RawAxNode = {
backendDOMNodeId?: number;
};
-type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
-
export type SlimNode = {
role: string;
name?: string;
@@ -273,7 +272,7 @@ export const collectInteractiveTargets = (
const liveValue = async (session: CdpSession, backendId: number): Promise<string | undefined> => {
const resolved = await session.call("DOM.resolveNode", { backendNodeId: backendId });
if (!resolved.success) return undefined;
- const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
+ const objectId = resolved.data.object.objectId;
if (!objectId) return undefined;
const fn = `function () {
if (this.type === "checkbox" || this.type === "radio") return this.checked ? "checked" : "unchecked";
@@ -286,7 +285,7 @@ const liveValue = async (session: CdpSession, backendId: number): Promise<string
returnByValue: true,
});
if (!r.success) return undefined;
- const value = (r.data as { result?: { value?: unknown } }).result?.value;
+ const value = r.data.result.value;
return typeof value === "string" ? value : undefined;
};
@@ -358,27 +357,10 @@ export const snapshotTool = defineBrowserTool({
await Promise.allSettled(
targets.map(async ({ node, backendId }) => {
if (ac.signal.aborted) return;
- const r = await session.call("DOM.getBoxModel", { backendNodeId: backendId });
- if (!r.success) return;
- // CDP boundary cast: DOM.getBoxModel
- const data = r.data as { model?: { content?: ReadonlyArray<number>; width?: number; height?: number } };
- const content = data.model?.content;
- if (!content || content.length < 8) return;
- // content is [x1,y1, x2,y1, x2,y2, x1,y2] in viewport CSS pixels.
- const x = content[0]!;
- const y = content[1]!;
- const width = data.model!.width ?? 0;
- const height = data.model!.height ?? 0;
- // Skip zero-size and off-viewport boxes — they can't be clicked.
- if (width <= 0 || height <= 0 || x < 0 || y < 0) return;
- node.box = {
- x: Math.round(x),
- y: Math.round(y),
- width: Math.round(width),
- height: Math.round(height),
- cx: Math.round(x + width / 2),
- cy: Math.round(y + height / 2),
- };
+ const box = await boxOf(client, backendId);
+ if (!box.success) return;
+ if (box.data.x < 0 || box.data.y < 0) return;
+ node.box = box.data;
// Override the AX value with the live DOM property for form controls.
// The AX `value` is stale for freshly-typed / controlled inputs, so the
// agent can't trust it as a read-back signal. Reading .value/.checked
@@ -410,9 +392,8 @@ export const snapshotTool = defineBrowserTool({
if (args.includeScreenshot) {
const shot = await session.call("Page.captureScreenshot", { format: "jpeg", quality: 80 });
if (shot.success) {
- const data = (shot.data as { data: string }).data;
shotPath = screenshotPath(client.namespace, "jpeg");
- writeFileSync(shotPath, Buffer.from(data, "base64"));
+ writeFileSync(shotPath, Buffer.from(shot.data.data, "base64"));
}
}
diff --git a/src/domains/tabs.ts b/src/domains/tabs.ts
index f706100..e3e2623 100644
--- a/src/domains/tabs.ts
+++ b/src/domains/tabs.ts
@@ -55,7 +55,7 @@ export const currentTabTool = defineBrowserTool({
if (!cur) return err({ kind: "invalid_state", message: "No tab attached." });
const ti = await client.session().callBrowser("Target.getTargetInfo", { targetId: cur.targetId });
if (!ti.success) return err({ kind: "cdp_error", message: ti.error.message });
- const info = (ti.data as { targetInfo: { targetId: string; url: string; title: string } }).targetInfo; // CDP boundary cast: Target.getTargetInfo returns { targetInfo: { targetId, url, title } }
+ const info = ti.data.targetInfo;
const owned = client.owns(info.targetId);
return ok({
text: `Current tab${owned ? " (owned)" : " (NOT owned by this session)"}:\n ${info.targetId}\n ${info.url}\n ${info.title}`,
@@ -104,7 +104,9 @@ export const switchTabTool = defineBrowserTool({
details: { matches: matches.map((t) => ({ targetId: t.targetId, url: t.url })) },
});
}
- resolved = matches[0]!.targetId;
+ const first = matches[0];
+ if (first === undefined) return err({ kind: "invalid_state", message: "no matching tab" });
+ resolved = first.targetId;
}
if (!client.owns(resolved)) {
return err({
@@ -172,7 +174,8 @@ export const closeTabTool = defineBrowserTool({
if (isHexPrefix) {
const owned = client.ownership().list();
const matches = owned.filter((id) => id.startsWith(args.targetId));
- if (matches.length === 1) resolved = matches[0]!;
+ const first = matches[0];
+ if (matches.length === 1 && first !== undefined) resolved = first;
else if (matches.length > 1) {
return err({
kind: "invalid_state",
diff --git a/test/domains/box-test.ts b/test/domains/box-test.ts
new file mode 100644
index 0000000..cb930a0
--- /dev/null
+++ b/test/domains/box-test.ts
@@ -0,0 +1,20 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { centreOf } from "../../src/domains/box";
+
+describe("box centre", () => {
+ test("computes the centre from a content quad", () => {
+ const b = centreOf({ content: [10, 20, 110, 20, 110, 70, 10, 70], width: 100, height: 50 });
+ assert.equal(b.cx, 60);
+ assert.equal(b.cy, 45);
+ assert.equal(b.width, 100);
+ assert.equal(b.height, 50);
+ });
+
+ test("a degenerate zero-size quad yields its own corner, not NaN", () => {
+ const b = centreOf({ content: [5, 5, 5, 5, 5, 5, 5, 5], width: 0, height: 0 });
+ assert.equal(b.cx, 5);
+ assert.equal(b.cy, 5);
+ assert.ok(!Number.isNaN(b.cx));
+ });
+});