pi-browser-harness 0.14.1 → 0.15.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 +17 -0
- package/README.md +8 -7
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +1 -2
- package/src/cdp/commands.ts +28 -0
- package/src/client.ts +10 -3
- package/src/daemon/bridge.ts +4 -3
- package/src/daemon/protocol.ts +11 -0
- package/src/daemon/transport.ts +1 -0
- package/src/domains/cdp-call.ts +2 -1
- package/src/domains/click.ts +5 -2
- package/src/domains/form.ts +40 -31
- package/src/domains/js.ts +20 -2
- package/src/domains/keyboard.ts +171 -35
- package/src/domains/page/click.page.ts +22 -0
- package/src/domains/page/fill.page.ts +70 -1
- package/src/domains/page.ts +1 -1
- package/src/domains/screenshot.ts +21 -1
- package/src/domains/snapshot.ts +1 -1
- package/src/domains/target.ts +83 -7
- package/src/recipes/recordable.ts +4 -3
- package/src/registry.ts +1 -2
- package/src/util/image-size.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.15.1 — 2026-11-07
|
|
6
|
+
|
|
7
|
+
- Re-release of 0.15.0, whose npm publish failed (the provenance log returned 502, then rejected the retry as a duplicate). No code changes.
|
|
8
|
+
|
|
9
|
+
## 0.15.0 — 2026-11-07
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **`browser_fill` and `browser_type` are one tool: `browser_type`.** Pass a `ref` (or `selector`) and it focuses the field, replaces its value (`clear:false` appends; `text: ""` empties it), types with real input events so autocomplete lists open, and reports the value read back. When typing does not produce the value (date or masked inputs) it sets the value directly and says so. With no ref it types into the focused element, appending as before. Saved recipes with `fill` steps still replay.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`browser_click` no longer clicks into nothing.** A ref is scrolled into view (nested scrolling lists included) and measured at click time; a point still outside the viewport, or an `x`/`y` outside it, is refused with the reason. When something covers the element (an overlay, a sticky header) the result says what was clicked instead. An `x`/`y` click names the element it landed on.
|
|
18
|
+
- **`browser_press_key` runs Cmd/Ctrl shortcuts.** Cmd/Ctrl + A, C, X, V, Z, Y now carry Chrome's editing command, so select-all, copy, paste and undo work. Letter keys send the right `code` (`KeyA`) and key code (65).
|
|
19
|
+
- **`browser_execute_js` waits up to 30s by default, and `timeoutMs` (max 120s) raises it.** It used to stop at 10s, mid-way through scripts that loop with waits. A timeout now says the script may have partly run.
|
|
20
|
+
- **`browser_screenshot` says how its pixels map to click coordinates** when the image is not 1:1 with CSS pixels (2× on Retina, or after `maxDim`).
|
|
21
|
+
|
|
5
22
|
## 0.14.1 — 2026-11-07
|
|
6
23
|
|
|
7
24
|
### Fixed
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ no MCP hop, no second browser, no extra API keys.
|
|
|
18
18
|
| **Browser** | Your running Chrome — real profile, live sessions | Launches a clean instance | Launches a clean instance | Launches a clean instance |
|
|
19
19
|
| **Element targeting** | AX-tree refs (`[eN]`) that re-resolve at action time; coordinate and selector fallbacks | Selectors / AX locators | CSS selectors | LLM-inferred selectors |
|
|
20
20
|
| **Input dispatch** | Compositor-level CDP input — works through iframes, shadow DOM, cross-origin frames | DOM-level | DOM-level | DOM-level |
|
|
21
|
-
| **Framework-safe writes** | `
|
|
21
|
+
| **Framework-safe writes** | `browser_type` types with real input events, reads the value back, and falls back to the native setter when typing doesn't take | Native fill | Manual | Native fill |
|
|
22
22
|
| **Transport** | Native pi extension, in-process | MCP / JSON-RPC round trip | MCP / JSON-RPC round trip | In-process + its own LLM calls |
|
|
23
23
|
| **Extra credentials** | None | None | None | Its own model API key |
|
|
24
24
|
| **Concurrency** | Reads run in parallel; mutations auto-serialize through a shared mutex | Sequential | Sequential | Sequential |
|
|
@@ -76,10 +76,12 @@ interactive element carries a stable ref (`[eN]`) plus click coordinates `@(x,y)
|
|
|
76
76
|
[e12] button "Create account" @(456,412)
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
Pass the ref — not the coordinates — to `browser_click`, `
|
|
79
|
+
Pass the ref — not the coordinates — to `browser_click`, `browser_type`, `browser_select_option`,
|
|
80
80
|
`browser_focus`, `browser_upload_file`, or `browser_dispatch_key`. Refs are keyed to element
|
|
81
81
|
identity, so they re-resolve position at action time and survive the re-renders that SPA forms
|
|
82
|
-
trigger on every keystroke. Coordinates go stale the moment the layout reflows.
|
|
82
|
+
trigger on every keystroke. Coordinates go stale the moment the layout reflows. `browser_click` by ref
|
|
83
|
+
scrolls the element into view first (inside scrolling lists too) and warns when something covers it;
|
|
84
|
+
an `x`/`y` outside the viewport is refused instead of clicking nothing.
|
|
83
85
|
|
|
84
86
|
Three things follow from that:
|
|
85
87
|
|
|
@@ -127,10 +129,9 @@ Both run in isolated tabs and never disturb your current page.
|
|
|
127
129
|
| Tool | Purpose |
|
|
128
130
|
|---|---|
|
|
129
131
|
| `browser_click` | Click by `ref` (preferred) or viewport `x`/`y`. Compositor-level, so iframes and shadow DOM work. |
|
|
130
|
-
| `browser_fill` | Set an input, textarea, or contenteditable. Fires bubbling `input`/`change`, returns the value written. |
|
|
131
132
|
| `browser_select_option` | Choose in a native `<select>` by value, label, or index. Returns available options on no match. |
|
|
132
133
|
| `browser_focus` | Deterministic `.focus()` — no coordinate accuracy needed. |
|
|
133
|
-
| `browser_type` |
|
|
134
|
+
| `browser_type` | Enter text into a field by `ref`/selector (replaces its value; `clear:false` appends) or into the focused element. Real input events, so autocomplete opens; returns the value read back. |
|
|
134
135
|
| `browser_press_key` | Press a key with an optional modifier bitfield. |
|
|
135
136
|
| `browser_dispatch_key` | Synthetic DOM `KeyboardEvent` on one element, for pages that ignore raw CDP keys. |
|
|
136
137
|
| `browser_scroll` | Scroll by delta. W3C wheel convention: positive `deltaY` = down. |
|
|
@@ -202,7 +203,7 @@ Wait, snapshot once, then act by ref. No screenshots anywhere in this loop.
|
|
|
202
203
|
```
|
|
203
204
|
browser_wait_for({ selector: "#email" })
|
|
204
205
|
browser_snapshot() // → [e7] textbox, [e9] combobox, [e12] button
|
|
205
|
-
|
|
206
|
+
browser_type({ ref: "e7", text: "a@b.com" })
|
|
206
207
|
browser_select_option({ ref: "e9", label: "India" })
|
|
207
208
|
browser_click({ ref: "e12" }) // re-resolves position even after reflow
|
|
208
209
|
```
|
|
@@ -352,7 +353,7 @@ re-runnable.
|
|
|
352
353
|
|
|
353
354
|
Another extension can add its own `browser_*` tools to this harness. Contributed tools share the
|
|
354
355
|
same client, mutation mutex, tab ownership, and lane prompt as the built-ins, so a `serialized` tool
|
|
355
|
-
you add queues behind `browser_click` exactly as `
|
|
356
|
+
you add queues behind `browser_click` exactly as `browser_type` does.
|
|
356
357
|
|
|
357
358
|
Define the tool with `defineBrowserTool` and hand it to `registerBrowserTools` at your extension's
|
|
358
359
|
load time, before pi emits `session_start`. The harness registers it with pi on the next
|
package/package.json
CHANGED
|
@@ -61,7 +61,6 @@ Generated from `src/registry.ts` by `npm run gen:skill`; do not edit by hand.
|
|
|
61
61
|
| `browser_download` | serialized | Configure download directory |
|
|
62
62
|
| `browser_drag_and_drop` | serialized | Drag and drop between two coordinates |
|
|
63
63
|
| `browser_execute_js` | parallel | Execute JS in the page — surgical DOM reads (preferred over screenshot) |
|
|
64
|
-
| `browser_fill` | serialized | Fill a form field by ref (preferred) or selector (works with React/Vue controlled inputs) |
|
|
65
64
|
| `browser_fill_form` | serialized | Fill many form fields at once by ref — React-safe, self-confirming |
|
|
66
65
|
| `browser_focus` | serialized | Focus an element by ref (preferred) or selector |
|
|
67
66
|
| `browser_go_back` | serialized | Go back one page |
|
|
@@ -89,7 +88,7 @@ Generated from `src/registry.ts` by `npm run gen:skill`; do not edit by hand.
|
|
|
89
88
|
| `browser_setup` | parallel | Initialize browser connection |
|
|
90
89
|
| `browser_snapshot` | parallel | Get accessibility-tree snapshot with stable element refs (default for page inspection) |
|
|
91
90
|
| `browser_switch_tab` | serialized | Switch tabs |
|
|
92
|
-
| `browser_type` | serialized | Type text into the focused element |
|
|
91
|
+
| `browser_type` | serialized | Type text into a field by ref (preferred), selector, or the focused element |
|
|
93
92
|
| `browser_upload_file` | serialized | Upload a file to a file input by ref (preferred) or selector |
|
|
94
93
|
| `browser_viewport_resize` | serialized | Resize the viewport (responsive testing) |
|
|
95
94
|
| `browser_wait` | parallel | Wait N seconds |
|
package/src/cdp/commands.ts
CHANGED
|
@@ -271,6 +271,18 @@ export const COMMANDS = {
|
|
|
271
271
|
}),
|
|
272
272
|
Type.Object({ data: Type.String(), stream: Type.Optional(Type.String()) }),
|
|
273
273
|
),
|
|
274
|
+
"Page.getLayoutMetrics": cmd(
|
|
275
|
+
Empty,
|
|
276
|
+
Type.Object(
|
|
277
|
+
{
|
|
278
|
+
cssLayoutViewport: Type.Object(
|
|
279
|
+
{ clientWidth: Type.Number(), clientHeight: Type.Number() },
|
|
280
|
+
{ additionalProperties: true },
|
|
281
|
+
),
|
|
282
|
+
},
|
|
283
|
+
{ additionalProperties: true },
|
|
284
|
+
),
|
|
285
|
+
),
|
|
274
286
|
"Page.handleJavaScriptDialog": cmd(
|
|
275
287
|
Type.Object({ accept: Type.Boolean(), promptText: Type.Optional(Type.String()) }),
|
|
276
288
|
Empty,
|
|
@@ -322,6 +334,22 @@ export const COMMANDS = {
|
|
|
322
334
|
),
|
|
323
335
|
}),
|
|
324
336
|
),
|
|
337
|
+
"DOM.scrollIntoViewIfNeeded": cmd(
|
|
338
|
+
Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
|
|
339
|
+
Empty,
|
|
340
|
+
),
|
|
341
|
+
"DOM.getNodeForLocation": cmd(
|
|
342
|
+
Type.Object({
|
|
343
|
+
x: Type.Integer(),
|
|
344
|
+
y: Type.Integer(),
|
|
345
|
+
includeUserAgentShadowDOM: Type.Optional(Type.Boolean()),
|
|
346
|
+
ignorePointerEventsNone: Type.Optional(Type.Boolean()),
|
|
347
|
+
}),
|
|
348
|
+
Type.Object(
|
|
349
|
+
{ backendNodeId: Type.Number(), frameId: Type.Optional(Type.String()), nodeId: Type.Optional(Type.Number()) },
|
|
350
|
+
{ additionalProperties: true },
|
|
351
|
+
),
|
|
352
|
+
),
|
|
325
353
|
"DOM.resolveNode": cmd(
|
|
326
354
|
Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
|
|
327
355
|
Type.Object({ object: RemoteObject }),
|
package/src/client.ts
CHANGED
|
@@ -59,7 +59,7 @@ export type BrowserClient = {
|
|
|
59
59
|
stop(): Promise<void>;
|
|
60
60
|
detach(): Promise<void>;
|
|
61
61
|
closeOwnedTabs(): Promise<void>;
|
|
62
|
-
evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
|
|
62
|
+
evaluateJs(expression: string, sessionId?: string, opts?: { readonly timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
63
63
|
pageInfo(): Promise<Result<PageInfoResult, CdpError>>;
|
|
64
64
|
takeDialog(): DialogInfo | null;
|
|
65
65
|
listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
|
|
@@ -260,9 +260,16 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
260
260
|
return running;
|
|
261
261
|
};
|
|
262
262
|
|
|
263
|
-
const evaluateJs = async (
|
|
263
|
+
const evaluateJs = async (
|
|
264
|
+
expression: string,
|
|
265
|
+
sessionId?: string,
|
|
266
|
+
opts?: { readonly timeoutMs?: number },
|
|
267
|
+
): Promise<Result<unknown, CdpError>> => {
|
|
264
268
|
const params = evaluateParams(expression);
|
|
265
|
-
const
|
|
269
|
+
const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
|
|
270
|
+
const r = sessionId
|
|
271
|
+
? await session.callOnTarget("Runtime.evaluate", params, sessionId, callOpts)
|
|
272
|
+
: await session.call("Runtime.evaluate", params, callOpts);
|
|
266
273
|
if (!r.success) return r;
|
|
267
274
|
if (r.data.exceptionDetails !== undefined) {
|
|
268
275
|
return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(r.data.exceptionDetails)}`, "Runtime.evaluate"));
|
package/src/daemon/bridge.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isCdpRawMessage } from "../cdp/types";
|
|
2
2
|
import type { CdpRawMessage } from "../cdp/types";
|
|
3
3
|
import type { WireRequest, WireResponse, WireEvent } from "./protocol";
|
|
4
|
-
import {
|
|
4
|
+
import { DAEMON_RECONNECT_METHOD, commandTimeoutFor } from "./protocol";
|
|
5
5
|
import { asString, isRecord } from "../util/guards";
|
|
6
6
|
import { type ChromeConnectionDependencies, type ConnectionStatus, createChromeConnection } from "./connection";
|
|
7
7
|
|
|
@@ -246,6 +246,7 @@ export const createCdpBridge = (options: ChromeConnectionDependencies = {}): Cdp
|
|
|
246
246
|
if (sessionId !== undefined) router.release(sessionId);
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
const timeoutMs = commandTimeoutFor(req);
|
|
249
250
|
const daemonId = mux.allocate(
|
|
250
251
|
{
|
|
251
252
|
clientId,
|
|
@@ -257,8 +258,8 @@ export const createCdpBridge = (options: ChromeConnectionDependencies = {}): Cdp
|
|
|
257
258
|
(id) =>
|
|
258
259
|
setTimeout(() => {
|
|
259
260
|
mux.take(id);
|
|
260
|
-
send(clientId, errorResponse(req.id, `Timeout after ${
|
|
261
|
-
},
|
|
261
|
+
send(clientId, errorResponse(req.id, `Timeout after ${timeoutMs}ms: ${req.method}`));
|
|
262
|
+
}, timeoutMs),
|
|
262
263
|
);
|
|
263
264
|
|
|
264
265
|
const payload: Record<string, unknown> = {
|
package/src/daemon/protocol.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type WireRequest = {
|
|
|
6
6
|
readonly method: string;
|
|
7
7
|
readonly params?: Record<string, unknown>;
|
|
8
8
|
readonly sessionId?: string;
|
|
9
|
+
// How long the daemon waits for Chrome's answer; see commandTimeoutFor.
|
|
10
|
+
readonly timeoutMs?: number;
|
|
9
11
|
};
|
|
10
12
|
|
|
11
13
|
export type WireResponse = {
|
|
@@ -71,6 +73,7 @@ const isWireMessage = (v: unknown): v is WireMessage => {
|
|
|
71
73
|
if (typeof v["id"] !== "number") return false;
|
|
72
74
|
if (typeof v["method"] !== "string") return false;
|
|
73
75
|
if (!isOptionalRecord(v["params"])) return false;
|
|
76
|
+
if (v["timeoutMs"] !== undefined && typeof v["timeoutMs"] !== "number") return false;
|
|
74
77
|
return isOptionalString(v["sessionId"]);
|
|
75
78
|
}
|
|
76
79
|
case "response": {
|
|
@@ -138,3 +141,11 @@ export const CDP_RECONNECT_COOLDOWN_MAX_MS = 5 * 60 * 1000;
|
|
|
138
141
|
export const DAEMON_RECONNECT_METHOD = "Daemon.reconnect";
|
|
139
142
|
|
|
140
143
|
export const CDP_COMMAND_TIMEOUT_MS = 10_000;
|
|
144
|
+
|
|
145
|
+
// The longest a caller may ask the daemon to wait for one command (browser_execute_js's timeoutMs).
|
|
146
|
+
export const CDP_COMMAND_TIMEOUT_MAX_MS = 120_000;
|
|
147
|
+
|
|
148
|
+
// A request may lengthen the daemon's wait (a long-running script) but never shorten it below the default: short
|
|
149
|
+
// client-side probes already give up on their own, and a shorter daemon timer would only race them.
|
|
150
|
+
export const commandTimeoutFor = (req: { readonly timeoutMs?: number }): number =>
|
|
151
|
+
Math.min(Math.max(req.timeoutMs ?? CDP_COMMAND_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS), CDP_COMMAND_TIMEOUT_MAX_MS);
|
package/src/daemon/transport.ts
CHANGED
|
@@ -194,6 +194,7 @@ export const createDaemonTransport = (clientId: string, socketPath: string = DAE
|
|
|
194
194
|
method,
|
|
195
195
|
params,
|
|
196
196
|
...(sessionId !== null ? { sessionId } : {}),
|
|
197
|
+
...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
197
198
|
};
|
|
198
199
|
|
|
199
200
|
return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n"));
|
package/src/domains/cdp-call.ts
CHANGED
|
@@ -55,8 +55,9 @@ export const evalJs = async (
|
|
|
55
55
|
client: BrowserClient,
|
|
56
56
|
expression: string,
|
|
57
57
|
sessionId?: string,
|
|
58
|
+
opts?: { readonly timeoutMs?: number },
|
|
58
59
|
): Promise<Result<unknown, ToolErr>> => {
|
|
59
|
-
const r = await client.evaluateJs(expression, sessionId);
|
|
60
|
+
const r = await client.evaluateJs(expression, sessionId, opts);
|
|
60
61
|
if (!r.success) return err(cdpErrToToolErr(r.error, "Runtime.evaluate"));
|
|
61
62
|
return r;
|
|
62
63
|
};
|
package/src/domains/click.ts
CHANGED
|
@@ -55,7 +55,7 @@ export const clickTool = defineBrowserTool({
|
|
|
55
55
|
name: "browser_click",
|
|
56
56
|
label: "Browser Click",
|
|
57
57
|
description:
|
|
58
|
-
"Click an element. PREFERRED: pass `ref` (e.g. 'e12') from browser_snapshot — it
|
|
58
|
+
"Click an element. PREFERRED: pass `ref` (e.g. 'e12') from browser_snapshot — it scrolls the element into view (inside scrolling lists too), measures it at click time, and warns if something covers it. Fallback: pass viewport CSS-pixel `x`/`y` (refused when outside the viewport; the result names the element that was hit). Compositor-level click works through iframes, shadow DOM, and cross-origin content. After clicking, a compact diff of page changes is appended.",
|
|
59
59
|
promptSnippet: "Click an element by ref (preferred) or pixel coordinates",
|
|
60
60
|
promptGuidelines: [
|
|
61
61
|
"If a click doesn't register, run pi with --browser-debug-clicks (or set BH_DEBUG_CLICKS=1) to get annotated screenshots. For React/Vue components ignoring clicks, try browser_dispatch_key.",
|
|
@@ -69,7 +69,10 @@ export const clickTool = defineBrowserTool({
|
|
|
69
69
|
|
|
70
70
|
const resolved = await resolveTarget(client, args);
|
|
71
71
|
if (!resolved.success) return resolved;
|
|
72
|
-
const { x, y, label
|
|
72
|
+
const { x, y, label, covered, hit } = resolved.data;
|
|
73
|
+
const target = label
|
|
74
|
+
+ (hit !== undefined ? ` on ${hit}` : "")
|
|
75
|
+
+ (covered !== undefined ? `\n⚠ The point was covered by ${covered}, so the click went to that element, not [${args.ref ?? ""}]. Close the overlay or scroll, then re-snapshot.` : "");
|
|
73
76
|
|
|
74
77
|
const clicked = await dispatchClick(client, x, y, button, count);
|
|
75
78
|
if (!clicked.success) return clicked;
|
package/src/domains/form.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { Type } from "typebox";
|
|
|
2
2
|
import { type Result, err, ok } from "../util/result";
|
|
3
3
|
import { defineBrowserTool, type JsonObject, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
4
|
import { detailsOf, type ElementOutcome, isPageResult, resolveAndCall, runOnElement } from "./element-call";
|
|
5
|
-
import { fillField, type FillOptions } from "./page/fill.page";
|
|
5
|
+
import { fillField, type FillOptions, type PageResult } from "./page/fill.page";
|
|
6
|
+
import type { BrowserClient } from "../client";
|
|
6
7
|
import { type FocusResult, focusElement, type SelectOption, type SelectResult, type SelectWanted, selectOption, setChecked } from "./page/form.page";
|
|
7
8
|
import { elementFunctionSource } from "./page/inject";
|
|
8
9
|
import { interactiveDiff } from "./ref-resolve";
|
|
@@ -56,45 +57,53 @@ const FillArgs = Type.Object({
|
|
|
56
57
|
const FILL_FN = elementFunctionSource(fillField);
|
|
57
58
|
const FILL_OPTIONS: FillOptions = { rejectSelect: true, focusFirst: false };
|
|
58
59
|
|
|
60
|
+
type FilledField = Extract<PageResult, { readonly ok: true }>;
|
|
61
|
+
|
|
62
|
+
// Writes `value` through the page's native value setter (the fill engine in page/fill.page.ts) and returns what the
|
|
63
|
+
// field holds afterwards. browser_type falls back to it when typing did not produce the value; recipe "fill" steps
|
|
64
|
+
// (recorded by the old browser_fill and by browser_fill_form) replay through it.
|
|
65
|
+
export const nativeFill = async (
|
|
66
|
+
client: BrowserClient,
|
|
67
|
+
t: Target,
|
|
68
|
+
value: string,
|
|
69
|
+
): Promise<Result<FilledField, ToolErr>> => {
|
|
70
|
+
const r = await runOnElement(client, { ref: t.ref, selector: t.selector, functionDeclaration: FILL_FN, args: [value, FILL_OPTIONS] });
|
|
71
|
+
if (!r.success) return r;
|
|
72
|
+
const narrowed = narrow(r.data, t, "browser_fill", isPageResult);
|
|
73
|
+
if (!narrowed.success) return narrowed;
|
|
74
|
+
const res = narrowed.data;
|
|
75
|
+
if (res.ok) return ok(res);
|
|
76
|
+
const target = targetLabel(t);
|
|
77
|
+
const details = { ...targetDetails(t), ...(res.tag !== undefined ? { tag: res.tag } : {}) };
|
|
78
|
+
if (res.code === "select_rejected") {
|
|
79
|
+
return err({
|
|
80
|
+
kind: "invalid_state",
|
|
81
|
+
message: `Element <select> is not fillable (not an input, textarea, or contenteditable): ${target}. For <select> use browser_select_option.`,
|
|
82
|
+
details,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return err({ kind: "invalid_state", message: `Could not fill ${target}: ${res.reason}`, details });
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Not registered for the agent any more (browser_type replaces it); kept so saved recipes' "fill" steps replay.
|
|
59
89
|
export const fillTool = defineBrowserTool({
|
|
60
90
|
name: "browser_fill",
|
|
61
91
|
label: "Browser Fill",
|
|
62
92
|
description:
|
|
63
|
-
"
|
|
64
|
-
promptSnippet: "
|
|
65
|
-
promptGuidelines: [
|
|
66
|
-
"Pass the desired value; no browser_click is needed first.",
|
|
67
|
-
"For a checkbox or radio pass 'true' or 'false' — it toggles `checked`, not the submit value. browser_set_checked does the same thing with a boolean.",
|
|
68
|
-
"Use browser_type instead only for keystroke-sensitive widgets (autocomplete, masked/segmented inputs).",
|
|
69
|
-
"Fill sets the value but does not focus or submit. To submit a tag/autocomplete input, follow with browser_dispatch_key({ ref, key: 'Enter' }) — not browser_press_key, which targets the focused element and may miss this field.",
|
|
70
|
-
"For a custom (div-based) dropdown, browser_fill won't validate against its option list — open it, run browser_snapshot, then browser_click the option's ref.",
|
|
71
|
-
"Confirm the field value browser_fill returns matches what you intended.",
|
|
72
|
-
],
|
|
93
|
+
"Recipe replay only: set a form field's value through the native setter, or tick a checkbox/radio with 'true'/'false'.",
|
|
94
|
+
promptSnippet: "Set a form field's value (recipe replay)",
|
|
95
|
+
promptGuidelines: [],
|
|
73
96
|
parameters: FillArgs,
|
|
74
97
|
concurrency: "serialized",
|
|
75
98
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
76
|
-
const
|
|
77
|
-
if (!
|
|
78
|
-
const
|
|
79
|
-
if (!narrowed.success) return narrowed;
|
|
80
|
-
const res = narrowed.data;
|
|
81
|
-
const target = targetLabel(args);
|
|
82
|
-
if (!res.ok) {
|
|
83
|
-
const details = { ref: args.ref, selector: args.selector, ...(res.tag !== undefined ? { tag: res.tag } : {}) };
|
|
84
|
-
if (res.code === "select_rejected") {
|
|
85
|
-
return err({
|
|
86
|
-
kind: "invalid_state",
|
|
87
|
-
message: `Element <select> is not fillable (not an input, textarea, or contenteditable): ${target}. For <select> use browser_select_option.`,
|
|
88
|
-
details,
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
return err({ kind: "invalid_state", message: `Could not fill ${target}: ${res.reason}`, details });
|
|
92
|
-
}
|
|
99
|
+
const filled = await nativeFill(client, args, args.value);
|
|
100
|
+
if (!filled.success) return filled;
|
|
101
|
+
const res = filled.data;
|
|
93
102
|
const verified = "checked" in res ? res.checked : res.value;
|
|
94
103
|
const shown = "checked" in res ? `checked=${res.checked}` : JSON.stringify(res.value);
|
|
95
104
|
const diff = await interactiveDiff(client);
|
|
96
105
|
return ok({
|
|
97
|
-
text: `Filled ${
|
|
106
|
+
text: `Filled ${targetLabel(args)} = ${shown}${diff}`,
|
|
98
107
|
details: { ...targetDetails(args), value: args.value, verified, tag: res.tag, kind: res.kind },
|
|
99
108
|
});
|
|
100
109
|
},
|
|
@@ -119,10 +128,10 @@ export const focusTool = defineBrowserTool({
|
|
|
119
128
|
name: "browser_focus",
|
|
120
129
|
label: "Browser Focus",
|
|
121
130
|
description:
|
|
122
|
-
"Focus an element via the DOM .focus() method — deterministic, no coordinate accuracy needed. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`.
|
|
131
|
+
"Focus an element via the DOM .focus() method — deterministic, no coordinate accuracy needed. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`.",
|
|
123
132
|
promptSnippet: "Focus an element by ref (preferred) or selector",
|
|
124
133
|
promptGuidelines: [
|
|
125
|
-
"
|
|
134
|
+
"To enter text, pass the ref straight to browser_type — it focuses the field itself.",
|
|
126
135
|
],
|
|
127
136
|
parameters: FocusArgs,
|
|
128
137
|
concurrency: "serialized",
|
package/src/domains/js.ts
CHANGED
|
@@ -40,6 +40,13 @@ const prettyJson = (text: string): string => {
|
|
|
40
40
|
const ExecuteJsArgs = Type.Object({
|
|
41
41
|
expression: Type.String({ description: "A JavaScript expression, or a function body: a body with a top-level `return` (after any statements) runs inside an async function and returns that value." }),
|
|
42
42
|
targetId: Type.Optional(Type.String({ description: "Optional iframe targetId; default = current page." })),
|
|
43
|
+
timeoutMs: Type.Optional(
|
|
44
|
+
Type.Integer({
|
|
45
|
+
minimum: 1_000,
|
|
46
|
+
maximum: 120_000,
|
|
47
|
+
description: "How long to wait for the result (an awaited promise included). Default 30000, max 120000. Raise it for scripts that loop with waits.",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
43
50
|
});
|
|
44
51
|
|
|
45
52
|
const ExecuteJsDetailsSchema = Type.Object({
|
|
@@ -51,6 +58,8 @@ const ExecuteJsDetailsSchema = Type.Object({
|
|
|
51
58
|
|
|
52
59
|
export type ExecuteJsDetails = DetailsOf<typeof ExecuteJsDetailsSchema>;
|
|
53
60
|
|
|
61
|
+
const EXECUTE_JS_DEFAULT_TIMEOUT_MS = 30_000;
|
|
62
|
+
|
|
54
63
|
export const executeJsTool = defineBrowserTool({
|
|
55
64
|
name: "browser_execute_js",
|
|
56
65
|
label: "Browser Execute JS",
|
|
@@ -63,13 +72,22 @@ export const executeJsTool = defineBrowserTool({
|
|
|
63
72
|
"For 'is this element visible / enabled / checked / focused', a one-liner here beats a screenshot every time.",
|
|
64
73
|
"For iframes, first run `Object.values(document.querySelectorAll('iframe'))` to find the iframe, then pass its targetId.",
|
|
65
74
|
"Result must be JSON-serializable (Runtime.evaluate returnByValue=true).",
|
|
75
|
+
"A script that awaits (loops with setTimeout, waits for UI) gets 30s by default; pass timeoutMs (max 120000) for longer ones.",
|
|
66
76
|
],
|
|
67
77
|
parameters: ExecuteJsArgs,
|
|
68
78
|
details: ExecuteJsDetailsSchema,
|
|
69
79
|
concurrency: "parallel",
|
|
70
80
|
async handler(args, { client }): Promise<Result<ToolOk<ExecuteJsDetails>, ToolErr>> {
|
|
71
|
-
const
|
|
72
|
-
|
|
81
|
+
const timeoutMs = args.timeoutMs ?? EXECUTE_JS_DEFAULT_TIMEOUT_MS;
|
|
82
|
+
const r = await evalJs(client, args.expression, args.targetId, { timeoutMs });
|
|
83
|
+
if (!r.success) {
|
|
84
|
+
if (r.error.kind !== "timeout" && !/timeout/i.test(r.error.message)) return r;
|
|
85
|
+
return err({
|
|
86
|
+
kind: "timeout",
|
|
87
|
+
message: `${r.error.message}. The script may have partly run before the wait ran out — check the page's state before retrying. Pass a larger timeoutMs (max 120000) or split the work into several calls.`,
|
|
88
|
+
details: { ...(r.error.details ?? {}), timeoutMs },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
73
91
|
const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data);
|
|
74
92
|
// The full value lives in the text (and in fullOutputPath when truncated); details carry only what the
|
|
75
93
|
// renderer needs so a large result is not persisted twice.
|
package/src/domains/keyboard.ts
CHANGED
|
@@ -7,6 +7,11 @@ import { cdpCall, evalJs } from "./cdp-call";
|
|
|
7
7
|
import { runOnElement } from "./element-call";
|
|
8
8
|
import { elementFunctionSource } from "./page/inject";
|
|
9
9
|
import { dispatchKeyEvent, type KeyEventSpec } from "./page/keyboard.page";
|
|
10
|
+
import { prepareTyping, readTypedValue, type TypePrep, type TypedValue } from "./page/fill.page";
|
|
11
|
+
import { nativeFill } from "./form";
|
|
12
|
+
import { interactiveDiff } from "./ref-resolve";
|
|
13
|
+
import { isRecord } from "../util/guards";
|
|
14
|
+
import type { BrowserClient } from "../client";
|
|
10
15
|
|
|
11
16
|
const DISPATCH_KEY_FN = elementFunctionSource(dispatchKeyEvent);
|
|
12
17
|
|
|
@@ -19,53 +24,180 @@ const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = {
|
|
|
19
24
|
F7: 118, F8: 119, F9: 120, F10: 121, F11: 122, F12: 123,
|
|
20
25
|
};
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
// Windows virtual-key codes for letters and digits are the upper-case ASCII code ("a" and "A" are both 65).
|
|
28
|
+
export const virtualKeyCode = (key: string): number => {
|
|
29
|
+
const named = VIRTUAL_KEY_CODES[key];
|
|
30
|
+
if (named !== undefined) return named;
|
|
31
|
+
if (/^[a-z0-9]$/i.test(key)) return key.toUpperCase().charCodeAt(0);
|
|
32
|
+
return key.length === 1 ? key.charCodeAt(0) : 0;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// KeyboardEvent.code: the physical key. Pages that check `code` (shortcut handlers) need "KeyA", not "a".
|
|
36
|
+
export const physicalCode = (key: string): string => {
|
|
37
|
+
if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
|
|
38
|
+
if (/^[0-9]$/.test(key)) return `Digit${key}`;
|
|
39
|
+
if (key === " ") return "Space";
|
|
40
|
+
return key;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Chrome only runs an editing shortcut sent over CDP when the event names the command (macOS does not map Cmd+A to
|
|
44
|
+
// select-all for synthetic events), so Ctrl/Cmd + A/C/X/V/Z/Y carry it explicitly.
|
|
45
|
+
export const editingCommands = (key: string, modifiers: number): ReadonlyArray<string> => {
|
|
46
|
+
const ctrlOrMeta = (modifiers & (2 | 4)) !== 0;
|
|
47
|
+
if (!ctrlOrMeta || (modifiers & 1) !== 0) return [];
|
|
48
|
+
const shift = (modifiers & 8) !== 0;
|
|
49
|
+
switch (key.toLowerCase()) {
|
|
50
|
+
case "a": return shift ? [] : ["selectAll"];
|
|
51
|
+
case "c": return shift ? [] : ["copy"];
|
|
52
|
+
case "x": return shift ? [] : ["cut"];
|
|
53
|
+
case "v": return shift ? [] : ["paste"];
|
|
54
|
+
case "z": return shift ? ["redo"] : ["undo"];
|
|
55
|
+
case "y": return shift ? [] : ["redo"];
|
|
56
|
+
default: return [];
|
|
57
|
+
}
|
|
58
|
+
};
|
|
24
59
|
|
|
25
60
|
const TypeArgs = Type.Object({
|
|
26
|
-
|
|
61
|
+
ref: Type.Optional(
|
|
62
|
+
Type.String({ description: "Stable element ref from browser_snapshot (e.g. 'e7'). PREFERRED. The field is focused for you." }),
|
|
63
|
+
),
|
|
64
|
+
selector: Type.Optional(Type.String({ description: "CSS selector of the field (fallback when no ref)." })),
|
|
65
|
+
text: Type.String({ description: "Text to enter. With `clear` (the default when a field is named) it replaces the field's value; \"\" empties it." }),
|
|
66
|
+
clear: Type.Optional(
|
|
67
|
+
Type.Boolean({
|
|
68
|
+
description:
|
|
69
|
+
"Replace the field's current value (true) or add to it at the cursor (false). Default: true when ref/selector is given, false when typing into the already-focused element.",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
27
72
|
});
|
|
28
73
|
|
|
29
|
-
type
|
|
74
|
+
type TypeTarget = { readonly ref?: string | undefined; readonly selector?: string | undefined };
|
|
30
75
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
76
|
+
const PREP_FN = elementFunctionSource(prepareTyping);
|
|
77
|
+
const READ_FN = elementFunctionSource(readTypedValue);
|
|
78
|
+
|
|
79
|
+
const isTypePrep = (v: unknown): v is TypePrep => {
|
|
80
|
+
if (!isRecord(v) || typeof v["ok"] !== "boolean") return false;
|
|
81
|
+
if (v["ok"]) return typeof v["kind"] === "string" && typeof v["value"] === "string" && typeof v["tag"] === "string";
|
|
82
|
+
return typeof v["code"] === "string" && typeof v["reason"] === "string";
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isTypedValue = (v: unknown): v is TypedValue =>
|
|
86
|
+
isRecord(v) && typeof v["value"] === "string" && typeof v["focused"] === "boolean";
|
|
87
|
+
|
|
88
|
+
const hasTarget = (t: TypeTarget): boolean => t.ref !== undefined || t.selector !== undefined;
|
|
89
|
+
|
|
90
|
+
const labelOf = (t: TypeTarget): string => (t.ref !== undefined ? `[${t.ref}]` : t.selector ?? "the focused element");
|
|
91
|
+
|
|
92
|
+
// Runs an element function on the named field, or on document.activeElement when none is named.
|
|
93
|
+
const onField = async (
|
|
94
|
+
client: BrowserClient,
|
|
95
|
+
t: TypeTarget,
|
|
96
|
+
fn: string,
|
|
97
|
+
args: ReadonlyArray<unknown>,
|
|
98
|
+
): Promise<Result<unknown, ToolErr>> => {
|
|
99
|
+
if (hasTarget(t)) {
|
|
100
|
+
const r = await runOnElement(client, { ref: t.ref, selector: t.selector, functionDeclaration: fn, args });
|
|
101
|
+
if (!r.success) return r;
|
|
102
|
+
if (!r.data.found) return err({ kind: "invalid_state", message: `No element matched: ${labelOf(t)}` });
|
|
103
|
+
return ok(r.data.value);
|
|
104
|
+
}
|
|
105
|
+
return evalJs(client, "(" + fn + ").apply(document.activeElement, " + safeJs`${args}` + ")");
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const prepErr = (t: TypeTarget, prep: Extract<TypePrep, { readonly ok: false }>): ToolErr => {
|
|
109
|
+
const where = labelOf(t);
|
|
110
|
+
const hint =
|
|
111
|
+
prep.code === "select" ? " For a <select> use browser_select_option."
|
|
112
|
+
: prep.code === "checkable" ? " For a checkbox or radio use browser_set_checked."
|
|
113
|
+
: prep.code === "not_element" ? " Pass the field's ref, or click it first so it has focus."
|
|
114
|
+
: "";
|
|
115
|
+
return { kind: "invalid_state", message: `Cannot type into ${where}: ${prep.reason}.${hint}`, details: { code: prep.code, ...(prep.tag !== undefined ? { tag: prep.tag } : {}) } };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Backspace with the field's content selected deletes it, the way a user empties a field.
|
|
119
|
+
const pressBackspace = async (client: BrowserClient): Promise<Result<void, ToolErr>> => {
|
|
120
|
+
const key = { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8, nativeVirtualKeyCode: 8 };
|
|
121
|
+
const down = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyDown", ...key });
|
|
122
|
+
if (!down.success) return down;
|
|
123
|
+
const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", ...key });
|
|
124
|
+
if (!up.success) return up;
|
|
125
|
+
return ok(undefined);
|
|
126
|
+
};
|
|
35
127
|
|
|
36
128
|
export const typeTool = defineBrowserTool({
|
|
37
129
|
name: "browser_type",
|
|
38
130
|
label: "Browser Type",
|
|
39
|
-
description:
|
|
40
|
-
|
|
131
|
+
description:
|
|
132
|
+
"Enter text into a field the way a user types: focuses it, replaces its value (or appends with clear:false), fires real input events so autocomplete/suggestion lists open and React/Vue controlled inputs update, then reads the value back. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`; with neither, types into the focused element. If typing does not produce the value (date/masked inputs), it falls back to setting the value directly and says so.",
|
|
133
|
+
promptSnippet: "Type text into a field by ref (preferred), selector, or the focused element",
|
|
41
134
|
promptGuidelines: [
|
|
42
|
-
"
|
|
135
|
+
"One tool for all text entry: browser_type({ ref, text }) replaces the field's value; pass clear:false to append; text \"\" empties the field.",
|
|
136
|
+
"Check the value it reports — a page can reformat or reject what was typed.",
|
|
137
|
+
"Suggestion lists open as you type: after browser_type, run browser_snapshot and browser_click the option's ref.",
|
|
138
|
+
"For a <select> use browser_select_option; for a checkbox/radio use browser_set_checked.",
|
|
139
|
+
"For special keys (Enter, Tab, Escape, arrows) use browser_press_key.",
|
|
43
140
|
],
|
|
44
141
|
parameters: TypeArgs,
|
|
45
142
|
concurrency: "serialized",
|
|
46
143
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
144
|
+
const t: TypeTarget = { ref: args.ref, selector: args.selector };
|
|
145
|
+
const named = hasTarget(t);
|
|
146
|
+
const clear = args.clear ?? named;
|
|
147
|
+
const where = labelOf(t);
|
|
148
|
+
|
|
149
|
+
const prepRaw = await onField(client, t, PREP_FN, [clear]);
|
|
150
|
+
if (!prepRaw.success) return prepRaw;
|
|
151
|
+
if (!isTypePrep(prepRaw.data)) {
|
|
152
|
+
if (named) return err({ kind: "internal", message: "unexpected page result shape from browser_type", details: { target: where } });
|
|
153
|
+
return err({
|
|
154
|
+
kind: "invalid_state",
|
|
155
|
+
message: "No editable element is focused — typed text would be lost. Pass the field's ref, or click it first.",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const prep = prepRaw.data;
|
|
159
|
+
if (!prep.ok) {
|
|
160
|
+
if (!named && prep.code !== "disabled" && prep.code !== "readonly") {
|
|
58
161
|
return err({
|
|
59
162
|
kind: "invalid_state",
|
|
60
|
-
message:
|
|
61
|
-
|
|
62
|
-
details: { activeElement: f.tag },
|
|
163
|
+
message: "No editable element is focused — typed text would be lost. Pass the field's ref, or click it first.",
|
|
164
|
+
details: { activeElement: prep.tag ?? null },
|
|
63
165
|
});
|
|
64
166
|
}
|
|
167
|
+
return err(prepErr(t, prep));
|
|
65
168
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
169
|
+
|
|
170
|
+
const typed = clear && args.text === "" ? await pressBackspace(client) : await cdpCall(client, "Input.insertText", { text: args.text });
|
|
171
|
+
if (!typed.success) return typed;
|
|
172
|
+
|
|
173
|
+
const readRaw = await onField(client, t, READ_FN, []);
|
|
174
|
+
const after = readRaw.success && isTypedValue(readRaw.data) ? readRaw.data : undefined;
|
|
175
|
+
const expected = clear ? args.text : prep.value + args.text;
|
|
176
|
+
let value = after?.value;
|
|
177
|
+
let note = "";
|
|
178
|
+
if (value !== undefined && value !== expected && clear && named && prep.kind !== "contenteditable") {
|
|
179
|
+
// Typing did not produce the value (a date or masked input, or a handler that swallowed the keys): set it.
|
|
180
|
+
const filled = await nativeFill(client, t, args.text);
|
|
181
|
+
if (filled.success && "value" in filled.data) {
|
|
182
|
+
value = filled.data.value;
|
|
183
|
+
note = " (typing did not take; value set directly)";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (value !== undefined && value !== expected) note += ` ⚠ the field holds ${JSON.stringify(value)}, not what was typed — the page reformatted or rejected it`;
|
|
187
|
+
if (after !== undefined && !after.focused && note === "") note = " (the field lost focus after typing)";
|
|
188
|
+
|
|
189
|
+
const diff = await interactiveDiff(client);
|
|
190
|
+
const shown = value !== undefined ? ` → value ${JSON.stringify(value)}` : "";
|
|
191
|
+
return ok({
|
|
192
|
+
text: `Typed "${args.text}" into ${where}${shown}${note}${diff}`,
|
|
193
|
+
details: {
|
|
194
|
+
...(args.ref !== undefined ? { ref: args.ref } : {}),
|
|
195
|
+
...(args.selector !== undefined ? { selector: args.selector } : {}),
|
|
196
|
+
text: args.text,
|
|
197
|
+
clear,
|
|
198
|
+
...(value !== undefined ? { value } : {}),
|
|
199
|
+
},
|
|
200
|
+
});
|
|
69
201
|
},
|
|
70
202
|
});
|
|
71
203
|
|
|
@@ -100,23 +232,26 @@ export const pressKeyTool = defineBrowserTool({
|
|
|
100
232
|
const code = virtualKeyCode(k);
|
|
101
233
|
const modifiers = args.modifiers ?? 0;
|
|
102
234
|
const isChar = k.length === 1;
|
|
235
|
+
const physical = physicalCode(k);
|
|
236
|
+
// A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable.
|
|
237
|
+
const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
|
|
238
|
+
const commands = editingCommands(k, modifiers);
|
|
103
239
|
const downParams: ParamsOf<"Input.dispatchKeyEvent"> = {
|
|
104
|
-
type: "keyDown",
|
|
240
|
+
type: isChar && hasCommandModifier ? "rawKeyDown" : "keyDown",
|
|
105
241
|
key: k,
|
|
106
|
-
code:
|
|
242
|
+
code: physical,
|
|
107
243
|
modifiers,
|
|
108
244
|
...(code ? { windowsVirtualKeyCode: code, nativeVirtualKeyCode: code } : {}),
|
|
109
|
-
...(isChar ? { text: k, unmodifiedText: k } : {}),
|
|
245
|
+
...(isChar && !hasCommandModifier ? { text: k, unmodifiedText: k } : {}),
|
|
246
|
+
...(commands.length > 0 ? { commands: [...commands] } : {}),
|
|
110
247
|
};
|
|
111
248
|
const down = await cdpCall(client, "Input.dispatchKeyEvent", downParams);
|
|
112
249
|
if (!down.success) return down;
|
|
113
|
-
// A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable.
|
|
114
|
-
const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
|
|
115
250
|
if (isChar && !hasCommandModifier) {
|
|
116
251
|
const charEv = await cdpCall(client, "Input.dispatchKeyEvent", { type: "char", text: k, key: k });
|
|
117
252
|
if (!charEv.success) return charEv;
|
|
118
253
|
}
|
|
119
|
-
const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", key: k, code:
|
|
254
|
+
const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", key: k, code: physical, modifiers });
|
|
120
255
|
if (!up.success) return up;
|
|
121
256
|
return ok({ text: `Pressed: ${k}${modifiers ? ` (modifiers=${modifiers})` : ""}` });
|
|
122
257
|
},
|
|
@@ -153,7 +288,8 @@ export const dispatchKeyTool = defineBrowserTool({
|
|
|
153
288
|
const eventType = args.eventType ?? "keydown";
|
|
154
289
|
const target = args.ref ?? args.selector ?? "";
|
|
155
290
|
// Legacy handlers branch on e.keyCode/e.which rather than e.key, so populate both.
|
|
156
|
-
|
|
291
|
+
// keypress carries the character code ("a" = 97); keydown/keyup carry the key code ("a" = 65).
|
|
292
|
+
const code = eventType === "keypress" && args.key.length === 1 ? args.key.charCodeAt(0) : virtualKeyCode(args.key);
|
|
157
293
|
if (args.ref !== undefined) {
|
|
158
294
|
// Through runOnElement so the resolved RemoteObject is released after the call.
|
|
159
295
|
const spec: KeyEventSpec = { type: eventType, key: args.key, code };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Page-side code for browser_click; see fill.page.ts for the rules every `*.page.ts` module follows.
|
|
2
|
+
|
|
3
|
+
// What sits under the click point, relative to the element the click was aimed at.
|
|
4
|
+
export type HitInfo = {
|
|
5
|
+
// The point lands on the target, inside it, or on an ancestor that wraps it (a label around an icon).
|
|
6
|
+
readonly onTarget: boolean;
|
|
7
|
+
// A short description of the element actually under the point, e.g. `div.modal-overlay "Close"`.
|
|
8
|
+
readonly hit: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// `target` is the element the click aims at (Runtime.callFunctionOn's `this`), `hit` the element hit-testing found.
|
|
12
|
+
export function hitInfo(target: Element | null, hit: Element | null): HitInfo {
|
|
13
|
+
if (hit?.nodeType !== 1) return { onTarget: false, hit: "nothing" };
|
|
14
|
+
const onTarget = target !== null && (target === hit || target.contains(hit) || hit.contains(target));
|
|
15
|
+
const tag = hit.tagName.toLowerCase();
|
|
16
|
+
const id = hit.id ? "#" + hit.id : "";
|
|
17
|
+
const cls = typeof hit.className === "string" && hit.className.trim() !== ""
|
|
18
|
+
? "." + hit.className.trim().split(/\s+/).slice(0, 2).join(".")
|
|
19
|
+
: "";
|
|
20
|
+
const label = (hit.getAttribute("aria-label") ?? hit.textContent).replace(/\s+/g, " ").trim().slice(0, 40);
|
|
21
|
+
return { onTarget, hit: tag + id + cls + (label !== "" ? ' "' + label + '"' : "") };
|
|
22
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
export type PageOption = { readonly value: string; readonly text: string };
|
|
11
11
|
|
|
12
12
|
export type FillOptions = {
|
|
13
|
-
// browser_fill refuses a <select> (its documented error points at browser_select_option); browser_fill_form fills it.
|
|
13
|
+
// The recipe-only browser_fill refuses a <select> (its documented error points at browser_select_option); browser_fill_form fills it.
|
|
14
14
|
readonly rejectSelect: boolean;
|
|
15
15
|
readonly focusFirst: boolean;
|
|
16
16
|
};
|
|
@@ -37,6 +37,8 @@ export type FormControl = HTMLElement & {
|
|
|
37
37
|
value?: string;
|
|
38
38
|
checked?: boolean;
|
|
39
39
|
disabled?: boolean;
|
|
40
|
+
readOnly?: boolean;
|
|
41
|
+
select?: () => void;
|
|
40
42
|
options?: HTMLOptionsCollection;
|
|
41
43
|
};
|
|
42
44
|
|
|
@@ -137,3 +139,70 @@ export function fillField(el: FormControl | null, value: unknown, opts: FillOpti
|
|
|
137
139
|
|
|
138
140
|
return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")", tag: tagName };
|
|
139
141
|
}
|
|
142
|
+
|
|
143
|
+
// What browser_type learns about its field before typing: whether it can take text, and its value right now.
|
|
144
|
+
export type TypePrep =
|
|
145
|
+
| { readonly ok: true; readonly kind: "input" | "textarea" | "contenteditable"; readonly value: string; readonly tag: string }
|
|
146
|
+
| {
|
|
147
|
+
readonly ok: false;
|
|
148
|
+
readonly code: "not_element" | "disabled" | "readonly" | "select" | "checkable" | "not_editable";
|
|
149
|
+
readonly reason: string;
|
|
150
|
+
readonly tag?: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Focuses `el` and, when `clear` is set, selects everything in it, so the text typed next replaces the old value the
|
|
154
|
+
// way select-all + typing does for a user. Refuses fields that cannot take typed text, naming the tool that can.
|
|
155
|
+
export function prepareTyping(el: FormControl | null, clear: boolean): TypePrep {
|
|
156
|
+
if (el?.nodeType !== 1) return { ok: false, code: "not_element", reason: "no element to type into" };
|
|
157
|
+
const tagName = el.tagName;
|
|
158
|
+
const tag = tagName.toLowerCase();
|
|
159
|
+
const type = (el.type ?? "").toLowerCase();
|
|
160
|
+
if (tag === "select") return { ok: false, code: "select", reason: "element is a <select>", tag: tagName };
|
|
161
|
+
if (tag === "input" && (type === "checkbox" || type === "radio")) {
|
|
162
|
+
return { ok: false, code: "checkable", reason: "element is a " + type, tag: tagName };
|
|
163
|
+
}
|
|
164
|
+
const editableTag = (tag === "input" && type !== "button" && type !== "submit" && type !== "file" && type !== "image" && type !== "reset")
|
|
165
|
+
|| tag === "textarea";
|
|
166
|
+
if (!editableTag && !el.isContentEditable) {
|
|
167
|
+
return { ok: false, code: "not_editable", reason: "element is not a text field (tag=" + tag + ")", tag: tagName };
|
|
168
|
+
}
|
|
169
|
+
if (el.disabled) return { ok: false, code: "disabled", reason: "element is disabled", tag: tagName };
|
|
170
|
+
if (editableTag && el.readOnly === true) {
|
|
171
|
+
return { ok: false, code: "readonly", reason: "element is read-only", tag: tagName };
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
el.focus();
|
|
175
|
+
} catch {
|
|
176
|
+
/* focus throws on some detached or shadow nodes; the caller checks where the text landed */
|
|
177
|
+
}
|
|
178
|
+
if (editableTag) {
|
|
179
|
+
if (clear) {
|
|
180
|
+
try {
|
|
181
|
+
el.select?.();
|
|
182
|
+
} catch {
|
|
183
|
+
/* select() is a no-op or throws on a few input types (date, color); the caller verifies and falls back */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, kind: tag === "textarea" ? "textarea" : "input", value: el.value ?? "", tag: tagName };
|
|
187
|
+
}
|
|
188
|
+
if (clear) {
|
|
189
|
+
const sel = window.getSelection();
|
|
190
|
+
if (sel) {
|
|
191
|
+
sel.removeAllRanges();
|
|
192
|
+
const rng = document.createRange();
|
|
193
|
+
rng.selectNodeContents(el);
|
|
194
|
+
sel.addRange(rng);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, kind: "contenteditable", value: el.textContent, tag: tagName };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The field's value after typing, and whether it still has focus (a blur handler may have moved it elsewhere).
|
|
201
|
+
export type TypedValue = { readonly value: string; readonly focused: boolean };
|
|
202
|
+
|
|
203
|
+
export function readTypedValue(el: FormControl | null): TypedValue {
|
|
204
|
+
if (el?.nodeType !== 1) return { value: "", focused: false };
|
|
205
|
+
const tag = el.tagName.toLowerCase();
|
|
206
|
+
const value = tag === "input" || tag === "textarea" ? (el.value ?? "") : el.textContent;
|
|
207
|
+
return { value, focused: document.activeElement === el };
|
|
208
|
+
}
|
package/src/domains/page.ts
CHANGED
|
@@ -91,7 +91,7 @@ export const waitForTool = defineBrowserTool({
|
|
|
91
91
|
"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.",
|
|
92
92
|
promptSnippet: "Wait for an element or text to appear (or an element to disappear)",
|
|
93
93
|
promptGuidelines: [
|
|
94
|
-
"Use before
|
|
94
|
+
"Use before browser_type / browser_click on dynamic pages so you act after the element renders.",
|
|
95
95
|
],
|
|
96
96
|
parameters: WaitForArgs,
|
|
97
97
|
concurrency: "parallel",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import type { BrowserClient } from "../client";
|
|
4
|
+
import { imageWidth } from "../util/image-size";
|
|
5
|
+
import { evalJs } from "./cdp-call";
|
|
2
6
|
import { Type } from "typebox";
|
|
3
7
|
import { Image, type ImageTheme, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
4
8
|
import { type Result, ok } from "../util/result";
|
|
@@ -20,6 +24,21 @@ const ScreenshotDetailsSchema = Type.Object({
|
|
|
20
24
|
|
|
21
25
|
export type ScreenshotDetails = DetailsOf<typeof ScreenshotDetailsSchema>;
|
|
22
26
|
|
|
27
|
+
// Screenshots come out in device pixels (2x on a Retina display) and maxDim may shrink them again, so a point read
|
|
28
|
+
// off the image is not a browser_click coordinate. Say how to convert, whenever the two differ.
|
|
29
|
+
const scaleNote = async (client: BrowserClient, path: string, fullPage: boolean): Promise<string> => {
|
|
30
|
+
try {
|
|
31
|
+
const width = imageWidth(await readFile(path));
|
|
32
|
+
const css = await evalJs(client, fullPage ? "document.documentElement.scrollWidth" : "window.innerWidth");
|
|
33
|
+
if (width === undefined || !css.success || typeof css.data !== "number" || css.data <= 0) return "";
|
|
34
|
+
const ratio = width / css.data;
|
|
35
|
+
if (Math.abs(ratio - 1) < 0.05) return "";
|
|
36
|
+
return `\nImage scale: ${ratio.toFixed(2)} image px per CSS px — divide coordinates read off this image by ${ratio.toFixed(2)} before passing them to browser_click (or use refs from browser_snapshot).`;
|
|
37
|
+
} catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
23
42
|
export const screenshotTool = defineBrowserTool({
|
|
24
43
|
name: "browser_screenshot",
|
|
25
44
|
label: "Browser Screenshot",
|
|
@@ -40,8 +59,9 @@ export const screenshotTool = defineBrowserTool({
|
|
|
40
59
|
const resized = await resizeIfNeeded(cap.data.path, args.maxDim);
|
|
41
60
|
note = resized.note;
|
|
42
61
|
}
|
|
62
|
+
const scale = await scaleNote(client, cap.data.path, args.fullPage ?? false);
|
|
43
63
|
return ok({
|
|
44
|
-
text: `Screenshot saved: ${cap.data.path}${note}`,
|
|
64
|
+
text: `Screenshot saved: ${cap.data.path}${note}${scale}`,
|
|
45
65
|
details: { path: cap.data.path, format: cap.data.format, attached: false },
|
|
46
66
|
});
|
|
47
67
|
},
|
package/src/domains/snapshot.ts
CHANGED
|
@@ -115,7 +115,7 @@ export const snapshotTool = defineBrowserTool({
|
|
|
115
115
|
name: "browser_snapshot",
|
|
116
116
|
label: "Browser Snapshot",
|
|
117
117
|
description:
|
|
118
|
-
"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/
|
|
118
|
+
"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_type/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.",
|
|
119
119
|
promptSnippet: "Get accessibility-tree snapshot with stable element refs (default for page inspection)",
|
|
120
120
|
promptGuidelines: [
|
|
121
121
|
"Re-run browser_snapshot after a navigation or a major re-render to get fresh refs.",
|
package/src/domains/target.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { BrowserClient } from "../client";
|
|
2
|
+
import { isRecord } from "../util/guards";
|
|
2
3
|
import { type Result, err, ok } from "../util/result";
|
|
3
4
|
import type { ToolErr } from "../util/tool";
|
|
4
|
-
import {
|
|
5
|
+
import { boxOf } from "./box";
|
|
6
|
+
import { cdpCall } from "./cdp-call";
|
|
7
|
+
import { hitInfo } from "./page/click.page";
|
|
8
|
+
import { elementFunctionSource } from "./page/inject";
|
|
9
|
+
import { resolveRefToBackendId } from "./ref-resolve";
|
|
5
10
|
|
|
6
11
|
export type PointArgs = {
|
|
7
12
|
readonly ref?: string | undefined;
|
|
@@ -13,23 +18,94 @@ export type ResolvedTarget = {
|
|
|
13
18
|
readonly x: number;
|
|
14
19
|
readonly y: number;
|
|
15
20
|
readonly label: string;
|
|
21
|
+
// Set when hit-testing found something other than the ref's element under the point (an overlay, a sticky header).
|
|
22
|
+
readonly covered?: string;
|
|
23
|
+
// For an x/y click: what is under the point, so the agent can tell whether it hit what it meant to.
|
|
24
|
+
readonly hit?: string;
|
|
16
25
|
};
|
|
17
26
|
|
|
27
|
+
type Viewport = { readonly width: number; readonly height: number };
|
|
28
|
+
|
|
29
|
+
// The layout viewport in CSS pixels, or undefined when Chrome would not say (then no bounds check is made).
|
|
30
|
+
export const viewportOf = async (client: BrowserClient): Promise<Viewport | undefined> => {
|
|
31
|
+
const r = await cdpCall(client, "Page.getLayoutMetrics", {});
|
|
32
|
+
if (!r.success) return undefined;
|
|
33
|
+
const { clientWidth, clientHeight } = r.data.cssLayoutViewport;
|
|
34
|
+
return clientWidth > 0 && clientHeight > 0 ? { width: clientWidth, height: clientHeight } : undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const inside = (vp: Viewport, x: number, y: number): boolean => x >= 0 && y >= 0 && x < vp.width && y < vp.height;
|
|
38
|
+
|
|
39
|
+
const outsideErr = (x: number, y: number, vp: Viewport, ref?: string): ToolErr => ({
|
|
40
|
+
kind: "invalid_state",
|
|
41
|
+
message: ref !== undefined
|
|
42
|
+
? `[${ref}] is at (${x}, ${y}), outside the ${vp.width}x${vp.height} viewport even after scrolling it into view — it may be clipped by a scroll container or positioned off-screen. Scroll its container (browser_scroll) and re-snapshot.`
|
|
43
|
+
: `Point (${x}, ${y}) is outside the ${vp.width}x${vp.height} viewport, so the click would hit nothing. Use the element's ref from browser_snapshot instead — browser_click scrolls it into view.`,
|
|
44
|
+
details: { x, y, viewportWidth: vp.width, viewportHeight: vp.height, ...(ref !== undefined ? { ref } : {}) },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const HIT_FN = elementFunctionSource(hitInfo);
|
|
48
|
+
|
|
49
|
+
const isHitInfo = (v: unknown): v is { readonly onTarget: boolean; readonly hit: string } =>
|
|
50
|
+
isRecord(v) && typeof v["onTarget"] === "boolean" && typeof v["hit"] === "string";
|
|
51
|
+
|
|
52
|
+
// What hit-testing finds at the point, relative to the target element (or just what is there, with no target);
|
|
53
|
+
// undefined when it cannot tell.
|
|
54
|
+
const hitTest = async (client: BrowserClient, backendId: number | undefined, x: number, y: number): Promise<{ readonly onTarget: boolean; readonly hit: string } | undefined> => {
|
|
55
|
+
const loc = await cdpCall(client, "DOM.getNodeForLocation", { x: Math.round(x), y: Math.round(y), ignorePointerEventsNone: true });
|
|
56
|
+
if (!loc.success) return undefined;
|
|
57
|
+
const target = await cdpCall(client, "DOM.resolveNode", { backendNodeId: backendId ?? loc.data.backendNodeId });
|
|
58
|
+
const hit = await cdpCall(client, "DOM.resolveNode", { backendNodeId: loc.data.backendNodeId });
|
|
59
|
+
const targetId = target.success ? target.data.object.objectId : undefined;
|
|
60
|
+
const hitId = hit.success ? hit.data.object.objectId : undefined;
|
|
61
|
+
try {
|
|
62
|
+
if (targetId === undefined || hitId === undefined) return undefined;
|
|
63
|
+
const called = await cdpCall(client, "Runtime.callFunctionOn", {
|
|
64
|
+
objectId: targetId,
|
|
65
|
+
functionDeclaration: HIT_FN,
|
|
66
|
+
arguments: [{ objectId: hitId }],
|
|
67
|
+
returnByValue: true,
|
|
68
|
+
});
|
|
69
|
+
if (!called.success || called.data.exceptionDetails !== undefined) return undefined;
|
|
70
|
+
const v: unknown = called.data.result.value;
|
|
71
|
+
return isHitInfo(v) ? v : undefined;
|
|
72
|
+
} finally {
|
|
73
|
+
for (const id of [targetId, hitId]) if (id !== undefined) await cdpCall(client, "Runtime.releaseObject", { objectId: id });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Turns a ref (or x/y) into the point a trusted mouse event should land on. For a ref: scroll the element into view
|
|
78
|
+
// (nested scroll containers included), measure it now, refuse a point still outside the viewport, and hit-test it so
|
|
79
|
+
// a covered element is reported instead of silently clicking whatever is on top.
|
|
18
80
|
export const resolveTarget = async (
|
|
19
81
|
client: BrowserClient,
|
|
20
82
|
args: PointArgs,
|
|
21
83
|
): Promise<Result<ResolvedTarget, ToolErr>> => {
|
|
22
84
|
if (args.ref !== undefined) {
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
85
|
+
const backendId = resolveRefToBackendId(client, args.ref);
|
|
86
|
+
if (!backendId.success) return backendId;
|
|
87
|
+
// A failed scroll is not fatal: the bounds check below catches an element that is still out of view.
|
|
88
|
+
await cdpCall(client, "DOM.scrollIntoViewIfNeeded", { backendNodeId: backendId.data });
|
|
89
|
+
const box = await boxOf(client, backendId.data);
|
|
90
|
+
if (!box.success) {
|
|
91
|
+
return err({ kind: "invalid_state", message: `Ref ${args.ref} is unknown or stale — re-run browser_snapshot to get fresh refs.`, details: { ref: args.ref } });
|
|
92
|
+
}
|
|
93
|
+
const { cx, cy } = box.data;
|
|
94
|
+
const vp = await viewportOf(client);
|
|
95
|
+
if (vp !== undefined && !inside(vp, cx, cy)) return err(outsideErr(cx, cy, vp, args.ref));
|
|
96
|
+
const hit = await hitTest(client, backendId.data, cx, cy);
|
|
25
97
|
return ok({
|
|
26
|
-
x:
|
|
27
|
-
y:
|
|
28
|
-
label: `[${args.ref}] (${
|
|
98
|
+
x: cx,
|
|
99
|
+
y: cy,
|
|
100
|
+
label: `[${args.ref}] (${cx}, ${cy})`,
|
|
101
|
+
...(hit !== undefined && !hit.onTarget ? { covered: hit.hit } : {}),
|
|
29
102
|
});
|
|
30
103
|
}
|
|
31
104
|
if (args.x !== undefined && args.y !== undefined) {
|
|
32
|
-
|
|
105
|
+
const vp = await viewportOf(client);
|
|
106
|
+
if (vp !== undefined && !inside(vp, args.x, args.y)) return err(outsideErr(args.x, args.y, vp));
|
|
107
|
+
const hit = await hitTest(client, undefined, args.x, args.y);
|
|
108
|
+
return ok({ x: args.x, y: args.y, label: `(${args.x}, ${args.y})`, ...(hit !== undefined ? { hit: hit.hit } : {}) });
|
|
33
109
|
}
|
|
34
110
|
return err({ kind: "invalid_args", message: "Provide either `ref` or both `x` and `y`." });
|
|
35
111
|
};
|
|
@@ -20,7 +20,7 @@ export const REPLAYABLE: ReadonlyMap<string, "ref" | "fields" | "none"> = new Ma
|
|
|
20
20
|
["browser_dispatch_key", "ref"],
|
|
21
21
|
["browser_upload_file", "ref"],
|
|
22
22
|
["browser_fill_form", "fields"],
|
|
23
|
-
["browser_type", "
|
|
23
|
+
["browser_type", "ref"],
|
|
24
24
|
["browser_press_key", "none"],
|
|
25
25
|
["browser_navigate", "none"],
|
|
26
26
|
["browser_go_back", "none"],
|
|
@@ -303,8 +303,9 @@ export const observeCall: CallObserver = async ({ name, concurrency, args, clien
|
|
|
303
303
|
}
|
|
304
304
|
const fields = mode === "fields" ? await fieldsOf(client, args) : undefined;
|
|
305
305
|
let password = fields?.some((f) => f.password) ?? false;
|
|
306
|
-
if (name === "browser_fill"
|
|
307
|
-
|
|
306
|
+
if (name === "browser_fill" || (name === "browser_type" && (ref !== undefined || selector !== undefined))) {
|
|
307
|
+
password = await isPasswordTarget(client, ref, selector);
|
|
308
|
+
} else if (name === "browser_type") {
|
|
308
309
|
const r = await evalJs(client, FOCUSED_IS_PASSWORD_EXPR);
|
|
309
310
|
password = r.success && r.data === true;
|
|
310
311
|
}
|
package/src/registry.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { BrowserTool } from "./util/tool";
|
|
|
4
4
|
import { type Result, err, ok } from "./util/result";
|
|
5
5
|
import { clickTool } from "./domains/click";
|
|
6
6
|
import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
|
|
7
|
-
import {
|
|
7
|
+
import { selectOptionTool, focusTool, setCheckedTool } from "./domains/form";
|
|
8
8
|
import { fillFormTool } from "./domains/form-batch";
|
|
9
9
|
import { pageInfoTool, waitTool, waitForLoadTool, waitForTool } from "./domains/page";
|
|
10
10
|
import { scrollTool } from "./domains/scroll";
|
|
@@ -31,7 +31,6 @@ export const ALL_TOOLS: ReadonlyArray<BrowserTool> = [
|
|
|
31
31
|
setupTool,
|
|
32
32
|
clickTool,
|
|
33
33
|
typeTool,
|
|
34
|
-
fillTool,
|
|
35
34
|
fillFormTool,
|
|
36
35
|
selectOptionTool,
|
|
37
36
|
setCheckedTool,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Reads an image's pixel width from its header, without decoding it (sharp is optional). PNG keeps it in IHDR; JPEG
|
|
2
|
+
// in the first start-of-frame marker. Returns undefined for anything else or a truncated file.
|
|
3
|
+
export const imageWidth = (buf: Buffer): number | undefined => {
|
|
4
|
+
if (buf.length >= 24 && buf.readUInt32BE(0) === 0x89504e47) return buf.readUInt32BE(16);
|
|
5
|
+
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return undefined;
|
|
6
|
+
let i = 2;
|
|
7
|
+
while (i + 9 < buf.length) {
|
|
8
|
+
if (buf[i] !== 0xff) return undefined;
|
|
9
|
+
const marker = buf[i + 1] ?? 0;
|
|
10
|
+
const len = buf.readUInt16BE(i + 2);
|
|
11
|
+
// SOF0..SOF15, minus DHT (C4), JPG (C8) and DAC (CC), carry the frame size: height then width.
|
|
12
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return buf.readUInt16BE(i + 7);
|
|
13
|
+
i += 2 + len;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
};
|