aboutsummaryrefslogtreecommitdiff
path: root/src/domains/snapshot.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/domains/snapshot.ts')
-rw-r--r--src/domains/snapshot.ts189
1 files changed, 11 insertions, 178 deletions
diff --git a/src/domains/snapshot.ts b/src/domains/snapshot.ts
index 848ebb1..86c5fe3 100644
--- a/src/domains/snapshot.ts
+++ b/src/domains/snapshot.ts
@@ -2,10 +2,18 @@ import { readFileSync, writeFileSync } from "node:fs";
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 { BrowserClient } from "../client";
-import type { AxNodeResult } from "../cdp/commands";
-import { type Box, boxOf } from "./box";
+import { boxOf } from "./box";
import { cdpCall } from "./cdp-call";
+import {
+ buildTree,
+ collectInteractiveTargets,
+ countNodes,
+ INTERACTIVE_ROLES,
+ liveValue,
+ type SlimNode,
+ stripInternals,
+ VALUE_ROLES,
+} from "./ax-tree";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
@@ -30,139 +38,6 @@ const SnapshotArgs = Type.Object({
),
});
-export type RawAxNode = AxNodeResult;
-type AxValue = NonNullable<RawAxNode["role"]>;
-
-export type SlimNode = {
- role: string;
- name?: string;
- value?: string;
- description?: string;
- state?: string;
- children: SlimNode[];
- _backendId?: number;
- ref?: string;
- box?: Box;
-};
-
-const INTERACTIVE_ROLES = new Set([
- "button",
- "link",
- "textbox",
- "checkbox",
- "radio",
- "combobox",
- "menuitem",
- "tab",
- "switch",
- "slider",
- "searchbox",
- "spinbutton",
-]);
-
-// The AX `value` is stale for freshly-typed or controlled inputs, so for these roles the live DOM property is read instead.
-const VALUE_ROLES = new Set([
- "textbox",
- "searchbox",
- "spinbutton",
- "combobox",
- "checkbox",
- "radio",
- "switch",
- "slider",
-]);
-
-const stringOf = (v: AxValue | undefined): string | undefined => {
- const x = v?.value;
- if (typeof x === "string" && x !== "") return x;
- if (typeof x === "number") return String(x);
- return undefined;
-};
-
-const collectState = (props: RawAxNode["properties"]): string | undefined => {
- if (!props || props.length === 0) return undefined;
- const flags: string[] = [];
- for (const p of props) {
- const name = p.name;
- if (!name) continue;
- const raw = p.value?.value;
- if (raw === true && (name === "focused" || name === "required" || name === "disabled" || name === "checked" || name === "expanded" || name === "selected" || name === "pressed" || name === "modal")) {
- flags.push(name);
- }
- if (typeof raw === "number" && name === "level") flags.push(`level ${raw}`);
- }
- return flags.length > 0 ? flags.join(", ") : undefined;
-};
-
-export const buildTree = (
- rawNodes: ReadonlyArray<RawAxNode>,
- opts: { interestingOnly: boolean; maxNodes: number },
-): SlimNode[] => {
- const byId = new Map<string, RawAxNode>();
- for (const n of rawNodes) byId.set(n.nodeId, n);
-
- const rootIds: string[] = [];
- for (const n of rawNodes) {
- if (!n.parentId || !byId.has(n.parentId)) rootIds.push(n.nodeId);
- }
-
- let budget = opts.maxNodes;
-
- const slim = (node: RawAxNode): SlimNode | undefined => {
- if (budget <= 0) return undefined;
- if (opts.interestingOnly && node.ignored) {
- // Chrome often ignores wrapper divs while their descendants remain meaningful, so recurse through an ignored node's children.
- const out: SlimNode[] = [];
- for (const cid of node.childIds ?? []) {
- const child = byId.get(cid);
- if (!child) continue;
- const slimChild = slim(child);
- if (slimChild) out.push(slimChild);
- }
- return out.length > 0 ? { role: "_hoist", children: out } : undefined;
- }
- budget--;
- const role = stringOf(node.role) ?? "unknown";
- const name = stringOf(node.name);
- const value = stringOf(node.value);
- const description = stringOf(node.description);
- const state = collectState(node.properties);
- const children: SlimNode[] = [];
- for (const cid of node.childIds ?? []) {
- const child = byId.get(cid);
- if (!child) continue;
- const slimChild = slim(child);
- if (!slimChild) continue;
- if (slimChild.role === "_hoist") children.push(...slimChild.children);
- else children.push(slimChild);
- }
- const out: SlimNode = { role, children };
- if (name !== undefined) out.name = name;
- if (value !== undefined) out.value = value;
- if (description !== undefined) out.description = description;
- if (state !== undefined) out.state = state;
- if (node.backendDOMNodeId !== undefined) out._backendId = node.backendDOMNodeId;
- return out;
- };
-
- const result: SlimNode[] = [];
- for (const rid of rootIds) {
- const root = byId.get(rid);
- if (!root) continue;
- const s = slim(root);
- if (!s) continue;
- if (s.role === "_hoist") result.push(...s.children);
- else result.push(s);
- }
- return result;
-};
-
-const countNodes = (nodes: ReadonlyArray<SlimNode>): number => {
- let n = 0;
- for (const node of nodes) n += 1 + countNodes(node.children);
- return n;
-};
-
const summarize = (nodes: ReadonlyArray<SlimNode>): string => {
const counts = new Map<string, number>();
const walk = (ns: ReadonlyArray<SlimNode>): void => {
@@ -207,48 +82,6 @@ const renderOutline = (nodes: ReadonlyArray<SlimNode>): string => {
return lines.join("\n");
};
-const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
- nodes.map((n) => {
- const { _backendId: _bid, ...rest } = n;
- return { ...rest, children: stripInternals(n.children) };
- });
-
-export const collectInteractiveTargets = (
- nodes: ReadonlyArray<SlimNode>,
-): Array<{ node: SlimNode; backendId: number }> => {
- const out: Array<{ node: SlimNode; backendId: number }> = [];
- const walk = (ns: ReadonlyArray<SlimNode>): void => {
- for (const n of ns) {
- if (n._backendId !== undefined && INTERACTIVE_ROLES.has(n.role)) {
- out.push({ node: n, backendId: n._backendId });
- }
- walk(n.children);
- }
- };
- walk(nodes);
- return out;
-};
-
-const liveValue = async (client: BrowserClient, backendId: number): Promise<string | undefined> => {
- const resolved = await cdpCall(client, "DOM.resolveNode", { backendNodeId: backendId });
- if (!resolved.success) return undefined;
- 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";
- if (typeof this.value === "string") return this.value;
- return null;
- }`;
- const r = await cdpCall(client, "Runtime.callFunctionOn", {
- objectId,
- functionDeclaration: fn,
- returnByValue: true,
- });
- if (!r.success) return undefined;
- const value = r.data.result.value;
- return typeof value === "string" ? value : undefined;
-};
-
type SnapshotDetails = {
nodeCount: number;
truncated: boolean;