pi-browser-harness 0.12.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 +36 -0
- package/README.md +86 -0
- package/package.json +12 -6
- package/skills/pi-browser-harness/SKILL.md +57 -2
- package/src/cdp/commands.ts +200 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +57 -25
- 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 +110 -349
- 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 +21 -22
- package/src/daemon/transport.ts +80 -49
- package/src/domains/ax-tree.ts +8 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -2
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -64
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +13 -9
- package/src/domains/form-batch.ts +13 -10
- package/src/domains/form.ts +78 -107
- package/src/domains/headers.ts +2 -2
- package/src/domains/history.ts +4 -4
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +65 -44
- package/src/domains/keyboard.ts +14 -11
- package/src/domains/navigate.ts +102 -66
- package/src/domains/network.ts +55 -95
- 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 +18 -51
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -53
- 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 -10
- package/src/domains/scroll.ts +4 -4
- package/src/domains/search/google-serp.ts +1 -1
- package/src/domains/search/web-search.ts +38 -33
- package/src/domains/snapshot.ts +71 -73
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +18 -69
- package/src/domains/target.ts +1 -1
- package/src/index.ts +90 -33
- 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 +32 -7
- package/src/registry.ts +65 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +2 -1
- 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 +3 -2
- 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
|
@@ -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.",
|
|
@@ -34,23 +36,24 @@ export const fillFormTool = defineBrowserTool({
|
|
|
34
36
|
parameters: FillFormArgs,
|
|
35
37
|
concurrency: "serialized",
|
|
36
38
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
37
|
-
const results:
|
|
39
|
+
const results: JsonObject[] = [];
|
|
38
40
|
let okCount = 0;
|
|
39
41
|
const issues: string[] = [];
|
|
40
42
|
for (const f of args.fields) {
|
|
41
|
-
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]);
|
|
42
44
|
if (!r.success) {
|
|
43
45
|
results.push({ ref: f.ref, ok: false, error: r.error.message });
|
|
44
46
|
issues.push(`ref ${f.ref} (${r.error.message})`);
|
|
45
47
|
continue;
|
|
46
48
|
}
|
|
47
49
|
const res = r.data;
|
|
48
|
-
const wanted = typeof f.value === "boolean" ? undefined :
|
|
49
|
-
const got = res.value
|
|
50
|
-
const
|
|
51
|
-
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;
|
|
52
55
|
if (fieldOk) okCount++;
|
|
53
|
-
else issues.push(`ref ${f.ref} (${res.ok
|
|
56
|
+
else issues.push(`ref ${f.ref} (${res.ok ? "value mismatch" : res.reason})`);
|
|
54
57
|
results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) });
|
|
55
58
|
}
|
|
56
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",
|
|
@@ -37,50 +73,29 @@ export const fillTool = defineBrowserTool({
|
|
|
37
73
|
parameters: FillArgs,
|
|
38
74
|
concurrency: "serialized",
|
|
39
75
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
40
|
-
const r = await runOnElement(client, {
|
|
41
|
-
ref: args.ref,
|
|
42
|
-
selector: args.selector,
|
|
43
|
-
fnBody: FILL_BODY,
|
|
44
|
-
arg: args.value,
|
|
45
|
-
notFound: NOT_FOUND,
|
|
46
|
-
});
|
|
76
|
+
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: FILL_FN, args: [args.value, FILL_OPTIONS] });
|
|
47
77
|
if (!r.success) return r;
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
kind: "invalid_state",
|
|
53
|
-
message: `No element matched: ${target}`,
|
|
54
|
-
details: { ref: args.ref, selector: args.selector },
|
|
55
|
-
});
|
|
56
|
-
}
|
|
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);
|
|
57
82
|
if (!res.ok) {
|
|
58
83
|
const details = { ref: args.ref, selector: args.selector, ...(res.tag !== undefined ? { tag: res.tag } : {}) };
|
|
59
|
-
if (res.
|
|
84
|
+
if (res.code === "select_rejected") {
|
|
60
85
|
return err({
|
|
61
86
|
kind: "invalid_state",
|
|
62
87
|
message: `Element <select> is not fillable (not an input, textarea, or contenteditable): ${target}. For <select> use browser_select_option.`,
|
|
63
88
|
details,
|
|
64
89
|
});
|
|
65
90
|
}
|
|
66
|
-
return err({
|
|
67
|
-
kind: "invalid_state",
|
|
68
|
-
message: `Could not fill ${target}: ${res.reason ?? "unknown reason"}`,
|
|
69
|
-
details,
|
|
70
|
-
});
|
|
91
|
+
return err({ kind: "invalid_state", message: `Could not fill ${target}: ${res.reason}`, details });
|
|
71
92
|
}
|
|
72
|
-
const
|
|
93
|
+
const verified = "checked" in res ? res.checked : res.value;
|
|
94
|
+
const shown = "checked" in res ? `checked=${res.checked}` : JSON.stringify(res.value);
|
|
73
95
|
const diff = await interactiveDiff(client);
|
|
74
96
|
return ok({
|
|
75
97
|
text: `Filled ${target} = ${shown}${diff}`,
|
|
76
|
-
details: {
|
|
77
|
-
ref: args.ref,
|
|
78
|
-
selector: args.selector,
|
|
79
|
-
value: args.value,
|
|
80
|
-
verified: res.value ?? res.checked,
|
|
81
|
-
...(res.tag !== undefined ? { tag: res.tag } : {}),
|
|
82
|
-
...(res.kind !== undefined ? { kind: res.kind } : {}),
|
|
83
|
-
},
|
|
98
|
+
details: { ...targetDetails(args), value: args.value, verified, tag: res.tag, kind: res.kind },
|
|
84
99
|
});
|
|
85
100
|
},
|
|
86
101
|
});
|
|
@@ -92,21 +107,13 @@ const FocusArgs = Type.Object({
|
|
|
92
107
|
selector: Type.Optional(Type.String({ description: "CSS selector of the element to focus (fallback when no ref)" })),
|
|
93
108
|
});
|
|
94
109
|
|
|
95
|
-
type FocusResult = { status: "ok"; tag: string } | { status: "not_found" } | { status: "not_focusable"; tag: string };
|
|
96
|
-
|
|
97
110
|
const isFocusResult = (v: unknown): v is FocusResult => {
|
|
98
111
|
if (typeof v !== "object" || v === null || !("status" in v)) return false;
|
|
99
|
-
if (v.status === "not_found") return true;
|
|
100
112
|
if (v.status === "ok" || v.status === "not_focusable") return "tag" in v && typeof v.tag === "string";
|
|
101
113
|
return false;
|
|
102
114
|
};
|
|
103
115
|
|
|
104
|
-
const FOCUS_FN =
|
|
105
|
-
const el = this;
|
|
106
|
-
if (typeof el.focus !== "function") return { status: "not_focusable", tag: el.tagName };
|
|
107
|
-
el.focus();
|
|
108
|
-
return document.activeElement === el ? { status: "ok", tag: el.tagName } : { status: "not_focusable", tag: el.tagName };
|
|
109
|
-
`;
|
|
116
|
+
const FOCUS_FN = elementFunctionSource(focusElement);
|
|
110
117
|
|
|
111
118
|
export const focusTool = defineBrowserTool({
|
|
112
119
|
name: "browser_focus",
|
|
@@ -120,17 +127,16 @@ export const focusTool = defineBrowserTool({
|
|
|
120
127
|
parameters: FocusArgs,
|
|
121
128
|
concurrency: "serialized",
|
|
122
129
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
123
|
-
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: [] });
|
|
124
131
|
if (!r.success) return r;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
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);
|
|
130
136
|
if (res.status === "not_focusable") {
|
|
131
|
-
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 } });
|
|
132
138
|
}
|
|
133
|
-
return ok({ text: `Focused ${target}`, details: {
|
|
139
|
+
return ok({ text: `Focused ${target}`, details: { ...targetDetails(args), tag: res.tag } });
|
|
134
140
|
},
|
|
135
141
|
});
|
|
136
142
|
|
|
@@ -144,20 +150,13 @@ const SelectOptionArgs = Type.Object({
|
|
|
144
150
|
index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based option index to select" })),
|
|
145
151
|
});
|
|
146
152
|
|
|
147
|
-
|
|
148
|
-
| { status: "ok"; value: string; label: string }
|
|
149
|
-
| { status: "not_found" }
|
|
150
|
-
| { status: "not_select"; tag: string }
|
|
151
|
-
| { status: "no_match"; options: ReadonlyArray<{ value: string; label: string }> };
|
|
152
|
-
|
|
153
|
-
const isSelectOption = (v: unknown): v is { value: string; label: string } =>
|
|
153
|
+
const isSelectOption = (v: unknown): v is SelectOption =>
|
|
154
154
|
typeof v === "object" && v !== null
|
|
155
155
|
&& "value" in v && typeof v.value === "string"
|
|
156
156
|
&& "label" in v && typeof v.label === "string";
|
|
157
157
|
|
|
158
158
|
const isSelectResult = (v: unknown): v is SelectResult => {
|
|
159
159
|
if (typeof v !== "object" || v === null || !("status" in v)) return false;
|
|
160
|
-
if (v.status === "not_found") return true;
|
|
161
160
|
if (v.status === "not_select") return "tag" in v && typeof v.tag === "string";
|
|
162
161
|
if (v.status === "ok") {
|
|
163
162
|
return "value" in v && typeof v.value === "string" && "label" in v && typeof v.label === "string";
|
|
@@ -168,21 +167,7 @@ const isSelectResult = (v: unknown): v is SelectResult => {
|
|
|
168
167
|
return false;
|
|
169
168
|
};
|
|
170
169
|
|
|
171
|
-
const SELECT_FN =
|
|
172
|
-
const el = this;
|
|
173
|
-
if (el.tagName !== "SELECT") return { status: "not_select", tag: el.tagName };
|
|
174
|
-
const opts = Array.from(el.options);
|
|
175
|
-
const wv = arg.wv, wl = arg.wl, wi = arg.wi;
|
|
176
|
-
let opt = null;
|
|
177
|
-
if (wi !== null) opt = opts[wi] || null;
|
|
178
|
-
else if (wv !== null) opt = opts.find(o => o.value === wv) || null;
|
|
179
|
-
else if (wl !== null) opt = opts.find(o => o.text === wl) || null;
|
|
180
|
-
if (!opt) return { status: "no_match", options: opts.map(o => ({ value: o.value, label: o.text })) };
|
|
181
|
-
el.value = opt.value;
|
|
182
|
-
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
183
|
-
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
184
|
-
return { status: "ok", value: opt.value, label: opt.text };
|
|
185
|
-
`;
|
|
170
|
+
const SELECT_FN = elementFunctionSource(selectOption);
|
|
186
171
|
|
|
187
172
|
export const selectOptionTool = defineBrowserTool({
|
|
188
173
|
name: "browser_select_option",
|
|
@@ -198,51 +183,34 @@ export const selectOptionTool = defineBrowserTool({
|
|
|
198
183
|
concurrency: "serialized",
|
|
199
184
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
200
185
|
if (args.value === undefined && args.label === undefined && args.index === undefined) {
|
|
201
|
-
return err({ kind: "
|
|
186
|
+
return err({ kind: "invalid_args", message: "Provide one of value, label, or index." });
|
|
202
187
|
}
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
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] });
|
|
206
190
|
if (!r.success) return r;
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
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);
|
|
212
195
|
if (res.status === "not_select") {
|
|
213
|
-
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 } });
|
|
214
197
|
}
|
|
215
198
|
if (res.status === "no_match") {
|
|
216
199
|
return err({
|
|
217
200
|
kind: "invalid_state",
|
|
218
201
|
message: `No matching option in ${target}. Available: ${res.options.map((o) => o.label).join(", ")}`,
|
|
219
|
-
details: {
|
|
202
|
+
details: { ...targetDetails(args), options: res.options },
|
|
220
203
|
});
|
|
221
204
|
}
|
|
222
205
|
const diff = await interactiveDiff(client);
|
|
223
206
|
return ok({
|
|
224
207
|
text: `Selected "${res.label}" (value=${JSON.stringify(res.value)}) in ${target}${diff}`,
|
|
225
|
-
details: {
|
|
208
|
+
details: { ...targetDetails(args), value: res.value, label: res.label },
|
|
226
209
|
});
|
|
227
210
|
},
|
|
228
211
|
});
|
|
229
212
|
|
|
230
|
-
const CHECK_FN =
|
|
231
|
-
function(checked) {
|
|
232
|
-
const el = this;
|
|
233
|
-
const tag = (el.tagName || "").toLowerCase();
|
|
234
|
-
const type = (el.type || "").toLowerCase();
|
|
235
|
-
if (tag !== "input" || (type !== "checkbox" && type !== "radio")) return { ok: false, reason: "ref is not a checkbox or radio" };
|
|
236
|
-
if (el.disabled) return { ok: false, reason: "element is disabled" };
|
|
237
|
-
const want = checked === true;
|
|
238
|
-
let changed = false;
|
|
239
|
-
if (el.checked !== want) {
|
|
240
|
-
try { el.click(); } catch (e) {}
|
|
241
|
-
if (el.checked !== want) { el.checked = want; el.dispatchEvent(new Event("change", { bubbles: true })); }
|
|
242
|
-
changed = true;
|
|
243
|
-
}
|
|
244
|
-
return { ok: true, checked: el.checked, changed: changed };
|
|
245
|
-
}`;
|
|
213
|
+
const CHECK_FN = elementFunctionSource(setChecked);
|
|
246
214
|
|
|
247
215
|
const SetCheckedArgs = Type.Object({
|
|
248
216
|
ref: Type.String({ description: "Stable element ref of a checkbox or radio from browser_snapshot (e.g. 'e7')." }),
|
|
@@ -265,10 +233,13 @@ export const setCheckedTool = defineBrowserTool({
|
|
|
265
233
|
if (!r.success) return r;
|
|
266
234
|
const res = r.data;
|
|
267
235
|
if (!res.ok) {
|
|
268
|
-
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 } });
|
|
269
240
|
}
|
|
270
241
|
return ok({
|
|
271
|
-
text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed
|
|
242
|
+
text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed ? "" : " (already)"),
|
|
272
243
|
details: detailsOf(args.ref, res),
|
|
273
244
|
});
|
|
274
245
|
},
|
package/src/domains/headers.ts
CHANGED
|
@@ -23,7 +23,7 @@ const currentOwnedTarget = (
|
|
|
23
23
|
client: BrowserClient,
|
|
24
24
|
): Result<{ readonly targetId: string }, ToolErr> => {
|
|
25
25
|
const current = client.current();
|
|
26
|
-
if (current ===
|
|
26
|
+
if (current === null) {
|
|
27
27
|
return err({ kind: "invalid_state", message: "No tab attached." });
|
|
28
28
|
}
|
|
29
29
|
if (!client.owns(current.targetId)) {
|
|
@@ -54,7 +54,7 @@ export const setHeadersTool = defineBrowserTool({
|
|
|
54
54
|
if (!target.success) return target;
|
|
55
55
|
const invalidName = Object.keys(args.headers).find((name) => !HEADER_NAME.test(name));
|
|
56
56
|
if (invalidName !== undefined) {
|
|
57
|
-
return err({ kind: "
|
|
57
|
+
return err({ kind: "invalid_args", message: `Invalid HTTP header name: ${JSON.stringify(invalidName)}` });
|
|
58
58
|
}
|
|
59
59
|
const result = await cdpCall(client, "Network.setExtraHTTPHeaders", { headers: args.headers });
|
|
60
60
|
if (!result.success) return result;
|
package/src/domains/history.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Type } from "typebox";
|
|
|
2
2
|
import type { BrowserClient } from "../client";
|
|
3
3
|
import { type Result, err, ok } from "../util/result";
|
|
4
4
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
5
|
-
import { cdpCall } from "./cdp-call";
|
|
5
|
+
import { cdpCall, fromClient } from "./cdp-call";
|
|
6
6
|
|
|
7
7
|
type HistoryEntry = { readonly id: number; readonly url: string; readonly title: string };
|
|
8
8
|
type History = { readonly entries: ReadonlyArray<HistoryEntry>; readonly currentIndex: number };
|
|
@@ -64,9 +64,9 @@ export const reloadTool = defineBrowserTool({
|
|
|
64
64
|
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
65
65
|
const r = await cdpCall(client, "Page.reload", {});
|
|
66
66
|
if (!r.success) return r;
|
|
67
|
-
const info = await client.pageInfo();
|
|
68
|
-
if (!info.success) return
|
|
69
|
-
if ("dialog"
|
|
67
|
+
const info = fromClient(await client.pageInfo(), "pageInfo");
|
|
68
|
+
if (!info.success) return info;
|
|
69
|
+
if (info.data.kind === "dialog") {
|
|
70
70
|
return ok({
|
|
71
71
|
text: `Reloaded. ⚠️ Dialog open: ${info.data.dialog.type}`,
|
|
72
72
|
details: { dialog: info.data.dialog },
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
import type { ToolErr } from "../util/tool";
|
|
5
|
+
|
|
6
|
+
const ioErr = (what: string, path: string, e: unknown): ToolErr => ({
|
|
7
|
+
kind: "io_error",
|
|
8
|
+
message: `${what}: ${path} (${e instanceof Error ? e.message : String(e)})`,
|
|
9
|
+
details: { path },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const writeFileResult = async (path: string, data: Uint8Array | string): Promise<Result<void, ToolErr>> => {
|
|
13
|
+
try {
|
|
14
|
+
await writeFile(path, data);
|
|
15
|
+
return ok(undefined);
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return err(ioErr("Cannot write file", path, e));
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const readFileResult = async (path: string): Promise<Result<Buffer, ToolErr>> => {
|
|
22
|
+
try {
|
|
23
|
+
return ok(await readFile(path));
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return err(ioErr("Cannot read file", path, e));
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const statResult = async (path: string): Promise<Result<Stats, ToolErr>> => {
|
|
30
|
+
try {
|
|
31
|
+
return ok(await stat(path));
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return err(ioErr("Cannot stat file", path, e));
|
|
34
|
+
}
|
|
35
|
+
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { BrowserClient } from "../client";
|
|
2
2
|
import { type Result, err, ok } from "../util/result";
|
|
3
|
-
import { safeJs } from "../util/js-template";
|
|
4
3
|
import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
|
|
5
4
|
import { evaluateJson } from "../cdp/session";
|
|
6
5
|
import type { ToolErr } from "../util/tool";
|
|
7
|
-
import { cdpCallBrowser, cdpCallOnTarget, cdpErrToToolErr
|
|
6
|
+
import { cdpCallBrowser, cdpCallOnTarget, cdpErrToToolErr } from "./cdp-call";
|
|
7
|
+
import { pollUntil, READY_STATE_COMPLETE } from "./poll";
|
|
8
8
|
|
|
9
9
|
export type IsolatedTab = {
|
|
10
10
|
readonly targetId: string;
|
|
@@ -12,7 +12,6 @@ export type IsolatedTab = {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
const EVAL_TIMEOUT_MS = 15_000;
|
|
15
|
-
const READY_POLL_INTERVAL_MS = 50;
|
|
16
15
|
|
|
17
16
|
export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => {
|
|
18
17
|
// Routed through the target factory so an isolated tab lands in the pinned profile, not another profile's cookies.
|
|
@@ -60,14 +59,9 @@ export const waitForIsolatedLoad = async (
|
|
|
60
59
|
timeoutMs: number,
|
|
61
60
|
signal?: AbortSignal,
|
|
62
61
|
): Promise<Result<void, ToolErr>> => {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const r = await evalJs(client, safeJs`document.readyState`, tab.sessionId);
|
|
67
|
-
if (r.success && r.data === "complete") return ok(undefined);
|
|
68
|
-
await new Promise((res) => setTimeout(res, READY_POLL_INTERVAL_MS));
|
|
69
|
-
}
|
|
70
|
-
return err({ kind: "timeout", message: `page did not finish loading in ${Math.round(timeoutMs / 1000)}s` });
|
|
62
|
+
const r = await pollUntil(client, READY_STATE_COMPLETE, { timeoutMs, signal, sessionId: tab.sessionId, what: "page load" });
|
|
63
|
+
if (!r.success) return r;
|
|
64
|
+
return ok(undefined);
|
|
71
65
|
};
|
|
72
66
|
|
|
73
67
|
export const isJsonText = (v: unknown): v is string => typeof v === "string";
|
|
@@ -87,5 +81,5 @@ export const evalInIsolatedTab = async <T>(
|
|
|
87
81
|
};
|
|
88
82
|
|
|
89
83
|
export const closeIsolatedTab = async (client: BrowserClient, tab: IsolatedTab): Promise<void> => {
|
|
90
|
-
await client.closeTab(tab.targetId)
|
|
84
|
+
await client.closeTab(tab.targetId);
|
|
91
85
|
};
|