gsd-pi 2.7.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -5
- package/dist/loader.js +0 -0
- package/dist/modes/interactive/theme/dark.json +85 -0
- package/dist/modes/interactive/theme/light.json +84 -0
- package/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/dist/modes/interactive/theme/theme.d.ts +78 -0
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -0
- package/dist/modes/interactive/theme/theme.js +949 -0
- package/dist/modes/interactive/theme/theme.js.map +1 -0
- package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
- package/node_modules/cliui/CHANGELOG.md +121 -0
- package/node_modules/color-convert/CHANGELOG.md +54 -0
- package/node_modules/esprima/ChangeLog +235 -0
- package/node_modules/mz/HISTORY.md +66 -0
- package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
- package/node_modules/source-map/CHANGELOG.md +301 -0
- package/node_modules/thenify/History.md +11 -0
- package/node_modules/thenify-all/History.md +11 -0
- package/node_modules/y18n/CHANGELOG.md +100 -0
- package/node_modules/yargs/CHANGELOG.md +88 -0
- package/node_modules/yargs-parser/CHANGELOG.md +263 -0
- package/package.json +5 -2
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
- package/src/resources/extensions/browser-tools/capture.ts +165 -0
- package/src/resources/extensions/browser-tools/evaluate-helpers.ts +184 -0
- package/src/resources/extensions/browser-tools/index.ts +47 -4985
- package/src/resources/extensions/browser-tools/lifecycle.ts +265 -0
- package/src/resources/extensions/browser-tools/package.json +5 -1
- package/src/resources/extensions/browser-tools/refs.ts +264 -0
- package/src/resources/extensions/browser-tools/settle.ts +197 -0
- package/src/resources/extensions/browser-tools/state.ts +408 -0
- package/src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs +652 -0
- package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +614 -0
- package/src/resources/extensions/browser-tools/tools/assertions.ts +342 -0
- package/src/resources/extensions/browser-tools/tools/forms.ts +801 -0
- package/src/resources/extensions/browser-tools/tools/inspection.ts +492 -0
- package/src/resources/extensions/browser-tools/tools/intent.ts +614 -0
- package/src/resources/extensions/browser-tools/tools/interaction.ts +865 -0
- package/src/resources/extensions/browser-tools/tools/navigation.ts +232 -0
- package/src/resources/extensions/browser-tools/tools/pages.ts +303 -0
- package/src/resources/extensions/browser-tools/tools/refs.ts +541 -0
- package/src/resources/extensions/browser-tools/tools/screenshot.ts +83 -0
- package/src/resources/extensions/browser-tools/tools/session.ts +400 -0
- package/src/resources/extensions/browser-tools/tools/wait.ts +247 -0
- package/src/resources/extensions/browser-tools/utils.ts +660 -0
- package/src/resources/extensions/gsd/git-service.ts +3 -0
- package/src/resources/extensions/shared/interview-ui.ts +1 -1
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { StringEnum } from "@gsd/pi-ai";
|
|
4
|
+
import {
|
|
5
|
+
diffCompactStates,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import type { ToolDeps, CompactPageState } from "../state.js";
|
|
8
|
+
import {
|
|
9
|
+
setLastActionBeforeState,
|
|
10
|
+
setLastActionAfterState,
|
|
11
|
+
} from "../state.js";
|
|
12
|
+
import { readFocusedDescriptor } from "../settle.js";
|
|
13
|
+
|
|
14
|
+
export function registerInteractionTools(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
15
|
+
// -------------------------------------------------------------------------
|
|
16
|
+
// browser_click
|
|
17
|
+
// -------------------------------------------------------------------------
|
|
18
|
+
pi.registerTool({
|
|
19
|
+
name: "browser_click",
|
|
20
|
+
label: "Browser Click",
|
|
21
|
+
description:
|
|
22
|
+
"Click an element on the page by CSS selector or by x,y coordinates. Returns a compact page summary plus lightweight verification details after clicking. Provide either selector or both x and y. Prefer selector over coordinates — selectors are more reliable because they handle shadow DOM via getByRole fallbacks. Use coordinates only when you have no other option.",
|
|
23
|
+
parameters: Type.Object({
|
|
24
|
+
selector: Type.Optional(
|
|
25
|
+
Type.String({ description: "CSS selector of the element to click. The tool will try getByRole fallbacks if the CSS selector fails (handles shadow DOM)." })
|
|
26
|
+
),
|
|
27
|
+
x: Type.Optional(Type.Number({ description: "X coordinate to click" })),
|
|
28
|
+
y: Type.Optional(Type.Number({ description: "Y coordinate to click" })),
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
32
|
+
let actionId: number | null = null;
|
|
33
|
+
let beforeState: CompactPageState | null = null;
|
|
34
|
+
try {
|
|
35
|
+
const { page: p } = await deps.ensureBrowser();
|
|
36
|
+
const target = deps.getActiveTarget();
|
|
37
|
+
beforeState = await deps.captureCompactPageState(p, { selectors: params.selector ? [params.selector] : [], includeBodyText: true, target });
|
|
38
|
+
actionId = deps.beginTrackedAction("browser_click", params, beforeState.url).id;
|
|
39
|
+
const beforeUrl = p.url();
|
|
40
|
+
const beforeHash = deps.getUrlHash(beforeUrl);
|
|
41
|
+
const beforeTargetState = params.selector
|
|
42
|
+
? await deps.captureClickTargetState(target, params.selector)
|
|
43
|
+
: null;
|
|
44
|
+
|
|
45
|
+
if (params.selector) {
|
|
46
|
+
try {
|
|
47
|
+
await target.locator(params.selector).first().click({ timeout: 5000 });
|
|
48
|
+
} catch {
|
|
49
|
+
const nameMatch = params.selector.match(/\[(?:aria-label|name|placeholder)="([^"]+)"\]/i);
|
|
50
|
+
const roleName = nameMatch?.[1];
|
|
51
|
+
let clicked = false;
|
|
52
|
+
for (const role of ["combobox", "searchbox", "textbox", "button", "link"] as const) {
|
|
53
|
+
try {
|
|
54
|
+
const loc = roleName
|
|
55
|
+
? target.getByRole(role, { name: new RegExp(roleName, "i") })
|
|
56
|
+
: target.getByRole(role);
|
|
57
|
+
await loc.first().click({ timeout: 3000 });
|
|
58
|
+
clicked = true;
|
|
59
|
+
break;
|
|
60
|
+
} catch { /* try next role */ }
|
|
61
|
+
}
|
|
62
|
+
if (!clicked) {
|
|
63
|
+
if (params.x !== undefined && params.y !== undefined) {
|
|
64
|
+
await p.mouse.click(params.x, params.y);
|
|
65
|
+
} else {
|
|
66
|
+
throw new Error(`Could not click selector "${params.selector}" — element not found (shadow DOM?)`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else if (params.x !== undefined && params.y !== undefined) {
|
|
71
|
+
await p.mouse.click(params.x, params.y);
|
|
72
|
+
} else {
|
|
73
|
+
return {
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
type: "text",
|
|
77
|
+
text: "Must provide either selector or both x and y coordinates",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
details: {},
|
|
81
|
+
isError: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const settle = await deps.settleAfterActionAdaptive(p);
|
|
86
|
+
|
|
87
|
+
const afterState = await deps.captureCompactPageState(p, { selectors: params.selector ? [params.selector] : [], includeBodyText: true, target });
|
|
88
|
+
const url = afterState.url;
|
|
89
|
+
const hash = deps.getUrlHash(url);
|
|
90
|
+
const afterTargetState = params.selector
|
|
91
|
+
? await deps.captureClickTargetState(target, params.selector)
|
|
92
|
+
: null;
|
|
93
|
+
const targetStateChanged = !!beforeTargetState && !!afterTargetState && (
|
|
94
|
+
beforeTargetState.exists !== afterTargetState.exists ||
|
|
95
|
+
beforeTargetState.ariaExpanded !== afterTargetState.ariaExpanded ||
|
|
96
|
+
beforeTargetState.ariaPressed !== afterTargetState.ariaPressed ||
|
|
97
|
+
beforeTargetState.ariaSelected !== afterTargetState.ariaSelected ||
|
|
98
|
+
beforeTargetState.open !== afterTargetState.open
|
|
99
|
+
);
|
|
100
|
+
const verification = deps.verificationFromChecks(
|
|
101
|
+
[
|
|
102
|
+
{ name: "url_changed", passed: url !== beforeUrl, value: url, expected: `!= ${beforeUrl}` },
|
|
103
|
+
{ name: "hash_changed", passed: hash !== beforeHash, value: hash, expected: `!= ${beforeHash}` },
|
|
104
|
+
{ name: "target_state_changed", passed: targetStateChanged, value: afterTargetState, expected: beforeTargetState },
|
|
105
|
+
{ name: "dialog_open", passed: afterState.dialog.count > beforeState!.dialog.count, value: afterState.dialog.count, expected: `> ${beforeState!.dialog.count}` },
|
|
106
|
+
],
|
|
107
|
+
"Try a more specific selector or click a clearly interactive element."
|
|
108
|
+
);
|
|
109
|
+
const clickTarget = params.selector ?? `(${params.x}, ${params.y})`;
|
|
110
|
+
const summary = deps.formatCompactStateSummary(afterState);
|
|
111
|
+
const jsErrors = deps.getRecentErrors(p.url());
|
|
112
|
+
const diff = diffCompactStates(beforeState!, afterState);
|
|
113
|
+
setLastActionBeforeState(beforeState!);
|
|
114
|
+
setLastActionAfterState(afterState);
|
|
115
|
+
deps.finishTrackedAction(actionId!, {
|
|
116
|
+
status: "success",
|
|
117
|
+
afterUrl: afterState.url,
|
|
118
|
+
verificationSummary: verification.verificationSummary,
|
|
119
|
+
warningSummary: jsErrors.trim() || undefined,
|
|
120
|
+
diffSummary: diff.summary,
|
|
121
|
+
changed: diff.changed,
|
|
122
|
+
beforeState: beforeState!,
|
|
123
|
+
afterState,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: "text", text: `Clicked: ${clickTarget}\nURL: ${url}\nAction: ${actionId}\n${deps.verificationLine(verification)}${jsErrors}\n\nDiff:\n${deps.formatDiffText(diff)}\n\nPage summary:\n${summary}` }],
|
|
128
|
+
details: { target: clickTarget, url, actionId, diff, ...settle, ...verification },
|
|
129
|
+
};
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
if (actionId !== null) {
|
|
132
|
+
deps.finishTrackedAction(actionId, { status: "error", afterUrl: deps.getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined });
|
|
133
|
+
}
|
|
134
|
+
const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
|
|
135
|
+
const content: any[] = [{ type: "text", text: `Click failed: ${err.message}` }];
|
|
136
|
+
if (errorShot) {
|
|
137
|
+
content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
content,
|
|
141
|
+
details: { error: err.message },
|
|
142
|
+
isError: true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// -------------------------------------------------------------------------
|
|
149
|
+
// browser_drag
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
pi.registerTool({
|
|
152
|
+
name: "browser_drag",
|
|
153
|
+
label: "Browser Drag",
|
|
154
|
+
description:
|
|
155
|
+
"Drag an element and drop it onto another element. Use for sortable lists, kanban boards, sliders, and any drag-and-drop UI.",
|
|
156
|
+
parameters: Type.Object({
|
|
157
|
+
sourceSelector: Type.String({
|
|
158
|
+
description: "CSS selector of the element to drag",
|
|
159
|
+
}),
|
|
160
|
+
targetSelector: Type.String({
|
|
161
|
+
description: "CSS selector of the element to drop onto",
|
|
162
|
+
}),
|
|
163
|
+
}),
|
|
164
|
+
|
|
165
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
166
|
+
try {
|
|
167
|
+
const { page: p } = await deps.ensureBrowser();
|
|
168
|
+
const target = deps.getActiveTarget();
|
|
169
|
+
await target.dragAndDrop(params.sourceSelector, params.targetSelector, { timeout: 10000 });
|
|
170
|
+
const settle = await deps.settleAfterActionAdaptive(p);
|
|
171
|
+
|
|
172
|
+
const afterState = await deps.captureCompactPageState(p, { includeBodyText: false, target });
|
|
173
|
+
const summary = deps.formatCompactStateSummary(afterState);
|
|
174
|
+
const jsErrors = deps.getRecentErrors(p.url());
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
content: [{
|
|
178
|
+
type: "text",
|
|
179
|
+
text: `Dragged "${params.sourceSelector}" → "${params.targetSelector}"${jsErrors}\n\nPage summary:\n${summary}`,
|
|
180
|
+
}],
|
|
181
|
+
details: { source: params.sourceSelector, target: params.targetSelector, ...settle },
|
|
182
|
+
};
|
|
183
|
+
} catch (err: any) {
|
|
184
|
+
const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
|
|
185
|
+
const content: any[] = [{ type: "text", text: `Drag failed: ${err.message}` }];
|
|
186
|
+
if (errorShot) {
|
|
187
|
+
content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
|
|
188
|
+
}
|
|
189
|
+
return { content, details: { error: err.message }, isError: true };
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// -------------------------------------------------------------------------
|
|
195
|
+
// browser_type
|
|
196
|
+
// -------------------------------------------------------------------------
|
|
197
|
+
pi.registerTool({
|
|
198
|
+
name: "browser_type",
|
|
199
|
+
label: "Browser Type",
|
|
200
|
+
description:
|
|
201
|
+
"Type text into an input element. By default uses atomic fill (clears and sets value instantly). Use 'slowly' for character-by-character typing when you need to trigger key handlers (e.g. search autocomplete). Use 'submit' to press Enter after typing. Returns a compact page summary plus lightweight verification details. IMPORTANT: Always provide a selector — do NOT rely on coordinate clicks to focus an input before calling this. CSS attribute selectors like combobox[aria-label='X'] work for most inputs; for shadow DOM inputs (e.g. Google Search), the tool automatically tries getByRole fallbacks.",
|
|
202
|
+
parameters: Type.Object({
|
|
203
|
+
text: Type.String({ description: "Text to type" }),
|
|
204
|
+
selector: Type.Optional(
|
|
205
|
+
Type.String({ description: "CSS selector of the input to type into (clicks it first). Examples: 'input[name=q]', 'textarea', 'combobox[aria-label=\"Search\"]'. The tool will try getByRole fallbacks if the CSS selector fails." })
|
|
206
|
+
),
|
|
207
|
+
clearFirst: Type.Optional(
|
|
208
|
+
Type.Boolean({
|
|
209
|
+
description:
|
|
210
|
+
"Clear the input's existing value before typing (default: false). Use this when replacing existing text.",
|
|
211
|
+
})
|
|
212
|
+
),
|
|
213
|
+
submit: Type.Optional(
|
|
214
|
+
Type.Boolean({
|
|
215
|
+
description: "Press Enter after typing to submit the form (default: false).",
|
|
216
|
+
})
|
|
217
|
+
),
|
|
218
|
+
slowly: Type.Optional(
|
|
219
|
+
Type.Boolean({
|
|
220
|
+
description:
|
|
221
|
+
"Type one character at a time instead of filling atomically. Use when you need to trigger key handlers (e.g. search autocomplete). Default: false.",
|
|
222
|
+
})
|
|
223
|
+
),
|
|
224
|
+
}),
|
|
225
|
+
|
|
226
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
227
|
+
let actionId: number | null = null;
|
|
228
|
+
let beforeState: CompactPageState | null = null;
|
|
229
|
+
try {
|
|
230
|
+
const { page: p } = await deps.ensureBrowser();
|
|
231
|
+
const target = deps.getActiveTarget();
|
|
232
|
+
beforeState = await deps.captureCompactPageState(p, { selectors: params.selector ? [params.selector] : [], includeBodyText: true, target });
|
|
233
|
+
actionId = deps.beginTrackedAction("browser_type", params, beforeState.url).id;
|
|
234
|
+
const beforeUrl = p.url();
|
|
235
|
+
|
|
236
|
+
async function focusViaRole(selector: string): Promise<boolean> {
|
|
237
|
+
const nameMatch = selector.match(/\[(?:aria-label|name|placeholder)="([^"]+)"\]/i);
|
|
238
|
+
const roleName = nameMatch?.[1];
|
|
239
|
+
for (const role of ["combobox", "searchbox", "textbox"] as const) {
|
|
240
|
+
try {
|
|
241
|
+
const loc = roleName
|
|
242
|
+
? target.getByRole(role, { name: new RegExp(roleName, "i") })
|
|
243
|
+
: target.getByRole(role);
|
|
244
|
+
await loc.first().click({ timeout: 3000 });
|
|
245
|
+
return true;
|
|
246
|
+
} catch { /* try next */ }
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (params.selector) {
|
|
252
|
+
if (params.slowly) {
|
|
253
|
+
let focused = false;
|
|
254
|
+
try {
|
|
255
|
+
await target.locator(params.selector).first().click({ timeout: 5000 });
|
|
256
|
+
focused = true;
|
|
257
|
+
} catch {
|
|
258
|
+
focused = await focusViaRole(params.selector);
|
|
259
|
+
}
|
|
260
|
+
if (!focused) throw new Error(`Could not focus selector "${params.selector}"`);
|
|
261
|
+
if (params.clearFirst) {
|
|
262
|
+
await p.keyboard.press("Control+A");
|
|
263
|
+
await p.keyboard.press("Delete");
|
|
264
|
+
}
|
|
265
|
+
await p.keyboard.type(params.text);
|
|
266
|
+
} else {
|
|
267
|
+
let filled = false;
|
|
268
|
+
try {
|
|
269
|
+
await target.locator(params.selector).first().fill(params.text, { timeout: 5000 });
|
|
270
|
+
filled = true;
|
|
271
|
+
} catch { /* fall through */ }
|
|
272
|
+
|
|
273
|
+
if (!filled) {
|
|
274
|
+
const nameMatch = params.selector.match(/\[(?:aria-label|name|placeholder)="([^"]+)"\]/i);
|
|
275
|
+
const roleName = nameMatch?.[1];
|
|
276
|
+
for (const role of ["combobox", "searchbox", "textbox"] as const) {
|
|
277
|
+
try {
|
|
278
|
+
const loc = roleName
|
|
279
|
+
? target.getByRole(role, { name: new RegExp(roleName, "i") })
|
|
280
|
+
: target.getByRole(role);
|
|
281
|
+
await loc.first().fill(params.text, { timeout: 3000 });
|
|
282
|
+
filled = true;
|
|
283
|
+
break;
|
|
284
|
+
} catch { /* try next */ }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!filled) {
|
|
289
|
+
let focused = false;
|
|
290
|
+
try {
|
|
291
|
+
await target.locator(params.selector).first().click({ timeout: 5000 });
|
|
292
|
+
focused = true;
|
|
293
|
+
} catch {
|
|
294
|
+
focused = await focusViaRole(params.selector);
|
|
295
|
+
}
|
|
296
|
+
if (!focused) throw new Error(`Could not focus selector "${params.selector}"`);
|
|
297
|
+
if (params.clearFirst) {
|
|
298
|
+
await p.keyboard.press("Control+A");
|
|
299
|
+
await p.keyboard.press("Delete");
|
|
300
|
+
}
|
|
301
|
+
await target.locator(":focus").pressSequentially(params.text, { timeout: 5000 }).catch(() =>
|
|
302
|
+
p.keyboard.type(params.text)
|
|
303
|
+
);
|
|
304
|
+
} else if (params.clearFirst) {
|
|
305
|
+
// fill() already replaced the value; clearFirst is a no-op here
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
const hasFocus = await target.evaluate(() => {
|
|
310
|
+
const el = document.activeElement;
|
|
311
|
+
return !!(el && el !== document.body && el !== document.documentElement);
|
|
312
|
+
});
|
|
313
|
+
if (!hasFocus) {
|
|
314
|
+
return {
|
|
315
|
+
content: [{ type: "text", text: "Type failed: no element is focused. Use browser_click to focus an input first, or provide a selector." }],
|
|
316
|
+
details: { error: "no focused element" },
|
|
317
|
+
isError: true,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
await target.locator(":focus").pressSequentially(params.text, { timeout: 10000 }).catch(() =>
|
|
321
|
+
p.keyboard.type(params.text)
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (params.submit) {
|
|
326
|
+
await p.keyboard.press("Enter");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const settle = await deps.settleAfterActionAdaptive(p);
|
|
330
|
+
|
|
331
|
+
const typedValue = await deps.readInputLikeValue(target, params.selector);
|
|
332
|
+
const afterUrl = p.url();
|
|
333
|
+
const verification = deps.verificationFromChecks(
|
|
334
|
+
[
|
|
335
|
+
{ name: "value_equals_expected", passed: typedValue === params.text, value: typedValue, expected: params.text },
|
|
336
|
+
{ name: "value_contains_expected", passed: typeof typedValue === "string" && typedValue.includes(params.text), value: typedValue, expected: params.text },
|
|
337
|
+
{ name: "url_changed_after_submit", passed: !!params.submit && afterUrl !== beforeUrl, value: afterUrl, expected: `!= ${beforeUrl}` },
|
|
338
|
+
],
|
|
339
|
+
"Try clearFirst=true, use a more specific selector, or set slowly=true for key-driven inputs."
|
|
340
|
+
);
|
|
341
|
+
const typeTarget = params.selector ? ` into "${params.selector}"` : "";
|
|
342
|
+
const afterState = await deps.captureCompactPageState(p, { selectors: params.selector ? [params.selector] : [], includeBodyText: true, target });
|
|
343
|
+
const summary = deps.formatCompactStateSummary(afterState);
|
|
344
|
+
const jsErrors = deps.getRecentErrors(p.url());
|
|
345
|
+
const diff = diffCompactStates(beforeState!, afterState);
|
|
346
|
+
setLastActionBeforeState(beforeState!);
|
|
347
|
+
setLastActionAfterState(afterState);
|
|
348
|
+
deps.finishTrackedAction(actionId!, {
|
|
349
|
+
status: "success",
|
|
350
|
+
afterUrl: afterState.url,
|
|
351
|
+
verificationSummary: verification.verificationSummary,
|
|
352
|
+
warningSummary: jsErrors.trim() || undefined,
|
|
353
|
+
diffSummary: diff.summary,
|
|
354
|
+
changed: diff.changed,
|
|
355
|
+
beforeState: beforeState!,
|
|
356
|
+
afterState,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
content: [{ type: "text", text: `Typed "${params.text}"${typeTarget}\nAction: ${actionId}\n${deps.verificationLine(verification)}${jsErrors}\n\nDiff:\n${deps.formatDiffText(diff)}\n\nPage summary:\n${summary}` }],
|
|
361
|
+
details: { text: params.text, selector: params.selector, typedValue, actionId, diff, ...settle, ...verification },
|
|
362
|
+
};
|
|
363
|
+
} catch (err: any) {
|
|
364
|
+
if (actionId !== null) {
|
|
365
|
+
deps.finishTrackedAction(actionId, { status: "error", afterUrl: deps.getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined });
|
|
366
|
+
}
|
|
367
|
+
const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
|
|
368
|
+
const content: any[] = [{ type: "text", text: `Type failed: ${err.message}` }];
|
|
369
|
+
if (errorShot) {
|
|
370
|
+
content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
content,
|
|
374
|
+
details: { error: err.message },
|
|
375
|
+
isError: true,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// -------------------------------------------------------------------------
|
|
382
|
+
// browser_upload_file
|
|
383
|
+
// -------------------------------------------------------------------------
|
|
384
|
+
pi.registerTool({
|
|
385
|
+
name: "browser_upload_file",
|
|
386
|
+
label: "Browser Upload File",
|
|
387
|
+
description:
|
|
388
|
+
"Set files on a file input element. The selector must target an <input type=\"file\"> element. Accepts one or more absolute file paths.",
|
|
389
|
+
parameters: Type.Object({
|
|
390
|
+
selector: Type.String({
|
|
391
|
+
description: 'CSS selector targeting the <input type="file"> element',
|
|
392
|
+
}),
|
|
393
|
+
files: Type.Array(Type.String({ description: "Absolute path to a file" }), {
|
|
394
|
+
description: "One or more file paths to upload",
|
|
395
|
+
}),
|
|
396
|
+
}),
|
|
397
|
+
|
|
398
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
399
|
+
try {
|
|
400
|
+
const { page: p } = await deps.ensureBrowser();
|
|
401
|
+
const target = deps.getActiveTarget();
|
|
402
|
+
const cleanFiles = params.files.map((f: string) => f.replace(/^@/, ""));
|
|
403
|
+
await target.locator(params.selector).first().setInputFiles(cleanFiles);
|
|
404
|
+
const settle = await deps.settleAfterActionAdaptive(p);
|
|
405
|
+
|
|
406
|
+
const afterState = await deps.captureCompactPageState(p, { includeBodyText: false, target });
|
|
407
|
+
const summary = deps.formatCompactStateSummary(afterState);
|
|
408
|
+
const jsErrors = deps.getRecentErrors(p.url());
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
content: [{
|
|
412
|
+
type: "text",
|
|
413
|
+
text: `Uploaded ${cleanFiles.length} file(s) to "${params.selector}": ${cleanFiles.join(", ")}${jsErrors}\n\nPage summary:\n${summary}`,
|
|
414
|
+
}],
|
|
415
|
+
details: { selector: params.selector, files: cleanFiles, ...settle },
|
|
416
|
+
};
|
|
417
|
+
} catch (err: any) {
|
|
418
|
+
const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
|
|
419
|
+
const content: any[] = [{ type: "text", text: `Upload failed: ${err.message}` }];
|
|
420
|
+
if (errorShot) {
|
|
421
|
+
content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
|
|
422
|
+
}
|
|
423
|
+
return { content, details: { error: err.message }, isError: true };
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// -------------------------------------------------------------------------
|
|
429
|
+
// browser_scroll
|
|
430
|
+
// -------------------------------------------------------------------------
|
|
431
|
+
pi.registerTool({
|
|
432
|
+
name: "browser_scroll",
|
|
433
|
+
label: "Browser Scroll",
|
|
434
|
+
description: "Scroll the page up or down by a given number of pixels. Returns scroll position (px and percentage) and an accessibility snapshot of the visible content.",
|
|
435
|
+
parameters: Type.Object({
|
|
436
|
+
direction: StringEnum(["up", "down"] as const),
|
|
437
|
+
amount: Type.Optional(
|
|
438
|
+
Type.Number({ description: "Pixels to scroll (default: 300)" })
|
|
439
|
+
),
|
|
440
|
+
}),
|
|
441
|
+
|
|
442
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
443
|
+
try {
|
|
444
|
+
const { page: p } = await deps.ensureBrowser();
|
|
445
|
+
const target = deps.getActiveTarget();
|
|
446
|
+
const pixels = params.amount ?? 300;
|
|
447
|
+
const delta = params.direction === "up" ? -pixels : pixels;
|
|
448
|
+
await p.mouse.wheel(0, delta);
|
|
449
|
+
|
|
450
|
+
const settle = await deps.settleAfterActionAdaptive(p);
|
|
451
|
+
|
|
452
|
+
const scrollInfo = await target.evaluate(() => ({
|
|
453
|
+
scrollY: Math.round(window.scrollY),
|
|
454
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
455
|
+
clientHeight: document.documentElement.clientHeight,
|
|
456
|
+
}));
|
|
457
|
+
const maxScroll = scrollInfo.scrollHeight - scrollInfo.clientHeight;
|
|
458
|
+
const percent = maxScroll > 0 ? Math.round((scrollInfo.scrollY / maxScroll) * 100) : 0;
|
|
459
|
+
|
|
460
|
+
const afterState = await deps.captureCompactPageState(p, { includeBodyText: false, target });
|
|
461
|
+
const summary = deps.formatCompactStateSummary(afterState);
|
|
462
|
+
const jsErrors = deps.getRecentErrors(p.url());
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
content: [
|
|
466
|
+
{
|
|
467
|
+
type: "text",
|
|
468
|
+
text: `Scrolled ${params.direction} by ${pixels}px\n` +
|
|
469
|
+
`Position: ${scrollInfo.scrollY}px / ${scrollInfo.scrollHeight}px (${percent}% down)\n` +
|
|
470
|
+
`Viewport height: ${scrollInfo.clientHeight}px${jsErrors}\n\nPage summary:\n${summary}`,
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
details: { direction: params.direction, amount: pixels, ...scrollInfo, percent, ...settle },
|
|
474
|
+
};
|
|
475
|
+
} catch (err: any) {
|
|
476
|
+
return {
|
|
477
|
+
content: [{ type: "text", text: `Scroll failed: ${err.message}` }],
|
|
478
|
+
details: { error: err.message },
|
|
479
|
+
isError: true,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// -------------------------------------------------------------------------
|
|
486
|
+
// browser_hover
|
|
487
|
+
// -------------------------------------------------------------------------
|
|
488
|
+
pi.registerTool({
|
|
489
|
+
name: "browser_hover",
|
|
490
|
+
label: "Browser Hover",
|
|
491
|
+
description:
|
|
492
|
+
"Move the mouse over an element to trigger hover states — reveals tooltips, dropdown menus, CSS :hover effects, and other hover-dependent UI. Returns a compact page summary showing the resulting hover state.",
|
|
493
|
+
parameters: Type.Object({
|
|
494
|
+
selector: Type.String({
|
|
495
|
+
description: "CSS selector of the element to hover over",
|
|
496
|
+
}),
|
|
497
|
+
}),
|
|
498
|
+
|
|
499
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
500
|
+
try {
|
|
501
|
+
const { page: p } = await deps.ensureBrowser();
|
|
502
|
+
const target = deps.getActiveTarget();
|
|
503
|
+
await target.locator(params.selector).first().hover({ timeout: 10000 });
|
|
504
|
+
const settle = await deps.settleAfterActionAdaptive(p);
|
|
505
|
+
|
|
506
|
+
const afterState = await deps.captureCompactPageState(p, { includeBodyText: false, target });
|
|
507
|
+
const summary = deps.formatCompactStateSummary(afterState);
|
|
508
|
+
const jsErrors = deps.getRecentErrors(p.url());
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
content: [{ type: "text", text: `Hovering over "${params.selector}"${jsErrors}\n\nPage summary:\n${summary}` }],
|
|
512
|
+
details: { selector: params.selector, ...settle },
|
|
513
|
+
};
|
|
514
|
+
} catch (err: any) {
|
|
515
|
+
const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
|
|
516
|
+
const content: any[] = [{ type: "text", text: `Hover failed: ${err.message}` }];
|
|
517
|
+
if (errorShot) {
|
|
518
|
+
content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
content,
|
|
522
|
+
details: { error: err.message },
|
|
523
|
+
isError: true,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// -------------------------------------------------------------------------
|
|
530
|
+
// browser_key_press
|
|
531
|
+
// -------------------------------------------------------------------------
|
|
532
|
+
pi.registerTool({
|
|
533
|
+
name: "browser_key_press",
|
|
534
|
+
label: "Browser Key Press",
|
|
535
|
+
description:
|
|
536
|
+
"Press a keyboard key or key combination. Returns a compact page summary plus lightweight verification details after the key press. Use for: submitting forms (Enter), closing modals (Escape), navigating focusable elements (Tab / Shift+Tab), operating dropdowns and menus (ArrowDown, ArrowUp, Space), copying/pasting (Meta+C, Meta+V). Key names follow the DOM KeyboardEvent key convention.",
|
|
537
|
+
parameters: Type.Object({
|
|
538
|
+
key: Type.String({
|
|
539
|
+
description:
|
|
540
|
+
"Key or combination to press, e.g. 'Enter', 'Escape', 'Tab', 'ArrowDown', 'ArrowUp', 'Space', 'Meta+A', 'Shift+Tab', 'Control+Enter'",
|
|
541
|
+
}),
|
|
542
|
+
}),
|
|
543
|
+
|
|
544
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
545
|
+
let actionId: number | null = null;
|
|
546
|
+
let beforeState: CompactPageState | null = null;
|
|
547
|
+
try {
|
|
548
|
+
const { page: p } = await deps.ensureBrowser();
|
|
549
|
+
const target = deps.getActiveTarget();
|
|
550
|
+
beforeState = await deps.captureCompactPageState(p, { includeBodyText: true, target });
|
|
551
|
+
actionId = deps.beginTrackedAction("browser_key_press", params, beforeState.url).id;
|
|
552
|
+
const beforeUrl = p.url();
|
|
553
|
+
const beforeFocus = await readFocusedDescriptor(target);
|
|
554
|
+
|
|
555
|
+
await p.keyboard.press(params.key);
|
|
556
|
+
const settle = await deps.settleAfterActionAdaptive(p, { checkFocusStability: true });
|
|
557
|
+
|
|
558
|
+
const afterState = await deps.captureCompactPageState(p, { includeBodyText: true, target });
|
|
559
|
+
const afterUrl = afterState.url;
|
|
560
|
+
const afterFocus = await readFocusedDescriptor(target);
|
|
561
|
+
const verification = deps.verificationFromChecks(
|
|
562
|
+
[
|
|
563
|
+
{ name: "url_changed", passed: afterUrl !== beforeUrl, value: afterUrl, expected: `!= ${beforeUrl}` },
|
|
564
|
+
{ name: "focus_changed", passed: afterFocus !== beforeFocus, value: afterFocus, expected: `!= ${beforeFocus}` },
|
|
565
|
+
{ name: "dialog_open", passed: afterState.dialog.count > beforeState!.dialog.count, value: afterState.dialog.count, expected: `> ${beforeState!.dialog.count}` },
|
|
566
|
+
],
|
|
567
|
+
"If this key should trigger UI changes, confirm focus is on the intended element first."
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const summary = deps.formatCompactStateSummary(afterState);
|
|
571
|
+
const jsErrors = deps.getRecentErrors(p.url());
|
|
572
|
+
const diff = diffCompactStates(beforeState!, afterState);
|
|
573
|
+
setLastActionBeforeState(beforeState!);
|
|
574
|
+
setLastActionAfterState(afterState);
|
|
575
|
+
deps.finishTrackedAction(actionId!, {
|
|
576
|
+
status: "success",
|
|
577
|
+
afterUrl: afterState.url,
|
|
578
|
+
verificationSummary: verification.verificationSummary,
|
|
579
|
+
warningSummary: jsErrors.trim() || undefined,
|
|
580
|
+
diffSummary: diff.summary,
|
|
581
|
+
changed: diff.changed,
|
|
582
|
+
beforeState: beforeState!,
|
|
583
|
+
afterState,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
content: [{ type: "text", text: `Pressed "${params.key}"\nAction: ${actionId}\n${deps.verificationLine(verification)}${jsErrors}\n\nDiff:\n${deps.formatDiffText(diff)}\n\nPage summary:\n${summary}` }],
|
|
588
|
+
details: { key: params.key, beforeFocus, afterFocus, actionId, diff, ...settle, ...verification },
|
|
589
|
+
};
|
|
590
|
+
} catch (err: any) {
|
|
591
|
+
if (actionId !== null) {
|
|
592
|
+
deps.finishTrackedAction(actionId, { status: "error", afterUrl: deps.getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined });
|
|
593
|
+
}
|
|
594
|
+
const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
|
|
595
|
+
const content: any[] = [{ type: "text", text: `Key press failed: ${err.message}` }];
|
|
596
|
+
if (errorShot) {
|
|
597
|
+
content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
content,
|
|
601
|
+
details: { error: err.message },
|
|
602
|
+
isError: true,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// -------------------------------------------------------------------------
|
|
609
|
+
// browser_select_option
|
|
610
|
+
// -------------------------------------------------------------------------
|
|
611
|
+
pi.registerTool({
|
|
612
|
+
name: "browser_select_option",
|
|
613
|
+
label: "Browser Select Option",
|
|
614
|
+
description:
|
|
615
|
+
"Select an option from a <select> dropdown element by its visible label or value. Returns a compact page summary plus lightweight verification details. For custom-built dropdowns use browser_click to open them then browser_click to pick the option.",
|
|
616
|
+
parameters: Type.Object({
|
|
617
|
+
selector: Type.String({
|
|
618
|
+
description: "CSS selector targeting the <select> element",
|
|
619
|
+
}),
|
|
620
|
+
option: Type.String({
|
|
621
|
+
description:
|
|
622
|
+
"The option to select — can be the visible label text or the value attribute. Will try label first, then value.",
|
|
623
|
+
}),
|
|
624
|
+
}),
|
|
625
|
+
|
|
626
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
627
|
+
let actionId: number | null = null;
|
|
628
|
+
let beforeState: CompactPageState | null = null;
|
|
629
|
+
try {
|
|
630
|
+
const { page: p } = await deps.ensureBrowser();
|
|
631
|
+
const target = deps.getActiveTarget();
|
|
632
|
+
beforeState = await deps.captureCompactPageState(p, { selectors: [params.selector], includeBodyText: true, target });
|
|
633
|
+
actionId = deps.beginTrackedAction("browser_select_option", params, beforeState.url).id;
|
|
634
|
+
|
|
635
|
+
let selected: string[];
|
|
636
|
+
try {
|
|
637
|
+
selected = await target.selectOption(params.selector, { label: params.option }, { timeout: 5000 });
|
|
638
|
+
} catch {
|
|
639
|
+
selected = await target.selectOption(params.selector, { value: params.option }, { timeout: 5000 });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const settle = await deps.settleAfterActionAdaptive(p);
|
|
643
|
+
|
|
644
|
+
const selectedState = await target.locator(params.selector).first().evaluate((el) => {
|
|
645
|
+
if (!(el instanceof HTMLSelectElement)) {
|
|
646
|
+
return { selectedValues: [] as string[], selectedLabels: [] as string[] };
|
|
647
|
+
}
|
|
648
|
+
const selectedOptions = Array.from(el.selectedOptions || []);
|
|
649
|
+
return {
|
|
650
|
+
selectedValues: selectedOptions.map((opt) => opt.value),
|
|
651
|
+
selectedLabels: selectedOptions.map((opt) => (opt.textContent || "").trim()),
|
|
652
|
+
};
|
|
653
|
+
});
|
|
654
|
+
const optionNeedle = params.option.toLowerCase();
|
|
655
|
+
const verification = deps.verificationFromChecks(
|
|
656
|
+
[
|
|
657
|
+
{ name: "selected_values_include_option", passed: selectedState.selectedValues.includes(params.option), value: selectedState.selectedValues, expected: params.option },
|
|
658
|
+
{ name: "selected_labels_include_option", passed: selectedState.selectedLabels.some((label) => label.toLowerCase().includes(optionNeedle)), value: selectedState.selectedLabels, expected: params.option },
|
|
659
|
+
],
|
|
660
|
+
"Confirm whether the target select uses option label or value, then retry with that exact text."
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
const afterState = await deps.captureCompactPageState(p, { selectors: [params.selector], includeBodyText: true, target });
|
|
664
|
+
const summary = deps.formatCompactStateSummary(afterState);
|
|
665
|
+
const jsErrors = deps.getRecentErrors(p.url());
|
|
666
|
+
const diff = diffCompactStates(beforeState!, afterState);
|
|
667
|
+
setLastActionBeforeState(beforeState!);
|
|
668
|
+
setLastActionAfterState(afterState);
|
|
669
|
+
deps.finishTrackedAction(actionId!, {
|
|
670
|
+
status: "success",
|
|
671
|
+
afterUrl: afterState.url,
|
|
672
|
+
verificationSummary: verification.verificationSummary,
|
|
673
|
+
warningSummary: jsErrors.trim() || undefined,
|
|
674
|
+
diffSummary: diff.summary,
|
|
675
|
+
changed: diff.changed,
|
|
676
|
+
beforeState: beforeState!,
|
|
677
|
+
afterState,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
content: [
|
|
682
|
+
{
|
|
683
|
+
type: "text",
|
|
684
|
+
text: `Selected "${params.option}" in "${params.selector}". Values: ${selected.join(", ")}\nAction: ${actionId}\n${deps.verificationLine(verification)}${jsErrors}\n\nDiff:\n${deps.formatDiffText(diff)}\n\nPage summary:\n${summary}`,
|
|
685
|
+
},
|
|
686
|
+
],
|
|
687
|
+
details: { selector: params.selector, option: params.option, selected, selectedState, actionId, diff, ...settle, ...verification },
|
|
688
|
+
};
|
|
689
|
+
} catch (err: any) {
|
|
690
|
+
if (actionId !== null) {
|
|
691
|
+
deps.finishTrackedAction(actionId, { status: "error", afterUrl: deps.getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined });
|
|
692
|
+
}
|
|
693
|
+
const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
|
|
694
|
+
const content: any[] = [{ type: "text", text: `Select option failed: ${err.message}` }];
|
|
695
|
+
if (errorShot) {
|
|
696
|
+
content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
content,
|
|
700
|
+
details: { error: err.message },
|
|
701
|
+
isError: true,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// -------------------------------------------------------------------------
|
|
708
|
+
// browser_set_checked
|
|
709
|
+
// -------------------------------------------------------------------------
|
|
710
|
+
pi.registerTool({
|
|
711
|
+
name: "browser_set_checked",
|
|
712
|
+
label: "Browser Set Checked",
|
|
713
|
+
description:
|
|
714
|
+
"Check or uncheck a checkbox or radio button. More reliable than clicking for form elements where you need a specific state.",
|
|
715
|
+
parameters: Type.Object({
|
|
716
|
+
selector: Type.String({
|
|
717
|
+
description: "CSS selector targeting the checkbox or radio input",
|
|
718
|
+
}),
|
|
719
|
+
checked: Type.Boolean({
|
|
720
|
+
description: "true to check, false to uncheck",
|
|
721
|
+
}),
|
|
722
|
+
}),
|
|
723
|
+
|
|
724
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
725
|
+
let actionId: number | null = null;
|
|
726
|
+
let beforeState: CompactPageState | null = null;
|
|
727
|
+
try {
|
|
728
|
+
const { page: p } = await deps.ensureBrowser();
|
|
729
|
+
const target = deps.getActiveTarget();
|
|
730
|
+
beforeState = await deps.captureCompactPageState(p, { selectors: [params.selector], includeBodyText: true, target });
|
|
731
|
+
actionId = deps.beginTrackedAction("browser_set_checked", params, beforeState.url).id;
|
|
732
|
+
await target.locator(params.selector).first().setChecked(params.checked, { timeout: 10000 });
|
|
733
|
+
const settle = await deps.settleAfterActionAdaptive(p);
|
|
734
|
+
|
|
735
|
+
const actualChecked = await target.locator(params.selector).first().isChecked().catch(() => null);
|
|
736
|
+
const verification = deps.verificationFromChecks(
|
|
737
|
+
[
|
|
738
|
+
{ name: "checked_state_matches", passed: actualChecked === params.checked, value: actualChecked, expected: params.checked },
|
|
739
|
+
],
|
|
740
|
+
"Ensure selector points to a checkbox/radio input and retry."
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
const state = params.checked ? "checked" : "unchecked";
|
|
744
|
+
const afterState = await deps.captureCompactPageState(p, { selectors: [params.selector], includeBodyText: true, target });
|
|
745
|
+
const summary = deps.formatCompactStateSummary(afterState);
|
|
746
|
+
const jsErrors = deps.getRecentErrors(p.url());
|
|
747
|
+
const diff = diffCompactStates(beforeState!, afterState);
|
|
748
|
+
setLastActionBeforeState(beforeState!);
|
|
749
|
+
setLastActionAfterState(afterState);
|
|
750
|
+
deps.finishTrackedAction(actionId!, {
|
|
751
|
+
status: "success",
|
|
752
|
+
afterUrl: afterState.url,
|
|
753
|
+
verificationSummary: verification.verificationSummary,
|
|
754
|
+
warningSummary: jsErrors.trim() || undefined,
|
|
755
|
+
diffSummary: diff.summary,
|
|
756
|
+
changed: diff.changed,
|
|
757
|
+
beforeState: beforeState!,
|
|
758
|
+
afterState,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
content: [{
|
|
763
|
+
type: "text",
|
|
764
|
+
text: `Set "${params.selector}" to ${state}\nAction: ${actionId}\n${deps.verificationLine(verification)}${jsErrors}\n\nDiff:\n${deps.formatDiffText(diff)}\n\nPage summary:\n${summary}`,
|
|
765
|
+
}],
|
|
766
|
+
details: { selector: params.selector, checked: params.checked, actualChecked, actionId, diff, ...settle, ...verification },
|
|
767
|
+
};
|
|
768
|
+
} catch (err: any) {
|
|
769
|
+
if (actionId !== null) {
|
|
770
|
+
deps.finishTrackedAction(actionId, { status: "error", afterUrl: deps.getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined });
|
|
771
|
+
}
|
|
772
|
+
const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
|
|
773
|
+
const content: any[] = [{ type: "text", text: `Set checked failed: ${err.message}` }];
|
|
774
|
+
if (errorShot) {
|
|
775
|
+
content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
|
|
776
|
+
}
|
|
777
|
+
return { content, details: { error: err.message }, isError: true };
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// -------------------------------------------------------------------------
|
|
783
|
+
// browser_set_viewport
|
|
784
|
+
// -------------------------------------------------------------------------
|
|
785
|
+
pi.registerTool({
|
|
786
|
+
name: "browser_set_viewport",
|
|
787
|
+
label: "Browser Set Viewport",
|
|
788
|
+
description:
|
|
789
|
+
"Resize the browser viewport to test responsive layouts at different screen sizes. Use presets for common breakpoints or specify exact pixel dimensions. Essential for verifying mobile/tablet/desktop layouts.",
|
|
790
|
+
parameters: Type.Object({
|
|
791
|
+
preset: Type.Optional(
|
|
792
|
+
StringEnum(["mobile", "tablet", "desktop", "wide"] as const)
|
|
793
|
+
),
|
|
794
|
+
width: Type.Optional(
|
|
795
|
+
Type.Number({ description: "Custom viewport width in pixels (requires height too)" })
|
|
796
|
+
),
|
|
797
|
+
height: Type.Optional(
|
|
798
|
+
Type.Number({ description: "Custom viewport height in pixels (requires width too)" })
|
|
799
|
+
),
|
|
800
|
+
}),
|
|
801
|
+
|
|
802
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
803
|
+
try {
|
|
804
|
+
const { page: p } = await deps.ensureBrowser();
|
|
805
|
+
|
|
806
|
+
let width: number;
|
|
807
|
+
let height: number;
|
|
808
|
+
let label: string;
|
|
809
|
+
|
|
810
|
+
if (params.preset) {
|
|
811
|
+
switch (params.preset) {
|
|
812
|
+
case "mobile":
|
|
813
|
+
width = 390;
|
|
814
|
+
height = 844;
|
|
815
|
+
label = "mobile (390×844)";
|
|
816
|
+
break;
|
|
817
|
+
case "tablet":
|
|
818
|
+
width = 768;
|
|
819
|
+
height = 1024;
|
|
820
|
+
label = "tablet (768×1024)";
|
|
821
|
+
break;
|
|
822
|
+
case "desktop":
|
|
823
|
+
width = 1280;
|
|
824
|
+
height = 800;
|
|
825
|
+
label = "desktop (1280×800)";
|
|
826
|
+
break;
|
|
827
|
+
case "wide":
|
|
828
|
+
width = 1920;
|
|
829
|
+
height = 1080;
|
|
830
|
+
label = "wide (1920×1080)";
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
} else if (params.width !== undefined && params.height !== undefined) {
|
|
834
|
+
width = params.width;
|
|
835
|
+
height = params.height;
|
|
836
|
+
label = `custom (${width}×${height})`;
|
|
837
|
+
} else {
|
|
838
|
+
return {
|
|
839
|
+
content: [
|
|
840
|
+
{
|
|
841
|
+
type: "text",
|
|
842
|
+
text: "Provide either a preset (mobile/tablet/desktop/wide) or both width and height.",
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
details: {},
|
|
846
|
+
isError: true,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
await p.setViewportSize({ width: width!, height: height! });
|
|
851
|
+
|
|
852
|
+
return {
|
|
853
|
+
content: [{ type: "text", text: `Viewport set to ${label!}` }],
|
|
854
|
+
details: { width: width!, height: height!, label: label! },
|
|
855
|
+
};
|
|
856
|
+
} catch (err: any) {
|
|
857
|
+
return {
|
|
858
|
+
content: [{ type: "text", text: `Set viewport failed: ${err.message}` }],
|
|
859
|
+
details: { error: err.message },
|
|
860
|
+
isError: true,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
}
|