pi-browser-harness 0.6.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/src/cdp/daemon-transport.ts +5 -71
- package/src/cdp/event-queue.ts +128 -0
- package/src/cdp/session.ts +31 -0
- package/src/cdp/transport.ts +4 -69
- package/src/client.ts +8 -6
- package/src/domains/click.ts +60 -24
- package/src/domains/files.ts +33 -4
- package/src/domains/form.ts +272 -0
- package/src/domains/keyboard.ts +67 -6
- package/src/domains/page.ts +45 -0
- package/src/domains/ref-resolve.ts +152 -0
- package/src/domains/snapshot.ts +87 -11
- package/src/prompt.ts +26 -7
- package/src/registry.ts +6 -1
package/src/domains/snapshot.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { Container, Image, type ImageTheme, Markdown, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
4
4
|
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import type { CdpSession } from "../cdp/session";
|
|
5
6
|
import { type Result, err, ok } from "../util/result";
|
|
6
7
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
7
8
|
import { applyTruncation } from "../util/truncate";
|
|
@@ -27,7 +28,7 @@ const SnapshotArgs = Type.Object({
|
|
|
27
28
|
|
|
28
29
|
// Raw shapes from CDP Accessibility.getFullAXTree. Cast at this boundary only.
|
|
29
30
|
type AxValue = { value?: unknown };
|
|
30
|
-
type RawAxNode = {
|
|
31
|
+
export type RawAxNode = {
|
|
31
32
|
nodeId: string;
|
|
32
33
|
parentId?: string;
|
|
33
34
|
childIds?: ReadonlyArray<string>;
|
|
@@ -42,7 +43,7 @@ type RawAxNode = {
|
|
|
42
43
|
|
|
43
44
|
type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
|
|
44
45
|
|
|
45
|
-
type SlimNode = {
|
|
46
|
+
export type SlimNode = {
|
|
46
47
|
role: string;
|
|
47
48
|
name?: string;
|
|
48
49
|
value?: string;
|
|
@@ -51,6 +52,8 @@ type SlimNode = {
|
|
|
51
52
|
children: SlimNode[];
|
|
52
53
|
/** Internal: kept for the post-build box fetch. Stripped before returning to the LLM. */
|
|
53
54
|
_backendId?: number;
|
|
55
|
+
/** Stable handle (e.g. "e12") for interaction tools. Survives re-renders via backendNodeId. */
|
|
56
|
+
ref?: string;
|
|
54
57
|
/** Click target — center of the element's bounding box, in viewport CSS pixels. */
|
|
55
58
|
box?: Box;
|
|
56
59
|
};
|
|
@@ -70,6 +73,20 @@ const INTERACTIVE_ROLES = new Set([
|
|
|
70
73
|
"spinbutton",
|
|
71
74
|
]);
|
|
72
75
|
|
|
76
|
+
// Roles whose live DOM value/checked state should override the (often stale)
|
|
77
|
+
// AX-tree value. The AX `value` is unreliable for freshly-typed or controlled
|
|
78
|
+
// inputs, so for these we read the real DOM property at snapshot time.
|
|
79
|
+
const VALUE_ROLES = new Set([
|
|
80
|
+
"textbox",
|
|
81
|
+
"searchbox",
|
|
82
|
+
"spinbutton",
|
|
83
|
+
"combobox",
|
|
84
|
+
"checkbox",
|
|
85
|
+
"radio",
|
|
86
|
+
"switch",
|
|
87
|
+
"slider",
|
|
88
|
+
]);
|
|
89
|
+
|
|
73
90
|
const stringOf = (v: AxValue | undefined): string | undefined => {
|
|
74
91
|
const x = v?.value;
|
|
75
92
|
if (typeof x === "string" && x !== "") return x;
|
|
@@ -93,7 +110,7 @@ const collectState = (props: RawAxNode["properties"]): string | undefined => {
|
|
|
93
110
|
return flags.length > 0 ? flags.join(", ") : undefined;
|
|
94
111
|
};
|
|
95
112
|
|
|
96
|
-
const buildTree = (
|
|
113
|
+
export const buildTree = (
|
|
97
114
|
rawNodes: ReadonlyArray<RawAxNode>,
|
|
98
115
|
opts: { interestingOnly: boolean; maxNodes: number },
|
|
99
116
|
): SlimNode[] => {
|
|
@@ -204,8 +221,9 @@ const renderOutline = (nodes: ReadonlyArray<SlimNode>): string => {
|
|
|
204
221
|
if (n.name) line += ` "${n.name}"`;
|
|
205
222
|
if (n.value && n.value !== n.name) line += ` = ${JSON.stringify(n.value)}`;
|
|
206
223
|
if (n.state) line += ` (${n.state})`;
|
|
207
|
-
// Surface click coordinates for interactive nodes
|
|
208
|
-
//
|
|
224
|
+
// Surface the stable ref + click coordinates for interactive nodes. Agents
|
|
225
|
+
// pass `ref` to interaction tools (survives re-renders); @(x,y) is a fallback.
|
|
226
|
+
if (n.ref) line += ` [${n.ref}]`;
|
|
209
227
|
if (n.box && INTERACTIVE_ROLES.has(n.role)) {
|
|
210
228
|
line += ` @(${n.box.cx},${n.box.cy})`;
|
|
211
229
|
}
|
|
@@ -229,7 +247,7 @@ const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
|
|
|
229
247
|
* roles. Caller fetches DOM.getBoxModel for each and writes box back into the
|
|
230
248
|
* slim node by reference.
|
|
231
249
|
*/
|
|
232
|
-
const collectInteractiveTargets = (
|
|
250
|
+
export const collectInteractiveTargets = (
|
|
233
251
|
nodes: ReadonlyArray<SlimNode>,
|
|
234
252
|
): Array<{ node: SlimNode; backendId: number }> => {
|
|
235
253
|
const out: Array<{ node: SlimNode; backendId: number }> = [];
|
|
@@ -245,6 +263,33 @@ const collectInteractiveTargets = (
|
|
|
245
263
|
return out;
|
|
246
264
|
};
|
|
247
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Read the live DOM value for a form control by backend node id. Resolves the
|
|
268
|
+
* node to a JS object then reads the property that matters for its type:
|
|
269
|
+
* `.value` for inputs/selects/textareas, `.checked` (→ "checked"/"unchecked")
|
|
270
|
+
* for checkboxes/radios. Returns undefined on any failure so the caller falls
|
|
271
|
+
* back to the AX value. Mirrors the box-fetch error handling: never throws.
|
|
272
|
+
*/
|
|
273
|
+
const liveValue = async (session: CdpSession, backendId: number): Promise<string | undefined> => {
|
|
274
|
+
const resolved = await session.call("DOM.resolveNode", { backendNodeId: backendId });
|
|
275
|
+
if (!resolved.success) return undefined;
|
|
276
|
+
const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
|
|
277
|
+
if (!objectId) return undefined;
|
|
278
|
+
const fn = `function () {
|
|
279
|
+
if (this.type === "checkbox" || this.type === "radio") return this.checked ? "checked" : "unchecked";
|
|
280
|
+
if (typeof this.value === "string") return this.value;
|
|
281
|
+
return null;
|
|
282
|
+
}`;
|
|
283
|
+
const r = await session.call("Runtime.callFunctionOn", {
|
|
284
|
+
objectId,
|
|
285
|
+
functionDeclaration: fn,
|
|
286
|
+
returnByValue: true,
|
|
287
|
+
});
|
|
288
|
+
if (!r.success) return undefined;
|
|
289
|
+
const value = (r.data as { result?: { value?: unknown } }).result?.value;
|
|
290
|
+
return typeof value === "string" ? value : undefined;
|
|
291
|
+
};
|
|
292
|
+
|
|
248
293
|
type SnapshotDetails = {
|
|
249
294
|
nodeCount: number;
|
|
250
295
|
truncated: boolean;
|
|
@@ -258,14 +303,15 @@ export const snapshotTool = defineBrowserTool({
|
|
|
258
303
|
name: "browser_snapshot",
|
|
259
304
|
label: "Browser Snapshot",
|
|
260
305
|
description:
|
|
261
|
-
"DEFAULT tool for understanding what is on the page. Returns the structured accessibility tree (roles, names, states, hierarchy)
|
|
262
|
-
promptSnippet: "Get accessibility-tree snapshot
|
|
306
|
+
"DEFAULT tool for understanding what is on the page. Returns the structured accessibility tree (roles, names, states, hierarchy). Every interactive element gets a stable ref shown as [eN] plus click coordinates @(x,y). Pass the ref to browser_click/browser_fill/etc. — refs survive re-renders, coordinates don't. Use this BEFORE deciding whether you need a screenshot. Pair with browser_execute_js for surgical reads of specific element values.",
|
|
307
|
+
promptSnippet: "Get accessibility-tree snapshot with stable element refs (default for page inspection)",
|
|
263
308
|
promptGuidelines: [
|
|
264
309
|
"DEFAULT — use this whenever you need to know what's on a page, what's clickable, or how the page is structured.",
|
|
265
|
-
"
|
|
266
|
-
"DO NOT call browser_screenshot just to understand the page. This tool already gives you structure, labels, states, and click targets.",
|
|
310
|
+
"Refs come for free: every interactive element shows '[eN]' in the outline. Pass eN as `ref` to browser_click/browser_fill/browser_select_option/browser_focus/browser_upload_file — refs survive re-renders, so prefer them over the '@(x,y)' coordinates (a fallback).",
|
|
311
|
+
"DO NOT call browser_screenshot just to understand the page. This tool already gives you structure, labels, states, refs, and click targets.",
|
|
312
|
+
"Re-run after a navigation or a major re-render to get fresh refs; a 'ref is stale' error from an interaction tool means you need a new snapshot.",
|
|
267
313
|
"Pass includeScreenshot:true ONLY if you also need to verify visual rendering (rare).",
|
|
268
|
-
"format:'json' returns the raw slim structure (with `box` per node) for programmatic use; default 'outline' is human/LLM-readable.",
|
|
314
|
+
"format:'json' returns the raw slim structure (with `ref` and `box` per node) for programmatic use; default 'outline' is human/LLM-readable.",
|
|
269
315
|
],
|
|
270
316
|
parameters: SnapshotArgs,
|
|
271
317
|
|
|
@@ -293,6 +339,19 @@ export const snapshotTool = defineBrowserTool({
|
|
|
293
339
|
// without a screenshot round-trip. Capped budget so a slow page can't
|
|
294
340
|
// wedge the call.
|
|
295
341
|
const targets = collectInteractiveTargets(slim);
|
|
342
|
+
|
|
343
|
+
// Assign stable refs (e1, e2, …) to every interactive target. Interaction
|
|
344
|
+
// tools resolve these refs to backendNodeIds at call time, so they stay
|
|
345
|
+
// valid across re-renders. The ref → backendNodeId map (and the per-ref
|
|
346
|
+
// signature baseline for the post-mutation diff) is published below, after
|
|
347
|
+
// the box/live-value fetch so the signature reflects the live DOM value.
|
|
348
|
+
const refMap = new Map<string, number>();
|
|
349
|
+
targets.forEach(({ node, backendId }, i) => {
|
|
350
|
+
const ref = `e${i + 1}`;
|
|
351
|
+
node.ref = ref;
|
|
352
|
+
refMap.set(ref, backendId);
|
|
353
|
+
});
|
|
354
|
+
|
|
296
355
|
if (targets.length > 0) {
|
|
297
356
|
const ac = new AbortController();
|
|
298
357
|
const timer = setTimeout(() => ac.abort(), 1_500);
|
|
@@ -320,11 +379,28 @@ export const snapshotTool = defineBrowserTool({
|
|
|
320
379
|
cx: Math.round(x + width / 2),
|
|
321
380
|
cy: Math.round(y + height / 2),
|
|
322
381
|
};
|
|
382
|
+
// Override the AX value with the live DOM property for form controls.
|
|
383
|
+
// The AX `value` is stale for freshly-typed / controlled inputs, so the
|
|
384
|
+
// agent can't trust it as a read-back signal. Reading .value/.checked
|
|
385
|
+
// from the actual element fixes that.
|
|
386
|
+
if (ac.signal.aborted || !VALUE_ROLES.has(node.role)) return;
|
|
387
|
+
const live = await liveValue(session, backendId);
|
|
388
|
+
if (live !== undefined) node.value = live;
|
|
323
389
|
}),
|
|
324
390
|
);
|
|
325
391
|
clearTimeout(timer);
|
|
326
392
|
}
|
|
327
393
|
|
|
394
|
+
// Publish refs to the active tab. The signature (role|name|value|state) is
|
|
395
|
+
// the baseline the post-mutation auto-diff compares against, so it must use
|
|
396
|
+
// the live values resolved above.
|
|
397
|
+
const refSig = new Map<string, string>();
|
|
398
|
+
for (const { node } of targets) {
|
|
399
|
+
if (node.ref === undefined) continue;
|
|
400
|
+
refSig.set(node.ref, `${node.role}|${node.name ?? ""}|${node.value ?? ""}|${node.state ?? ""}`);
|
|
401
|
+
}
|
|
402
|
+
session.setRefMap(refMap, refSig);
|
|
403
|
+
|
|
328
404
|
const stripped = stripInternals(slim);
|
|
329
405
|
const text = (args.format ?? "outline") === "outline" ? renderOutline(slim) : JSON.stringify(stripped, null, 2);
|
|
330
406
|
const trunc = await applyTruncation(text, "snapshot");
|
package/src/prompt.ts
CHANGED
|
@@ -21,9 +21,13 @@ to interact with pages visually, and other tools alongside them.
|
|
|
21
21
|
|
|
22
22
|
| Tool | Purpose |
|
|
23
23
|
|------|---------|
|
|
24
|
+
| browser_snapshot | Accessibility-tree snapshot. Assigns every interactive element a stable **ref** (\`[eN]\`) plus \`@(x,y)\`. Refs are the primary handle for interaction tools — they survive re-renders, coordinates don't. |
|
|
24
25
|
| browser_screenshot | Capture a PNG or JPEG screenshot of the current page (rendered inline in the TUI; the LLM reads the image via the saved file path) |
|
|
25
|
-
| browser_click | Click
|
|
26
|
-
| browser_type | Type text into the focused element |
|
|
26
|
+
| browser_click | Click an element by **ref** (preferred — re-resolves position, survives re-renders) or viewport x/y. Appends a compact diff of page changes. |
|
|
27
|
+
| browser_type | Type text into the focused element (real keystrokes; requires a focused field) |
|
|
28
|
+
| browser_fill | Fill an input/textarea by **ref** (preferred) or selector — updates React/Vue controlled components, verifies the value, appends a page-changes diff |
|
|
29
|
+
| browser_select_option | Select an option in a native <select> by **ref** (preferred) or selector, choosing by value/label/index (fires change) |
|
|
30
|
+
| browser_focus | Focus an element by **ref** (preferred) or selector (deterministic; use before browser_type) |
|
|
27
31
|
| browser_press_key | Press a keyboard key (Enter, Tab, Escape, arrows, etc.) |
|
|
28
32
|
| browser_scroll | Scroll the page at coordinates |
|
|
29
33
|
| browser_navigate | Navigate to a URL (result details.outcome.kind is "in_place" or "new_tab_created") |
|
|
@@ -38,6 +42,7 @@ to interact with pages visually, and other tools alongside them.
|
|
|
38
42
|
| browser_http_get | Direct HTTP GET (outside browser, for APIs) |
|
|
39
43
|
| browser_console | Read JS errors / console output on the current tab (diagnostic — use when an action looks broken; pass sinceSeq from the previous nextCursor to see only new messages) |
|
|
40
44
|
| browser_wait | Wait N seconds |
|
|
45
|
+
| browser_wait_for | Wait until a selector/text appears (or a selector disappears) — use for SPA content before interacting |
|
|
41
46
|
| browser_wait_for_load | Wait for document.readyState === 'complete' (returns a typed timeout error if the page doesn't reach readyState=complete in N seconds, default 15) |
|
|
42
47
|
| browser_handle_dialog | Accept or dismiss a JS dialog |
|
|
43
48
|
| browser_run_script | Execute a temporary script file with daemon access (write script to disk, then run) |
|
|
@@ -83,11 +88,25 @@ browser_http_get("https://api.example.com/data") + browser_click(x, y)
|
|
|
83
88
|
browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot()
|
|
84
89
|
\`\`\`
|
|
85
90
|
|
|
86
|
-
**Form filling:**
|
|
91
|
+
**Form filling (ref-first — this is the reliable path on React/SPA forms):**
|
|
87
92
|
\`\`\`
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
browser_wait_for({ selector: "#email" }) // ensure SPA field is rendered
|
|
94
|
+
browser_snapshot() // each interactive element gets a [eN] ref
|
|
95
|
+
→ browser_fill({ ref: "e7", value: "a@b.com" }) // target by ref, not a guessed selector
|
|
96
|
+
→ browser_select_option({ ref: "e9", label: "India" }) // native <select> by ref
|
|
97
|
+
→ browser_click({ ref: "e12" }) // e.g. Save — re-resolves position even if the form reflowed
|
|
98
|
+
// each mutating call appends a compact "Page changes" diff — read it to confirm
|
|
99
|
+
// the change landed (e.g. a save closed the form, a new field appeared as *[eN]).
|
|
90
100
|
\`\`\`
|
|
101
|
+
**Why ref over selector/coordinates:** refs are keyed to the element's identity, so
|
|
102
|
+
they survive the re-renders that React/Vue forms trigger on every edit and save —
|
|
103
|
+
coordinates go stale and selectors you guess often don't match. If a call returns
|
|
104
|
+
"ref is stale", the page changed: re-run browser_snapshot for fresh refs.
|
|
105
|
+
|
|
106
|
+
Prefer browser_fill for text inputs/textareas — it fires input/change so controlled
|
|
107
|
+
components update, and returns the field's value for verification. Use browser_click +
|
|
108
|
+
browser_type only for keystroke-sensitive widgets (autocomplete, masked inputs);
|
|
109
|
+
browser_type requires an already-focused field and errors if none is focused.
|
|
91
110
|
|
|
92
111
|
**Data extraction:**
|
|
93
112
|
\`\`\`
|
|
@@ -121,8 +140,8 @@ write("/tmp/extract.js", "...script with daemon access...") → browser_run_scri
|
|
|
121
140
|
|
|
122
141
|
| Tool | Purpose |
|
|
123
142
|
|------|---------|
|
|
124
|
-
| browser_upload_file | Upload a file to a file input (bypasses file picker) |
|
|
125
|
-
| browser_dispatch_key | Dispatch a DOM KeyboardEvent on
|
|
143
|
+
| browser_upload_file | Upload a file to a file input by **ref** (preferred) or selector (bypasses file picker) |
|
|
144
|
+
| browser_dispatch_key | Dispatch a DOM KeyboardEvent on an element by **ref** (preferred) or selector (for React/Vue inputs); returns details.matched |
|
|
126
145
|
| browser_download | Configure download directory and disable save-as prompts |
|
|
127
146
|
| browser_viewport_resize | Resize the viewport for responsive testing |
|
|
128
147
|
| browser_drag_and_drop | Perform drag-and-drop from one coordinate to another |
|
package/src/registry.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { BrowserClient } from "./client";
|
|
|
3
3
|
import { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool";
|
|
4
4
|
import { clickTool } from "./domains/click";
|
|
5
5
|
import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
|
|
6
|
-
import {
|
|
6
|
+
import { fillTool, selectOptionTool, focusTool } from "./domains/form";
|
|
7
|
+
import { pageInfoTool, waitTool, waitForLoadTool, waitForTool } from "./domains/page";
|
|
7
8
|
import { scrollTool } from "./domains/scroll";
|
|
8
9
|
import { handleDialogTool } from "./domains/dialog";
|
|
9
10
|
import { screenshotTool } from "./domains/screenshot";
|
|
@@ -27,11 +28,15 @@ const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
|
|
|
27
28
|
setupTool,
|
|
28
29
|
{ ...clickTool, serialized: true },
|
|
29
30
|
{ ...typeTool, serialized: true },
|
|
31
|
+
{ ...fillTool, serialized: true },
|
|
32
|
+
{ ...selectOptionTool, serialized: true },
|
|
33
|
+
{ ...focusTool, serialized: true },
|
|
30
34
|
{ ...pressKeyTool, serialized: true },
|
|
31
35
|
{ ...dispatchKeyTool, serialized: true },
|
|
32
36
|
{ ...scrollTool, serialized: true },
|
|
33
37
|
pageInfoTool,
|
|
34
38
|
waitTool,
|
|
39
|
+
waitForTool,
|
|
35
40
|
{ ...waitForLoadTool, serialized: true },
|
|
36
41
|
{ ...handleDialogTool, serialized: true },
|
|
37
42
|
screenshotTool,
|