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,801 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import type { ToolDeps, CompactPageState } from "../state.js";
|
|
4
|
+
import {
|
|
5
|
+
setLastActionBeforeState,
|
|
6
|
+
setLastActionAfterState,
|
|
7
|
+
} from "../state.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Form analysis evaluate callback — runs in the browser context.
|
|
11
|
+
// Self-contained: no external deps, no window.__pi calls.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
interface FormFieldInfo {
|
|
15
|
+
type: string;
|
|
16
|
+
name: string;
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
required: boolean;
|
|
20
|
+
value: string;
|
|
21
|
+
checked?: boolean;
|
|
22
|
+
options?: Array<{ value: string; label: string; selected: boolean }>;
|
|
23
|
+
validation: { valid: boolean; message: string };
|
|
24
|
+
hidden: boolean;
|
|
25
|
+
disabled: boolean;
|
|
26
|
+
group?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FormSubmitButton {
|
|
30
|
+
tag: string;
|
|
31
|
+
type: string;
|
|
32
|
+
text: string;
|
|
33
|
+
name: string;
|
|
34
|
+
disabled: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface FormAnalysisResult {
|
|
38
|
+
formSelector: string;
|
|
39
|
+
fields: FormFieldInfo[];
|
|
40
|
+
submitButtons: FormSubmitButton[];
|
|
41
|
+
fieldCount: number;
|
|
42
|
+
visibleFieldCount: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Runs inside page.evaluate(). Finds the target form, inventories all fields
|
|
47
|
+
* with full label resolution, and returns a structured result.
|
|
48
|
+
*/
|
|
49
|
+
function buildFormAnalysisScript(selector?: string): string {
|
|
50
|
+
// We return a string that will be evaluated in the page context.
|
|
51
|
+
// This avoids serialization issues with passing functions.
|
|
52
|
+
return `(() => {
|
|
53
|
+
// --- helpers ---
|
|
54
|
+
function isVisible(el) {
|
|
55
|
+
if (!el) return false;
|
|
56
|
+
const style = window.getComputedStyle(el);
|
|
57
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
58
|
+
if (el.offsetWidth === 0 && el.offsetHeight === 0) return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function humanizeName(name) {
|
|
63
|
+
if (!name) return '';
|
|
64
|
+
return name
|
|
65
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
66
|
+
.replace(/[_\\-]+/g, ' ')
|
|
67
|
+
.replace(/\\bid\\b/i, 'ID')
|
|
68
|
+
.trim()
|
|
69
|
+
.replace(/^./, c => c.toUpperCase());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getTextContent(el) {
|
|
73
|
+
if (!el) return '';
|
|
74
|
+
return (el.textContent || '').trim().replace(/\\s+/g, ' ');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- label resolution (7-level priority chain) ---
|
|
78
|
+
function resolveLabel(field) {
|
|
79
|
+
// 1. aria-labelledby
|
|
80
|
+
const labelledBy = field.getAttribute('aria-labelledby');
|
|
81
|
+
if (labelledBy) {
|
|
82
|
+
const parts = labelledBy.split(/\\s+/).map(id => {
|
|
83
|
+
const el = document.getElementById(id);
|
|
84
|
+
return el ? getTextContent(el) : '';
|
|
85
|
+
}).filter(Boolean);
|
|
86
|
+
if (parts.length) return parts.join(' ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. aria-label
|
|
90
|
+
const ariaLabel = field.getAttribute('aria-label');
|
|
91
|
+
if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim();
|
|
92
|
+
|
|
93
|
+
// 3. label[for="id"]
|
|
94
|
+
const fieldId = field.id;
|
|
95
|
+
if (fieldId) {
|
|
96
|
+
const labelFor = document.querySelector('label[for="' + CSS.escape(fieldId) + '"]');
|
|
97
|
+
if (labelFor) {
|
|
98
|
+
const text = getTextContent(labelFor);
|
|
99
|
+
if (text) return text;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 4. wrapping label
|
|
104
|
+
const wrappingLabel = field.closest('label');
|
|
105
|
+
if (wrappingLabel) {
|
|
106
|
+
// Clone and remove the field itself to get just the label text
|
|
107
|
+
const clone = wrappingLabel.cloneNode(true);
|
|
108
|
+
const inputs = clone.querySelectorAll('input, select, textarea');
|
|
109
|
+
inputs.forEach(inp => inp.remove());
|
|
110
|
+
const text = (clone.textContent || '').trim().replace(/\\s+/g, ' ');
|
|
111
|
+
if (text) return text;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 5. placeholder
|
|
115
|
+
const placeholder = field.getAttribute('placeholder');
|
|
116
|
+
if (placeholder && placeholder.trim()) return placeholder.trim();
|
|
117
|
+
|
|
118
|
+
// 6. title
|
|
119
|
+
const title = field.getAttribute('title');
|
|
120
|
+
if (title && title.trim()) return title.trim();
|
|
121
|
+
|
|
122
|
+
// 7. humanized name
|
|
123
|
+
const name = field.getAttribute('name');
|
|
124
|
+
if (name) return humanizeName(name);
|
|
125
|
+
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- form detection ---
|
|
130
|
+
let form;
|
|
131
|
+
const selectorArg = ${JSON.stringify(selector ?? null)};
|
|
132
|
+
|
|
133
|
+
if (selectorArg) {
|
|
134
|
+
form = document.querySelector(selectorArg);
|
|
135
|
+
if (!form) return { error: 'Form not found for selector: ' + selectorArg };
|
|
136
|
+
} else {
|
|
137
|
+
const forms = Array.from(document.querySelectorAll('form'));
|
|
138
|
+
if (forms.length === 1) {
|
|
139
|
+
form = forms[0];
|
|
140
|
+
} else if (forms.length > 1) {
|
|
141
|
+
// Pick form with most visible inputs
|
|
142
|
+
let best = null;
|
|
143
|
+
let bestCount = -1;
|
|
144
|
+
for (const f of forms) {
|
|
145
|
+
const inputs = f.querySelectorAll('input, select, textarea');
|
|
146
|
+
let visCount = 0;
|
|
147
|
+
inputs.forEach(inp => { if (isVisible(inp)) visCount++; });
|
|
148
|
+
if (visCount > bestCount) {
|
|
149
|
+
bestCount = visCount;
|
|
150
|
+
best = f;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
form = best;
|
|
154
|
+
} else {
|
|
155
|
+
form = document.body;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Build a useful selector for the form
|
|
160
|
+
let formSelector = 'body';
|
|
161
|
+
if (form !== document.body) {
|
|
162
|
+
if (form.id) {
|
|
163
|
+
formSelector = '#' + CSS.escape(form.id);
|
|
164
|
+
} else if (form.getAttribute('name')) {
|
|
165
|
+
formSelector = 'form[name="' + form.getAttribute('name') + '"]';
|
|
166
|
+
} else if (form.getAttribute('action')) {
|
|
167
|
+
formSelector = 'form[action="' + form.getAttribute('action') + '"]';
|
|
168
|
+
} else {
|
|
169
|
+
// nth-of-type fallback
|
|
170
|
+
const allForms = Array.from(document.querySelectorAll('form'));
|
|
171
|
+
const idx = allForms.indexOf(form);
|
|
172
|
+
formSelector = idx >= 0 ? 'form:nth-of-type(' + (idx + 1) + ')' : 'form';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- field inventory ---
|
|
177
|
+
const fieldElements = form.querySelectorAll('input, select, textarea');
|
|
178
|
+
const fields = [];
|
|
179
|
+
|
|
180
|
+
fieldElements.forEach(field => {
|
|
181
|
+
const tag = field.tagName.toLowerCase();
|
|
182
|
+
const type = tag === 'select' ? 'select'
|
|
183
|
+
: tag === 'textarea' ? 'textarea'
|
|
184
|
+
: (field.getAttribute('type') || 'text').toLowerCase();
|
|
185
|
+
|
|
186
|
+
// Skip submit/button/reset/image inputs — they're not data fields
|
|
187
|
+
if (tag === 'input' && ['submit', 'button', 'reset', 'image'].includes(type)) return;
|
|
188
|
+
|
|
189
|
+
const label = resolveLabel(field);
|
|
190
|
+
const name = field.getAttribute('name') || '';
|
|
191
|
+
const id = field.id || '';
|
|
192
|
+
const required = field.required || field.getAttribute('aria-required') === 'true';
|
|
193
|
+
const hidden = type === 'hidden' || !isVisible(field);
|
|
194
|
+
const disabled = field.disabled;
|
|
195
|
+
|
|
196
|
+
// Value
|
|
197
|
+
let value = '';
|
|
198
|
+
if (tag === 'select') {
|
|
199
|
+
const selected = field.querySelector('option:checked');
|
|
200
|
+
value = selected ? selected.value : '';
|
|
201
|
+
} else {
|
|
202
|
+
value = field.value || '';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const info = {
|
|
206
|
+
type,
|
|
207
|
+
name,
|
|
208
|
+
id,
|
|
209
|
+
label,
|
|
210
|
+
required,
|
|
211
|
+
value,
|
|
212
|
+
hidden,
|
|
213
|
+
disabled,
|
|
214
|
+
validation: {
|
|
215
|
+
valid: field.validity ? field.validity.valid : true,
|
|
216
|
+
message: field.validationMessage || '',
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Checked state for checkboxes/radios
|
|
221
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
222
|
+
info.checked = field.checked;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Options for select elements
|
|
226
|
+
if (tag === 'select') {
|
|
227
|
+
info.options = Array.from(field.querySelectorAll('option')).map(opt => ({
|
|
228
|
+
value: opt.value,
|
|
229
|
+
label: opt.textContent.trim(),
|
|
230
|
+
selected: opt.selected,
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Fieldset/legend group
|
|
235
|
+
const fieldset = field.closest('fieldset');
|
|
236
|
+
if (fieldset) {
|
|
237
|
+
const legend = fieldset.querySelector('legend');
|
|
238
|
+
if (legend) {
|
|
239
|
+
info.group = getTextContent(legend);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
fields.push(info);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// --- submit buttons ---
|
|
247
|
+
const submitButtons = [];
|
|
248
|
+
const buttonCandidates = form.querySelectorAll('button, input[type="submit"]');
|
|
249
|
+
buttonCandidates.forEach(btn => {
|
|
250
|
+
const tag = btn.tagName.toLowerCase();
|
|
251
|
+
const type = (btn.getAttribute('type') || (tag === 'button' ? 'submit' : '')).toLowerCase();
|
|
252
|
+
// Include: explicit submit, or button without explicit type (defaults to submit)
|
|
253
|
+
if (type === 'submit' || (tag === 'button' && !btn.getAttribute('type'))) {
|
|
254
|
+
submitButtons.push({
|
|
255
|
+
tag,
|
|
256
|
+
type: type || 'submit',
|
|
257
|
+
text: tag === 'input' ? (btn.value || '') : getTextContent(btn),
|
|
258
|
+
name: btn.getAttribute('name') || '',
|
|
259
|
+
disabled: btn.disabled,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const visibleFieldCount = fields.filter(f => !f.hidden).length;
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
formSelector,
|
|
268
|
+
fields,
|
|
269
|
+
submitButtons,
|
|
270
|
+
fieldCount: fields.length,
|
|
271
|
+
visibleFieldCount,
|
|
272
|
+
};
|
|
273
|
+
})()`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Post-fill validation collection — runs in browser context.
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
function buildPostFillValidationScript(formSelector: string): string {
|
|
281
|
+
return `(() => {
|
|
282
|
+
const form = ${JSON.stringify(formSelector)} === 'body'
|
|
283
|
+
? document.body
|
|
284
|
+
: document.querySelector(${JSON.stringify(formSelector)});
|
|
285
|
+
if (!form) return { valid: false, invalidCount: 0, fields: [] };
|
|
286
|
+
|
|
287
|
+
const fieldEls = form.querySelectorAll('input, select, textarea');
|
|
288
|
+
let validCount = 0;
|
|
289
|
+
let invalidCount = 0;
|
|
290
|
+
const invalidFields = [];
|
|
291
|
+
|
|
292
|
+
fieldEls.forEach(f => {
|
|
293
|
+
const tag = f.tagName.toLowerCase();
|
|
294
|
+
const type = tag === 'select' ? 'select'
|
|
295
|
+
: tag === 'textarea' ? 'textarea'
|
|
296
|
+
: (f.getAttribute('type') || 'text').toLowerCase();
|
|
297
|
+
if (['submit', 'button', 'reset', 'image', 'hidden'].includes(type)) return;
|
|
298
|
+
|
|
299
|
+
if (f.validity && !f.validity.valid) {
|
|
300
|
+
invalidCount++;
|
|
301
|
+
invalidFields.push({
|
|
302
|
+
name: f.getAttribute('name') || f.id || type,
|
|
303
|
+
message: f.validationMessage || 'Invalid',
|
|
304
|
+
});
|
|
305
|
+
} else {
|
|
306
|
+
validCount++;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
valid: invalidCount === 0,
|
|
312
|
+
validCount,
|
|
313
|
+
invalidCount,
|
|
314
|
+
invalidFields,
|
|
315
|
+
};
|
|
316
|
+
})()`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Registration
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
export function registerFormTools(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
324
|
+
// -----------------------------------------------------------------------
|
|
325
|
+
// browser_analyze_form
|
|
326
|
+
// -----------------------------------------------------------------------
|
|
327
|
+
pi.registerTool({
|
|
328
|
+
name: "browser_analyze_form",
|
|
329
|
+
label: "Analyze Form",
|
|
330
|
+
description:
|
|
331
|
+
"Analyze a form on the current page and return a structured field inventory. Auto-detects the form if no selector is provided (picks the single <form>, or the form with most visible inputs, or falls back to document.body). Returns field types, labels (resolved via aria-labelledby → aria-label → label[for] → wrapping label → placeholder → title → name), values, validation state, and submit buttons.",
|
|
332
|
+
parameters: Type.Object({
|
|
333
|
+
selector: Type.Optional(
|
|
334
|
+
Type.String({
|
|
335
|
+
description:
|
|
336
|
+
"CSS selector targeting the form element to analyze. If omitted, auto-detects the primary form on the page.",
|
|
337
|
+
})
|
|
338
|
+
),
|
|
339
|
+
}),
|
|
340
|
+
|
|
341
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
342
|
+
let actionId: number | null = null;
|
|
343
|
+
let beforeState: CompactPageState | null = null;
|
|
344
|
+
try {
|
|
345
|
+
const { page: p } = await deps.ensureBrowser();
|
|
346
|
+
const target = deps.getActiveTarget();
|
|
347
|
+
beforeState = await deps.captureCompactPageState(p, {
|
|
348
|
+
selectors: params.selector ? [params.selector] : [],
|
|
349
|
+
includeBodyText: false,
|
|
350
|
+
target,
|
|
351
|
+
});
|
|
352
|
+
actionId = deps.beginTrackedAction("browser_analyze_form", params, beforeState.url).id;
|
|
353
|
+
|
|
354
|
+
const script = buildFormAnalysisScript(params.selector);
|
|
355
|
+
const result = await target.evaluate(script) as FormAnalysisResult & { error?: string };
|
|
356
|
+
|
|
357
|
+
if (result.error) {
|
|
358
|
+
deps.finishTrackedAction(actionId!, {
|
|
359
|
+
status: "error",
|
|
360
|
+
error: result.error,
|
|
361
|
+
beforeState,
|
|
362
|
+
});
|
|
363
|
+
return {
|
|
364
|
+
content: [{ type: "text" as const, text: result.error }],
|
|
365
|
+
details: {},
|
|
366
|
+
isError: true,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const afterState = await deps.captureCompactPageState(p, {
|
|
371
|
+
selectors: params.selector ? [params.selector] : [],
|
|
372
|
+
includeBodyText: false,
|
|
373
|
+
target,
|
|
374
|
+
});
|
|
375
|
+
setLastActionBeforeState(beforeState);
|
|
376
|
+
setLastActionAfterState(afterState);
|
|
377
|
+
|
|
378
|
+
deps.finishTrackedAction(actionId!, {
|
|
379
|
+
status: "success",
|
|
380
|
+
afterUrl: afterState.url,
|
|
381
|
+
beforeState,
|
|
382
|
+
afterState,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// Format output
|
|
386
|
+
const lines: string[] = [];
|
|
387
|
+
lines.push(`Form: ${result.formSelector}`);
|
|
388
|
+
lines.push(`Fields: ${result.fieldCount} total, ${result.visibleFieldCount} visible`);
|
|
389
|
+
lines.push(`Submit buttons: ${result.submitButtons.length}`);
|
|
390
|
+
lines.push("");
|
|
391
|
+
|
|
392
|
+
if (result.fields.length > 0) {
|
|
393
|
+
lines.push("## Fields");
|
|
394
|
+
for (const f of result.fields) {
|
|
395
|
+
const flags: string[] = [];
|
|
396
|
+
if (f.required) flags.push("required");
|
|
397
|
+
if (f.hidden) flags.push("hidden");
|
|
398
|
+
if (f.disabled) flags.push("disabled");
|
|
399
|
+
if (f.checked !== undefined) flags.push(f.checked ? "checked" : "unchecked");
|
|
400
|
+
if (!f.validation.valid) flags.push(`invalid: ${f.validation.message}`);
|
|
401
|
+
|
|
402
|
+
const flagStr = flags.length ? ` [${flags.join(", ")}]` : "";
|
|
403
|
+
const valueStr = f.value ? ` = "${f.value}"` : "";
|
|
404
|
+
const labelStr = f.label || "(no label)";
|
|
405
|
+
const selectorHint = f.id ? `#${f.id}` : f.name ? `[name="${f.name}"]` : f.type;
|
|
406
|
+
const groupStr = f.group ? ` (group: ${f.group})` : "";
|
|
407
|
+
|
|
408
|
+
lines.push(`- **${labelStr}** \`${f.type}\` \`${selectorHint}\`${valueStr}${flagStr}${groupStr}`);
|
|
409
|
+
|
|
410
|
+
if (f.options && f.options.length > 0) {
|
|
411
|
+
for (const opt of f.options) {
|
|
412
|
+
const sel = opt.selected ? " ✓" : "";
|
|
413
|
+
lines.push(` - ${opt.label} (${opt.value})${sel}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
lines.push("");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (result.submitButtons.length > 0) {
|
|
421
|
+
lines.push("## Submit Buttons");
|
|
422
|
+
for (const btn of result.submitButtons) {
|
|
423
|
+
const disStr = btn.disabled ? " [disabled]" : "";
|
|
424
|
+
lines.push(`- "${btn.text}" \`<${btn.tag} type="${btn.type}">\`${btn.name ? ` name="${btn.name}"` : ""}${disStr}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
430
|
+
details: { formAnalysis: result },
|
|
431
|
+
};
|
|
432
|
+
} catch (err: unknown) {
|
|
433
|
+
const screenshot = await deps.captureErrorScreenshot(
|
|
434
|
+
(() => { try { return deps.getActivePage(); } catch { return null; } })()
|
|
435
|
+
);
|
|
436
|
+
const errMsg = deps.firstErrorLine(err);
|
|
437
|
+
|
|
438
|
+
if (actionId !== null) {
|
|
439
|
+
deps.finishTrackedAction(actionId, {
|
|
440
|
+
status: "error",
|
|
441
|
+
error: errMsg,
|
|
442
|
+
beforeState: beforeState ?? undefined,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [
|
|
447
|
+
{ type: "text", text: `browser_analyze_form failed: ${errMsg}` },
|
|
448
|
+
];
|
|
449
|
+
if (screenshot) {
|
|
450
|
+
content.push({ type: "image", data: screenshot.data, mimeType: screenshot.mimeType });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return { content, details: {}, isError: true };
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// -----------------------------------------------------------------------
|
|
459
|
+
// browser_fill_form
|
|
460
|
+
// -----------------------------------------------------------------------
|
|
461
|
+
pi.registerTool({
|
|
462
|
+
name: "browser_fill_form",
|
|
463
|
+
label: "Fill Form",
|
|
464
|
+
description:
|
|
465
|
+
"Fill a form on the current page using a values mapping. Keys are field identifiers (label text, name attribute, placeholder, or aria-label). Resolves fields by label → name → placeholder → aria-label (exact first, then case-insensitive). Uses fill() for text inputs, selectOption() for selects, setChecked() for checkboxes/radios. Skips file and hidden inputs. Optionally submits the form.",
|
|
466
|
+
parameters: Type.Object({
|
|
467
|
+
selector: Type.Optional(
|
|
468
|
+
Type.String({
|
|
469
|
+
description:
|
|
470
|
+
"CSS selector targeting the form element. If omitted, auto-detects the primary form.",
|
|
471
|
+
})
|
|
472
|
+
),
|
|
473
|
+
values: Type.Record(Type.String(), Type.String(), {
|
|
474
|
+
description:
|
|
475
|
+
"Mapping of field identifiers to values. Keys can be label text, name, placeholder, or aria-label. Values are strings — for checkboxes use 'true'/'false' or 'on'/'off', for selects use the option label or value.",
|
|
476
|
+
}),
|
|
477
|
+
submit: Type.Optional(
|
|
478
|
+
Type.Boolean({
|
|
479
|
+
description: "If true, clicks the form's submit button after filling all fields.",
|
|
480
|
+
})
|
|
481
|
+
),
|
|
482
|
+
}),
|
|
483
|
+
|
|
484
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
485
|
+
let actionId: number | null = null;
|
|
486
|
+
let beforeState: CompactPageState | null = null;
|
|
487
|
+
try {
|
|
488
|
+
const { page: p } = await deps.ensureBrowser();
|
|
489
|
+
const target = deps.getActiveTarget();
|
|
490
|
+
beforeState = await deps.captureCompactPageState(p, {
|
|
491
|
+
selectors: params.selector ? [params.selector] : [],
|
|
492
|
+
includeBodyText: false,
|
|
493
|
+
target,
|
|
494
|
+
});
|
|
495
|
+
actionId = deps.beginTrackedAction("browser_fill_form", params, beforeState.url).id;
|
|
496
|
+
|
|
497
|
+
// --- Detect form selector ---
|
|
498
|
+
// Reuse the same detection logic as analyze_form via a lightweight evaluate
|
|
499
|
+
const formSelector: string = params.selector ?? await target.evaluate(`(() => {
|
|
500
|
+
const forms = Array.from(document.querySelectorAll('form'));
|
|
501
|
+
if (forms.length === 1) {
|
|
502
|
+
const f = forms[0];
|
|
503
|
+
if (f.id) return '#' + CSS.escape(f.id);
|
|
504
|
+
if (f.getAttribute('name')) return 'form[name="' + f.getAttribute('name') + '"]';
|
|
505
|
+
return 'form';
|
|
506
|
+
} else if (forms.length > 1) {
|
|
507
|
+
let best = null;
|
|
508
|
+
let bestCount = -1;
|
|
509
|
+
let bestIdx = 0;
|
|
510
|
+
for (let i = 0; i < forms.length; i++) {
|
|
511
|
+
const inputs = forms[i].querySelectorAll('input, select, textarea');
|
|
512
|
+
let vis = 0;
|
|
513
|
+
inputs.forEach(inp => {
|
|
514
|
+
const s = window.getComputedStyle(inp);
|
|
515
|
+
if (s.display !== 'none' && s.visibility !== 'hidden') vis++;
|
|
516
|
+
});
|
|
517
|
+
if (vis > bestCount) { bestCount = vis; best = forms[i]; bestIdx = i; }
|
|
518
|
+
}
|
|
519
|
+
if (best.id) return '#' + CSS.escape(best.id);
|
|
520
|
+
if (best.getAttribute('name')) return 'form[name="' + best.getAttribute('name') + '"]';
|
|
521
|
+
return 'form:nth-of-type(' + (bestIdx + 1) + ')';
|
|
522
|
+
}
|
|
523
|
+
return 'body';
|
|
524
|
+
})()`) as string;
|
|
525
|
+
|
|
526
|
+
const formLocator = formSelector === "body"
|
|
527
|
+
? target.locator("body")
|
|
528
|
+
: target.locator(formSelector);
|
|
529
|
+
|
|
530
|
+
// --- Resolve and fill each field ---
|
|
531
|
+
interface MatchedField {
|
|
532
|
+
key: string;
|
|
533
|
+
resolvedBy: string;
|
|
534
|
+
value: string;
|
|
535
|
+
fieldType: string;
|
|
536
|
+
}
|
|
537
|
+
interface UnmatchedField {
|
|
538
|
+
key: string;
|
|
539
|
+
reason: string;
|
|
540
|
+
}
|
|
541
|
+
interface SkippedField {
|
|
542
|
+
key: string;
|
|
543
|
+
reason: string;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const matched: MatchedField[] = [];
|
|
547
|
+
const unmatched: UnmatchedField[] = [];
|
|
548
|
+
const skipped: SkippedField[] = [];
|
|
549
|
+
|
|
550
|
+
for (const [key, value] of Object.entries(params.values)) {
|
|
551
|
+
// Try to resolve the field in priority order
|
|
552
|
+
let resolvedLocator: ReturnType<typeof formLocator.locator> | null = null;
|
|
553
|
+
let resolvedBy = "";
|
|
554
|
+
|
|
555
|
+
// 1. Exact label match
|
|
556
|
+
try {
|
|
557
|
+
const loc = formLocator.getByLabel(key, { exact: true });
|
|
558
|
+
const count = await loc.count();
|
|
559
|
+
if (count === 1) {
|
|
560
|
+
resolvedLocator = loc;
|
|
561
|
+
resolvedBy = "label (exact)";
|
|
562
|
+
} else if (count > 1) {
|
|
563
|
+
skipped.push({ key, reason: `Ambiguous: ${count} fields match label "${key}"` });
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
} catch { /* not found, try next */ }
|
|
567
|
+
|
|
568
|
+
// 2. Case-insensitive label match
|
|
569
|
+
if (!resolvedLocator) {
|
|
570
|
+
try {
|
|
571
|
+
const loc = formLocator.getByLabel(key);
|
|
572
|
+
const count = await loc.count();
|
|
573
|
+
if (count === 1) {
|
|
574
|
+
resolvedLocator = loc;
|
|
575
|
+
resolvedBy = "label";
|
|
576
|
+
} else if (count > 1) {
|
|
577
|
+
skipped.push({ key, reason: `Ambiguous: ${count} fields match label "${key}" (case-insensitive)` });
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
} catch { /* not found, try next */ }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// 3. name attribute
|
|
584
|
+
if (!resolvedLocator) {
|
|
585
|
+
try {
|
|
586
|
+
const loc = formLocator.locator(`[name="${CSS.escape(key)}"]`);
|
|
587
|
+
const count = await loc.count();
|
|
588
|
+
if (count === 1) {
|
|
589
|
+
resolvedLocator = loc;
|
|
590
|
+
resolvedBy = "name";
|
|
591
|
+
} else if (count > 1) {
|
|
592
|
+
skipped.push({ key, reason: `Ambiguous: ${count} fields match name="${key}"` });
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
} catch { /* not found, try next */ }
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 4. placeholder attribute (case-insensitive)
|
|
599
|
+
if (!resolvedLocator) {
|
|
600
|
+
try {
|
|
601
|
+
const loc = formLocator.locator(`[placeholder="${key}" i]`);
|
|
602
|
+
const count = await loc.count();
|
|
603
|
+
if (count === 1) {
|
|
604
|
+
resolvedLocator = loc;
|
|
605
|
+
resolvedBy = "placeholder";
|
|
606
|
+
} else if (count > 1) {
|
|
607
|
+
skipped.push({ key, reason: `Ambiguous: ${count} fields match placeholder="${key}"` });
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
} catch { /* not found, try next */ }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// 5. aria-label attribute (case-insensitive)
|
|
614
|
+
if (!resolvedLocator) {
|
|
615
|
+
try {
|
|
616
|
+
const loc = formLocator.locator(`[aria-label="${key}" i]`);
|
|
617
|
+
const count = await loc.count();
|
|
618
|
+
if (count === 1) {
|
|
619
|
+
resolvedLocator = loc;
|
|
620
|
+
resolvedBy = "aria-label";
|
|
621
|
+
} else if (count > 1) {
|
|
622
|
+
skipped.push({ key, reason: `Ambiguous: ${count} fields match aria-label="${key}"` });
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
} catch { /* not found, try next */ }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (!resolvedLocator) {
|
|
629
|
+
unmatched.push({ key, reason: "No matching field found" });
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Determine field type
|
|
634
|
+
const fieldInfo = await resolvedLocator.first().evaluate((el: Element) => {
|
|
635
|
+
const tag = el.tagName.toLowerCase();
|
|
636
|
+
const type = tag === "select" ? "select"
|
|
637
|
+
: tag === "textarea" ? "textarea"
|
|
638
|
+
: ((el as HTMLInputElement).type || "text").toLowerCase();
|
|
639
|
+
const hidden = type === "hidden" ||
|
|
640
|
+
(window.getComputedStyle(el).display === "none") ||
|
|
641
|
+
(window.getComputedStyle(el).visibility === "hidden");
|
|
642
|
+
return { tag, type, hidden };
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// Skip file inputs
|
|
646
|
+
if (fieldInfo.type === "file") {
|
|
647
|
+
skipped.push({ key, reason: "File input — use browser_upload_file instead" });
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Skip hidden inputs
|
|
652
|
+
if (fieldInfo.hidden) {
|
|
653
|
+
skipped.push({ key, reason: "Hidden input" });
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Fill based on type
|
|
658
|
+
try {
|
|
659
|
+
if (fieldInfo.type === "checkbox" || fieldInfo.type === "radio") {
|
|
660
|
+
const checked = value === "true" || value === "on";
|
|
661
|
+
await resolvedLocator.first().setChecked(checked, { timeout: 5000 });
|
|
662
|
+
matched.push({ key, resolvedBy, value: checked ? "checked" : "unchecked", fieldType: fieldInfo.type });
|
|
663
|
+
} else if (fieldInfo.tag === "select") {
|
|
664
|
+
// Try label first, then value
|
|
665
|
+
try {
|
|
666
|
+
await resolvedLocator.first().selectOption({ label: value }, { timeout: 5000 });
|
|
667
|
+
} catch {
|
|
668
|
+
await resolvedLocator.first().selectOption({ value }, { timeout: 5000 });
|
|
669
|
+
}
|
|
670
|
+
matched.push({ key, resolvedBy, value, fieldType: "select" });
|
|
671
|
+
} else {
|
|
672
|
+
// Text-like inputs and textarea
|
|
673
|
+
await resolvedLocator.first().fill(value, { timeout: 5000 });
|
|
674
|
+
matched.push({ key, resolvedBy, value, fieldType: fieldInfo.type });
|
|
675
|
+
}
|
|
676
|
+
} catch (fillErr: unknown) {
|
|
677
|
+
const msg = fillErr instanceof Error ? fillErr.message : String(fillErr);
|
|
678
|
+
skipped.push({ key, reason: `Fill failed: ${msg.split("\n")[0]}` });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// --- Settle after all fills ---
|
|
683
|
+
await deps.settleAfterActionAdaptive(p);
|
|
684
|
+
|
|
685
|
+
// --- Submit if requested ---
|
|
686
|
+
let submitted = false;
|
|
687
|
+
if (params.submit) {
|
|
688
|
+
try {
|
|
689
|
+
// Find submit button in form
|
|
690
|
+
const submitLoc = formLocator.locator('[type="submit"], button:not([type])').first();
|
|
691
|
+
const submitExists = await submitLoc.count();
|
|
692
|
+
if (submitExists > 0) {
|
|
693
|
+
await submitLoc.click({ timeout: 5000 });
|
|
694
|
+
await deps.settleAfterActionAdaptive(p);
|
|
695
|
+
submitted = true;
|
|
696
|
+
} else {
|
|
697
|
+
skipped.push({ key: "_submit", reason: "No submit button found in form" });
|
|
698
|
+
}
|
|
699
|
+
} catch (submitErr: unknown) {
|
|
700
|
+
const msg = submitErr instanceof Error ? submitErr.message : String(submitErr);
|
|
701
|
+
skipped.push({ key: "_submit", reason: `Submit failed: ${msg.split("\n")[0]}` });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// --- Post-fill validation state ---
|
|
706
|
+
const validationSummary = await target.evaluate(
|
|
707
|
+
buildPostFillValidationScript(formSelector)
|
|
708
|
+
) as { valid: boolean; validCount: number; invalidCount: number; invalidFields: Array<{ name: string; message: string }> };
|
|
709
|
+
|
|
710
|
+
const afterState = await deps.captureCompactPageState(p, {
|
|
711
|
+
selectors: params.selector ? [params.selector] : [],
|
|
712
|
+
includeBodyText: false,
|
|
713
|
+
target,
|
|
714
|
+
});
|
|
715
|
+
setLastActionBeforeState(beforeState);
|
|
716
|
+
setLastActionAfterState(afterState);
|
|
717
|
+
|
|
718
|
+
deps.finishTrackedAction(actionId!, {
|
|
719
|
+
status: "success",
|
|
720
|
+
afterUrl: afterState.url,
|
|
721
|
+
beforeState,
|
|
722
|
+
afterState,
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// --- Format output ---
|
|
726
|
+
const lines: string[] = [];
|
|
727
|
+
lines.push(`Form: ${formSelector}`);
|
|
728
|
+
lines.push(`Filled: ${matched.length} | Unmatched: ${unmatched.length} | Skipped: ${skipped.length}${submitted ? " | Submitted: yes" : ""}`);
|
|
729
|
+
lines.push("");
|
|
730
|
+
|
|
731
|
+
if (matched.length > 0) {
|
|
732
|
+
lines.push("## Matched");
|
|
733
|
+
for (const m of matched) {
|
|
734
|
+
lines.push(`- ✓ **${m.key}** → "${m.value}" (${m.fieldType}, resolved by ${m.resolvedBy})`);
|
|
735
|
+
}
|
|
736
|
+
lines.push("");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (unmatched.length > 0) {
|
|
740
|
+
lines.push("## Unmatched");
|
|
741
|
+
for (const u of unmatched) {
|
|
742
|
+
lines.push(`- ✗ **${u.key}** — ${u.reason}`);
|
|
743
|
+
}
|
|
744
|
+
lines.push("");
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (skipped.length > 0) {
|
|
748
|
+
lines.push("## Skipped");
|
|
749
|
+
for (const s of skipped) {
|
|
750
|
+
lines.push(`- ⊘ **${s.key}** — ${s.reason}`);
|
|
751
|
+
}
|
|
752
|
+
lines.push("");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (!validationSummary.valid) {
|
|
756
|
+
lines.push("## Validation Issues");
|
|
757
|
+
for (const inv of validationSummary.invalidFields) {
|
|
758
|
+
lines.push(`- ${inv.name}: ${inv.message}`);
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
lines.push("Validation: all fields valid ✓");
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const fillResult = {
|
|
765
|
+
matched,
|
|
766
|
+
unmatched,
|
|
767
|
+
skipped,
|
|
768
|
+
submitted,
|
|
769
|
+
validationSummary,
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
774
|
+
details: { fillResult },
|
|
775
|
+
};
|
|
776
|
+
} catch (err: unknown) {
|
|
777
|
+
const screenshot = await deps.captureErrorScreenshot(
|
|
778
|
+
(() => { try { return deps.getActivePage(); } catch { return null; } })()
|
|
779
|
+
);
|
|
780
|
+
const errMsg = deps.firstErrorLine(err);
|
|
781
|
+
|
|
782
|
+
if (actionId !== null) {
|
|
783
|
+
deps.finishTrackedAction(actionId, {
|
|
784
|
+
status: "error",
|
|
785
|
+
error: errMsg,
|
|
786
|
+
beforeState: beforeState ?? undefined,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [
|
|
791
|
+
{ type: "text", text: `browser_fill_form failed: ${errMsg}` },
|
|
792
|
+
];
|
|
793
|
+
if (screenshot) {
|
|
794
|
+
content.push({ type: "image", data: screenshot.data, mimeType: screenshot.mimeType });
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return { content, details: {}, isError: true };
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
}
|