diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-27 23:26:50 +0530 |
|---|---|---|
| committer | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-08-02 19:12:46 +0530 |
| commit | 9f2f75dd9281153888776e5d39d4926655c15f2a (patch) | |
| tree | 89c23c8ad333b5096d39b661b142eaad544cf004 | |
| parent | 931576443dcb990761cb7e68e354465145f9292d (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.ts | 18 | ||||
| -rw-r--r-- | src/domains/box.ts | 47 | ||||
| -rw-r--r-- | src/domains/dialog.ts | 7 | ||||
| -rw-r--r-- | src/domains/drag.ts | 5 | ||||
| -rw-r--r-- | src/domains/files.ts | 11 | ||||
| -rw-r--r-- | src/domains/form.ts | 45 | ||||
| -rw-r--r-- | src/domains/forms.ts | 30 | ||||
| -rw-r--r-- | src/domains/history.ts | 2 | ||||
| -rw-r--r-- | src/domains/isolated-tab.ts | 30 | ||||
| -rw-r--r-- | src/domains/keyboard.ts | 11 | ||||
| -rw-r--r-- | src/domains/network.ts | 2 | ||||
| -rw-r--r-- | src/domains/readpage/read-page.ts | 5 | ||||
| -rw-r--r-- | src/domains/ref-resolve.ts | 26 | ||||
| -rw-r--r-- | src/domains/screenshot.ts | 12 | ||||
| -rw-r--r-- | src/domains/search/web-search.ts | 5 | ||||
| -rw-r--r-- | src/domains/snapshot.ts | 35 | ||||
| -rw-r--r-- | src/domains/tabs.ts | 9 | ||||
| -rw-r--r-- | test/domains/box-test.ts | 20 |
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)); + }); +}); |
