pi-browser-harness 0.11.0 → 0.13.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.
- package/CHANGELOG.md +62 -0
- package/README.md +102 -1
- package/package.json +13 -7
- package/skills/pi-browser-harness/SKILL.md +84 -0
- package/src/cdp/commands.ts +204 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +72 -37
- package/src/cdp/events.ts +8 -3
- package/src/cdp/network-buffer.ts +38 -32
- package/src/cdp/ownership.ts +22 -4
- package/src/cdp/record-store.ts +2 -2
- package/src/cdp/session.ts +320 -217
- package/src/cdp/target-factory.ts +34 -15
- package/src/cdp/types.ts +17 -1
- package/src/client.ts +76 -43
- package/src/daemon/bridge.ts +115 -191
- package/src/daemon/connection.ts +323 -0
- package/src/daemon/index.ts +77 -48
- package/src/daemon/lines.ts +40 -0
- package/src/daemon/main.ts +12 -0
- package/src/daemon/protocol.ts +55 -25
- package/src/daemon/server.ts +100 -63
- package/src/daemon/socket.ts +10 -0
- package/src/daemon/spawn.ts +84 -27
- package/src/daemon/transport.ts +88 -52
- package/src/domains/ax-tree.ts +8 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -7
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -66
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +64 -11
- package/src/domains/form-batch.ts +13 -11
- package/src/domains/form.ts +79 -116
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +7 -7
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +68 -53
- package/src/domains/keyboard.ts +16 -18
- package/src/domains/navigate.ts +102 -69
- package/src/domains/network.ts +56 -98
- package/src/domains/page/fill.page.ts +139 -0
- package/src/domains/page/form.page.ts +61 -0
- package/src/domains/page/inject.ts +37 -0
- package/src/domains/page/keyboard.page.ts +16 -0
- package/src/domains/page/live-value.page.ts +10 -0
- package/src/domains/page/read-page.page.ts +51 -0
- package/src/domains/page/serp.page.ts +39 -0
- package/src/domains/page.ts +19 -58
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -54
- package/src/domains/ref-resolve.ts +43 -29
- package/src/domains/ref-signature.ts +11 -0
- package/src/domains/render.ts +12 -23
- package/src/domains/screenshot-capture.ts +4 -2
- package/src/domains/screenshot.ts +13 -14
- package/src/domains/scroll.ts +4 -5
- package/src/domains/search/google-serp.ts +1 -1
- package/src/domains/search/web-search.ts +38 -34
- package/src/domains/setup.ts +0 -2
- package/src/domains/snapshot.ts +72 -77
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +19 -78
- package/src/domains/target.ts +1 -1
- package/src/domains/viewport.ts +0 -1
- package/src/index.ts +100 -39
- package/src/profile/bind.ts +45 -14
- package/src/profile/browser-process.ts +50 -12
- package/src/profile/command.ts +20 -12
- package/src/profile/launch.ts +13 -9
- package/src/profile/list.ts +9 -13
- package/src/profile/store.ts +2 -1
- package/src/prompt.ts +42 -117
- package/src/registry.ts +68 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +33 -2
- package/src/util/debug.ts +7 -1
- package/src/util/guards.ts +3 -0
- package/src/util/mutex.ts +1 -1
- package/src/util/paths.ts +12 -1
- package/src/util/result.ts +8 -0
- package/src/util/sharp-shim.ts +18 -11
- package/src/util/sleep.ts +18 -0
- package/src/util/tool.ts +163 -114
- package/src/util/truncate.ts +1 -1
- package/tsconfig.json +2 -1
- package/src/domains/fill-engine.ts +0 -80
package/src/domains/files.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, access, constants, stat
|
|
1
|
+
import { mkdir, access, constants, stat } from "node:fs/promises";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import type { BrowserClient } from "../client";
|
|
@@ -7,6 +7,7 @@ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
|
7
7
|
import { safeJs } from "../util/js-template";
|
|
8
8
|
import { pdfPath } from "../util/paths";
|
|
9
9
|
import { cdpCall, cdpCallBrowser, evalJs } from "./cdp-call";
|
|
10
|
+
import { readFileResult, statResult, writeFileResult } from "./io";
|
|
10
11
|
import { resolveRefToBackendId } from "./ref-resolve";
|
|
11
12
|
|
|
12
13
|
const UploadArgs = Type.Object({
|
|
@@ -73,18 +74,20 @@ const jsFallbackUpload = async (
|
|
|
73
74
|
selector: string,
|
|
74
75
|
filePath: string,
|
|
75
76
|
): Promise<Result<void, ToolErr>> => {
|
|
76
|
-
const buf = await
|
|
77
|
-
|
|
77
|
+
const buf = await readFileResult(filePath);
|
|
78
|
+
if (!buf.success) return buf;
|
|
79
|
+
const st = await statResult(filePath);
|
|
80
|
+
if (!st.success) return st;
|
|
78
81
|
const name = basename(filePath);
|
|
79
82
|
const mime = inferMime(name);
|
|
80
83
|
const expr = safeJs`
|
|
81
84
|
(() => {
|
|
82
85
|
const input = document.querySelector(${selector});
|
|
83
86
|
if (!input || input.type !== 'file') throw new Error('File input not found');
|
|
84
|
-
const bin = atob(${buf.toString("base64")});
|
|
87
|
+
const bin = atob(${buf.data.toString("base64")});
|
|
85
88
|
const bytes = new Uint8Array(bin.length);
|
|
86
89
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
87
|
-
const file = new File([bytes], ${name}, { type: ${mime}, lastModified: ${st.mtimeMs} });
|
|
90
|
+
const file = new File([bytes], ${name}, { type: ${mime}, lastModified: ${st.data.mtimeMs} });
|
|
88
91
|
const dt = new DataTransfer();
|
|
89
92
|
dt.items.add(file);
|
|
90
93
|
input.files = dt.files;
|
|
@@ -104,7 +107,7 @@ export const uploadFileTool = defineBrowserTool({
|
|
|
104
107
|
description: "Set files on a file <input> via CDP, with a JS-DataTransfer fallback for stubborn pages. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`.",
|
|
105
108
|
promptSnippet: "Upload a file to a file input by ref (preferred) or selector",
|
|
106
109
|
promptGuidelines: [
|
|
107
|
-
"
|
|
110
|
+
"File inputs are often hidden; browser_snapshot still surfaces them.",
|
|
108
111
|
"File path must be absolute and readable.",
|
|
109
112
|
],
|
|
110
113
|
parameters: UploadArgs,
|
|
@@ -122,7 +125,7 @@ export const uploadFileTool = defineBrowserTool({
|
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
if (args.selector === undefined) {
|
|
125
|
-
return err({ kind: "
|
|
128
|
+
return err({ kind: "invalid_args", message: "Provide either `ref` or `selector`." });
|
|
126
129
|
}
|
|
127
130
|
const cdp = await tryCdpUpload(client, args.selector, args.filePath);
|
|
128
131
|
if (cdp.success) {
|
|
@@ -145,22 +148,70 @@ export const uploadFileTool = defineBrowserTool({
|
|
|
145
148
|
},
|
|
146
149
|
});
|
|
147
150
|
|
|
151
|
+
// Chrome stores the override in its default browser context, so it outlives this client and
|
|
152
|
+
// silently redirects the user's own downloads. Track it, so session teardown can undo it.
|
|
153
|
+
// The flag only records what THIS process set: the daemon is shared by every pi process on the
|
|
154
|
+
// machine and the CDP primitive is last-writer-wins, so another session may have reset or
|
|
155
|
+
// re-pointed it since. A crashed or killed session never reaches teardown, so the override can
|
|
156
|
+
// also outlive the process that set it.
|
|
157
|
+
let activeDownloadOverride: string | undefined;
|
|
158
|
+
|
|
159
|
+
// Shutdown must not block on a browser that is already gone: a pending restore against a
|
|
160
|
+
// disconnected daemon would otherwise trigger the one-shot reconnect (and Chrome's consent
|
|
161
|
+
// prompt) that 0.11.1 confined to explicit tool calls, and wait up to the connect timeout.
|
|
162
|
+
const SHUTDOWN_RESTORE_TIMEOUT_MS = 2_000;
|
|
163
|
+
|
|
164
|
+
export const restoreDownloadBehavior = async (client: BrowserClient): Promise<Result<void, ToolErr>> => {
|
|
165
|
+
const pending = activeDownloadOverride;
|
|
166
|
+
if (pending === undefined) return ok(undefined);
|
|
167
|
+
if (!client.status().alive) {
|
|
168
|
+
return err({ kind: "not_connected", message: `browser not connected; downloads may keep going to ${pending} until Chrome restarts` });
|
|
169
|
+
}
|
|
170
|
+
const r = await cdpCallBrowser(client, "Browser.setDownloadBehavior", { behavior: "default" }, { timeoutMs: SHUTDOWN_RESTORE_TIMEOUT_MS });
|
|
171
|
+
if (!r.success) {
|
|
172
|
+
return err({ ...r.error, message: `${r.error.message} (downloads may keep going to ${pending})` });
|
|
173
|
+
}
|
|
174
|
+
activeDownloadOverride = undefined;
|
|
175
|
+
return ok(undefined);
|
|
176
|
+
};
|
|
177
|
+
|
|
148
178
|
const DownloadArgs = Type.Object({
|
|
149
|
-
downloadPath: Type.
|
|
179
|
+
downloadPath: Type.Optional(
|
|
180
|
+
Type.String({ description: "Absolute path to a writable directory where downloads should be saved" }),
|
|
181
|
+
),
|
|
182
|
+
restore: Type.Optional(
|
|
183
|
+
Type.Boolean({
|
|
184
|
+
description:
|
|
185
|
+
"Undo a previous browser_download: reset Chrome's download behavior back to the user's own default.",
|
|
186
|
+
}),
|
|
187
|
+
),
|
|
150
188
|
});
|
|
151
189
|
|
|
152
190
|
export const downloadTool = defineBrowserTool({
|
|
153
191
|
name: "browser_download",
|
|
154
192
|
label: "Browser Download",
|
|
155
|
-
description:
|
|
193
|
+
description:
|
|
194
|
+
"Configure Chrome's download behavior: set the save directory and disable the save-as prompt, or restore the user's default. Restored automatically when this session shuts down cleanly.",
|
|
156
195
|
promptSnippet: "Configure download directory",
|
|
157
196
|
promptGuidelines: [
|
|
158
197
|
"Pass an absolute path to an existing writable directory.",
|
|
159
|
-
"
|
|
198
|
+
"The override applies to the user's own downloads too, not just yours, and only a clean session shutdown undoes it. Call browser_download({restore:true}) as soon as you are done.",
|
|
160
199
|
],
|
|
161
200
|
parameters: DownloadArgs,
|
|
162
201
|
concurrency: "serialized",
|
|
163
202
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
203
|
+
if (args.restore === true) {
|
|
204
|
+
if (args.downloadPath !== undefined) {
|
|
205
|
+
return err({ kind: "invalid_args", message: "Pass either downloadPath or restore, not both" });
|
|
206
|
+
}
|
|
207
|
+
const r = await cdpCallBrowser(client, "Browser.setDownloadBehavior", { behavior: "default" });
|
|
208
|
+
if (!r.success) return r;
|
|
209
|
+
activeDownloadOverride = undefined;
|
|
210
|
+
return ok({ text: "Downloads restored to Chrome's default location", details: { restored: true } });
|
|
211
|
+
}
|
|
212
|
+
if (args.downloadPath === undefined) {
|
|
213
|
+
return err({ kind: "invalid_args", message: "Provide downloadPath to set a directory, or restore:true to undo" });
|
|
214
|
+
}
|
|
164
215
|
try {
|
|
165
216
|
await mkdir(args.downloadPath, { recursive: true });
|
|
166
217
|
} catch (e) {
|
|
@@ -187,6 +238,7 @@ export const downloadTool = defineBrowserTool({
|
|
|
187
238
|
eventsEnabled: true,
|
|
188
239
|
});
|
|
189
240
|
if (!r.success) return r;
|
|
241
|
+
activeDownloadOverride = args.downloadPath;
|
|
190
242
|
return ok({
|
|
191
243
|
text: `Downloads will save to: ${args.downloadPath}`,
|
|
192
244
|
details: { downloadPath: args.downloadPath },
|
|
@@ -213,7 +265,8 @@ export const printToPdfTool = defineBrowserTool({
|
|
|
213
265
|
});
|
|
214
266
|
if (!r.success) return r;
|
|
215
267
|
const path = args.outputPath ?? pdfPath(client.namespace);
|
|
216
|
-
await
|
|
268
|
+
const written = await writeFileResult(path, Buffer.from(r.data.data, "base64"));
|
|
269
|
+
if (!written.success) return written;
|
|
217
270
|
return ok({ text: `PDF saved: ${path}`, details: { path } });
|
|
218
271
|
},
|
|
219
272
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { type Result, err, ok } from "../util/result";
|
|
3
|
-
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
|
-
import { fillDeclaration } from "./fill-engine";
|
|
3
|
+
import { defineBrowserTool, type JsonObject, type ToolErr, type ToolOk } from "../util/tool";
|
|
5
4
|
import { detailsOf, resolveAndCall } from "./element-call";
|
|
5
|
+
import { fillField, type FillOptions } from "./page/fill.page";
|
|
6
|
+
import { elementFunctionSource } from "./page/inject";
|
|
6
7
|
|
|
7
|
-
const FILL_FN =
|
|
8
|
+
const FILL_FN = elementFunctionSource(fillField);
|
|
9
|
+
const FILL_OPTIONS: FillOptions = { rejectSelect: false, focusFirst: true };
|
|
8
10
|
|
|
9
11
|
const FieldValue = Type.Union([Type.String(), Type.Boolean()], {
|
|
10
12
|
description: "Value to set. String for text inputs / textareas / selects / contenteditable; boolean for checkboxes & radios.",
|
|
@@ -28,30 +30,30 @@ export const fillFormTool = defineBrowserTool({
|
|
|
28
30
|
promptSnippet: "Fill many form fields at once by ref — React-safe, self-confirming",
|
|
29
31
|
promptGuidelines: [
|
|
30
32
|
"Preferred for forms: snapshot once, then fill all fields in a single browser_fill_form call.",
|
|
31
|
-
"Each field: { ref, value }. value is a string for text/select/contenteditable, boolean for checkbox/radio.",
|
|
32
33
|
"The result summarizes how many fields filled cleanly and flags any value mismatches or per-field errors.",
|
|
33
34
|
"Re-run browser_snapshot afterwards to verify the form state if needed.",
|
|
34
35
|
],
|
|
35
36
|
parameters: FillFormArgs,
|
|
36
37
|
concurrency: "serialized",
|
|
37
38
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
38
|
-
const results:
|
|
39
|
+
const results: JsonObject[] = [];
|
|
39
40
|
let okCount = 0;
|
|
40
41
|
const issues: string[] = [];
|
|
41
42
|
for (const f of args.fields) {
|
|
42
|
-
const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value]);
|
|
43
|
+
const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value, FILL_OPTIONS]);
|
|
43
44
|
if (!r.success) {
|
|
44
45
|
results.push({ ref: f.ref, ok: false, error: r.error.message });
|
|
45
46
|
issues.push(`ref ${f.ref} (${r.error.message})`);
|
|
46
47
|
continue;
|
|
47
48
|
}
|
|
48
49
|
const res = r.data;
|
|
49
|
-
const wanted = typeof f.value === "boolean" ? undefined :
|
|
50
|
-
const got = res.value
|
|
51
|
-
const
|
|
52
|
-
const
|
|
50
|
+
const wanted = typeof f.value === "boolean" ? undefined : f.value;
|
|
51
|
+
const got = res.ok && "value" in res ? res.value : undefined;
|
|
52
|
+
const label = res.ok && res.kind === "select" ? res.text : undefined;
|
|
53
|
+
const mismatch = wanted !== undefined && got !== undefined && got !== wanted && label !== wanted;
|
|
54
|
+
const fieldOk = res.ok && !mismatch;
|
|
53
55
|
if (fieldOk) okCount++;
|
|
54
|
-
else issues.push(`ref ${f.ref} (${res.ok
|
|
56
|
+
else issues.push(`ref ${f.ref} (${res.ok ? "value mismatch" : res.reason})`);
|
|
55
57
|
results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) });
|
|
56
58
|
}
|
|
57
59
|
const text =
|
package/src/domains/form.ts
CHANGED
|
@@ -1,10 +1,47 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { type Result, err, ok } from "../util/result";
|
|
3
|
-
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
|
-
import { detailsOf,
|
|
5
|
-
import {
|
|
3
|
+
import { defineBrowserTool, type JsonObject, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
|
+
import { detailsOf, type ElementOutcome, isPageResult, resolveAndCall, runOnElement } from "./element-call";
|
|
5
|
+
import { fillField, type FillOptions } from "./page/fill.page";
|
|
6
|
+
import { type FocusResult, focusElement, type SelectOption, type SelectResult, type SelectWanted, selectOption, setChecked } from "./page/form.page";
|
|
7
|
+
import { elementFunctionSource } from "./page/inject";
|
|
6
8
|
import { interactiveDiff } from "./ref-resolve";
|
|
7
9
|
|
|
10
|
+
type Target = { readonly ref?: string | undefined; readonly selector?: string | undefined };
|
|
11
|
+
|
|
12
|
+
const targetLabel = (t: Target): string => t.ref ?? t.selector ?? "";
|
|
13
|
+
|
|
14
|
+
// Only the locator the caller actually passed goes into details; an undefined key would not survive pi's JSON store.
|
|
15
|
+
const targetDetails = (t: Target): JsonObject => ({
|
|
16
|
+
...(t.ref !== undefined ? { ref: t.ref } : {}),
|
|
17
|
+
...(t.selector !== undefined ? { selector: t.selector } : {}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const noMatch = (t: Target): ToolErr => ({
|
|
21
|
+
kind: "invalid_state",
|
|
22
|
+
message: `No element matched: ${targetLabel(t)}`,
|
|
23
|
+
details: targetDetails(t),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// A page function that returned something the guard does not recognise is a harness bug, not a missing element.
|
|
27
|
+
const badShape = (t: Target, what: string): ToolErr => ({
|
|
28
|
+
kind: "internal",
|
|
29
|
+
message: `unexpected page result shape from ${what}`,
|
|
30
|
+
details: targetDetails(t),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Narrows an element outcome to the page's typed result, or returns the error to surface.
|
|
34
|
+
const narrow = <T>(
|
|
35
|
+
outcome: ElementOutcome,
|
|
36
|
+
t: Target,
|
|
37
|
+
what: string,
|
|
38
|
+
guard: (v: unknown) => v is T,
|
|
39
|
+
): Result<T, ToolErr> => {
|
|
40
|
+
if (!outcome.found) return err(noMatch(t));
|
|
41
|
+
if (!guard(outcome.value)) return err(badShape(t, what));
|
|
42
|
+
return ok(outcome.value);
|
|
43
|
+
};
|
|
44
|
+
|
|
8
45
|
const FillArgs = Type.Object({
|
|
9
46
|
ref: Type.Optional(
|
|
10
47
|
Type.String({
|
|
@@ -16,9 +53,8 @@ const FillArgs = Type.Object({
|
|
|
16
53
|
value: Type.String({ description: "Value to set in the field" }),
|
|
17
54
|
});
|
|
18
55
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
const NOT_FOUND: PageResult = { ok: false, reason: "not_found" };
|
|
56
|
+
const FILL_FN = elementFunctionSource(fillField);
|
|
57
|
+
const FILL_OPTIONS: FillOptions = { rejectSelect: true, focusFirst: false };
|
|
22
58
|
|
|
23
59
|
export const fillTool = defineBrowserTool({
|
|
24
60
|
name: "browser_fill",
|
|
@@ -27,63 +63,39 @@ export const fillTool = defineBrowserTool({
|
|
|
27
63
|
"Fill a form field (input/textarea/contenteditable), or tick a checkbox/radio by passing 'true'/'false'. 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. Refuses a disabled field rather than writing a value the page will ignore. Returns the field's value after writing, plus a compact diff of page changes.",
|
|
28
64
|
promptSnippet: "Fill a form field by ref (preferred) or selector (works with React/Vue controlled inputs)",
|
|
29
65
|
promptGuidelines: [
|
|
30
|
-
"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.",
|
|
31
66
|
"Pass the desired value; no browser_click is needed first.",
|
|
32
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.",
|
|
33
|
-
"A disabled field is refused: the error says so, rather than reporting a write the page never accepted.",
|
|
34
|
-
"A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
|
|
35
68
|
"Use browser_type instead only for keystroke-sensitive widgets (autocomplete, masked/segmented inputs).",
|
|
36
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.",
|
|
37
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.",
|
|
38
|
-
"
|
|
71
|
+
"Confirm the field value browser_fill returns matches what you intended.",
|
|
39
72
|
],
|
|
40
73
|
parameters: FillArgs,
|
|
41
74
|
concurrency: "serialized",
|
|
42
75
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
43
|
-
const r = await runOnElement(client, {
|
|
44
|
-
ref: args.ref,
|
|
45
|
-
selector: args.selector,
|
|
46
|
-
fnBody: FILL_BODY,
|
|
47
|
-
arg: args.value,
|
|
48
|
-
notFound: NOT_FOUND,
|
|
49
|
-
});
|
|
76
|
+
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: FILL_FN, args: [args.value, FILL_OPTIONS] });
|
|
50
77
|
if (!r.success) return r;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
kind: "invalid_state",
|
|
56
|
-
message: `No element matched: ${target}`,
|
|
57
|
-
details: { ref: args.ref, selector: args.selector },
|
|
58
|
-
});
|
|
59
|
-
}
|
|
78
|
+
const narrowed = narrow(r.data, args, "browser_fill", isPageResult);
|
|
79
|
+
if (!narrowed.success) return narrowed;
|
|
80
|
+
const res = narrowed.data;
|
|
81
|
+
const target = targetLabel(args);
|
|
60
82
|
if (!res.ok) {
|
|
61
83
|
const details = { ref: args.ref, selector: args.selector, ...(res.tag !== undefined ? { tag: res.tag } : {}) };
|
|
62
|
-
if (res.
|
|
84
|
+
if (res.code === "select_rejected") {
|
|
63
85
|
return err({
|
|
64
86
|
kind: "invalid_state",
|
|
65
87
|
message: `Element <select> is not fillable (not an input, textarea, or contenteditable): ${target}. For <select> use browser_select_option.`,
|
|
66
88
|
details,
|
|
67
89
|
});
|
|
68
90
|
}
|
|
69
|
-
return err({
|
|
70
|
-
kind: "invalid_state",
|
|
71
|
-
message: `Could not fill ${target}: ${res.reason ?? "unknown reason"}`,
|
|
72
|
-
details,
|
|
73
|
-
});
|
|
91
|
+
return err({ kind: "invalid_state", message: `Could not fill ${target}: ${res.reason}`, details });
|
|
74
92
|
}
|
|
75
|
-
const
|
|
93
|
+
const verified = "checked" in res ? res.checked : res.value;
|
|
94
|
+
const shown = "checked" in res ? `checked=${res.checked}` : JSON.stringify(res.value);
|
|
76
95
|
const diff = await interactiveDiff(client);
|
|
77
96
|
return ok({
|
|
78
97
|
text: `Filled ${target} = ${shown}${diff}`,
|
|
79
|
-
details: {
|
|
80
|
-
ref: args.ref,
|
|
81
|
-
selector: args.selector,
|
|
82
|
-
value: args.value,
|
|
83
|
-
verified: res.value ?? res.checked,
|
|
84
|
-
...(res.tag !== undefined ? { tag: res.tag } : {}),
|
|
85
|
-
...(res.kind !== undefined ? { kind: res.kind } : {}),
|
|
86
|
-
},
|
|
98
|
+
details: { ...targetDetails(args), value: args.value, verified, tag: res.tag, kind: res.kind },
|
|
87
99
|
});
|
|
88
100
|
},
|
|
89
101
|
});
|
|
@@ -95,21 +107,13 @@ const FocusArgs = Type.Object({
|
|
|
95
107
|
selector: Type.Optional(Type.String({ description: "CSS selector of the element to focus (fallback when no ref)" })),
|
|
96
108
|
});
|
|
97
109
|
|
|
98
|
-
type FocusResult = { status: "ok"; tag: string } | { status: "not_found" } | { status: "not_focusable"; tag: string };
|
|
99
|
-
|
|
100
110
|
const isFocusResult = (v: unknown): v is FocusResult => {
|
|
101
111
|
if (typeof v !== "object" || v === null || !("status" in v)) return false;
|
|
102
|
-
if (v.status === "not_found") return true;
|
|
103
112
|
if (v.status === "ok" || v.status === "not_focusable") return "tag" in v && typeof v.tag === "string";
|
|
104
113
|
return false;
|
|
105
114
|
};
|
|
106
115
|
|
|
107
|
-
const FOCUS_FN =
|
|
108
|
-
const el = this;
|
|
109
|
-
if (typeof el.focus !== "function") return { status: "not_focusable", tag: el.tagName };
|
|
110
|
-
el.focus();
|
|
111
|
-
return document.activeElement === el ? { status: "ok", tag: el.tagName } : { status: "not_focusable", tag: el.tagName };
|
|
112
|
-
`;
|
|
116
|
+
const FOCUS_FN = elementFunctionSource(focusElement);
|
|
113
117
|
|
|
114
118
|
export const focusTool = defineBrowserTool({
|
|
115
119
|
name: "browser_focus",
|
|
@@ -118,24 +122,21 @@ export const focusTool = defineBrowserTool({
|
|
|
118
122
|
"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.",
|
|
119
123
|
promptSnippet: "Focus an element by ref (preferred) or selector",
|
|
120
124
|
promptGuidelines: [
|
|
121
|
-
"PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders.",
|
|
122
|
-
"Use before browser_type to guarantee the right field is focused without relying on click coordinates.",
|
|
123
125
|
"For simply setting a value, prefer browser_fill (it doesn't need a separate focus step).",
|
|
124
126
|
],
|
|
125
127
|
parameters: FocusArgs,
|
|
126
128
|
concurrency: "serialized",
|
|
127
129
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
128
|
-
const r = await runOnElement(client, { ref: args.ref, selector: args.selector,
|
|
130
|
+
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: FOCUS_FN, args: [] });
|
|
129
131
|
if (!r.success) return r;
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
132
|
+
const narrowed = narrow(r.data, args, "browser_focus", isFocusResult);
|
|
133
|
+
if (!narrowed.success) return narrowed;
|
|
134
|
+
const res = narrowed.data;
|
|
135
|
+
const target = targetLabel(args);
|
|
135
136
|
if (res.status === "not_focusable") {
|
|
136
|
-
return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> could not be focused: ${target}`, details: {
|
|
137
|
+
return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> could not be focused: ${target}`, details: { ...targetDetails(args), tag: res.tag } });
|
|
137
138
|
}
|
|
138
|
-
return ok({ text: `Focused ${target}`, details: {
|
|
139
|
+
return ok({ text: `Focused ${target}`, details: { ...targetDetails(args), tag: res.tag } });
|
|
139
140
|
},
|
|
140
141
|
});
|
|
141
142
|
|
|
@@ -149,20 +150,13 @@ const SelectOptionArgs = Type.Object({
|
|
|
149
150
|
index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based option index to select" })),
|
|
150
151
|
});
|
|
151
152
|
|
|
152
|
-
|
|
153
|
-
| { status: "ok"; value: string; label: string }
|
|
154
|
-
| { status: "not_found" }
|
|
155
|
-
| { status: "not_select"; tag: string }
|
|
156
|
-
| { status: "no_match"; options: ReadonlyArray<{ value: string; label: string }> };
|
|
157
|
-
|
|
158
|
-
const isSelectOption = (v: unknown): v is { value: string; label: string } =>
|
|
153
|
+
const isSelectOption = (v: unknown): v is SelectOption =>
|
|
159
154
|
typeof v === "object" && v !== null
|
|
160
155
|
&& "value" in v && typeof v.value === "string"
|
|
161
156
|
&& "label" in v && typeof v.label === "string";
|
|
162
157
|
|
|
163
158
|
const isSelectResult = (v: unknown): v is SelectResult => {
|
|
164
159
|
if (typeof v !== "object" || v === null || !("status" in v)) return false;
|
|
165
|
-
if (v.status === "not_found") return true;
|
|
166
160
|
if (v.status === "not_select") return "tag" in v && typeof v.tag === "string";
|
|
167
161
|
if (v.status === "ok") {
|
|
168
162
|
return "value" in v && typeof v.value === "string" && "label" in v && typeof v.label === "string";
|
|
@@ -173,21 +167,7 @@ const isSelectResult = (v: unknown): v is SelectResult => {
|
|
|
173
167
|
return false;
|
|
174
168
|
};
|
|
175
169
|
|
|
176
|
-
const SELECT_FN =
|
|
177
|
-
const el = this;
|
|
178
|
-
if (el.tagName !== "SELECT") return { status: "not_select", tag: el.tagName };
|
|
179
|
-
const opts = Array.from(el.options);
|
|
180
|
-
const wv = arg.wv, wl = arg.wl, wi = arg.wi;
|
|
181
|
-
let opt = null;
|
|
182
|
-
if (wi !== null) opt = opts[wi] || null;
|
|
183
|
-
else if (wv !== null) opt = opts.find(o => o.value === wv) || null;
|
|
184
|
-
else if (wl !== null) opt = opts.find(o => o.text === wl) || null;
|
|
185
|
-
if (!opt) return { status: "no_match", options: opts.map(o => ({ value: o.value, label: o.text })) };
|
|
186
|
-
el.value = opt.value;
|
|
187
|
-
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
188
|
-
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
189
|
-
return { status: "ok", value: opt.value, label: opt.text };
|
|
190
|
-
`;
|
|
170
|
+
const SELECT_FN = elementFunctionSource(selectOption);
|
|
191
171
|
|
|
192
172
|
export const selectOptionTool = defineBrowserTool({
|
|
193
173
|
name: "browser_select_option",
|
|
@@ -196,60 +176,41 @@ export const selectOptionTool = defineBrowserTool({
|
|
|
196
176
|
"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.",
|
|
197
177
|
promptSnippet: "Select an option in a native <select> by ref (preferred) or selector",
|
|
198
178
|
promptGuidelines: [
|
|
199
|
-
"PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders.",
|
|
200
179
|
"Provide exactly one of value, label, or index to choose the option.",
|
|
201
180
|
"For custom (non-native) dropdowns built from divs, click to open then browser_click the option instead.",
|
|
202
|
-
"If no option matches, the error details list the available options so you can retry.",
|
|
203
181
|
],
|
|
204
182
|
parameters: SelectOptionArgs,
|
|
205
183
|
concurrency: "serialized",
|
|
206
184
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
207
185
|
if (args.value === undefined && args.label === undefined && args.index === undefined) {
|
|
208
|
-
return err({ kind: "
|
|
186
|
+
return err({ kind: "invalid_args", message: "Provide one of value, label, or index." });
|
|
209
187
|
}
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: SELECT_FN, arg });
|
|
188
|
+
const wanted: SelectWanted = { wv: args.value ?? null, wl: args.label ?? null, wi: args.index ?? null };
|
|
189
|
+
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: SELECT_FN, args: [wanted] });
|
|
213
190
|
if (!r.success) return r;
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
191
|
+
const narrowed = narrow(r.data, args, "browser_select_option", isSelectResult);
|
|
192
|
+
if (!narrowed.success) return narrowed;
|
|
193
|
+
const res = narrowed.data;
|
|
194
|
+
const target = targetLabel(args);
|
|
219
195
|
if (res.status === "not_select") {
|
|
220
|
-
return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> is not a <select>: ${target}`, details: {
|
|
196
|
+
return err({ kind: "invalid_state", message: `Element <${res.tag.toLowerCase()}> is not a <select>: ${target}`, details: { ...targetDetails(args), tag: res.tag } });
|
|
221
197
|
}
|
|
222
198
|
if (res.status === "no_match") {
|
|
223
199
|
return err({
|
|
224
200
|
kind: "invalid_state",
|
|
225
201
|
message: `No matching option in ${target}. Available: ${res.options.map((o) => o.label).join(", ")}`,
|
|
226
|
-
details: {
|
|
202
|
+
details: { ...targetDetails(args), options: res.options },
|
|
227
203
|
});
|
|
228
204
|
}
|
|
229
205
|
const diff = await interactiveDiff(client);
|
|
230
206
|
return ok({
|
|
231
207
|
text: `Selected "${res.label}" (value=${JSON.stringify(res.value)}) in ${target}${diff}`,
|
|
232
|
-
details: {
|
|
208
|
+
details: { ...targetDetails(args), value: res.value, label: res.label },
|
|
233
209
|
});
|
|
234
210
|
},
|
|
235
211
|
});
|
|
236
212
|
|
|
237
|
-
const CHECK_FN =
|
|
238
|
-
function(checked) {
|
|
239
|
-
const el = this;
|
|
240
|
-
const tag = (el.tagName || "").toLowerCase();
|
|
241
|
-
const type = (el.type || "").toLowerCase();
|
|
242
|
-
if (tag !== "input" || (type !== "checkbox" && type !== "radio")) return { ok: false, reason: "ref is not a checkbox or radio" };
|
|
243
|
-
if (el.disabled) return { ok: false, reason: "element is disabled" };
|
|
244
|
-
const want = checked === true;
|
|
245
|
-
let changed = false;
|
|
246
|
-
if (el.checked !== want) {
|
|
247
|
-
try { el.click(); } catch (e) {}
|
|
248
|
-
if (el.checked !== want) { el.checked = want; el.dispatchEvent(new Event("change", { bubbles: true })); }
|
|
249
|
-
changed = true;
|
|
250
|
-
}
|
|
251
|
-
return { ok: true, checked: el.checked, changed: changed };
|
|
252
|
-
}`;
|
|
213
|
+
const CHECK_FN = elementFunctionSource(setChecked);
|
|
253
214
|
|
|
254
215
|
const SetCheckedArgs = Type.Object({
|
|
255
216
|
ref: Type.String({ description: "Stable element ref of a checkbox or radio from browser_snapshot (e.g. 'e7')." }),
|
|
@@ -263,7 +224,6 @@ export const setCheckedTool = defineBrowserTool({
|
|
|
263
224
|
"Set a checkbox or radio to a desired checked state by ref. Only acts if the current state differs, fires change, and reports the final state. Get the ref from browser_snapshot's [eN].",
|
|
264
225
|
promptSnippet: "Set a checkbox/radio checked state by ref",
|
|
265
226
|
promptGuidelines: [
|
|
266
|
-
"checked:true ticks the box, checked:false unticks it. Idempotent — no-op if already in the desired state.",
|
|
267
227
|
"For radios, set checked:true on the option you want; the group deselects the others.",
|
|
268
228
|
],
|
|
269
229
|
parameters: SetCheckedArgs,
|
|
@@ -273,10 +233,13 @@ export const setCheckedTool = defineBrowserTool({
|
|
|
273
233
|
if (!r.success) return r;
|
|
274
234
|
const res = r.data;
|
|
275
235
|
if (!res.ok) {
|
|
276
|
-
return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason
|
|
236
|
+
return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason}`, details: detailsOf(args.ref, res) });
|
|
237
|
+
}
|
|
238
|
+
if (!("checked" in res)) {
|
|
239
|
+
return err({ kind: "internal", message: `unexpected page result shape from browser_set_checked (kind ${res.kind})`, details: { ref: args.ref } });
|
|
277
240
|
}
|
|
278
241
|
return ok({
|
|
279
|
-
text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed
|
|
242
|
+
text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed ? "" : " (already)"),
|
|
280
243
|
details: detailsOf(args.ref, res),
|
|
281
244
|
});
|
|
282
245
|
},
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { BrowserClient } from "../client";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
5
|
+
import { cdpCall } from "./cdp-call";
|
|
6
|
+
|
|
7
|
+
const HeaderValue = Type.String({
|
|
8
|
+
maxLength: 8192,
|
|
9
|
+
pattern: "^[^\\r\\n]*$",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const SetHeadersArgs = Type.Object({
|
|
13
|
+
headers: Type.Record(Type.String(), HeaderValue, {
|
|
14
|
+
minProperties: 1,
|
|
15
|
+
maxProperties: 64,
|
|
16
|
+
description: "Extra HTTP request headers for the current owned tab. Names must be valid HTTP tokens.",
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
21
|
+
|
|
22
|
+
const currentOwnedTarget = (
|
|
23
|
+
client: BrowserClient,
|
|
24
|
+
): Result<{ readonly targetId: string }, ToolErr> => {
|
|
25
|
+
const current = client.current();
|
|
26
|
+
if (current === null) {
|
|
27
|
+
return err({ kind: "invalid_state", message: "No tab attached." });
|
|
28
|
+
}
|
|
29
|
+
if (!client.owns(current.targetId)) {
|
|
30
|
+
return err({
|
|
31
|
+
kind: "invalid_state",
|
|
32
|
+
message: `Current tab ${current.targetId} is not owned by this harness session.`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return ok({ targetId: current.targetId });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const setHeadersTool = defineBrowserTool({
|
|
39
|
+
name: "browser_set_headers",
|
|
40
|
+
label: "Browser Set Headers",
|
|
41
|
+
description:
|
|
42
|
+
"Set extra HTTP request headers on the current owned tab. Headers persist for subsequent document, subresource, fetch, and XHR requests until cleared or the tab closes.",
|
|
43
|
+
promptSnippet: "Set extra request headers on the current owned tab",
|
|
44
|
+
promptGuidelines: [
|
|
45
|
+
"If no tab is attached yet, call browser_new_tab without a URL first.",
|
|
46
|
+
"Call before navigation when a site requires routing or environment headers.",
|
|
47
|
+
"Call browser_clear_headers before switching tabs or navigating this tab to another origin.",
|
|
48
|
+
"Header values are never echoed in tool output.",
|
|
49
|
+
],
|
|
50
|
+
parameters: SetHeadersArgs,
|
|
51
|
+
concurrency: "serialized",
|
|
52
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
53
|
+
const target = currentOwnedTarget(client);
|
|
54
|
+
if (!target.success) return target;
|
|
55
|
+
const invalidName = Object.keys(args.headers).find((name) => !HEADER_NAME.test(name));
|
|
56
|
+
if (invalidName !== undefined) {
|
|
57
|
+
return err({ kind: "invalid_args", message: `Invalid HTTP header name: ${JSON.stringify(invalidName)}` });
|
|
58
|
+
}
|
|
59
|
+
const result = await cdpCall(client, "Network.setExtraHTTPHeaders", { headers: args.headers });
|
|
60
|
+
if (!result.success) return result;
|
|
61
|
+
const headerNames = Object.keys(args.headers).sort();
|
|
62
|
+
return ok({
|
|
63
|
+
text: `Set ${headerNames.length} extra HTTP header(s) on tab ${target.data.targetId}: ${headerNames.join(", ")}. Values are hidden. Clear them before switching tabs or navigating to another origin.`,
|
|
64
|
+
details: { targetId: target.data.targetId, headerNames, count: headerNames.length },
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const clearHeadersTool = defineBrowserTool({
|
|
70
|
+
name: "browser_clear_headers",
|
|
71
|
+
label: "Browser Clear Headers",
|
|
72
|
+
description: "Clear all extra HTTP request headers previously set on the current owned tab.",
|
|
73
|
+
promptSnippet: "Clear extra request headers from the current owned tab",
|
|
74
|
+
promptGuidelines: [
|
|
75
|
+
"Call after finishing an environment-specific workflow, before switching tabs, or before navigating to another origin.",
|
|
76
|
+
],
|
|
77
|
+
parameters: Type.Object({}),
|
|
78
|
+
concurrency: "serialized",
|
|
79
|
+
async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
80
|
+
const target = currentOwnedTarget(client);
|
|
81
|
+
if (!target.success) return target;
|
|
82
|
+
const result = await cdpCall(client, "Network.setExtraHTTPHeaders", { headers: {} });
|
|
83
|
+
if (!result.success) return result;
|
|
84
|
+
return ok({
|
|
85
|
+
text: `Cleared extra HTTP headers from tab ${target.data.targetId}.`,
|
|
86
|
+
details: { targetId: target.data.targetId },
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
});
|