pi-browser-harness 0.10.2 → 0.11.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 +28 -0
- package/README.md +5 -9
- package/package.json +5 -2
- package/skills/pi-browser-harness/SKILL.md +1 -1
- package/src/cdp/commands.ts +256 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +28 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +150 -203
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +18 -68
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -32
- package/src/domains/console.ts +48 -2
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +22 -26
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +65 -0
- package/src/domains/form.ts +116 -105
- package/src/domains/history.ts +13 -9
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +29 -42
- package/src/domains/keyboard.ts +36 -27
- package/src/domains/navigate.ts +16 -23
- package/src/domains/network.ts +60 -10
- package/src/domains/page.ts +24 -3
- package/src/domains/readpage/read-page.ts +74 -19
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -86
- package/src/domains/scroll.ts +10 -11
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -20
- package/src/domains/setup.ts +1 -11
- package/src/domains/snapshot.ts +35 -276
- package/src/domains/tabs.ts +14 -7
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -3
- package/src/index.ts +8 -58
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/registry.ts +30 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +16 -16
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
package/src/domains/drag.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
-
import { type Result,
|
|
2
|
+
import { type Result, ok } from "../util/result";
|
|
3
3
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
|
+
import { cdpCall } from "./cdp-call";
|
|
4
5
|
|
|
5
6
|
const DragArgs = Type.Object({
|
|
6
7
|
startX: Type.Number(),
|
|
@@ -24,6 +25,7 @@ export const dragAndDropTool = defineBrowserTool({
|
|
|
24
25
|
"dataTransfer is an optional MIME→data map (e.g., { 'text/plain': 'hello' }).",
|
|
25
26
|
],
|
|
26
27
|
parameters: DragArgs,
|
|
28
|
+
concurrency: "serialized",
|
|
27
29
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
28
30
|
const data = args.dataTransfer
|
|
29
31
|
? {
|
|
@@ -34,12 +36,11 @@ export const dragAndDropTool = defineBrowserTool({
|
|
|
34
36
|
dragOperationsMask: 1,
|
|
35
37
|
}
|
|
36
38
|
: { items: [], dragOperationsMask: 1 };
|
|
37
|
-
|
|
38
|
-
const calls: ReadonlyArray<
|
|
39
|
+
type InputCall = readonly ["Input.dispatchMouseEvent" | "Input.dispatchDragEvent", Record<string, unknown>];
|
|
40
|
+
const calls: ReadonlyArray<InputCall> = [
|
|
39
41
|
["Input.dispatchMouseEvent", { type: "mousePressed", x: args.startX, y: args.startY, button: "left", clickCount: 1 }],
|
|
40
42
|
["Input.dispatchDragEvent", { type: "dragEnter", x: args.startX, y: args.startY, data, modifiers: 0 }],
|
|
41
|
-
|
|
42
|
-
...Array.from({ length: 5 }, (_, i): readonly [string, Record<string, unknown>] => {
|
|
43
|
+
...Array.from({ length: 5 }, (_, i): InputCall => {
|
|
43
44
|
const t = (i + 1) / 5;
|
|
44
45
|
return [
|
|
45
46
|
"Input.dispatchDragEvent",
|
|
@@ -56,8 +57,8 @@ export const dragAndDropTool = defineBrowserTool({
|
|
|
56
57
|
["Input.dispatchMouseEvent", { type: "mouseReleased", x: args.endX, y: args.endY, button: "left", clickCount: 1 }],
|
|
57
58
|
];
|
|
58
59
|
for (const [method, params] of calls) {
|
|
59
|
-
const r = await
|
|
60
|
-
if (!r.success) return
|
|
60
|
+
const r = await cdpCall(client, method, params);
|
|
61
|
+
if (!r.success) return r;
|
|
61
62
|
}
|
|
62
63
|
return ok({
|
|
63
64
|
text: `Dragged from (${args.startX},${args.startY}) to (${args.endX},${args.endY})`,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { BrowserClient } from "../client";
|
|
2
|
+
import { safeJs } from "../util/js-template";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
import type { ToolErr } from "../util/tool";
|
|
5
|
+
import { cdpCall, evalJs } from "./cdp-call";
|
|
6
|
+
import { resolveRefToObjectId } from "./ref-resolve";
|
|
7
|
+
|
|
8
|
+
export type PageResult = {
|
|
9
|
+
readonly ok: boolean;
|
|
10
|
+
readonly reason?: string;
|
|
11
|
+
readonly kind?: string;
|
|
12
|
+
readonly value?: unknown;
|
|
13
|
+
readonly text?: string;
|
|
14
|
+
readonly checked?: boolean;
|
|
15
|
+
readonly changed?: boolean;
|
|
16
|
+
readonly tag?: string;
|
|
17
|
+
readonly options?: ReadonlyArray<{ value: string; text: string }>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const isOption = (v: unknown): v is { readonly value: string; readonly text: string } =>
|
|
21
|
+
typeof v === "object" && v !== null
|
|
22
|
+
&& "value" in v && typeof v.value === "string"
|
|
23
|
+
&& "text" in v && typeof v.text === "string";
|
|
24
|
+
|
|
25
|
+
export const isPageResult = (v: unknown): v is PageResult => {
|
|
26
|
+
if (typeof v !== "object" || v === null) return false;
|
|
27
|
+
if (!("ok" in v) || typeof v.ok !== "boolean") return false;
|
|
28
|
+
if ("reason" in v && typeof v.reason !== "string") return false;
|
|
29
|
+
if ("kind" in v && typeof v.kind !== "string") return false;
|
|
30
|
+
if ("text" in v && typeof v.text !== "string") return false;
|
|
31
|
+
if ("checked" in v && typeof v.checked !== "boolean") return false;
|
|
32
|
+
if ("changed" in v && typeof v.changed !== "boolean") return false;
|
|
33
|
+
if ("tag" in v && typeof v.tag !== "string") return false;
|
|
34
|
+
if ("options" in v && !(Array.isArray(v.options) && v.options.every(isOption))) return false;
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const runOnElement = async (
|
|
39
|
+
client: BrowserClient,
|
|
40
|
+
opts: {
|
|
41
|
+
ref?: string | undefined;
|
|
42
|
+
selector?: string | undefined;
|
|
43
|
+
fnBody: string;
|
|
44
|
+
arg: unknown;
|
|
45
|
+
notFound?: unknown;
|
|
46
|
+
},
|
|
47
|
+
): Promise<Result<unknown, ToolErr>> => {
|
|
48
|
+
if (opts.ref !== undefined) {
|
|
49
|
+
const objectId = await resolveRefToObjectId(client, opts.ref);
|
|
50
|
+
if (!objectId.success) return objectId;
|
|
51
|
+
const r = await cdpCall(client, "Runtime.callFunctionOn", {
|
|
52
|
+
objectId: objectId.data,
|
|
53
|
+
functionDeclaration: `function (arg) { ${opts.fnBody} }`,
|
|
54
|
+
arguments: [{ value: opts.arg }],
|
|
55
|
+
returnByValue: true,
|
|
56
|
+
});
|
|
57
|
+
if (!r.success) return r;
|
|
58
|
+
if (r.data.exceptionDetails !== undefined) {
|
|
59
|
+
return err({
|
|
60
|
+
kind: "cdp_error",
|
|
61
|
+
message: `page function threw: ${r.data.exceptionDetails.text}`,
|
|
62
|
+
details: { ref: opts.ref },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return ok(r.data.result.value);
|
|
66
|
+
}
|
|
67
|
+
if (opts.selector === undefined) {
|
|
68
|
+
return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
|
|
69
|
+
}
|
|
70
|
+
// fnBody is harness-authored, not user input; only the selector and value are interpolated, and those go through safeJs.
|
|
71
|
+
const prelude = safeJs`
|
|
72
|
+
(() => {
|
|
73
|
+
const el = document.querySelector(${opts.selector});
|
|
74
|
+
if (!el) return ${opts.notFound ?? { status: "not_found" }};
|
|
75
|
+
const __arg = ${opts.arg};
|
|
76
|
+
return (function (arg) {`;
|
|
77
|
+
const expr = `${prelude} ${opts.fnBody} }).call(el, __arg); })()`;
|
|
78
|
+
const r = await evalJs(client, expr);
|
|
79
|
+
if (!r.success) return r;
|
|
80
|
+
return ok(r.data);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const resolveAndCall = async (
|
|
84
|
+
client: BrowserClient,
|
|
85
|
+
ref: string,
|
|
86
|
+
functionDeclaration: string,
|
|
87
|
+
args: ReadonlyArray<unknown>,
|
|
88
|
+
): Promise<Result<PageResult, ToolErr>> => {
|
|
89
|
+
const objectId = await resolveRefToObjectId(client, ref);
|
|
90
|
+
if (!objectId.success) return objectId;
|
|
91
|
+
const handle = objectId.data;
|
|
92
|
+
try {
|
|
93
|
+
const called = await cdpCall(client, "Runtime.callFunctionOn", {
|
|
94
|
+
objectId: handle,
|
|
95
|
+
functionDeclaration,
|
|
96
|
+
arguments: args.map((v) => ({ value: v })),
|
|
97
|
+
returnByValue: true,
|
|
98
|
+
awaitPromise: true,
|
|
99
|
+
});
|
|
100
|
+
if (!called.success) return err({ ...called.error, details: { ref } });
|
|
101
|
+
if (called.data.exceptionDetails !== undefined) {
|
|
102
|
+
return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(called.data.exceptionDetails)}`, details: { ref } });
|
|
103
|
+
}
|
|
104
|
+
const value = called.data.result.value;
|
|
105
|
+
if (!isPageResult(value)) {
|
|
106
|
+
return err({ kind: "internal", message: "page function returned no result object", details: { ref } });
|
|
107
|
+
}
|
|
108
|
+
return ok(value);
|
|
109
|
+
} finally {
|
|
110
|
+
await cdpCall(client, "Runtime.releaseObject", { objectId: handle });
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const detailsOf = (ref: string, res: PageResult, extra?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => {
|
|
115
|
+
const { ok: _ok, ...rest } = res;
|
|
116
|
+
return { ref, ...rest, ...(extra ?? {}) };
|
|
117
|
+
};
|
package/src/domains/files.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type Result, err, ok } from "../util/result";
|
|
|
6
6
|
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
|
+
import { cdpCall, cdpCallBrowser, evalJs } from "./cdp-call";
|
|
9
10
|
import { resolveRefToBackendId } from "./ref-resolve";
|
|
10
11
|
|
|
11
12
|
const UploadArgs = Type.Object({
|
|
@@ -16,15 +17,13 @@ const UploadArgs = Type.Object({
|
|
|
16
17
|
filePath: Type.String({ description: "Absolute path to the file to upload" }),
|
|
17
18
|
});
|
|
18
19
|
|
|
19
|
-
// Ref path: set files directly on the resolved backendNodeId via CDP — simplest
|
|
20
|
-
// and most reliable, no selector needed.
|
|
21
20
|
const tryRefUpload = async (
|
|
22
21
|
client: BrowserClient,
|
|
23
22
|
backendId: number,
|
|
24
23
|
filePath: string,
|
|
25
24
|
): Promise<Result<void, ToolErr>> => {
|
|
26
|
-
const set = await client
|
|
27
|
-
if (!set.success) return
|
|
25
|
+
const set = await cdpCall(client, "DOM.setFileInputFiles", { files: [filePath], backendNodeId: backendId });
|
|
26
|
+
if (!set.success) return set;
|
|
28
27
|
return ok(undefined);
|
|
29
28
|
};
|
|
30
29
|
|
|
@@ -45,19 +44,16 @@ const tryCdpUpload = async (
|
|
|
45
44
|
selector: string,
|
|
46
45
|
filePath: string,
|
|
47
46
|
): Promise<Result<void, ToolErr>> => {
|
|
48
|
-
const doc = await client
|
|
49
|
-
if (!doc.success) return
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
if (!q.success) return err({ kind: "cdp_error", message: q.error.message });
|
|
54
|
-
// CDP boundary cast: DOM.querySelector returns { nodeId: number }
|
|
55
|
-
const nodeId = (q.data as { nodeId: number }).nodeId;
|
|
47
|
+
const doc = await cdpCall(client, "DOM.getDocument", { depth: -1 });
|
|
48
|
+
if (!doc.success) return doc;
|
|
49
|
+
const q = await cdpCall(client, "DOM.querySelector", { nodeId: doc.data.root.nodeId, selector });
|
|
50
|
+
if (!q.success) return q;
|
|
51
|
+
const nodeId = q.data.nodeId;
|
|
56
52
|
if (!nodeId) return err({ kind: "invalid_state", message: `Selector matched 0 file inputs: ${selector}` });
|
|
57
|
-
const set = await client
|
|
58
|
-
if (!set.success) return
|
|
59
|
-
const verify = await client
|
|
60
|
-
if (!verify.success) return
|
|
53
|
+
const set = await cdpCall(client, "DOM.setFileInputFiles", { files: [filePath], nodeId });
|
|
54
|
+
if (!set.success) return set;
|
|
55
|
+
const verify = await evalJs(client, safeJs`document.querySelector(${selector})?.files?.length || 0`);
|
|
56
|
+
if (!verify.success) return verify;
|
|
61
57
|
if (Number(verify.data ?? 0) === 0) {
|
|
62
58
|
return err({ kind: "invalid_state", message: "CDP upload reported success but file count is 0" });
|
|
63
59
|
}
|
|
@@ -96,8 +92,8 @@ const jsFallbackUpload = async (
|
|
|
96
92
|
return input.files.length;
|
|
97
93
|
})()
|
|
98
94
|
`;
|
|
99
|
-
const r = await client
|
|
100
|
-
if (!r.success) return
|
|
95
|
+
const r = await evalJs(client, expr);
|
|
96
|
+
if (!r.success) return r;
|
|
101
97
|
if (!Number(r.data ?? 0)) return err({ kind: "invalid_state", message: "JS fallback set 0 files" });
|
|
102
98
|
return ok(undefined);
|
|
103
99
|
};
|
|
@@ -112,11 +108,11 @@ export const uploadFileTool = defineBrowserTool({
|
|
|
112
108
|
"File path must be absolute and readable.",
|
|
113
109
|
],
|
|
114
110
|
parameters: UploadArgs,
|
|
111
|
+
concurrency: "serialized",
|
|
115
112
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
116
113
|
const readable = await verifyReadable(args.filePath);
|
|
117
114
|
if (!readable.success) return readable;
|
|
118
115
|
|
|
119
|
-
// Ref path: resolve to backendNodeId and set files directly via CDP.
|
|
120
116
|
if (args.ref !== undefined) {
|
|
121
117
|
const backendId = resolveRefToBackendId(client, args.ref);
|
|
122
118
|
if (!backendId.success) return backendId;
|
|
@@ -163,6 +159,7 @@ export const downloadTool = defineBrowserTool({
|
|
|
163
159
|
"Affects all subsequent downloads on this browser.",
|
|
164
160
|
],
|
|
165
161
|
parameters: DownloadArgs,
|
|
162
|
+
concurrency: "serialized",
|
|
166
163
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
167
164
|
try {
|
|
168
165
|
await mkdir(args.downloadPath, { recursive: true });
|
|
@@ -184,12 +181,12 @@ export const downloadTool = defineBrowserTool({
|
|
|
184
181
|
message: `Download path unusable: ${e instanceof Error ? e.message : String(e)}`,
|
|
185
182
|
});
|
|
186
183
|
}
|
|
187
|
-
const r = await client
|
|
184
|
+
const r = await cdpCallBrowser(client, "Browser.setDownloadBehavior", {
|
|
188
185
|
behavior: "allow",
|
|
189
186
|
downloadPath: args.downloadPath,
|
|
190
187
|
eventsEnabled: true,
|
|
191
188
|
});
|
|
192
|
-
if (!r.success) return
|
|
189
|
+
if (!r.success) return r;
|
|
193
190
|
return ok({
|
|
194
191
|
text: `Downloads will save to: ${args.downloadPath}`,
|
|
195
192
|
details: { downloadPath: args.downloadPath },
|
|
@@ -208,16 +205,15 @@ export const printToPdfTool = defineBrowserTool({
|
|
|
208
205
|
promptSnippet: "Print the current page to PDF",
|
|
209
206
|
promptGuidelines: ["Default output path is in tmpdir; pass outputPath to control."],
|
|
210
207
|
parameters: PrintPdfArgs,
|
|
208
|
+
concurrency: "serialized",
|
|
211
209
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
212
|
-
const r = await client
|
|
210
|
+
const r = await cdpCall(client, "Page.printToPDF", {
|
|
213
211
|
printBackground: true,
|
|
214
212
|
preferCSSPageSize: true,
|
|
215
213
|
});
|
|
216
|
-
if (!r.success) return
|
|
217
|
-
// CDP boundary cast: Page.printToPDF returns { data: string } (base64-encoded PDF)
|
|
218
|
-
const data = (r.data as { data: string }).data;
|
|
214
|
+
if (!r.success) return r;
|
|
219
215
|
const path = args.outputPath ?? pdfPath(client.namespace);
|
|
220
|
-
await writeFile(path, Buffer.from(data, "base64"));
|
|
216
|
+
await writeFile(path, Buffer.from(r.data.data, "base64"));
|
|
221
217
|
return ok({ text: `PDF saved: ${path}`, details: { path } });
|
|
222
218
|
},
|
|
223
219
|
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export type FillOptions = {
|
|
2
|
+
readonly rejectSelect: boolean;
|
|
3
|
+
readonly focusFirst: boolean;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const ENGINE = `
|
|
7
|
+
const el = this;
|
|
8
|
+
if (!el || el.nodeType !== 1) return { ok: false, reason: "ref does not point to an element" };
|
|
9
|
+
const tagName = el.tagName || "";
|
|
10
|
+
const tag = tagName.toLowerCase();
|
|
11
|
+
const type = (el.type || "").toLowerCase();
|
|
12
|
+
|
|
13
|
+
if (__rejectSelect && tag === "select") {
|
|
14
|
+
return { ok: false, reason: "element is a <select>", kind: "select", tag: tagName };
|
|
15
|
+
}
|
|
16
|
+
if (el.disabled) return { ok: false, reason: "element is disabled", tag: tagName };
|
|
17
|
+
|
|
18
|
+
const fire = function (t) { el.dispatchEvent(new Event(t, { bubbles: true })); };
|
|
19
|
+
if (__focusFirst) { try { el.focus(); } catch (e) {} }
|
|
20
|
+
|
|
21
|
+
if (tag === "select") {
|
|
22
|
+
const want = String(value);
|
|
23
|
+
let matched = null;
|
|
24
|
+
for (let i = 0; i < el.options.length; i++) {
|
|
25
|
+
const o = el.options[i];
|
|
26
|
+
if (o.value === want || o.label === want || o.text === want) { matched = o; break; }
|
|
27
|
+
}
|
|
28
|
+
if (!matched) {
|
|
29
|
+
const opts = [];
|
|
30
|
+
for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text });
|
|
31
|
+
return { ok: false, reason: "no matching option", kind: "select", options: opts, tag: tagName };
|
|
32
|
+
}
|
|
33
|
+
el.value = matched.value;
|
|
34
|
+
fire("input"); fire("change");
|
|
35
|
+
return { ok: true, kind: "select", value: el.value, text: matched.text, tag: tagName };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (tag === "input" && (type === "checkbox" || type === "radio")) {
|
|
39
|
+
const want = value === true || value === "true" || value === "on" || value === 1;
|
|
40
|
+
let changed = false;
|
|
41
|
+
if (el.checked !== want) {
|
|
42
|
+
try { el.click(); } catch (e) {}
|
|
43
|
+
if (el.checked !== want) { el.checked = want; fire("input"); fire("change"); }
|
|
44
|
+
changed = true;
|
|
45
|
+
}
|
|
46
|
+
return { ok: true, kind: type, checked: el.checked, changed: changed, tag: tagName };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (el.isContentEditable) {
|
|
50
|
+
try { el.focus(); } catch (e) {}
|
|
51
|
+
let done = false;
|
|
52
|
+
try {
|
|
53
|
+
const sel = window.getSelection();
|
|
54
|
+
if (sel) { sel.removeAllRanges(); const rng = document.createRange(); rng.selectNodeContents(el); sel.addRange(rng); }
|
|
55
|
+
done = document.execCommand("insertText", false, String(value));
|
|
56
|
+
} catch (e) { done = false; }
|
|
57
|
+
if (!done) {
|
|
58
|
+
el.textContent = String(value);
|
|
59
|
+
el.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, kind: "contenteditable", value: el.textContent || "", tag: tagName };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (tag === "input" || tag === "textarea") {
|
|
65
|
+
const proto = tag === "textarea" ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
|
66
|
+
const desc = Object.getOwnPropertyDescriptor(proto, "value");
|
|
67
|
+
if (desc && desc.set) desc.set.call(el, String(value));
|
|
68
|
+
else el.value = String(value);
|
|
69
|
+
fire("input"); fire("change");
|
|
70
|
+
return { ok: true, kind: el.type || tag, value: el.value, tag: tagName };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")", tag: tagName };
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
export const fillBody = (opts: FillOptions): string =>
|
|
77
|
+
`const __rejectSelect = ${opts.rejectSelect}; const __focusFirst = ${opts.focusFirst};\n${ENGINE}`;
|
|
78
|
+
|
|
79
|
+
export const fillDeclaration = (opts: FillOptions): string =>
|
|
80
|
+
`function (value) { ${fillBody(opts)} }`;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
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";
|
|
5
|
+
import { detailsOf, resolveAndCall } from "./element-call";
|
|
6
|
+
|
|
7
|
+
const FILL_FN = fillDeclaration({ rejectSelect: false, focusFirst: true });
|
|
8
|
+
|
|
9
|
+
const FieldValue = Type.Union([Type.String(), Type.Boolean()], {
|
|
10
|
+
description: "Value to set. String for text inputs / textareas / selects / contenteditable; boolean for checkboxes & radios.",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const FillFormArgs = Type.Object({
|
|
14
|
+
fields: Type.Array(
|
|
15
|
+
Type.Object({
|
|
16
|
+
ref: Type.String({ description: "Stable element ref from browser_snapshot (e.g. 'e7')." }),
|
|
17
|
+
value: FieldValue,
|
|
18
|
+
}),
|
|
19
|
+
{ minItems: 1, description: "Fields to fill in one batch, each by ref." },
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const fillFormTool = defineBrowserTool({
|
|
24
|
+
name: "browser_fill_form",
|
|
25
|
+
label: "Browser Fill Form",
|
|
26
|
+
description:
|
|
27
|
+
"Fill multiple form fields in one call — the efficient way to complete a form. Each field is { ref, value }; refs come from browser_snapshot's [eN]. Handles text inputs, textareas, selects, checkboxes, radios, and contenteditable, drives React/Vue controlled inputs correctly, and reports the resulting value of every field.",
|
|
28
|
+
promptSnippet: "Fill many form fields at once by ref — React-safe, self-confirming",
|
|
29
|
+
promptGuidelines: [
|
|
30
|
+
"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
|
+
"The result summarizes how many fields filled cleanly and flags any value mismatches or per-field errors.",
|
|
33
|
+
"Re-run browser_snapshot afterwards to verify the form state if needed.",
|
|
34
|
+
],
|
|
35
|
+
parameters: FillFormArgs,
|
|
36
|
+
concurrency: "serialized",
|
|
37
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
38
|
+
const results: Array<Readonly<Record<string, unknown>>> = [];
|
|
39
|
+
let okCount = 0;
|
|
40
|
+
const issues: string[] = [];
|
|
41
|
+
for (const f of args.fields) {
|
|
42
|
+
const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value]);
|
|
43
|
+
if (!r.success) {
|
|
44
|
+
results.push({ ref: f.ref, ok: false, error: r.error.message });
|
|
45
|
+
issues.push(`ref ${f.ref} (${r.error.message})`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const res = r.data;
|
|
49
|
+
const wanted = typeof f.value === "boolean" ? undefined : String(f.value);
|
|
50
|
+
const got = res.value !== undefined ? String(res.value) : undefined;
|
|
51
|
+
const mismatch = wanted !== undefined && got !== undefined && got !== wanted && res.text !== wanted;
|
|
52
|
+
const fieldOk = res.ok === true && !mismatch;
|
|
53
|
+
if (fieldOk) okCount++;
|
|
54
|
+
else issues.push(`ref ${f.ref} (${res.ok === false ? (res.reason ?? "failed") : "value mismatch"})`);
|
|
55
|
+
results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) });
|
|
56
|
+
}
|
|
57
|
+
const text =
|
|
58
|
+
`Filled ${okCount}/${args.fields.length} fields` +
|
|
59
|
+
(issues.length > 0 ? `; issues: ${issues.join(", ")}` : "");
|
|
60
|
+
if (okCount === 0) {
|
|
61
|
+
return err({ kind: "invalid_state", message: text, details: { results } });
|
|
62
|
+
}
|
|
63
|
+
return ok({ text, details: { results } });
|
|
64
|
+
},
|
|
65
|
+
});
|