pi-browser-harness 0.6.0 → 0.8.0

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.
@@ -0,0 +1,270 @@
1
+ import { Type } from "typebox";
2
+ import type { BrowserClient } from "../client";
3
+ import { safeJs } from "../util/js-template";
4
+ import { type Result, err, ok } from "../util/result";
5
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
6
+ import { interactiveDiff, resolveRefToObjectId } from "./ref-resolve";
7
+
8
+ const FillArgs = Type.Object({
9
+ ref: Type.Optional(
10
+ Type.String({
11
+ description:
12
+ "Stable element ref from browser_snapshot (e.g. 'e7'). PREFERRED over selector — survives re-renders. When set, selector is ignored.",
13
+ }),
14
+ ),
15
+ selector: Type.Optional(Type.String({ description: "CSS selector of the form field to fill (fallback when no ref)" })),
16
+ value: Type.String({ description: "Value to set in the field" }),
17
+ });
18
+
19
+ /**
20
+ * Run an element-scoped function either against a CSS selector (querySelector +
21
+ * apply) or a ref (resolve to objectId, callFunctionOn with element as `this`).
22
+ * The shared `fnBody` is a function body where `this` is the target element and
23
+ * the single argument is the value — identical logic on both paths. Returns the
24
+ * function's return value (parsed) or a ToolErr.
25
+ *
26
+ * On the ref path a detached node yields a stale-ref error; on the selector path
27
+ * a missing element returns { status: "not_found" } from the function body.
28
+ */
29
+ const runOnElement = async (
30
+ client: BrowserClient,
31
+ opts: { ref?: string | undefined; selector?: string | undefined; fnBody: string; arg: unknown },
32
+ ): Promise<Result<unknown, ToolErr>> => {
33
+ if (opts.ref !== undefined) {
34
+ const objectId = await resolveRefToObjectId(client, opts.ref);
35
+ if (!objectId.success) return objectId;
36
+ const r = await client.session().call("Runtime.callFunctionOn", {
37
+ objectId: objectId.data,
38
+ functionDeclaration: `function (arg) { ${opts.fnBody} }`,
39
+ arguments: [{ value: opts.arg }],
40
+ returnByValue: true,
41
+ });
42
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
43
+ return ok((r.data as { result?: { value?: unknown } }).result?.value);
44
+ }
45
+ if (opts.selector === undefined) {
46
+ return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
47
+ }
48
+ // Selector path: bind the same trusted function body to the matched element
49
+ // via .call(). fnBody is harness-authored (not user input); only the selector
50
+ // and value are interpolated, and those go through safeJs.
51
+ const prelude = safeJs`
52
+ (() => {
53
+ const el = document.querySelector(${opts.selector});
54
+ if (!el) return { status: "not_found" };
55
+ const __arg = ${opts.arg};
56
+ return (function (arg) {`;
57
+ const expr = `${prelude} ${opts.fnBody} }).call(el, __arg); })()`;
58
+ const r = await client.evaluateJs(expr);
59
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
60
+ return ok(r.data);
61
+ };
62
+
63
+ /**
64
+ * Result shape returned by the in-page fill script. Discriminated by `status`
65
+ * so the handler can map page-level outcomes to typed ToolErr kinds.
66
+ */
67
+ type FillResult =
68
+ | { status: "ok"; tag: string; kind: string; value: string }
69
+ | { status: "not_found" }
70
+ | { status: "not_fillable"; tag: string };
71
+
72
+ // Element-scoped fill logic. `this` is the target element, `arg` is the value.
73
+ // Write via the native prototype value setter so React's _valueTracker
74
+ // registers the change — assigning el.value directly does NOT, which is why
75
+ // controlled inputs revert on re-render. Shared verbatim by the ref path
76
+ // (callFunctionOn) and the selector path (querySelector + .call).
77
+ const FILL_FN = `
78
+ const el = this;
79
+ const tag = el.tagName;
80
+ const proto =
81
+ el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype
82
+ : el instanceof HTMLInputElement ? HTMLInputElement.prototype
83
+ : null;
84
+ if (proto) {
85
+ const desc = Object.getOwnPropertyDescriptor(proto, "value");
86
+ const setter = desc && desc.set;
87
+ if (setter) setter.call(el, arg);
88
+ else el.value = arg;
89
+ el.dispatchEvent(new Event("input", { bubbles: true }));
90
+ el.dispatchEvent(new Event("change", { bubbles: true }));
91
+ return { status: "ok", tag, kind: el.type || tag.toLowerCase(), value: el.value };
92
+ }
93
+ if (el.isContentEditable) {
94
+ // Rich-text editors (Slack/Notion/ProseMirror) listen for beforeinput/input.
95
+ el.focus();
96
+ let done = false;
97
+ try {
98
+ const sel = window.getSelection();
99
+ if (sel) { sel.removeAllRanges(); const rng = document.createRange(); rng.selectNodeContents(el); sel.addRange(rng); }
100
+ done = document.execCommand("insertText", false, arg);
101
+ } catch (e) { done = false; }
102
+ if (!done) {
103
+ el.textContent = arg;
104
+ el.dispatchEvent(new InputEvent("input", { bubbles: true }));
105
+ }
106
+ return { status: "ok", tag, kind: "contenteditable", value: el.textContent || "" };
107
+ }
108
+ return { status: "not_fillable", tag };
109
+ `;
110
+
111
+ export const fillTool = defineBrowserTool({
112
+ name: "browser_fill",
113
+ label: "Browser Fill",
114
+ description:
115
+ "Fill a form field (input/textarea/contenteditable). PREFERRED: pass `ref` (e.g. 'e7') from browser_snapshot — survives re-renders. Fallback: a CSS `selector`. Writes through the native value setter and fires bubbling 'input'/'change' events, so React/Vue/Angular controlled components and rich-text editors update correctly — unlike browser_type. Returns the field's value after writing, plus a compact diff of page changes.",
116
+ promptSnippet: "Fill a form field by ref (preferred) or selector (works with React/Vue controlled inputs)",
117
+ promptGuidelines: [
118
+ "PREFER `ref` from browser_snapshot (the '[eN]' handle) over a guessed CSS selector — refs survive re-renders and don't require you to invent a selector.",
119
+ "Pass the desired value; no browser_click is needed first.",
120
+ "A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
121
+ "Use browser_type instead only for keystroke-sensitive widgets (autocomplete, masked/segmented inputs).",
122
+ "The result reports the field's value after writing and an appended page-changes diff — confirm both match what you intended before moving on.",
123
+ ],
124
+ parameters: FillArgs,
125
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
126
+ const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: FILL_FN, arg: args.value });
127
+ if (!r.success) return r;
128
+ const res = r.data as FillResult | undefined;
129
+ const target = args.ref ?? args.selector ?? "";
130
+ if (res === undefined || res.status === "not_found") {
131
+ return err({
132
+ kind: "invalid_state",
133
+ message: `No element matched: ${target}`,
134
+ details: { ref: args.ref, selector: args.selector },
135
+ });
136
+ }
137
+ if (res.status === "not_fillable") {
138
+ return err({
139
+ kind: "invalid_state",
140
+ message: `Element <${res.tag.toLowerCase()}> is not fillable (not an input, textarea, or contenteditable): ${target}. For <select> use browser_select_option.`,
141
+ details: { ref: args.ref, selector: args.selector, tag: res.tag },
142
+ });
143
+ }
144
+ const diff = await interactiveDiff(client);
145
+ return ok({
146
+ text: `Filled ${target} = ${JSON.stringify(res.value)}${diff}`,
147
+ details: { ref: args.ref, selector: args.selector, value: args.value, verified: res.value, tag: res.tag, kind: res.kind },
148
+ });
149
+ },
150
+ });
151
+
152
+ const FocusArgs = Type.Object({
153
+ ref: Type.Optional(
154
+ Type.String({ description: "Stable element ref from browser_snapshot (e.g. 'e7'). PREFERRED over selector." }),
155
+ ),
156
+ selector: Type.Optional(Type.String({ description: "CSS selector of the element to focus (fallback when no ref)" })),
157
+ });
158
+
159
+ type FocusResult = { status: "ok"; tag: string } | { status: "not_found" } | { status: "not_focusable"; tag: string };
160
+
161
+ const FOCUS_FN = `
162
+ const el = this;
163
+ if (typeof el.focus !== "function") return { status: "not_focusable", tag: el.tagName };
164
+ el.focus();
165
+ return document.activeElement === el ? { status: "ok", tag: el.tagName } : { status: "not_focusable", tag: el.tagName };
166
+ `;
167
+
168
+ export const focusTool = defineBrowserTool({
169
+ name: "browser_focus",
170
+ label: "Browser Focus",
171
+ description:
172
+ "Focus an element via the DOM .focus() method — deterministic, no coordinate accuracy needed. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`. Use before browser_type when a click might miss the field.",
173
+ promptSnippet: "Focus an element by ref (preferred) or selector",
174
+ promptGuidelines: [
175
+ "PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders.",
176
+ "Use before browser_type to guarantee the right field is focused without relying on click coordinates.",
177
+ "For simply setting a value, prefer browser_fill (it doesn't need a separate focus step).",
178
+ ],
179
+ parameters: FocusArgs,
180
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
181
+ const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: FOCUS_FN, arg: "" });
182
+ if (!r.success) return r;
183
+ const res = r.data as FocusResult | undefined;
184
+ const target = args.ref ?? args.selector ?? "";
185
+ if (res === undefined || res.status === "not_found") {
186
+ return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
187
+ }
188
+ if (res.status === "not_focusable") {
189
+ return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> could not be focused: ${target}`, details: { ref: args.ref, selector: args.selector, tag: res.tag } });
190
+ }
191
+ return ok({ text: `Focused ${target}`, details: { ref: args.ref, selector: args.selector, tag: res.tag } });
192
+ },
193
+ });
194
+
195
+ const SelectOptionArgs = Type.Object({
196
+ ref: Type.Optional(
197
+ Type.String({ description: "Stable element ref of the <select> from browser_snapshot. PREFERRED over selector." }),
198
+ ),
199
+ selector: Type.Optional(Type.String({ description: "CSS selector of the <select> element (fallback when no ref)" })),
200
+ value: Type.Optional(Type.String({ description: "Option value attribute to select" })),
201
+ label: Type.Optional(Type.String({ description: "Visible option text to select (exact match)" })),
202
+ index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based option index to select" })),
203
+ });
204
+
205
+ type SelectResult =
206
+ | { status: "ok"; value: string; label: string }
207
+ | { status: "not_found" }
208
+ | { status: "not_select"; tag: string }
209
+ | { status: "no_match"; options: ReadonlyArray<{ value: string; label: string }> };
210
+
211
+ // `this` is the <select>; `arg` is { wv, wl, wi } (null = not provided).
212
+ const SELECT_FN = `
213
+ const el = this;
214
+ if (el.tagName !== "SELECT") return { status: "not_select", tag: el.tagName };
215
+ const opts = Array.from(el.options);
216
+ const wv = arg.wv, wl = arg.wl, wi = arg.wi;
217
+ let opt = null;
218
+ if (wi !== null) opt = opts[wi] || null;
219
+ else if (wv !== null) opt = opts.find(o => o.value === wv) || null;
220
+ else if (wl !== null) opt = opts.find(o => o.text === wl) || null;
221
+ if (!opt) return { status: "no_match", options: opts.map(o => ({ value: o.value, label: o.text })) };
222
+ el.value = opt.value;
223
+ el.dispatchEvent(new Event("input", { bubbles: true }));
224
+ el.dispatchEvent(new Event("change", { bubbles: true }));
225
+ return { status: "ok", value: opt.value, label: opt.text };
226
+ `;
227
+
228
+ export const selectOptionTool = defineBrowserTool({
229
+ name: "browser_select_option",
230
+ label: "Browser Select Option",
231
+ description:
232
+ "Select an option in a native <select> element by value, visible label, or index. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`. Sets the selection and fires bubbling 'input'/'change' events so framework listeners update. On no match, returns the available options.",
233
+ promptSnippet: "Select an option in a native <select> by ref (preferred) or selector",
234
+ promptGuidelines: [
235
+ "PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders.",
236
+ "Provide exactly one of value, label, or index to choose the option.",
237
+ "For custom (non-native) dropdowns built from divs, click to open then browser_click the option instead.",
238
+ "If no option matches, the error details list the available options so you can retry.",
239
+ ],
240
+ parameters: SelectOptionArgs,
241
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
242
+ if (args.value === undefined && args.label === undefined && args.index === undefined) {
243
+ return err({ kind: "invalid_state", message: "Provide one of value, label, or index." });
244
+ }
245
+ // null sentinels distinguish "not provided" from a real empty-string match.
246
+ const arg = { wv: args.value ?? null, wl: args.label ?? null, wi: args.index ?? null };
247
+ const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: SELECT_FN, arg });
248
+ if (!r.success) return r;
249
+ const res = r.data as SelectResult | undefined;
250
+ const target = args.ref ?? args.selector ?? "";
251
+ if (res === undefined || res.status === "not_found") {
252
+ return err({ kind: "invalid_state", message: `No element matched: ${target}`, details: { ref: args.ref, selector: args.selector } });
253
+ }
254
+ if (res.status === "not_select") {
255
+ return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> is not a <select>: ${target}`, details: { ref: args.ref, selector: args.selector, tag: res.tag } });
256
+ }
257
+ if (res.status === "no_match") {
258
+ return err({
259
+ kind: "invalid_state",
260
+ message: `No matching option in ${target}. Available: ${res.options.map((o) => o.label).join(", ")}`,
261
+ details: { ref: args.ref, selector: args.selector, options: res.options },
262
+ });
263
+ }
264
+ const diff = await interactiveDiff(client);
265
+ return ok({
266
+ text: `Selected "${res.label}" (value=${JSON.stringify(res.value)}) in ${target}${diff}`,
267
+ details: { ref: args.ref, selector: args.selector, value: res.value, label: res.label },
268
+ });
269
+ },
270
+ });
@@ -3,6 +3,7 @@ import { safeJs } from "../util/js-template";
3
3
  import { virtualKeyCode } from "../util/keycodes";
4
4
  import { type Result, err, ok } from "../util/result";
5
5
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
6
+ import { resolveRefToObjectId } from "./ref-resolve";
6
7
 
7
8
  const TypeArgs = Type.Object({
8
9
  text: Type.String({ description: "Text to type" }),
@@ -19,6 +20,29 @@ export const typeTool = defineBrowserTool({
19
20
  ],
20
21
  parameters: TypeArgs,
21
22
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
23
+ // Guard against silent text loss: Input.insertText succeeds regardless of
24
+ // whether an editable element is focused, so without this check typed text
25
+ // can vanish into the void with no error. Fail loudly with an actionable
26
+ // message instead. IIFE starts with "(" so evaluateJs returns it directly.
27
+ const focused = await client.evaluateJs(safeJs`
28
+ (() => {
29
+ const el = document.activeElement;
30
+ const ok = !!el && el !== document.body &&
31
+ (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
32
+ return { ok, tag: el ? el.tagName : null };
33
+ })()
34
+ `);
35
+ if (focused.success) {
36
+ const f = focused.data as { ok: boolean; tag: string | null };
37
+ if (!f.ok) {
38
+ return err({
39
+ kind: "invalid_state",
40
+ message:
41
+ "No editable element is focused — typed text would be lost. Click the field with browser_click first, or use browser_fill with a selector.",
42
+ details: { activeElement: f.tag },
43
+ });
44
+ }
45
+ }
22
46
  const r = await client.session().call("Input.insertText", { text: args.text });
23
47
  if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
24
48
  return ok({ text: `Typed: "${args.text}"` });
@@ -67,6 +91,14 @@ export const pressKeyTool = defineBrowserTool({
67
91
  };
68
92
  const down = await client.session().call("Input.dispatchKeyEvent", downParams);
69
93
  if (!down.success) return err({ kind: "cdp_error", message: down.error.message });
94
+ // For a printable character with no command modifier (Ctrl/Meta/Alt = 1|2|4),
95
+ // emit a `char` event so the page receives keypress/textInput and the
96
+ // character is actually inserted. Shift (8) is allowed (capitals/symbols).
97
+ const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
98
+ if (isChar && !hasCommandModifier) {
99
+ const charEv = await client.session().call("Input.dispatchKeyEvent", { type: "char", text: k, key: k });
100
+ if (!charEv.success) return err({ kind: "cdp_error", message: charEv.error.message });
101
+ }
70
102
  const up = await client.session().call("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, modifiers });
71
103
  if (!up.success) return err({ kind: "cdp_error", message: up.error.message });
72
104
  return ok({ text: `Pressed: ${k}${modifiers ? ` (modifiers=${modifiers})` : ""}` });
@@ -74,7 +106,10 @@ export const pressKeyTool = defineBrowserTool({
74
106
  });
75
107
 
76
108
  const DispatchKeyArgs = Type.Object({
77
- selector: Type.String({ description: "CSS selector of the target element" }),
109
+ ref: Type.Optional(
110
+ Type.String({ description: "Stable element ref from browser_snapshot (e.g. 'e7'). PREFERRED over selector." }),
111
+ ),
112
+ selector: Type.Optional(Type.String({ description: "CSS selector of the target element (fallback when no ref)" })),
78
113
  key: Type.String({ description: "Key value (e.g., 'Enter', 'a')" }),
79
114
  eventType: Type.Optional(
80
115
  Type.Union(
@@ -88,17 +123,38 @@ export const dispatchKeyTool = defineBrowserTool({
88
123
  name: "browser_dispatch_key",
89
124
  label: "Browser Dispatch Key",
90
125
  description:
91
- "Dispatch a DOM KeyboardEvent on a specific element via JS injection. Use for React/Vue components that listen to synthetic events more reliably than CDP input.",
92
- promptSnippet: "Dispatch a DOM KeyboardEvent on a specific element",
126
+ "Dispatch a DOM KeyboardEvent on a specific element via JS injection. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`. Use for React/Vue components that listen to synthetic events more reliably than CDP input.",
127
+ promptSnippet: "Dispatch a DOM KeyboardEvent on an element by ref (preferred) or selector",
93
128
  promptGuidelines: [
94
- "Dispatches a synthetic DOM KeyboardEvent — for React/Vue synthetic event listeners. Does NOT insert text into inputs (use browser_type or browser_press_key for actual typing).",
129
+ "PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders.",
130
+ "Dispatches a synthetic DOM KeyboardEvent — for React/Vue synthetic event listeners. Does NOT insert text (use browser_type or browser_press_key for actual typing).",
95
131
  "Try browser_press_key first; only use browser_dispatch_key when the page ignores raw CDP key events.",
96
- "The selector must match exactly one or more elements; zero matches is reported as an error.",
97
132
  "eventType defaults to 'keydown'.",
98
133
  ],
99
134
  parameters: DispatchKeyArgs,
100
135
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
101
136
  const eventType = args.eventType ?? "keydown";
137
+ const target = args.ref ?? args.selector ?? "";
138
+ // Ref path: dispatch on the single resolved node. Selector path: dispatch on
139
+ // all matches (preserves the original multi-match behavior).
140
+ if (args.ref !== undefined) {
141
+ const objectId = await resolveRefToObjectId(client, args.ref);
142
+ if (!objectId.success) return objectId;
143
+ const r = await client.session().call("Runtime.callFunctionOn", {
144
+ objectId: objectId.data,
145
+ functionDeclaration: `function (type, key) { this.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true })); return 1; }`,
146
+ arguments: [{ value: eventType }, { value: args.key }],
147
+ returnByValue: true,
148
+ });
149
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
150
+ return ok({
151
+ text: `Dispatched ${eventType}('${args.key}') on ${target}`,
152
+ details: { matched: 1, ref: args.ref },
153
+ });
154
+ }
155
+ if (args.selector === undefined) {
156
+ return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
157
+ }
102
158
  const expr = safeJs`
103
159
  (() => {
104
160
  const els = document.querySelectorAll(${args.selector});
@@ -86,3 +86,48 @@ export const waitForLoadTool = defineBrowserTool({
86
86
  return err({ kind: "timeout", message: `Page did not finish loading in ${args.timeout ?? 15}s` });
87
87
  },
88
88
  });
89
+
90
+ const WaitForArgs = Type.Object({
91
+ selector: Type.Optional(Type.String({ description: "CSS selector to wait for (or wait to disappear with gone:true)" })),
92
+ text: Type.Optional(Type.String({ description: "Wait until this text appears anywhere in document.body" })),
93
+ gone: Type.Optional(Type.Boolean({ default: false, description: "If true with a selector, wait until it is absent instead of present" })),
94
+ timeout: Type.Optional(Type.Number({ default: 5, minimum: 1, maximum: 60, description: "Max seconds to wait. Default: 5." })),
95
+ });
96
+
97
+ export const waitForTool = defineBrowserTool({
98
+ name: "browser_wait_for",
99
+ label: "Browser Wait For",
100
+ description:
101
+ "Poll until a condition holds: a selector appears (or disappears with gone:true), or text appears in the page. Returns a typed timeout error if the deadline elapses. Use to wait for SPA content to render before interacting.",
102
+ promptSnippet: "Wait for an element or text to appear (or an element to disappear)",
103
+ promptGuidelines: [
104
+ "Use before browser_fill / browser_click on dynamic pages so you act after the element renders.",
105
+ "Provide selector (default: wait until present; gone:true: wait until absent) or text.",
106
+ "Returns a typed timeout error if the condition isn't met within the timeout.",
107
+ ],
108
+ parameters: WaitForArgs,
109
+ async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
110
+ if (args.selector === undefined && args.text === undefined) {
111
+ return err({ kind: "invalid_state", message: "Provide a selector or text to wait for." });
112
+ }
113
+ const gone = args.gone ?? false;
114
+ const timeoutMs = (args.timeout ?? 5) * 1000;
115
+ const expr =
116
+ args.selector !== undefined
117
+ ? safeJs`(() => { const found = !!document.querySelector(${args.selector}); return ${gone} ? !found : found; })()`
118
+ : safeJs`(() => { const t = document.body ? document.body.innerText : ""; return t.includes(${args.text}); })()`;
119
+ const start = Date.now();
120
+ while (Date.now() - start < timeoutMs) {
121
+ if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
122
+ const r = await client.evaluateJs(expr);
123
+ if (r.success && r.data === true) {
124
+ const ms = Date.now() - start;
125
+ const what = args.selector !== undefined ? `${args.selector}${gone ? " gone" : ""}` : `text "${args.text}"`;
126
+ return ok({ text: `Condition met (${what}) in ${Math.round(ms / 100) / 10}s`, details: { ms } });
127
+ }
128
+ await sleep(50, signal);
129
+ }
130
+ const what = args.selector !== undefined ? `${args.selector}${gone ? " to disappear" : ""}` : `text "${args.text}"`;
131
+ return err({ kind: "timeout", message: `Timed out after ${args.timeout ?? 5}s waiting for ${what}` });
132
+ },
133
+ });
@@ -0,0 +1,152 @@
1
+ import type { BrowserClient } from "../client";
2
+ import { type Result, err, ok } from "../util/result";
3
+ import type { ToolErr } from "../util/tool";
4
+ import { buildTree, collectInteractiveTargets, type RawAxNode, type SlimNode } from "./snapshot";
5
+
6
+ type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
7
+
8
+ const staleErr = (ref: string): ToolErr => ({
9
+ kind: "invalid_state",
10
+ message: `Ref ${ref} is unknown or stale — re-run browser_snapshot to get fresh refs.`,
11
+ details: { ref },
12
+ });
13
+
14
+ /**
15
+ * Resolve a ref to its CDP objectId (a handle to the live JS node). Use when a
16
+ * tool needs to run JS against the element (fill, focus, select, dispatch_key).
17
+ * Re-resolves through the backendNodeId at call time, so it reflects the current
18
+ * DOM even after re-renders — and fails loudly if the node was detached.
19
+ */
20
+ export const resolveRefToObjectId = async (
21
+ client: BrowserClient,
22
+ ref: string,
23
+ ): Promise<Result<string, ToolErr>> => {
24
+ const backendId = client.session().resolveRef(ref);
25
+ if (backendId === undefined) return err(staleErr(ref));
26
+ const resolved = await client.session().call("DOM.resolveNode", { backendNodeId: backendId });
27
+ if (!resolved.success) {
28
+ // A detached node (removed by a re-render) fails to resolve — surface it as
29
+ // a stale ref rather than a raw CDP error so the agent knows to re-snapshot.
30
+ return err(staleErr(ref));
31
+ }
32
+ const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
33
+ if (!objectId) return err(staleErr(ref));
34
+ return ok(objectId);
35
+ };
36
+
37
+ /** Resolve a ref to its current backendNodeId (for CDP calls that take one, e.g. DOM.setFileInputFiles). */
38
+ export const resolveRefToBackendId = (client: BrowserClient, ref: string): Result<number, ToolErr> => {
39
+ const backendId = client.session().resolveRef(ref);
40
+ if (backendId === undefined) return err(staleErr(ref));
41
+ return ok(backendId);
42
+ };
43
+
44
+ /**
45
+ * Resolve a ref to the current center of its bounding box, in viewport CSS
46
+ * pixels. Use for browser_click. Fetched fresh via DOM.getBoxModel, so a
47
+ * re-rendered/moved element is clicked at its new position.
48
+ */
49
+ export const resolveRefToBox = async (
50
+ client: BrowserClient,
51
+ ref: string,
52
+ ): Promise<Result<Box, ToolErr>> => {
53
+ const backendId = client.session().resolveRef(ref);
54
+ if (backendId === undefined) return err(staleErr(ref));
55
+ const r = await client.session().call("DOM.getBoxModel", { backendNodeId: backendId });
56
+ if (!r.success) return err(staleErr(ref));
57
+ const data = r.data as { model?: { content?: ReadonlyArray<number>; width?: number; height?: number } };
58
+ const content = data.model?.content;
59
+ if (!content || content.length < 8) return err(staleErr(ref));
60
+ const x = content[0]!;
61
+ const y = content[1]!;
62
+ const width = data.model!.width ?? 0;
63
+ const height = data.model!.height ?? 0;
64
+ if (width <= 0 || height <= 0) return err(staleErr(ref));
65
+ return ok({
66
+ x: Math.round(x),
67
+ y: Math.round(y),
68
+ width: Math.round(width),
69
+ height: Math.round(height),
70
+ cx: Math.round(x + width / 2),
71
+ cy: Math.round(y + height / 2),
72
+ });
73
+ };
74
+
75
+ const sigOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}|${n.value ?? ""}|${n.state ?? ""}`;
76
+ const keyOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}`;
77
+
78
+ /**
79
+ * Recompute the interactive elements after a mutation and return a compact,
80
+ * human/LLM-readable diff vs. the prior snapshot's baseline — changed values,
81
+ * newly-appeared elements (marked `*`), and removed ones. Re-publishes the fresh
82
+ * ref map so subsequent ref-based calls target the post-mutation DOM.
83
+ *
84
+ * Deliberately cheap: it re-reads the AX tree (no per-node box fetch — the
85
+ * expensive part of a full snapshot) and emits a few lines, not a full tree, so
86
+ * a long action sequence doesn't blow up the context.
87
+ *
88
+ * Returns "" on any failure or when nothing changed — callers append it only if
89
+ * non-empty, so a diff is best-effort and never blocks the primary action.
90
+ */
91
+ export const interactiveDiff = async (client: BrowserClient): Promise<string> => {
92
+ const session = client.session();
93
+ const prev = session.refSignatures();
94
+
95
+ const axRes = await session.call("Accessibility.getFullAXTree", {});
96
+ if (!axRes.success) return "";
97
+ const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
98
+ const slim = buildTree(rawNodes, { interestingOnly: true, maxNodes: 1000 });
99
+ const targets = collectInteractiveTargets(slim);
100
+
101
+ // Re-publish fresh refs so later ref calls resolve against the new DOM.
102
+ const refMap = new Map<string, number>();
103
+ const refSig = new Map<string, string>();
104
+ targets.forEach(({ node, backendId }, i) => {
105
+ const ref = `e${i + 1}`;
106
+ node.ref = ref;
107
+ refMap.set(ref, backendId);
108
+ refSig.set(ref, sigOf(node));
109
+ });
110
+ session.setRefMap(refMap, refSig);
111
+
112
+ // Compare by stable identity (role|name) rather than ref position, since refs
113
+ // renumber when the element set changes. Build prev/current identity → value.
114
+ const prevByKey = new Map<string, string>();
115
+ for (const sig of prev.values()) {
116
+ const parts = sig.split("|");
117
+ prevByKey.set(`${parts[0]}|${parts[1]}`, sig);
118
+ }
119
+ const curByKey = new Map<string, { ref: string; sig: string }>();
120
+ for (const { node } of targets) {
121
+ if (node.ref) curByKey.set(keyOf(node), { ref: node.ref, sig: sigOf(node) });
122
+ }
123
+
124
+ const appeared: string[] = [];
125
+ const changed: string[] = [];
126
+ for (const [key, { ref, sig }] of curByKey) {
127
+ const before = prevByKey.get(key);
128
+ if (before === undefined) {
129
+ appeared.push(` *[${ref}] ${key.replace("|", ' "')}"`);
130
+ } else if (before !== sig) {
131
+ const va = before.split("|")[2] ?? "";
132
+ const vb = sig.split("|")[2] ?? "";
133
+ const sa = before.split("|")[3] ?? "";
134
+ const sb = sig.split("|")[3] ?? "";
135
+ const detail = va !== vb ? `value ${JSON.stringify(va)} → ${JSON.stringify(vb)}` : `state ${JSON.stringify(sa)} → ${JSON.stringify(sb)}`;
136
+ changed.push(` [${ref}] ${key.replace("|", ' "')}": ${detail}`);
137
+ }
138
+ }
139
+ const removed: string[] = [];
140
+ for (const key of prevByKey.keys()) {
141
+ if (!curByKey.has(key)) removed.push(` ${key.replace("|", ' "')}"`);
142
+ }
143
+
144
+ if (appeared.length === 0 && changed.length === 0 && removed.length === 0) return "";
145
+ const lines: string[] = ["", "Page changes (re-snapshot for full tree):"];
146
+ if (changed.length) lines.push("Changed:", ...changed.slice(0, 12));
147
+ if (appeared.length) lines.push("New (*):", ...appeared.slice(0, 12));
148
+ if (removed.length) lines.push("Removed:", ...removed.slice(0, 8));
149
+ const overflow = Math.max(0, changed.length - 12) + Math.max(0, appeared.length - 12) + Math.max(0, removed.length - 8);
150
+ if (overflow > 0) lines.push(` …and ${overflow} more (browser_snapshot for full state)`);
151
+ return lines.join("\n");
152
+ };