diff options
Diffstat (limited to 'src/domains')
| -rw-r--r-- | src/domains/console.ts | 47 | ||||
| -rw-r--r-- | src/domains/js.ts | 38 | ||||
| -rw-r--r-- | src/domains/network.ts | 54 | ||||
| -rw-r--r-- | src/domains/ref-resolve.ts | 4 | ||||
| -rw-r--r-- | src/domains/render.ts | 14 | ||||
| -rw-r--r-- | src/domains/screenshot.ts | 8 | ||||
| -rw-r--r-- | src/domains/snapshot.ts | 35 |
7 files changed, 146 insertions, 54 deletions
diff --git a/src/domains/console.ts b/src/domains/console.ts index 9696cd9..c38da2a 100644 --- a/src/domains/console.ts +++ b/src/domains/console.ts @@ -5,6 +5,7 @@ import { type Result, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer"; +import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards"; const ConsoleArgs = Type.Object({ levels: Type.Optional( @@ -106,6 +107,50 @@ type ConsoleDetails = { nextCursor?: number; }; +const CONSOLE_LEVELS: ReadonlyArray<ConsoleLevel> = ["log", "info", "warn", "error", "debug"]; + +const asConsoleLevel = (v: unknown): ConsoleLevel | undefined => + CONSOLE_LEVELS.find((l) => l === v); + +const asConsoleRecord = (v: unknown): ConsoleRecord | undefined => { + const o = asRecord(v); + if (o === undefined) return undefined; + const level = asConsoleLevel(o["level"]); + const text = asString(o["text"]); + if (level === undefined || text === undefined) return undefined; + const url = asString(o["url"]); + const lineNumber = asNumber(o["lineNumber"]); + const stackTrace = asString(o["stackTrace"]); + return { + seq: asNumber(o["seq"]) ?? 0, + timestampMs: asNumber(o["timestampMs"]) ?? 0, + level, + text, + source: o["source"] === "log-entry" ? "log-entry" : "console-api", + ...(url !== undefined ? { url } : {}), + ...(lineNumber !== undefined ? { lineNumber } : {}), + ...(stackTrace !== undefined ? { stackTrace } : {}), + }; +}; + +const asConsoleDetails = (v: unknown): ConsoleDetails | undefined => { + const o = asRecord(v); + if (o === undefined) return undefined; + const records = asArrayOf(o["records"], asConsoleRecord); + if (records === undefined) return undefined; + const fullOutputPath = asString(o["fullOutputPath"]); + const nextCursor = asNumber(o["nextCursor"]); + return { + total: asNumber(o["total"]) ?? records.length, + returned: asNumber(o["returned"]) ?? records.length, + bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false, + truncated: asBoolean(o["truncated"]) ?? false, + records, + ...(fullOutputPath !== undefined ? { fullOutputPath } : {}), + ...(nextCursor !== undefined ? { nextCursor } : {}), + }; +}; + export const consoleTool = defineBrowserTool({ name: "browser_console", label: "Browser Console", @@ -156,7 +201,7 @@ export const consoleTool = defineBrowserTool({ }, renderResult(result, expanded, theme) { - const details = result.details as ConsoleDetails | undefined; + const details = asConsoleDetails(result.details); if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0); const md = renderConsoleMarkdown(details.records, { diff --git a/src/domains/js.ts b/src/domains/js.ts index 61dbe53..6d9ace0 100644 --- a/src/domains/js.ts +++ b/src/domains/js.ts @@ -8,6 +8,7 @@ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; +import { asNumber, asRecord, asString, isRecord } from "../util/guards"; const COMPACT_PREVIEW_BYTES = 120; @@ -80,10 +81,14 @@ export const executeJsTool = defineBrowserTool({ }, renderResult(result, expanded, theme) { - const details = result.details as - | { valueLength?: number; full?: string; pretty?: string; fullOutputPath?: string } - | undefined; - if (!details) return new Text(theme.fg("error", "execute_js: no details"), 0, 0); + const raw = asRecord(result.details); + if (raw === undefined) return new Text(theme.fg("error", "execute_js: no details"), 0, 0); + const details = { + valueLength: asNumber(raw["valueLength"]), + full: asString(raw["full"]), + pretty: asString(raw["pretty"]), + fullOutputPath: asString(raw["fullOutputPath"]), + }; const len = details.valueLength ?? 0; const full = details.full ?? ""; @@ -141,9 +146,7 @@ const MAX_SOURCE_BYTES = 1_000_000; type ContentItem = { readonly type: "text"; readonly text: string }; const isContentItem = (v: unknown): v is ContentItem => - typeof v === "object" && v !== null - && (v as Readonly<Record<string, unknown>>)["type"] === "text" - && typeof (v as Readonly<Record<string, unknown>>)["text"] === "string"; + isRecord(v) && v["type"] === "text" && typeof v["text"] === "string"; export const runScriptTool = defineBrowserTool({ name: "browser_run_script", @@ -211,12 +214,12 @@ export const runScriptTool = defineBrowserTool({ requireFromHere, ac.signal, (u: unknown) => { - if (typeof u !== "object" || u === null) return; - const content = (u as Readonly<Record<string, unknown>>)["content"]; + if (!isRecord(u)) return; + const content = u["content"]; if (!Array.isArray(content) || content.length === 0) return; - const first = content[0]; - if (typeof first !== "object" || first === null) return; - const txt = (first as Readonly<Record<string, unknown>>)["text"]; + const first: unknown = content[0]; + if (!isRecord(first)) return; + const txt = first["text"]; if (typeof txt !== "string") return; try { onUpdate({ text: txt }); } catch { /* swallow */ } }, @@ -230,10 +233,10 @@ export const runScriptTool = defineBrowserTool({ clearTimeout(timeoutTimer); if (signal !== undefined) signal.removeEventListener("abort", onAbort); - if (typeof result !== "object" || result === null) { + if (!isRecord(result)) { return err({ kind: "invalid_state", message: `Script must return an object; got ${JSON.stringify(result)}` }); } - const content = (result as Readonly<Record<string, unknown>>)["content"]; + const content = result["content"]; if (!Array.isArray(content)) { return err({ kind: "invalid_state", message: "Script return value must have a content array" }); } @@ -241,11 +244,8 @@ export const runScriptTool = defineBrowserTool({ return err({ kind: "invalid_state", message: "Script content array must contain { type: 'text', text: string } items" }); } const textOut = content.map((c) => c.text).join("\n"); - const details = (result as Readonly<Record<string, unknown>>)["details"]; - const isPlainObject = typeof details === "object" && details !== null && !Array.isArray(details); - return isPlainObject - ? ok({ text: textOut, details: details as Readonly<Record<string, unknown>> }) - : ok({ text: textOut }); + const details = result["details"]; + return isRecord(details) ? ok({ text: textOut, details }) : ok({ text: textOut }); } catch (e) { clearTimeout(timeoutTimer); if (signal !== undefined) signal.removeEventListener("abort", onAbort); diff --git a/src/domains/network.ts b/src/domains/network.ts index dc23c6d..fa755b3 100644 --- a/src/domains/network.ts +++ b/src/domains/network.ts @@ -4,6 +4,7 @@ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; +import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards"; import type { NetworkRecord } from "../cdp/network-buffer"; const HttpGetArgs = Type.Object({ @@ -168,6 +169,57 @@ type NetworkDetails = { includeBodies: boolean; }; +const asNetworkRecord = (v: unknown): NetworkRecord | undefined => { + const o = asRecord(v); + if (o === undefined) return undefined; + const method = asString(o["method"]); + const url = asString(o["url"]); + const type = asString(o["type"]); + if (method === undefined || url === undefined || type === undefined) return undefined; + const status = asNumber(o["status"]); + const statusText = asString(o["statusText"]); + const mimeType = asString(o["mimeType"]); + const durationMs = asNumber(o["durationMs"]); + const responseBodySize = asNumber(o["responseBodySize"]); + const failed = asBoolean(o["failed"]); + const errorText = asString(o["errorText"]); + const bodyRaw = o["body"]; + const body = typeof bodyRaw === "string" || bodyRaw === null ? bodyRaw : undefined; + return { + requestId: asString(o["requestId"]) ?? "", + method, + url, + type, + requestStartedMs: asNumber(o["requestStartedMs"]) ?? 0, + requestBodySize: asNumber(o["requestBodySize"]) ?? 0, + ...(status !== undefined ? { status } : {}), + ...(statusText !== undefined ? { statusText } : {}), + ...(mimeType !== undefined ? { mimeType } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(responseBodySize !== undefined ? { responseBodySize } : {}), + ...(failed !== undefined ? { failed } : {}), + ...(errorText !== undefined ? { errorText } : {}), + ...(body !== undefined ? { body } : {}), + }; +}; + +const asNetworkDetails = (v: unknown): NetworkDetails | undefined => { + const o = asRecord(v); + if (o === undefined) return undefined; + const requests = asArrayOf(o["requests"], asNetworkRecord); + if (requests === undefined) return undefined; + const fullOutputPath = asString(o["fullOutputPath"]); + return { + total: asNumber(o["total"]) ?? requests.length, + returned: asNumber(o["returned"]) ?? requests.length, + bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false, + truncated: asBoolean(o["truncated"]) ?? false, + requests, + includeBodies: asBoolean(o["includeBodies"]) ?? false, + ...(fullOutputPath !== undefined ? { fullOutputPath } : {}), + }; +}; + export const networkRequestsTool = defineBrowserTool({ name: "browser_network_requests", label: "Browser Network Requests", @@ -235,7 +287,7 @@ export const networkRequestsTool = defineBrowserTool({ }, renderResult(result, expanded, theme) { - const details = result.details as NetworkDetails | undefined; + const details = asNetworkDetails(result.details); if (!details) return new Text(theme.fg("error", "network: no details"), 0, 0); const md = renderNetworkMarkdown(details.requests, { diff --git a/src/domains/ref-resolve.ts b/src/domains/ref-resolve.ts index de83521..a80c620 100644 --- a/src/domains/ref-resolve.ts +++ b/src/domains/ref-resolve.ts @@ -2,7 +2,7 @@ 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"; +import { buildTree, collectInteractiveTargets, type SlimNode } from "./snapshot"; const staleErr = (ref: string): ToolErr => ({ kind: "invalid_state", @@ -78,7 +78,7 @@ export const interactiveDiff = async (client: BrowserClient): Promise<string> => const axRes = await session.call("Accessibility.getFullAXTree", {}); if (!axRes.success) return ""; - const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes; + const rawNodes = axRes.data.nodes; const slim = buildTree(rawNodes, { interestingOnly: true, maxNodes: 1000 }); const targets = collectInteractiveTargets(slim); diff --git a/src/domains/render.ts b/src/domains/render.ts index f6a6535..1e4e30d 100644 --- a/src/domains/render.ts +++ b/src/domains/render.ts @@ -11,6 +11,7 @@ import { Markdown, Text } from "@mariozechner/pi-tui"; import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "@mariozechner/pi-coding-agent"; +import { isRecord } from "../util/guards"; const COMPACT_PREVIEW_LINES = 6; @@ -24,16 +25,11 @@ export type ExpandableText = { readonly fullOutputPath?: string; }; -const isExpandableText = (value: unknown): value is ExpandableText => { - if (typeof value !== "object" || value === null) return false; - const v = value as Record<string, unknown>; - return typeof v["summary"] === "string" && typeof v["body"] === "string"; -}; +const isExpandableText = (value: unknown): value is ExpandableText => + isRecord(value) && typeof value["summary"] === "string" && typeof value["body"] === "string"; -const extractRender = (details: unknown): unknown => { - if (typeof details !== "object" || details === null) return undefined; - return (details as Record<string, unknown>)["render"]; -}; +const extractRender = (details: unknown): unknown => + isRecord(details) ? details["render"] : undefined; /** * Build a renderResult over an ExpandableText stored in `details.render`. diff --git a/src/domains/screenshot.ts b/src/domains/screenshot.ts index c6cc87b..2e9cfdc 100644 --- a/src/domains/screenshot.ts +++ b/src/domains/screenshot.ts @@ -7,6 +7,7 @@ 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 { asRecord, asString } from "../util/guards"; import { safeJs } from "../util/js-template"; import { cdpCall } from "./cdp-call"; @@ -91,15 +92,16 @@ export const screenshotTool = defineBrowserTool({ }, renderResult(result, _expanded, theme) { - const details = result.details as { path?: string; format?: string } | undefined; - const filePath = details?.path; + const raw = asRecord(result.details); + const filePath = raw === undefined ? undefined : asString(raw["path"]); if (!filePath) { return new Text(theme.fg("error", "Screenshot path missing"), 0, 0); } + const format = raw === undefined ? undefined : asString(raw["format"]); try { const buf = readFileSync(filePath); const b64 = buf.toString("base64"); - const mimeType = details?.format === "jpeg" ? "image/jpeg" : "image/png"; + const mimeType = format === "jpeg" ? "image/jpeg" : "image/png"; const imageTheme: ImageTheme = { fallbackColor: (str: string) => theme.fg("dim", str), }; diff --git a/src/domains/snapshot.ts b/src/domains/snapshot.ts index ec5c894..c015203 100644 --- a/src/domains/snapshot.ts +++ b/src/domains/snapshot.ts @@ -3,12 +3,14 @@ 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 { AxNodeResult } from "../cdp/commands"; 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"; import { screenshotPath } from "../util/paths"; +import { asBoolean, asNumber, asRecord, asString } from "../util/guards"; const SnapshotArgs = Type.Object({ includeScreenshot: Type.Optional( @@ -28,20 +30,8 @@ const SnapshotArgs = Type.Object({ ), }); -// Raw shapes from CDP Accessibility.getFullAXTree. Cast at this boundary only. -type AxValue = { value?: unknown }; -export type RawAxNode = { - nodeId: string; - parentId?: string; - childIds?: ReadonlyArray<string>; - ignored?: boolean; - role?: AxValue; - name?: AxValue; - value?: AxValue; - description?: AxValue; - properties?: ReadonlyArray<{ name?: string; value?: AxValue }>; - backendDOMNodeId?: number; -}; +export type RawAxNode = AxNodeResult; +type AxValue = NonNullable<RawAxNode["role"]>; export type SlimNode = { role: string; @@ -322,7 +312,7 @@ export const snapshotTool = defineBrowserTool({ // 1. AX tree const axRes = await cdpCall(client, "Accessibility.getFullAXTree", {}); if (!axRes.success) return axRes; - const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes; + const rawNodes = axRes.data.nodes; // 2. Page url/title const piRes = await client.pageInfo(); @@ -412,10 +402,17 @@ export const snapshotTool = defineBrowserTool({ }, renderResult(result, expanded, theme) { - const details = result.details as - | (SnapshotDetails & { summary?: string; ok?: boolean }) - | undefined; - if (!details) return new Text(theme.fg("error", "snapshot: no details"), 0, 0); + const raw = asRecord(result.details); + if (raw === undefined) return new Text(theme.fg("error", "snapshot: no details"), 0, 0); + const details = { + nodeCount: asNumber(raw["nodeCount"]), + truncated: asBoolean(raw["truncated"]), + fullOutputPath: asString(raw["fullOutputPath"]), + screenshotPath: asString(raw["screenshotPath"]), + url: asString(raw["url"]), + title: asString(raw["title"]), + summary: asString(raw["summary"]), + }; const titleLine = details.title ? ` "${details.title}"` : ""; const summary = details.summary ?? ""; |
