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.
Files changed (53) hide show
  1. package/README.md +12 -5
  2. package/dist/loader.js +0 -0
  3. package/dist/modes/interactive/theme/dark.json +85 -0
  4. package/dist/modes/interactive/theme/light.json +84 -0
  5. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  6. package/dist/modes/interactive/theme/theme.d.ts +78 -0
  7. package/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  8. package/dist/modes/interactive/theme/theme.js +949 -0
  9. package/dist/modes/interactive/theme/theme.js.map +1 -0
  10. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  11. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  12. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  13. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  14. package/node_modules/cliui/CHANGELOG.md +121 -0
  15. package/node_modules/color-convert/CHANGELOG.md +54 -0
  16. package/node_modules/esprima/ChangeLog +235 -0
  17. package/node_modules/mz/HISTORY.md +66 -0
  18. package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
  19. package/node_modules/source-map/CHANGELOG.md +301 -0
  20. package/node_modules/thenify/History.md +11 -0
  21. package/node_modules/thenify-all/History.md +11 -0
  22. package/node_modules/y18n/CHANGELOG.md +100 -0
  23. package/node_modules/yargs/CHANGELOG.md +88 -0
  24. package/node_modules/yargs-parser/CHANGELOG.md +263 -0
  25. package/package.json +5 -2
  26. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  27. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  28. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  29. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  30. package/src/resources/extensions/browser-tools/capture.ts +165 -0
  31. package/src/resources/extensions/browser-tools/evaluate-helpers.ts +184 -0
  32. package/src/resources/extensions/browser-tools/index.ts +47 -4985
  33. package/src/resources/extensions/browser-tools/lifecycle.ts +265 -0
  34. package/src/resources/extensions/browser-tools/package.json +5 -1
  35. package/src/resources/extensions/browser-tools/refs.ts +264 -0
  36. package/src/resources/extensions/browser-tools/settle.ts +197 -0
  37. package/src/resources/extensions/browser-tools/state.ts +408 -0
  38. package/src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs +652 -0
  39. package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +614 -0
  40. package/src/resources/extensions/browser-tools/tools/assertions.ts +342 -0
  41. package/src/resources/extensions/browser-tools/tools/forms.ts +801 -0
  42. package/src/resources/extensions/browser-tools/tools/inspection.ts +492 -0
  43. package/src/resources/extensions/browser-tools/tools/intent.ts +614 -0
  44. package/src/resources/extensions/browser-tools/tools/interaction.ts +865 -0
  45. package/src/resources/extensions/browser-tools/tools/navigation.ts +232 -0
  46. package/src/resources/extensions/browser-tools/tools/pages.ts +303 -0
  47. package/src/resources/extensions/browser-tools/tools/refs.ts +541 -0
  48. package/src/resources/extensions/browser-tools/tools/screenshot.ts +83 -0
  49. package/src/resources/extensions/browser-tools/tools/session.ts +400 -0
  50. package/src/resources/extensions/browser-tools/tools/wait.ts +247 -0
  51. package/src/resources/extensions/browser-tools/utils.ts +660 -0
  52. package/src/resources/extensions/gsd/git-service.ts +3 -0
  53. 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
+ }