From 9f2f75dd9281153888776e5d39d4926655c15f2a Mon Sep 17 00:00:00 2001 From: amankumarsingh77 Date: Mon, 27 Jul 2026 23:26:50 +0530 Subject: 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) --- src/cdp/commands.ts | 18 ++++++++++++++- src/domains/box.ts | 47 +++++++++++++++++++++++++++++++++++++++ src/domains/dialog.ts | 7 +++--- src/domains/drag.ts | 5 +++-- src/domains/files.ts | 11 +++------ src/domains/form.ts | 45 +++++++++++++++++++++++++++++++++---- src/domains/forms.ts | 30 +++++++++++++++++++------ src/domains/history.ts | 2 +- src/domains/isolated-tab.ts | 30 ++++++++++--------------- src/domains/keyboard.ts | 11 +++++++-- src/domains/network.ts | 2 +- src/domains/readpage/read-page.ts | 5 +++-- src/domains/ref-resolve.ts | 26 +++++----------------- src/domains/screenshot.ts | 12 +++++----- src/domains/search/web-search.ts | 5 +++-- src/domains/snapshot.ts | 35 +++++++---------------------- src/domains/tabs.ts | 9 +++++--- test/domains/box-test.ts | 20 +++++++++++++++++ 18 files changed, 212 insertions(+), 108 deletions(-) create mode 100644 src/domains/box.ts create mode 100644 test/domains/box-test.ts 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; + 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> => { + 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> { - const params: Record = { 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]> = [ + type InputCall = readonly ["Input.dispatchMouseEvent" | "Input.dispatchDragEvent", Record]; + const calls: ReadonlyArray = [ ["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] => { + ...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> => { 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> { 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> { 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