browser-pilot 0.0.10 → 0.0.12

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/dist/index.cjs CHANGED
@@ -62,13 +62,599 @@ __export(src_exports, {
62
62
  });
63
63
  module.exports = __toCommonJS(src_exports);
64
64
 
65
+ // src/browser/actionability.ts
66
+ var ActionabilityError = class extends Error {
67
+ failureType;
68
+ coveringElement;
69
+ constructor(message, failureType, coveringElement) {
70
+ super(message);
71
+ this.name = "ActionabilityError";
72
+ this.failureType = failureType;
73
+ this.coveringElement = coveringElement;
74
+ }
75
+ };
76
+ var CHECK_VISIBLE = `function() {
77
+ // checkVisibility handles display:none, visibility:hidden, content-visibility up the tree
78
+ if (typeof this.checkVisibility === 'function' && !this.checkVisibility()) {
79
+ return { actionable: false, reason: 'Element is not visible (checkVisibility failed). Try scrolling or check if a prior action is needed to reveal it.' };
80
+ }
81
+
82
+ var style = getComputedStyle(this);
83
+
84
+ if (style.visibility !== 'visible') {
85
+ return { actionable: false, reason: 'Element has visibility: ' + style.visibility + '. Try scrolling or check if a prior action is needed to reveal it.' };
86
+ }
87
+
88
+ // display:contents elements have no box themselves \u2014 check children
89
+ if (style.display === 'contents') {
90
+ var children = this.children;
91
+ if (children.length === 0) {
92
+ return { actionable: false, reason: 'Element has display:contents with no children. Try scrolling or check if a prior action is needed to reveal it.' };
93
+ }
94
+ for (var i = 0; i < children.length; i++) {
95
+ var childRect = children[i].getBoundingClientRect();
96
+ if (childRect.width > 0 && childRect.height > 0) {
97
+ return { actionable: true };
98
+ }
99
+ }
100
+ return { actionable: false, reason: 'Element has display:contents but no visible children. Try scrolling or check if a prior action is needed to reveal it.' };
101
+ }
102
+
103
+ var rect = this.getBoundingClientRect();
104
+ if (rect.width <= 0 || rect.height <= 0) {
105
+ return { actionable: false, reason: 'Element has zero size (' + rect.width + 'x' + rect.height + '). Try scrolling or check if a prior action is needed to reveal it.' };
106
+ }
107
+
108
+ return { actionable: true };
109
+ }`;
110
+ var CHECK_ENABLED = `function() {
111
+ // Native disabled property
112
+ var disableable = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'];
113
+ if (disableable.indexOf(this.tagName) !== -1 && this.disabled) {
114
+ return { actionable: false, reason: 'Element is disabled. Check if a prerequisite field needs to be filled first.' };
115
+ }
116
+
117
+ // Check ancestor FIELDSET[disabled]
118
+ var parent = this.parentElement;
119
+ while (parent) {
120
+ if (parent.tagName === 'FIELDSET' && parent.disabled) {
121
+ // Exception: elements inside the first <legend> of a disabled fieldset are NOT disabled
122
+ var legend = parent.querySelector(':scope > legend');
123
+ if (!legend || !legend.contains(this)) {
124
+ return { actionable: false, reason: 'Element is inside a disabled fieldset. Check if a prerequisite field needs to be filled first.' };
125
+ }
126
+ }
127
+ parent = parent.parentElement;
128
+ }
129
+
130
+ // aria-disabled="true" walking up ancestor chain (crosses shadow DOM)
131
+ var node = this;
132
+ while (node) {
133
+ if (node.nodeType === 1 && node.getAttribute && node.getAttribute('aria-disabled') === 'true') {
134
+ return { actionable: false, reason: 'Element or ancestor has aria-disabled="true". Check if a prerequisite field needs to be filled first.' };
135
+ }
136
+ if (node.parentElement) {
137
+ node = node.parentElement;
138
+ } else if (node.getRootNode && node.getRootNode() !== node) {
139
+ // Cross shadow DOM boundary
140
+ var root = node.getRootNode();
141
+ node = root.host || null;
142
+ } else {
143
+ break;
144
+ }
145
+ }
146
+
147
+ return { actionable: true };
148
+ }`;
149
+ var CHECK_STABLE = `function() {
150
+ var self = this;
151
+ return new Promise(function(resolve) {
152
+ // If tab is backgrounded, RAF won't fire reliably \u2014 skip stability check
153
+ if (document.visibilityState === 'hidden') {
154
+ var rect = self.getBoundingClientRect();
155
+ resolve({ actionable: rect.width > 0 && rect.height > 0 });
156
+ return;
157
+ }
158
+
159
+ var maxFrames = 30;
160
+ var prev = null;
161
+ var frame = 0;
162
+ var resolved = false;
163
+
164
+ var fallbackTimer = setTimeout(function() {
165
+ if (!resolved) {
166
+ resolved = true;
167
+ resolve({ actionable: false, reason: 'Element stability check timed out (tab may be backgrounded)' });
168
+ }
169
+ }, 2000);
170
+
171
+ function check() {
172
+ if (resolved) return;
173
+ frame++;
174
+ if (frame > maxFrames) {
175
+ resolved = true;
176
+ clearTimeout(fallbackTimer);
177
+ resolve({ actionable: false, reason: 'Element position not stable after ' + maxFrames + ' frames' });
178
+ return;
179
+ }
180
+
181
+ var rect = self.getBoundingClientRect();
182
+ var cur = { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
183
+
184
+ if (prev !== null &&
185
+ prev.x === cur.x && prev.y === cur.y &&
186
+ prev.w === cur.w && prev.h === cur.h) {
187
+ resolved = true;
188
+ clearTimeout(fallbackTimer);
189
+ resolve({ actionable: true });
190
+ return;
191
+ }
192
+
193
+ prev = cur;
194
+ requestAnimationFrame(check);
195
+ }
196
+
197
+ requestAnimationFrame(check);
198
+ });
199
+ }`;
200
+ var CHECK_HIT_TARGET = `function(x, y) {
201
+ // Compute click center if coordinates not provided
202
+ if (x === undefined || y === undefined) {
203
+ var rect = this.getBoundingClientRect();
204
+ x = rect.x + rect.width / 2;
205
+ y = rect.y + rect.height / 2;
206
+ }
207
+
208
+ function checkPoint(root, px, py) {
209
+ var method = root.elementsFromPoint || root.msElementsFromPoint;
210
+ if (!method) return [];
211
+ return method.call(root, px, py) || [];
212
+ }
213
+
214
+ // Follow only the top-most hit through nested shadow roots.
215
+ // Accepting any hit in the stack creates false positives for covered elements.
216
+ var root = document;
217
+ var topHits = [];
218
+ var seenRoots = [];
219
+ while (root && seenRoots.indexOf(root) === -1) {
220
+ seenRoots.push(root);
221
+ var hits = checkPoint(root, x, y);
222
+ if (!hits.length) break;
223
+ var top = hits[0];
224
+ topHits.push(top);
225
+ if (top && top.shadowRoot) {
226
+ root = top.shadowRoot;
227
+ continue;
228
+ }
229
+ break;
230
+ }
231
+
232
+ // Target must be the top-most hit element or an ancestor/descendant
233
+ for (var j = 0; j < topHits.length; j++) {
234
+ var hit = topHits[j];
235
+ if (hit === this || this.contains(hit) || hit.contains(this)) {
236
+ return { actionable: true };
237
+ }
238
+ }
239
+
240
+ // Report the covering element
241
+ var top = topHits.length > 0 ? topHits[topHits.length - 1] : null;
242
+ if (top) {
243
+ return {
244
+ actionable: false,
245
+ reason: 'Element is covered by <' + top.tagName.toLowerCase() + '>' +
246
+ (top.id ? '#' + top.id : '') +
247
+ (top.className && typeof top.className === 'string' ? '.' + top.className.split(' ').join('.') : '') +
248
+ '. Try dismissing overlays first.',
249
+ coveringElement: {
250
+ tag: top.tagName.toLowerCase(),
251
+ id: top.id || undefined,
252
+ className: (typeof top.className === 'string' && top.className) || undefined
253
+ }
254
+ };
255
+ }
256
+
257
+ return { actionable: false, reason: 'No element found at click point (' + x + ', ' + y + '). Try scrolling the element into view first.' };
258
+ }`;
259
+ var CHECK_EDITABLE = `function() {
260
+ // Must be an editable element type
261
+ var tag = this.tagName;
262
+ var isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' ||
263
+ this.isContentEditable;
264
+ if (!isEditable) {
265
+ return { actionable: false, reason: 'Element is not an editable type (<' + tag.toLowerCase() + '>). Target an <input>, <textarea>, <select>, or [contenteditable] element instead.' };
266
+ }
267
+
268
+ // Check disabled
269
+ var disableable = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'];
270
+ if (disableable.indexOf(tag) !== -1 && this.disabled) {
271
+ return { actionable: false, reason: 'Element is disabled. Check if a prerequisite field needs to be filled first.' };
272
+ }
273
+
274
+ // Check ancestor FIELDSET[disabled]
275
+ var parent = this.parentElement;
276
+ while (parent) {
277
+ if (parent.tagName === 'FIELDSET' && parent.disabled) {
278
+ var legend = parent.querySelector(':scope > legend');
279
+ if (!legend || !legend.contains(this)) {
280
+ return { actionable: false, reason: 'Element is inside a disabled fieldset. Check if a prerequisite field needs to be filled first.' };
281
+ }
282
+ }
283
+ parent = parent.parentElement;
284
+ }
285
+
286
+ // aria-disabled walking up (crosses shadow DOM)
287
+ var node = this;
288
+ while (node) {
289
+ if (node.nodeType === 1 && node.getAttribute && node.getAttribute('aria-disabled') === 'true') {
290
+ return { actionable: false, reason: 'Element or ancestor has aria-disabled="true". Check if a prerequisite field needs to be filled first.' };
291
+ }
292
+ if (node.parentElement) {
293
+ node = node.parentElement;
294
+ } else if (node.getRootNode && node.getRootNode() !== node) {
295
+ var root = node.getRootNode();
296
+ node = root.host || null;
297
+ } else {
298
+ break;
299
+ }
300
+ }
301
+
302
+ // Check readonly
303
+ if (this.hasAttribute && this.hasAttribute('readonly')) {
304
+ return { actionable: false, reason: 'Cannot fill a readonly input. Remove the readonly attribute or target a different element.' };
305
+ }
306
+ if (this.getAttribute && this.getAttribute('aria-readonly') === 'true') {
307
+ return { actionable: false, reason: 'Cannot fill a readonly input (aria-readonly="true"). Remove the attribute or target a different element.' };
308
+ }
309
+
310
+ return { actionable: true };
311
+ }`;
312
+ function sleep(ms) {
313
+ return new Promise((resolve) => setTimeout(resolve, ms));
314
+ }
315
+ var BACKOFF = [0, 20, 100, 100];
316
+ async function runCheck(cdp, objectId, check, options) {
317
+ let script;
318
+ let awaitPromise = false;
319
+ const args = [];
320
+ switch (check) {
321
+ case "visible":
322
+ script = CHECK_VISIBLE;
323
+ break;
324
+ case "enabled":
325
+ script = CHECK_ENABLED;
326
+ break;
327
+ case "stable":
328
+ script = CHECK_STABLE;
329
+ awaitPromise = true;
330
+ break;
331
+ case "hitTarget":
332
+ script = CHECK_HIT_TARGET;
333
+ if (options?.coordinates) {
334
+ args.push({ value: options.coordinates.x });
335
+ args.push({ value: options.coordinates.y });
336
+ } else {
337
+ args.push({ value: void 0 });
338
+ args.push({ value: void 0 });
339
+ }
340
+ break;
341
+ case "editable":
342
+ script = CHECK_EDITABLE;
343
+ break;
344
+ default: {
345
+ const _exhaustive = check;
346
+ throw new Error(`Unknown actionability check: ${_exhaustive}`);
347
+ }
348
+ }
349
+ const params = {
350
+ functionDeclaration: script,
351
+ objectId,
352
+ returnByValue: true,
353
+ arguments: args
354
+ };
355
+ if (awaitPromise) {
356
+ params["awaitPromise"] = true;
357
+ }
358
+ const response = await cdp.send("Runtime.callFunctionOn", params);
359
+ if (response.exceptionDetails) {
360
+ return {
361
+ actionable: false,
362
+ reason: `Check "${check}" threw: ${response.exceptionDetails.text}`,
363
+ failureType: check
364
+ };
365
+ }
366
+ const result = response.result.value;
367
+ if (!result.actionable) {
368
+ result.failureType = check;
369
+ }
370
+ return result;
371
+ }
372
+ async function runChecks(cdp, objectId, checks, options) {
373
+ for (const check of checks) {
374
+ const result = await runCheck(cdp, objectId, check, options);
375
+ if (!result.actionable) {
376
+ return result;
377
+ }
378
+ }
379
+ return { actionable: true };
380
+ }
381
+ async function ensureActionable(cdp, objectId, checks, options) {
382
+ const timeout = options?.timeout ?? 3e4;
383
+ const start = Date.now();
384
+ let attempt = 0;
385
+ while (true) {
386
+ const result = await runChecks(cdp, objectId, checks, options);
387
+ if (result.actionable) return;
388
+ if (Date.now() - start >= timeout) {
389
+ throw new ActionabilityError(
390
+ `Element not actionable: ${result.reason}`,
391
+ result.failureType,
392
+ result.coveringElement
393
+ );
394
+ }
395
+ const delay = attempt < BACKOFF.length ? BACKOFF[attempt] ?? 0 : 500;
396
+ if (delay > 0) await sleep(delay);
397
+ attempt++;
398
+ }
399
+ }
400
+
401
+ // src/browser/fuzzy-match.ts
402
+ function jaroWinkler(a, b) {
403
+ if (a.length === 0 && b.length === 0) return 0;
404
+ if (a.length === 0 || b.length === 0) return 0;
405
+ if (a === b) return 1;
406
+ const s1 = a.toLowerCase();
407
+ const s2 = b.toLowerCase();
408
+ const matchWindow = Math.max(0, Math.floor(Math.max(s1.length, s2.length) / 2) - 1);
409
+ const s1Matches = Array.from({ length: s1.length }, () => false);
410
+ const s2Matches = Array.from({ length: s2.length }, () => false);
411
+ let matches = 0;
412
+ let transpositions = 0;
413
+ for (let i = 0; i < s1.length; i++) {
414
+ const start = Math.max(0, i - matchWindow);
415
+ const end = Math.min(i + matchWindow + 1, s2.length);
416
+ for (let j = start; j < end; j++) {
417
+ if (s2Matches[j] || s1[i] !== s2[j]) continue;
418
+ s1Matches[i] = true;
419
+ s2Matches[j] = true;
420
+ matches++;
421
+ break;
422
+ }
423
+ }
424
+ if (matches === 0) return 0;
425
+ let k = 0;
426
+ for (let i = 0; i < s1.length; i++) {
427
+ if (!s1Matches[i]) continue;
428
+ while (!s2Matches[k]) k++;
429
+ if (s1[i] !== s2[k]) transpositions++;
430
+ k++;
431
+ }
432
+ const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
433
+ let prefix = 0;
434
+ for (let i = 0; i < Math.min(4, Math.min(s1.length, s2.length)); i++) {
435
+ if (s1[i] === s2[i]) {
436
+ prefix++;
437
+ } else {
438
+ break;
439
+ }
440
+ }
441
+ const WINKLER_SCALING = 0.1;
442
+ return jaro + prefix * WINKLER_SCALING * (1 - jaro);
443
+ }
444
+ function stringSimilarity(a, b) {
445
+ if (a.length === 0 || b.length === 0) return 0;
446
+ const lowerA = a.toLowerCase();
447
+ const lowerB = b.toLowerCase();
448
+ if (lowerA === lowerB) return 1;
449
+ const jw = jaroWinkler(a, b);
450
+ let containsBonus = 0;
451
+ if (lowerB.includes(lowerA)) {
452
+ containsBonus = 0.2;
453
+ } else if (lowerA.includes(lowerB)) {
454
+ containsBonus = 0.1;
455
+ }
456
+ return Math.min(1, jw + containsBonus);
457
+ }
458
+ function scoreElement(query, element) {
459
+ const lowerQuery = query.toLowerCase();
460
+ const words = lowerQuery.split(/\s+/).filter((w) => w.length > 0);
461
+ let nameScore = 0;
462
+ if (element.name) {
463
+ const lowerName = element.name.toLowerCase();
464
+ if (lowerName === lowerQuery) {
465
+ nameScore = 1;
466
+ } else if (lowerName.includes(lowerQuery)) {
467
+ nameScore = 0.8;
468
+ } else if (words.length > 0) {
469
+ const matchedWords = words.filter((w) => lowerName.includes(w));
470
+ nameScore = matchedWords.length / words.length * 0.7;
471
+ } else {
472
+ nameScore = stringSimilarity(query, element.name) * 0.6;
473
+ }
474
+ }
475
+ let roleScore = 0;
476
+ const lowerRole = element.role.toLowerCase();
477
+ if (lowerRole === lowerQuery || lowerQuery.includes(lowerRole)) {
478
+ roleScore = 0.3;
479
+ } else if (words.some((w) => lowerRole.includes(w))) {
480
+ roleScore = 0.2;
481
+ }
482
+ let selectorScore = 0;
483
+ const lowerSelector = element.selector.toLowerCase();
484
+ if (words.some((w) => lowerSelector.includes(w))) {
485
+ selectorScore = 0.2;
486
+ }
487
+ const totalScore = nameScore * 0.6 + roleScore * 0.25 + selectorScore * 0.15;
488
+ return totalScore;
489
+ }
490
+ function explainMatch(query, element, score) {
491
+ const reasons = [];
492
+ const lowerQuery = query.toLowerCase();
493
+ const words = lowerQuery.split(/\s+/).filter((w) => w.length > 0);
494
+ if (element.name) {
495
+ const lowerName = element.name.toLowerCase();
496
+ if (lowerName === lowerQuery) {
497
+ reasons.push("exact name match");
498
+ } else if (lowerName.includes(lowerQuery)) {
499
+ reasons.push("name contains query");
500
+ } else if (words.some((w) => lowerName.includes(w))) {
501
+ const matchedWords = words.filter((w) => lowerName.includes(w));
502
+ reasons.push(`name contains: ${matchedWords.join(", ")}`);
503
+ } else if (stringSimilarity(query, element.name) > 0.5) {
504
+ reasons.push("similar name");
505
+ }
506
+ }
507
+ const lowerRole = element.role.toLowerCase();
508
+ if (lowerRole === lowerQuery || words.some((w) => w === lowerRole)) {
509
+ reasons.push(`role: ${element.role}`);
510
+ }
511
+ if (words.some((w) => element.selector.toLowerCase().includes(w))) {
512
+ reasons.push("selector match");
513
+ }
514
+ if (reasons.length === 0) {
515
+ reasons.push(`fuzzy match (score: ${score.toFixed(2)})`);
516
+ }
517
+ return reasons.join(", ");
518
+ }
519
+ function fuzzyMatchElements(query, elements, maxResults = 5) {
520
+ if (!query || query.length === 0) {
521
+ return [];
522
+ }
523
+ const THRESHOLD = 0.3;
524
+ const scored = elements.map((element) => ({
525
+ element,
526
+ score: scoreElement(query, element)
527
+ }));
528
+ return scored.filter((s) => s.score >= THRESHOLD).sort((a, b) => b.score - a.score).slice(0, maxResults).map((s) => ({
529
+ element: s.element,
530
+ score: s.score,
531
+ matchReason: explainMatch(query, s.element, s.score)
532
+ }));
533
+ }
534
+
535
+ // src/browser/hint-generator.ts
536
+ var ACTION_ROLE_MAP = {
537
+ click: ["button", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "option"],
538
+ fill: ["textbox", "searchbox", "textarea"],
539
+ type: ["textbox", "searchbox", "textarea"],
540
+ submit: ["button", "form"],
541
+ select: ["combobox", "listbox", "option"],
542
+ check: ["checkbox", "radio", "switch"],
543
+ uncheck: ["checkbox", "switch"],
544
+ focus: [],
545
+ // Any focusable element
546
+ hover: [],
547
+ // Any element
548
+ clear: ["textbox", "searchbox", "textarea"]
549
+ };
550
+ function extractIntent(selectors) {
551
+ const patterns = [];
552
+ let text = "";
553
+ for (const selector of selectors) {
554
+ if (selector.startsWith("ref:")) {
555
+ continue;
556
+ }
557
+ const idMatch = selector.match(/#([a-zA-Z0-9_-]+)/);
558
+ if (idMatch) {
559
+ patterns.push(idMatch[1]);
560
+ }
561
+ const ariaMatch = selector.match(/\[aria-label=["']([^"']+)["']\]/);
562
+ if (ariaMatch) {
563
+ patterns.push(ariaMatch[1]);
564
+ }
565
+ const testidMatch = selector.match(/\[data-testid=["']([^"']+)["']\]/);
566
+ if (testidMatch) {
567
+ patterns.push(testidMatch[1]);
568
+ }
569
+ const classMatch = selector.match(/\.([a-zA-Z0-9_-]+)/);
570
+ if (classMatch) {
571
+ patterns.push(classMatch[1]);
572
+ }
573
+ }
574
+ patterns.sort((a, b) => b.length - a.length);
575
+ text = patterns[0] ?? selectors[0] ?? "";
576
+ return { text, patterns };
577
+ }
578
+ function getHintType(selector) {
579
+ if (selector.startsWith("ref:")) return "ref";
580
+ if (selector.includes("data-testid")) return "testid";
581
+ if (selector.includes("aria-label")) return "aria";
582
+ if (selector.startsWith("#")) return "id";
583
+ return "css";
584
+ }
585
+ function getConfidence(score) {
586
+ if (score >= 0.8) return "high";
587
+ if (score >= 0.5) return "medium";
588
+ return "low";
589
+ }
590
+ function diversifyHints(candidates, maxHints) {
591
+ const hints = [];
592
+ const usedTypes = /* @__PURE__ */ new Set();
593
+ for (const candidate of candidates) {
594
+ if (hints.length >= maxHints) break;
595
+ const refSelector = `ref:${candidate.element.ref}`;
596
+ const hintType = getHintType(refSelector);
597
+ if (!usedTypes.has(hintType)) {
598
+ hints.push({
599
+ selector: refSelector,
600
+ reason: candidate.matchReason,
601
+ confidence: getConfidence(candidate.score),
602
+ element: {
603
+ ref: candidate.element.ref,
604
+ role: candidate.element.role,
605
+ name: candidate.element.name,
606
+ disabled: candidate.element.disabled
607
+ }
608
+ });
609
+ usedTypes.add(hintType);
610
+ } else if (hints.length < maxHints) {
611
+ hints.push({
612
+ selector: refSelector,
613
+ reason: candidate.matchReason,
614
+ confidence: getConfidence(candidate.score),
615
+ element: {
616
+ ref: candidate.element.ref,
617
+ role: candidate.element.role,
618
+ name: candidate.element.name,
619
+ disabled: candidate.element.disabled
620
+ }
621
+ });
622
+ }
623
+ }
624
+ return hints;
625
+ }
626
+ async function generateHints(page, failedSelectors, actionType, maxHints = 3) {
627
+ let snapshot;
628
+ try {
629
+ snapshot = await page.snapshot();
630
+ } catch {
631
+ return [];
632
+ }
633
+ const intent = extractIntent(failedSelectors);
634
+ const roleFilter = ACTION_ROLE_MAP[actionType] ?? [];
635
+ let candidates = snapshot.interactiveElements;
636
+ if (roleFilter.length > 0) {
637
+ candidates = candidates.filter((el) => roleFilter.includes(el.role));
638
+ }
639
+ const matches = fuzzyMatchElements(intent.text, candidates, maxHints * 2);
640
+ if (matches.length === 0) {
641
+ return [];
642
+ }
643
+ return diversifyHints(matches, maxHints);
644
+ }
645
+
65
646
  // src/browser/types.ts
66
647
  var ElementNotFoundError = class extends Error {
67
648
  selectors;
68
649
  hints;
69
650
  constructor(selectors, hints) {
70
651
  const selectorList = Array.isArray(selectors) ? selectors : [selectors];
71
- super(`Element not found: ${selectorList.join(", ")}`);
652
+ let msg = `Element not found: ${selectorList.join(", ")}`;
653
+ if (hints?.length) {
654
+ msg += `. Did you mean: ${hints.slice(0, 3).map((h) => `${h.element.ref} (${h.element.role} "${h.element.name}")`).join(", ")}`;
655
+ }
656
+ msg += `. Run 'bp snapshot' to see available elements.`;
657
+ super(msg);
72
658
  this.name = "ElementNotFoundError";
73
659
  this.selectors = selectorList;
74
660
  this.hints = hints;
@@ -76,7 +662,8 @@ var ElementNotFoundError = class extends Error {
76
662
  };
77
663
  var TimeoutError = class extends Error {
78
664
  constructor(message = "Operation timed out") {
79
- super(message);
665
+ const msg = message.includes("bp snapshot") ? message : `${message}. Run 'bp snapshot' to check current page state.`;
666
+ super(msg);
80
667
  this.name = "TimeoutError";
81
668
  }
82
669
  };
@@ -87,8 +674,87 @@ var NavigationError = class extends Error {
87
674
  }
88
675
  };
89
676
 
677
+ // src/cdp/protocol.ts
678
+ var CDPError = class extends Error {
679
+ code;
680
+ data;
681
+ constructor(error) {
682
+ super(error.message);
683
+ this.name = "CDPError";
684
+ this.code = error.code;
685
+ this.data = error.data;
686
+ }
687
+ };
688
+
90
689
  // src/actions/executor.ts
91
690
  var DEFAULT_TIMEOUT = 3e4;
691
+ function classifyFailure(error) {
692
+ if (error instanceof ElementNotFoundError) {
693
+ return { reason: "missing" };
694
+ }
695
+ if (error instanceof ActionabilityError) {
696
+ switch (error.failureType) {
697
+ case "visible":
698
+ return { reason: "hidden" };
699
+ case "hitTarget":
700
+ return { reason: "covered", coveringElement: error.coveringElement };
701
+ case "enabled":
702
+ return { reason: "disabled" };
703
+ case "editable":
704
+ return { reason: error.message?.includes("readonly") ? "readonly" : "notEditable" };
705
+ case "stable":
706
+ return { reason: "replaced" };
707
+ default:
708
+ return { reason: "unknown" };
709
+ }
710
+ }
711
+ if (error instanceof TimeoutError) {
712
+ return { reason: "timeout" };
713
+ }
714
+ if (error instanceof NavigationError) {
715
+ return { reason: "navigation" };
716
+ }
717
+ if (error instanceof CDPError) {
718
+ return { reason: "cdpError" };
719
+ }
720
+ const msg = String(error?.message ?? error);
721
+ if (msg.includes("Could not find node") || msg.includes("does not belong to the document")) {
722
+ return { reason: "detached" };
723
+ }
724
+ return { reason: "unknown" };
725
+ }
726
+ function getSuggestion(reason) {
727
+ switch (reason) {
728
+ case "missing":
729
+ return "Element not found. Run 'snapshot' to see available elements, or try alternative selectors.";
730
+ case "hidden":
731
+ return "Element exists but is not visible. Try 'scroll' or wait for it to appear.";
732
+ case "covered":
733
+ return "Element is blocked by another element. Dismiss the covering element first.";
734
+ case "disabled":
735
+ return "Element is disabled. Complete prerequisite steps to enable it.";
736
+ case "readonly":
737
+ return "Element is readonly and cannot be edited directly.";
738
+ case "detached":
739
+ return "Element was removed from the DOM. Run 'snapshot' for fresh element refs.";
740
+ case "replaced":
741
+ return "Element was replaced in the DOM. Run 'snapshot' to get updated refs.";
742
+ case "notEditable":
743
+ return "Element is not an editable field. Try a different selector targeting an input or textarea.";
744
+ case "timeout":
745
+ return "Timed out waiting. The page may still be loading. Try increasing timeout.";
746
+ case "navigation":
747
+ return "Navigation failed. Check the URL and network connectivity.";
748
+ case "cdpError":
749
+ return "Browser connection error. Try 'bp connect' again.";
750
+ case "unknown":
751
+ return "Unexpected error. Run 'snapshot' to check page state.";
752
+ default: {
753
+ const _exhaustive = reason;
754
+ return `Unknown failure: ${_exhaustive}`;
755
+ }
756
+ }
757
+ }
92
758
  var BatchExecutor = class {
93
759
  page;
94
760
  constructor(page) {
@@ -104,21 +770,46 @@ var BatchExecutor = class {
104
770
  for (let i = 0; i < steps.length; i++) {
105
771
  const step = steps[i];
106
772
  const stepStart = Date.now();
107
- try {
108
- const result = await this.executeStep(step, timeout);
109
- results.push({
110
- index: i,
111
- action: step.action,
112
- selector: step.selector,
113
- selectorUsed: result.selectorUsed,
114
- success: true,
115
- durationMs: Date.now() - stepStart,
116
- result: result.value,
117
- text: result.text
118
- });
119
- } catch (error) {
120
- const errorMessage = error instanceof Error ? error.message : String(error);
121
- const hints = error instanceof ElementNotFoundError ? error.hints : void 0;
773
+ const maxAttempts = (step.retry ?? 0) + 1;
774
+ const retryDelay = step.retryDelay ?? 500;
775
+ let lastError;
776
+ let succeeded = false;
777
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
778
+ if (attempt > 0) {
779
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
780
+ }
781
+ try {
782
+ const result = await this.executeStep(step, timeout);
783
+ results.push({
784
+ index: i,
785
+ action: step.action,
786
+ selector: step.selector,
787
+ selectorUsed: result.selectorUsed,
788
+ success: true,
789
+ durationMs: Date.now() - stepStart,
790
+ result: result.value,
791
+ text: result.text
792
+ });
793
+ succeeded = true;
794
+ break;
795
+ } catch (error) {
796
+ lastError = error instanceof Error ? error : new Error(String(error));
797
+ }
798
+ }
799
+ if (!succeeded) {
800
+ const errorMessage = lastError?.message ?? "Unknown error";
801
+ let hints = lastError instanceof ElementNotFoundError ? lastError.hints : void 0;
802
+ const { reason, coveringElement } = classifyFailure(lastError);
803
+ if (step.selector && !step.optional && ["missing", "hidden", "covered", "disabled", "detached", "replaced"].includes(reason)) {
804
+ try {
805
+ const selectors = Array.isArray(step.selector) ? step.selector : [step.selector];
806
+ const autoHints = await generateHints(this.page, selectors, step.action, 3);
807
+ if (autoHints.length > 0) {
808
+ hints = autoHints;
809
+ }
810
+ } catch {
811
+ }
812
+ }
122
813
  results.push({
123
814
  index: i,
124
815
  action: step.action,
@@ -126,7 +817,10 @@ var BatchExecutor = class {
126
817
  success: false,
127
818
  durationMs: Date.now() - stepStart,
128
819
  error: errorMessage,
129
- hints
820
+ hints,
821
+ failureReason: reason,
822
+ coveringElement,
823
+ suggestion: getSuggestion(reason)
130
824
  });
131
825
  if (onFail === "stop" && !step.optional) {
132
826
  return {
@@ -159,7 +853,7 @@ var BatchExecutor = class {
159
853
  }
160
854
  case "click": {
161
855
  if (!step.selector) throw new Error("click requires selector");
162
- if (step.waitForNavigation) {
856
+ if (step.waitForNavigation === true) {
163
857
  const navPromise = this.page.waitForNavigation({ timeout, optional });
164
858
  await this.page.click(step.selector, { timeout, optional });
165
859
  await navPromise;
@@ -174,7 +868,6 @@ var BatchExecutor = class {
174
868
  await this.page.fill(step.selector, step.value, {
175
869
  timeout,
176
870
  optional,
177
- clear: step.clear ?? true,
178
871
  blur: step.blur
179
872
  });
180
873
  return { selectorUsed: this.getUsedSelector(step.selector) };
@@ -222,13 +915,31 @@ var BatchExecutor = class {
222
915
  await this.page.submit(step.selector, {
223
916
  timeout,
224
917
  optional,
225
- method: step.method ?? "enter+click"
918
+ method: step.method ?? "enter+click",
919
+ waitForNavigation: step.waitForNavigation
226
920
  });
227
921
  return { selectorUsed: this.getUsedSelector(step.selector) };
228
922
  }
229
923
  case "press": {
230
924
  if (!step.key) throw new Error("press requires key");
231
- await this.page.press(step.key);
925
+ try {
926
+ await this.page.press(step.key, {
927
+ modifiers: step.modifiers
928
+ });
929
+ } catch (e) {
930
+ if (optional) return {};
931
+ throw e;
932
+ }
933
+ return {};
934
+ }
935
+ case "shortcut": {
936
+ if (!step.combo) throw new Error("shortcut requires combo");
937
+ try {
938
+ await this.page.shortcut(step.combo);
939
+ } catch (e) {
940
+ if (optional) return {};
941
+ throw e;
942
+ }
232
943
  return {};
233
944
  }
234
945
  case "focus": {
@@ -315,6 +1026,80 @@ var BatchExecutor = class {
315
1026
  await this.page.switchToMain();
316
1027
  return {};
317
1028
  }
1029
+ case "assertVisible": {
1030
+ if (!step.selector) throw new Error("assertVisible requires selector");
1031
+ const el = await this.page.waitFor(step.selector, {
1032
+ timeout,
1033
+ optional: true,
1034
+ state: "visible"
1035
+ });
1036
+ if (!el) {
1037
+ throw new Error(
1038
+ `Assertion failed: selector ${JSON.stringify(step.selector)} is not visible`
1039
+ );
1040
+ }
1041
+ return { selectorUsed: this.getUsedSelector(step.selector) };
1042
+ }
1043
+ case "assertExists": {
1044
+ if (!step.selector) throw new Error("assertExists requires selector");
1045
+ const el = await this.page.waitFor(step.selector, {
1046
+ timeout,
1047
+ optional: true,
1048
+ state: "attached"
1049
+ });
1050
+ if (!el) {
1051
+ throw new Error(
1052
+ `Assertion failed: selector ${JSON.stringify(step.selector)} does not exist`
1053
+ );
1054
+ }
1055
+ return { selectorUsed: this.getUsedSelector(step.selector) };
1056
+ }
1057
+ case "assertText": {
1058
+ const selector = Array.isArray(step.selector) ? step.selector[0] : step.selector;
1059
+ const text = await this.page.text(selector);
1060
+ const expected = step.expect ?? step.value;
1061
+ if (typeof expected !== "string") throw new Error("assertText requires expect or value");
1062
+ if (!text.includes(expected)) {
1063
+ throw new Error(
1064
+ `Assertion failed: text does not contain ${JSON.stringify(expected)}. Got: ${JSON.stringify(text.slice(0, 200))}`
1065
+ );
1066
+ }
1067
+ return { selectorUsed: selector, text };
1068
+ }
1069
+ case "assertUrl": {
1070
+ const currentUrl = await this.page.url();
1071
+ const expected = step.expect ?? step.url;
1072
+ if (typeof expected !== "string") throw new Error("assertUrl requires expect or url");
1073
+ if (!currentUrl.includes(expected)) {
1074
+ throw new Error(
1075
+ `Assertion failed: URL does not contain ${JSON.stringify(expected)}. Got: ${JSON.stringify(currentUrl)}`
1076
+ );
1077
+ }
1078
+ return { value: currentUrl };
1079
+ }
1080
+ case "assertValue": {
1081
+ if (!step.selector) throw new Error("assertValue requires selector");
1082
+ const expected = step.expect ?? step.value;
1083
+ if (typeof expected !== "string") throw new Error("assertValue requires expect or value");
1084
+ const found = await this.page.waitFor(step.selector, {
1085
+ timeout,
1086
+ optional: true,
1087
+ state: "attached"
1088
+ });
1089
+ if (!found) {
1090
+ throw new Error(`Assertion failed: selector ${JSON.stringify(step.selector)} not found`);
1091
+ }
1092
+ const usedSelector = this.getUsedSelector(step.selector);
1093
+ const actual = await this.page.evaluate(
1094
+ `(function() { var el = document.querySelector(${JSON.stringify(usedSelector)}); return el ? el.value : null; })()`
1095
+ );
1096
+ if (actual !== expected) {
1097
+ throw new Error(
1098
+ `Assertion failed: value of ${JSON.stringify(usedSelector)} is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`
1099
+ );
1100
+ }
1101
+ return { selectorUsed: usedSelector, value: actual };
1102
+ }
318
1103
  default: {
319
1104
  const action = step.action;
320
1105
  const aliases = {
@@ -327,16 +1112,43 @@ var BatchExecutor = class {
327
1112
  capture: "screenshot",
328
1113
  inspect: "snapshot",
329
1114
  enter: "press",
1115
+ keypress: "press",
1116
+ hotkey: "shortcut",
1117
+ keybinding: "shortcut",
1118
+ nav: "goto",
330
1119
  open: "goto",
331
1120
  visit: "goto",
1121
+ browse: "goto",
1122
+ load: "goto",
1123
+ write: "fill",
1124
+ set: "fill",
1125
+ pick: "select",
1126
+ choose: "select",
1127
+ send: "press",
332
1128
  eval: "evaluate",
333
1129
  js: "evaluate",
1130
+ script: "evaluate",
334
1131
  snap: "snapshot",
335
- frame: "switchFrame"
1132
+ accessibility: "snapshot",
1133
+ a11y: "snapshot",
1134
+ image: "screenshot",
1135
+ pic: "screenshot",
1136
+ frame: "switchFrame",
1137
+ iframe: "switchFrame",
1138
+ assert_visible: "assertVisible",
1139
+ assert_exists: "assertExists",
1140
+ assert_text: "assertText",
1141
+ assert_url: "assertUrl",
1142
+ assert_value: "assertValue",
1143
+ checkvisible: "assertVisible",
1144
+ checkexists: "assertExists",
1145
+ checktext: "assertText",
1146
+ checkurl: "assertUrl",
1147
+ checkvalue: "assertValue"
336
1148
  };
337
1149
  const suggestion = aliases[action.toLowerCase()];
338
1150
  const hint = suggestion ? ` Did you mean "${suggestion}"?` : "";
339
- const valid = "goto, click, fill, type, select, check, uncheck, submit, press, focus, hover, scroll, wait, snapshot, screenshot, evaluate, text, switchFrame, switchToMain";
1151
+ const valid = "goto, click, fill, type, select, check, uncheck, submit, press, shortcut, focus, hover, scroll, wait, snapshot, screenshot, evaluate, text, switchFrame, switchToMain, assertVisible, assertExists, assertText, assertUrl, assertValue";
340
1152
  throw new Error(`Unknown action "${action}".${hint}
341
1153
 
342
1154
  Valid actions: ${valid}`);
@@ -385,6 +1197,8 @@ var ACTION_ALIASES = {
385
1197
  inspect: "snapshot",
386
1198
  enter: "press",
387
1199
  keypress: "press",
1200
+ hotkey: "shortcut",
1201
+ keybinding: "shortcut",
388
1202
  nav: "goto",
389
1203
  open: "goto",
390
1204
  visit: "goto",
@@ -404,7 +1218,17 @@ var ACTION_ALIASES = {
404
1218
  image: "screenshot",
405
1219
  pic: "screenshot",
406
1220
  frame: "switchFrame",
407
- iframe: "switchFrame"
1221
+ iframe: "switchFrame",
1222
+ assert_visible: "assertVisible",
1223
+ assert_exists: "assertExists",
1224
+ assert_text: "assertText",
1225
+ assert_url: "assertUrl",
1226
+ assert_value: "assertValue",
1227
+ checkvisible: "assertVisible",
1228
+ checkexists: "assertExists",
1229
+ checktext: "assertText",
1230
+ checkurl: "assertUrl",
1231
+ checkvalue: "assertValue"
408
1232
  };
409
1233
  var PROPERTY_ALIASES = {
410
1234
  expression: "value",
@@ -425,6 +1249,9 @@ var PROPERTY_ALIASES = {
425
1249
  input: "value",
426
1250
  content: "value",
427
1251
  keys: "key",
1252
+ shortcutKey: "combo",
1253
+ hotkey: "combo",
1254
+ keybinding: "combo",
428
1255
  button: "key",
429
1256
  address: "url",
430
1257
  page: "url",
@@ -438,20 +1265,20 @@ var ACTION_RULES = {
438
1265
  click: {
439
1266
  required: { selector: { type: "string|string[]" } },
440
1267
  optional: {
441
- waitForNavigation: { type: "boolean" }
1268
+ waitForNavigation: { type: "boolean|auto" }
442
1269
  }
443
1270
  },
444
1271
  fill: {
445
1272
  required: { selector: { type: "string|string[]" }, value: { type: "string" } },
446
1273
  optional: {
447
- clear: { type: "boolean" },
448
1274
  blur: { type: "boolean" }
449
1275
  }
450
1276
  },
451
1277
  type: {
452
1278
  required: { selector: { type: "string|string[]" }, value: { type: "string" } },
453
1279
  optional: {
454
- delay: { type: "number" }
1280
+ delay: { type: "number" },
1281
+ blur: { type: "boolean" }
455
1282
  }
456
1283
  },
457
1284
  select: {
@@ -475,11 +1302,18 @@ var ACTION_RULES = {
475
1302
  submit: {
476
1303
  required: { selector: { type: "string|string[]" } },
477
1304
  optional: {
478
- method: { type: "string", enum: ["enter", "click", "enter+click"] }
1305
+ method: { type: "string", enum: ["enter", "click", "enter+click"] },
1306
+ waitForNavigation: { type: "boolean|auto" }
479
1307
  }
480
1308
  },
481
1309
  press: {
482
1310
  required: { key: { type: "string" } },
1311
+ optional: {
1312
+ modifiers: { type: "string|string[]" }
1313
+ }
1314
+ },
1315
+ shortcut: {
1316
+ required: { combo: { type: "string" } },
483
1317
  optional: {}
484
1318
  },
485
1319
  focus: {
@@ -539,6 +1373,36 @@ var ACTION_RULES = {
539
1373
  switchToMain: {
540
1374
  required: {},
541
1375
  optional: {}
1376
+ },
1377
+ assertVisible: {
1378
+ required: { selector: { type: "string|string[]" } },
1379
+ optional: {}
1380
+ },
1381
+ assertExists: {
1382
+ required: { selector: { type: "string|string[]" } },
1383
+ optional: {}
1384
+ },
1385
+ assertText: {
1386
+ required: {},
1387
+ optional: {
1388
+ selector: { type: "string|string[]" },
1389
+ expect: { type: "string" },
1390
+ value: { type: "string" }
1391
+ }
1392
+ },
1393
+ assertUrl: {
1394
+ required: {},
1395
+ optional: {
1396
+ expect: { type: "string" },
1397
+ url: { type: "string" }
1398
+ }
1399
+ },
1400
+ assertValue: {
1401
+ required: { selector: { type: "string|string[]" } },
1402
+ optional: {
1403
+ expect: { type: "string" },
1404
+ value: { type: "string" }
1405
+ }
542
1406
  }
543
1407
  };
544
1408
  var VALID_ACTIONS = Object.keys(ACTION_RULES);
@@ -549,11 +1413,12 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
549
1413
  "url",
550
1414
  "value",
551
1415
  "key",
1416
+ "combo",
1417
+ "modifiers",
552
1418
  "waitFor",
553
1419
  "timeout",
554
1420
  "optional",
555
1421
  "method",
556
- "clear",
557
1422
  "blur",
558
1423
  "delay",
559
1424
  "waitForNavigation",
@@ -566,7 +1431,10 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
566
1431
  "amount",
567
1432
  "format",
568
1433
  "quality",
569
- "fullPage"
1434
+ "fullPage",
1435
+ "expect",
1436
+ "retry",
1437
+ "retryDelay"
570
1438
  ]);
571
1439
  function resolveAction(name) {
572
1440
  if (VALID_ACTIONS.includes(name)) {
@@ -634,6 +1502,15 @@ function checkFieldType(value, rule) {
634
1502
  case "boolean":
635
1503
  if (typeof value !== "boolean") return `expected boolean, got ${typeof value}`;
636
1504
  return null;
1505
+ case "boolean|auto":
1506
+ if (typeof value !== "boolean" && value !== "auto") {
1507
+ return `expected boolean or "auto", got ${typeof value}`;
1508
+ }
1509
+ return null;
1510
+ default: {
1511
+ const _exhaustive = rule.type;
1512
+ return `unknown type: ${_exhaustive}`;
1513
+ }
637
1514
  }
638
1515
  }
639
1516
  function validateSteps(steps) {
@@ -747,6 +1624,51 @@ function validateSteps(steps) {
747
1624
  });
748
1625
  }
749
1626
  }
1627
+ if ("retry" in obj && obj["retry"] !== void 0) {
1628
+ if (typeof obj["retry"] !== "number") {
1629
+ errors.push({
1630
+ stepIndex: i,
1631
+ field: "retry",
1632
+ message: `"retry" expected number, got ${typeof obj["retry"]}.`
1633
+ });
1634
+ }
1635
+ }
1636
+ if ("retryDelay" in obj && obj["retryDelay"] !== void 0) {
1637
+ if (typeof obj["retryDelay"] !== "number") {
1638
+ errors.push({
1639
+ stepIndex: i,
1640
+ field: "retryDelay",
1641
+ message: `"retryDelay" expected number, got ${typeof obj["retryDelay"]}.`
1642
+ });
1643
+ }
1644
+ }
1645
+ if (action === "assertText") {
1646
+ if (!("expect" in obj) && !("value" in obj)) {
1647
+ errors.push({
1648
+ stepIndex: i,
1649
+ field: "expect",
1650
+ message: 'assertText requires "expect" or "value" containing the expected text.'
1651
+ });
1652
+ }
1653
+ }
1654
+ if (action === "assertUrl") {
1655
+ if (!("expect" in obj) && !("url" in obj)) {
1656
+ errors.push({
1657
+ stepIndex: i,
1658
+ field: "expect",
1659
+ message: 'assertUrl requires "expect" or "url" containing the expected URL substring.'
1660
+ });
1661
+ }
1662
+ }
1663
+ if (action === "assertValue") {
1664
+ if (!("expect" in obj) && !("value" in obj)) {
1665
+ errors.push({
1666
+ stepIndex: i,
1667
+ field: "expect",
1668
+ message: 'assertValue requires "expect" or "value" containing the expected value.'
1669
+ });
1670
+ }
1671
+ }
750
1672
  if (action === "select") {
751
1673
  const hasNative = "selector" in obj && "value" in obj;
752
1674
  const hasCustom = "trigger" in obj && "option" in obj && "value" in obj;
@@ -1899,7 +2821,7 @@ var AudioOutput = class {
1899
2821
  awaitPromise: false
1900
2822
  });
1901
2823
  this.capturing = false;
1902
- await sleep(250);
2824
+ await sleep2(250);
1903
2825
  return this.mergeChunks();
1904
2826
  }
1905
2827
  /**
@@ -1924,35 +2846,37 @@ var AudioOutput = class {
1924
2846
  let heardAudio = false;
1925
2847
  let lastSoundTime = 0;
1926
2848
  const startTime = Date.now();
1927
- const checkInterval = setInterval(async () => {
1928
- const elapsed = Date.now() - startTime;
1929
- if (elapsed > maxDuration) {
1930
- clearInterval(checkInterval);
1931
- this.onDiagHandler?.(`max duration reached (${maxDuration}ms), stopping`);
1932
- resolve(await this.stop());
1933
- return;
1934
- }
1935
- const latest = this.chunks[this.chunks.length - 1];
1936
- if (latest) {
1937
- const rms = calculateRMS(latest.left);
1938
- if (rms > silenceThreshold) {
1939
- if (!heardAudio) {
1940
- heardAudio = true;
1941
- this.onDiagHandler?.("first audio detected \u2014 silence countdown begins");
2849
+ const checkInterval = setInterval(() => {
2850
+ void (async () => {
2851
+ const elapsed = Date.now() - startTime;
2852
+ if (elapsed > maxDuration) {
2853
+ clearInterval(checkInterval);
2854
+ this.onDiagHandler?.(`max duration reached (${maxDuration}ms), stopping`);
2855
+ resolve(await this.stop());
2856
+ return;
2857
+ }
2858
+ const latest = this.chunks[this.chunks.length - 1];
2859
+ if (latest) {
2860
+ const rms = calculateRMS(latest.left);
2861
+ if (rms > silenceThreshold) {
2862
+ if (!heardAudio) {
2863
+ heardAudio = true;
2864
+ this.onDiagHandler?.("first audio detected \u2014 silence countdown begins");
2865
+ }
2866
+ lastSoundTime = Date.now();
1942
2867
  }
1943
- lastSoundTime = Date.now();
1944
2868
  }
1945
- }
1946
- if (!heardAudio && elapsed > noAudioTimeout) {
1947
- clearInterval(checkInterval);
1948
- this.onDiagHandler?.(`no audio detected after ${noAudioTimeout}ms, stopping early`);
1949
- resolve(await this.stop());
1950
- return;
1951
- }
1952
- if (heardAudio && Date.now() - lastSoundTime > silenceTimeout) {
1953
- clearInterval(checkInterval);
1954
- resolve(await this.stop());
1955
- }
2869
+ if (!heardAudio && elapsed > noAudioTimeout) {
2870
+ clearInterval(checkInterval);
2871
+ this.onDiagHandler?.(`no audio detected after ${noAudioTimeout}ms, stopping early`);
2872
+ resolve(await this.stop());
2873
+ return;
2874
+ }
2875
+ if (heardAudio && Date.now() - lastSoundTime > silenceTimeout) {
2876
+ clearInterval(checkInterval);
2877
+ resolve(await this.stop());
2878
+ }
2879
+ })();
1956
2880
  }, 200);
1957
2881
  });
1958
2882
  }
@@ -2113,7 +3037,7 @@ function emptyCaptureResult() {
2113
3037
  chunkCount: 0
2114
3038
  };
2115
3039
  }
2116
- function sleep(ms) {
3040
+ function sleep2(ms) {
2117
3041
  return new Promise((resolve) => setTimeout(resolve, ms));
2118
3042
  }
2119
3043
 
@@ -2212,18 +3136,6 @@ Content-Type: ${contentType}\r
2212
3136
  parts.push(data);
2213
3137
  }
2214
3138
 
2215
- // src/cdp/protocol.ts
2216
- var CDPError = class extends Error {
2217
- code;
2218
- data;
2219
- constructor(error) {
2220
- super(error.message);
2221
- this.name = "CDPError";
2222
- this.code = error.code;
2223
- this.data = error.data;
2224
- }
2225
- };
2226
-
2227
3139
  // src/cdp/transport.ts
2228
3140
  function createTransport(wsUrl, options = {}) {
2229
3141
  const { timeout = 3e4 } = options;
@@ -2253,13 +3165,28 @@ function createTransport(wsUrl, options = {}) {
2253
3165
  resolveClose();
2254
3166
  return;
2255
3167
  }
2256
- const onClose = () => {
3168
+ let settled = false;
3169
+ let fallbackTimer;
3170
+ const finish = () => {
3171
+ if (settled) return;
3172
+ settled = true;
3173
+ if (fallbackTimer) clearTimeout(fallbackTimer);
2257
3174
  ws.removeEventListener("close", onClose);
2258
3175
  resolveClose();
2259
3176
  };
3177
+ const onClose = () => {
3178
+ finish();
3179
+ };
2260
3180
  ws.addEventListener("close", onClose);
2261
- ws.close();
2262
- setTimeout(resolveClose, 5e3);
3181
+ try {
3182
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
3183
+ ws.close();
3184
+ }
3185
+ } catch {
3186
+ finish();
3187
+ return;
3188
+ }
3189
+ fallbackTimer = setTimeout(finish, 200);
2263
3190
  });
2264
3191
  },
2265
3192
  onMessage(handler) {
@@ -2589,6 +3516,34 @@ var BrowserlessProvider = class {
2589
3516
  };
2590
3517
 
2591
3518
  // src/providers/generic.ts
3519
+ function sleep3(ms) {
3520
+ return new Promise((resolve) => setTimeout(resolve, ms));
3521
+ }
3522
+ async function fetchDevToolsJson(host, path, errorPrefix, options = {}) {
3523
+ const protocol = host.includes("://") ? "" : "http://";
3524
+ const attempts = options.attempts ?? 1;
3525
+ let delayMs = options.initialDelayMs ?? 50;
3526
+ const maxDelayMs = options.maxDelayMs ?? 250;
3527
+ let lastError;
3528
+ for (let attempt = 1; attempt <= attempts; attempt++) {
3529
+ try {
3530
+ const response = await fetch(`${protocol}${host}${path}`);
3531
+ if (response.ok) {
3532
+ return await response.json();
3533
+ }
3534
+ lastError = new Error(`${errorPrefix}: ${response.status}`);
3535
+ } catch (error) {
3536
+ lastError = new Error(
3537
+ `${errorPrefix}: ${error instanceof Error ? error.message : String(error)}`
3538
+ );
3539
+ }
3540
+ if (attempt < attempts) {
3541
+ await sleep3(delayMs);
3542
+ delayMs = Math.min(delayMs * 2, maxDelayMs);
3543
+ }
3544
+ }
3545
+ throw lastError ?? new Error(errorPrefix);
3546
+ }
2592
3547
  var GenericProvider = class {
2593
3548
  name = "generic";
2594
3549
  wsUrl;
@@ -2607,20 +3562,14 @@ var GenericProvider = class {
2607
3562
  }
2608
3563
  };
2609
3564
  async function discoverTargets(host = "localhost:9222") {
2610
- const protocol = host.includes("://") ? "" : "http://";
2611
- const response = await fetch(`${protocol}${host}/json/list`);
2612
- if (!response.ok) {
2613
- throw new Error(`Failed to discover targets: ${response.status}`);
2614
- }
2615
- return await response.json();
3565
+ return fetchDevToolsJson(host, "/json/list", "Failed to discover targets");
2616
3566
  }
2617
3567
  async function getBrowserWebSocketUrl(host = "localhost:9222") {
2618
- const protocol = host.includes("://") ? "" : "http://";
2619
- const response = await fetch(`${protocol}${host}/json/version`);
2620
- if (!response.ok) {
2621
- throw new Error(`Failed to get browser info: ${response.status}`);
2622
- }
2623
- const info = await response.json();
3568
+ const info = await fetchDevToolsJson(host, "/json/version", "Failed to get browser info", {
3569
+ attempts: 10,
3570
+ initialDelayMs: 50,
3571
+ maxDelayMs: 250
3572
+ });
2624
3573
  return info.webSocketDebuggerUrl;
2625
3574
  }
2626
3575
 
@@ -2667,8 +3616,12 @@ var RequestInterceptor = class {
2667
3616
  boundHandleAuthRequired;
2668
3617
  constructor(cdp) {
2669
3618
  this.cdp = cdp;
2670
- this.boundHandleRequestPaused = this.handleRequestPaused.bind(this);
2671
- this.boundHandleAuthRequired = this.handleAuthRequired.bind(this);
3619
+ this.boundHandleRequestPaused = (params) => {
3620
+ void this.handleRequestPaused(params);
3621
+ };
3622
+ this.boundHandleAuthRequired = (params) => {
3623
+ void this.handleAuthRequired(params);
3624
+ };
2672
3625
  }
2673
3626
  /**
2674
3627
  * Enable request interception with optional patterns
@@ -2895,429 +3848,463 @@ async function isElementVisible(cdp, selector, contextId) {
2895
3848
  }
2896
3849
  async function isElementAttached(cdp, selector, contextId) {
2897
3850
  const params = {
2898
- expression: `(() => {
2899
- ${DEEP_QUERY_SCRIPT}
2900
- return deepQuery(${JSON.stringify(selector)}) !== null;
2901
- })()`,
2902
- returnByValue: true
2903
- };
2904
- if (contextId !== void 0) {
2905
- params["contextId"] = contextId;
2906
- }
2907
- const result = await cdp.send("Runtime.evaluate", params);
2908
- return result.result.value === true;
2909
- }
2910
- function sleep2(ms) {
2911
- return new Promise((resolve) => setTimeout(resolve, ms));
2912
- }
2913
- async function waitForElement(cdp, selector, options = {}) {
2914
- const { state = "visible", timeout = 3e4, pollInterval = 100, contextId } = options;
2915
- const startTime = Date.now();
2916
- const deadline = startTime + timeout;
2917
- while (Date.now() < deadline) {
2918
- let conditionMet = false;
2919
- switch (state) {
2920
- case "visible":
2921
- conditionMet = await isElementVisible(cdp, selector, contextId);
2922
- break;
2923
- case "hidden":
2924
- conditionMet = !await isElementVisible(cdp, selector, contextId);
2925
- break;
2926
- case "attached":
2927
- conditionMet = await isElementAttached(cdp, selector, contextId);
2928
- break;
2929
- case "detached":
2930
- conditionMet = !await isElementAttached(cdp, selector, contextId);
2931
- break;
2932
- }
2933
- if (conditionMet) {
2934
- return { success: true, waitedMs: Date.now() - startTime };
2935
- }
2936
- await sleep2(pollInterval);
2937
- }
2938
- return { success: false, waitedMs: Date.now() - startTime };
2939
- }
2940
- async function waitForAnyElement(cdp, selectors, options = {}) {
2941
- const { state = "visible", timeout = 3e4, pollInterval = 100, contextId } = options;
2942
- const startTime = Date.now();
2943
- const deadline = startTime + timeout;
2944
- while (Date.now() < deadline) {
2945
- for (const selector of selectors) {
2946
- let conditionMet = false;
2947
- switch (state) {
2948
- case "visible":
2949
- conditionMet = await isElementVisible(cdp, selector, contextId);
2950
- break;
2951
- case "hidden":
2952
- conditionMet = !await isElementVisible(cdp, selector, contextId);
2953
- break;
2954
- case "attached":
2955
- conditionMet = await isElementAttached(cdp, selector, contextId);
2956
- break;
2957
- case "detached":
2958
- conditionMet = !await isElementAttached(cdp, selector, contextId);
2959
- break;
2960
- }
2961
- if (conditionMet) {
2962
- return { success: true, selector, waitedMs: Date.now() - startTime };
2963
- }
2964
- }
2965
- await sleep2(pollInterval);
2966
- }
2967
- return { success: false, waitedMs: Date.now() - startTime };
2968
- }
2969
- async function getCurrentUrl(cdp) {
2970
- const result = await cdp.send("Runtime.evaluate", {
2971
- expression: "location.href",
2972
- returnByValue: true
2973
- });
2974
- return result.result.value;
2975
- }
2976
- async function waitForNavigation(cdp, options = {}) {
2977
- const { timeout = 3e4, allowSameDocument = true } = options;
2978
- const startTime = Date.now();
2979
- let startUrl;
2980
- try {
2981
- startUrl = await getCurrentUrl(cdp);
2982
- } catch {
2983
- startUrl = "";
2984
- }
2985
- return new Promise((resolve) => {
2986
- let resolved = false;
2987
- const cleanup = [];
2988
- const done = (success) => {
2989
- if (resolved) return;
2990
- resolved = true;
2991
- for (const fn of cleanup) fn();
2992
- resolve({ success, waitedMs: Date.now() - startTime });
2993
- };
2994
- const timer = setTimeout(() => done(false), timeout);
2995
- cleanup.push(() => clearTimeout(timer));
2996
- const onLoad = () => done(true);
2997
- cdp.on("Page.loadEventFired", onLoad);
2998
- cleanup.push(() => cdp.off("Page.loadEventFired", onLoad));
2999
- const onFrameNavigated = (params) => {
3000
- const frame = params["frame"];
3001
- if (frame && !frame.parentId && frame.url !== startUrl) {
3002
- done(true);
3003
- }
3004
- };
3005
- cdp.on("Page.frameNavigated", onFrameNavigated);
3006
- cleanup.push(() => cdp.off("Page.frameNavigated", onFrameNavigated));
3007
- if (allowSameDocument) {
3008
- const onSameDoc = () => done(true);
3009
- cdp.on("Page.navigatedWithinDocument", onSameDoc);
3010
- cleanup.push(() => cdp.off("Page.navigatedWithinDocument", onSameDoc));
3011
- }
3012
- const pollUrl = async () => {
3013
- while (!resolved && Date.now() < startTime + timeout) {
3014
- await sleep2(100);
3015
- if (resolved) return;
3016
- try {
3017
- const currentUrl = await getCurrentUrl(cdp);
3018
- if (startUrl && currentUrl !== startUrl) {
3019
- done(true);
3020
- return;
3021
- }
3022
- } catch {
3023
- }
3024
- }
3025
- };
3026
- pollUrl();
3027
- });
3028
- }
3029
- async function waitForNetworkIdle(cdp, options = {}) {
3030
- const { timeout = 3e4, idleTime = 500 } = options;
3031
- const startTime = Date.now();
3032
- await cdp.send("Network.enable");
3033
- return new Promise((resolve) => {
3034
- let inFlight = 0;
3035
- let idleTimer = null;
3036
- const timeoutTimer = setTimeout(() => {
3037
- cleanup();
3038
- resolve({ success: false, waitedMs: Date.now() - startTime });
3039
- }, timeout);
3040
- const checkIdle = () => {
3041
- if (inFlight === 0) {
3042
- if (idleTimer) clearTimeout(idleTimer);
3043
- idleTimer = setTimeout(() => {
3044
- cleanup();
3045
- resolve({ success: true, waitedMs: Date.now() - startTime });
3046
- }, idleTime);
3047
- }
3048
- };
3049
- const onRequestStart = () => {
3050
- inFlight++;
3051
- if (idleTimer) {
3052
- clearTimeout(idleTimer);
3053
- idleTimer = null;
3054
- }
3055
- };
3056
- const onRequestEnd = () => {
3057
- inFlight = Math.max(0, inFlight - 1);
3058
- checkIdle();
3059
- };
3060
- const cleanup = () => {
3061
- clearTimeout(timeoutTimer);
3062
- if (idleTimer) clearTimeout(idleTimer);
3063
- cdp.off("Network.requestWillBeSent", onRequestStart);
3064
- cdp.off("Network.loadingFinished", onRequestEnd);
3065
- cdp.off("Network.loadingFailed", onRequestEnd);
3066
- };
3067
- cdp.on("Network.requestWillBeSent", onRequestStart);
3068
- cdp.on("Network.loadingFinished", onRequestEnd);
3069
- cdp.on("Network.loadingFailed", onRequestEnd);
3070
- checkIdle();
3071
- });
3072
- }
3073
-
3074
- // src/browser/fuzzy-match.ts
3075
- function jaroWinkler(a, b) {
3076
- if (a.length === 0 && b.length === 0) return 0;
3077
- if (a.length === 0 || b.length === 0) return 0;
3078
- if (a === b) return 1;
3079
- const s1 = a.toLowerCase();
3080
- const s2 = b.toLowerCase();
3081
- const matchWindow = Math.max(0, Math.floor(Math.max(s1.length, s2.length) / 2) - 1);
3082
- const s1Matches = new Array(s1.length).fill(false);
3083
- const s2Matches = new Array(s2.length).fill(false);
3084
- let matches = 0;
3085
- let transpositions = 0;
3086
- for (let i = 0; i < s1.length; i++) {
3087
- const start = Math.max(0, i - matchWindow);
3088
- const end = Math.min(i + matchWindow + 1, s2.length);
3089
- for (let j = start; j < end; j++) {
3090
- if (s2Matches[j] || s1[i] !== s2[j]) continue;
3091
- s1Matches[i] = true;
3092
- s2Matches[j] = true;
3093
- matches++;
3094
- break;
3095
- }
3096
- }
3097
- if (matches === 0) return 0;
3098
- let k = 0;
3099
- for (let i = 0; i < s1.length; i++) {
3100
- if (!s1Matches[i]) continue;
3101
- while (!s2Matches[k]) k++;
3102
- if (s1[i] !== s2[k]) transpositions++;
3103
- k++;
3104
- }
3105
- const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
3106
- let prefix = 0;
3107
- for (let i = 0; i < Math.min(4, Math.min(s1.length, s2.length)); i++) {
3108
- if (s1[i] === s2[i]) {
3109
- prefix++;
3110
- } else {
3111
- break;
3112
- }
3113
- }
3114
- const WINKLER_SCALING = 0.1;
3115
- return jaro + prefix * WINKLER_SCALING * (1 - jaro);
3116
- }
3117
- function stringSimilarity(a, b) {
3118
- if (a.length === 0 || b.length === 0) return 0;
3119
- const lowerA = a.toLowerCase();
3120
- const lowerB = b.toLowerCase();
3121
- if (lowerA === lowerB) return 1;
3122
- const jw = jaroWinkler(a, b);
3123
- let containsBonus = 0;
3124
- if (lowerB.includes(lowerA)) {
3125
- containsBonus = 0.2;
3126
- } else if (lowerA.includes(lowerB)) {
3127
- containsBonus = 0.1;
3128
- }
3129
- return Math.min(1, jw + containsBonus);
3130
- }
3131
- function scoreElement(query, element) {
3132
- const lowerQuery = query.toLowerCase();
3133
- const words = lowerQuery.split(/\s+/).filter((w) => w.length > 0);
3134
- let nameScore = 0;
3135
- if (element.name) {
3136
- const lowerName = element.name.toLowerCase();
3137
- if (lowerName === lowerQuery) {
3138
- nameScore = 1;
3139
- } else if (lowerName.includes(lowerQuery)) {
3140
- nameScore = 0.8;
3141
- } else if (words.length > 0) {
3142
- const matchedWords = words.filter((w) => lowerName.includes(w));
3143
- nameScore = matchedWords.length / words.length * 0.7;
3144
- } else {
3145
- nameScore = stringSimilarity(query, element.name) * 0.6;
3146
- }
3147
- }
3148
- let roleScore = 0;
3149
- const lowerRole = element.role.toLowerCase();
3150
- if (lowerRole === lowerQuery || lowerQuery.includes(lowerRole)) {
3151
- roleScore = 0.3;
3152
- } else if (words.some((w) => lowerRole.includes(w))) {
3153
- roleScore = 0.2;
3851
+ expression: `(() => {
3852
+ ${DEEP_QUERY_SCRIPT}
3853
+ return deepQuery(${JSON.stringify(selector)}) !== null;
3854
+ })()`,
3855
+ returnByValue: true
3856
+ };
3857
+ if (contextId !== void 0) {
3858
+ params["contextId"] = contextId;
3154
3859
  }
3155
- let selectorScore = 0;
3156
- const lowerSelector = element.selector.toLowerCase();
3157
- if (words.some((w) => lowerSelector.includes(w))) {
3158
- selectorScore = 0.2;
3860
+ const result = await cdp.send("Runtime.evaluate", params);
3861
+ return result.result.value === true;
3862
+ }
3863
+ function sleep4(ms) {
3864
+ return new Promise((resolve) => setTimeout(resolve, ms));
3865
+ }
3866
+ async function isPageStatic(cdp, windowMs = 200, contextId) {
3867
+ const params = {
3868
+ expression: `new Promise(resolve => {
3869
+ // If page is still loading, it's not static
3870
+ if (document.readyState !== 'complete') { resolve(false); return; }
3871
+ // Check for recent page load (navigationStart within last 1s = page just loaded)
3872
+ try {
3873
+ var nav = performance.getEntriesByType('navigation')[0];
3874
+ if (nav && (performance.now() - nav.loadEventEnd) < 500) { resolve(false); return; }
3875
+ } catch(e) {}
3876
+ // Observe for DOM mutations
3877
+ var seen = false;
3878
+ var obs = new MutationObserver(function() { seen = true; });
3879
+ obs.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
3880
+ setTimeout(function() { obs.disconnect(); resolve(!seen); }, ${windowMs});
3881
+ })`,
3882
+ returnByValue: true,
3883
+ awaitPromise: true
3884
+ };
3885
+ if (contextId !== void 0) params["contextId"] = contextId;
3886
+ try {
3887
+ const result = await cdp.send("Runtime.evaluate", params);
3888
+ return result.result.value === true;
3889
+ } catch {
3890
+ return false;
3159
3891
  }
3160
- const totalScore = nameScore * 0.6 + roleScore * 0.25 + selectorScore * 0.15;
3161
- return totalScore;
3162
3892
  }
3163
- function explainMatch(query, element, score) {
3164
- const reasons = [];
3165
- const lowerQuery = query.toLowerCase();
3166
- const words = lowerQuery.split(/\s+/).filter((w) => w.length > 0);
3167
- if (element.name) {
3168
- const lowerName = element.name.toLowerCase();
3169
- if (lowerName === lowerQuery) {
3170
- reasons.push("exact name match");
3171
- } else if (lowerName.includes(lowerQuery)) {
3172
- reasons.push("name contains query");
3173
- } else if (words.some((w) => lowerName.includes(w))) {
3174
- const matchedWords = words.filter((w) => lowerName.includes(w));
3175
- reasons.push(`name contains: ${matchedWords.join(", ")}`);
3176
- } else if (stringSimilarity(query, element.name) > 0.5) {
3177
- reasons.push("similar name");
3893
+ async function waitForElement(cdp, selector, options = {}) {
3894
+ const { state = "visible", timeout = 3e4, pollInterval = 100, contextId } = options;
3895
+ const startTime = Date.now();
3896
+ const deadline = startTime + timeout;
3897
+ const checkCondition = async () => {
3898
+ switch (state) {
3899
+ case "visible":
3900
+ return isElementVisible(cdp, selector, contextId);
3901
+ case "hidden":
3902
+ return !await isElementVisible(cdp, selector, contextId);
3903
+ case "attached":
3904
+ return isElementAttached(cdp, selector, contextId);
3905
+ case "detached":
3906
+ return !await isElementAttached(cdp, selector, contextId);
3907
+ default: {
3908
+ const _exhaustive = state;
3909
+ throw new Error(`Unhandled wait state: ${_exhaustive}`);
3910
+ }
3178
3911
  }
3912
+ };
3913
+ if (await checkCondition()) {
3914
+ return { success: true, waitedMs: Date.now() - startTime };
3179
3915
  }
3180
- const lowerRole = element.role.toLowerCase();
3181
- if (lowerRole === lowerQuery || words.some((w) => w === lowerRole)) {
3182
- reasons.push(`role: ${element.role}`);
3183
- }
3184
- if (words.some((w) => element.selector.toLowerCase().includes(w))) {
3185
- reasons.push("selector match");
3186
- }
3187
- if (reasons.length === 0) {
3188
- reasons.push(`fuzzy match (score: ${score.toFixed(2)})`);
3916
+ const waitingForPresence = state === "visible" || state === "attached";
3917
+ if (waitingForPresence && timeout >= 300) {
3918
+ const pageStatic = await isPageStatic(cdp, 200, contextId);
3919
+ if (pageStatic) {
3920
+ if (await checkCondition()) {
3921
+ return { success: true, waitedMs: Date.now() - startTime };
3922
+ }
3923
+ return { success: false, waitedMs: Date.now() - startTime };
3924
+ }
3189
3925
  }
3190
- return reasons.join(", ");
3191
- }
3192
- function fuzzyMatchElements(query, elements, maxResults = 5) {
3193
- if (!query || query.length === 0) {
3194
- return [];
3926
+ while (Date.now() < deadline) {
3927
+ await sleep4(pollInterval);
3928
+ if (await checkCondition()) {
3929
+ return { success: true, waitedMs: Date.now() - startTime };
3930
+ }
3195
3931
  }
3196
- const THRESHOLD = 0.3;
3197
- const scored = elements.map((element) => ({
3198
- element,
3199
- score: scoreElement(query, element)
3200
- }));
3201
- return scored.filter((s) => s.score >= THRESHOLD).sort((a, b) => b.score - a.score).slice(0, maxResults).map((s) => ({
3202
- element: s.element,
3203
- score: s.score,
3204
- matchReason: explainMatch(query, s.element, s.score)
3205
- }));
3932
+ return { success: false, waitedMs: Date.now() - startTime };
3206
3933
  }
3207
-
3208
- // src/browser/hint-generator.ts
3209
- var ACTION_ROLE_MAP = {
3210
- click: ["button", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "option"],
3211
- fill: ["textbox", "searchbox", "textarea"],
3212
- type: ["textbox", "searchbox", "textarea"],
3213
- submit: ["button", "form"],
3214
- select: ["combobox", "listbox", "option"],
3215
- check: ["checkbox", "radio", "switch"],
3216
- uncheck: ["checkbox", "switch"],
3217
- focus: [],
3218
- // Any focusable element
3219
- hover: [],
3220
- // Any element
3221
- clear: ["textbox", "searchbox", "textarea"]
3222
- };
3223
- function extractIntent(selectors) {
3224
- const patterns = [];
3225
- let text = "";
3226
- for (const selector of selectors) {
3227
- if (selector.startsWith("ref:")) {
3228
- continue;
3229
- }
3230
- const idMatch = selector.match(/#([a-zA-Z0-9_-]+)/);
3231
- if (idMatch) {
3232
- patterns.push(idMatch[1]);
3934
+ async function waitForAnyElement(cdp, selectors, options = {}) {
3935
+ const { state = "visible", timeout = 3e4, pollInterval = 100, contextId } = options;
3936
+ const startTime = Date.now();
3937
+ const deadline = startTime + timeout;
3938
+ const checkSelector = async (selector) => {
3939
+ switch (state) {
3940
+ case "visible":
3941
+ return isElementVisible(cdp, selector, contextId);
3942
+ case "hidden":
3943
+ return !await isElementVisible(cdp, selector, contextId);
3944
+ case "attached":
3945
+ return isElementAttached(cdp, selector, contextId);
3946
+ case "detached":
3947
+ return !await isElementAttached(cdp, selector, contextId);
3948
+ default: {
3949
+ const _exhaustive = state;
3950
+ throw new Error(`Unhandled wait state: ${_exhaustive}`);
3951
+ }
3233
3952
  }
3234
- const ariaMatch = selector.match(/\[aria-label=["']([^"']+)["']\]/);
3235
- if (ariaMatch) {
3236
- patterns.push(ariaMatch[1]);
3953
+ };
3954
+ for (const selector of selectors) {
3955
+ if (await checkSelector(selector)) {
3956
+ return { success: true, selector, waitedMs: Date.now() - startTime };
3237
3957
  }
3238
- const testidMatch = selector.match(/\[data-testid=["']([^"']+)["']\]/);
3239
- if (testidMatch) {
3240
- patterns.push(testidMatch[1]);
3958
+ }
3959
+ const waitingForPresence = state === "visible" || state === "attached";
3960
+ if (waitingForPresence && timeout >= 300) {
3961
+ const pageStatic = await isPageStatic(cdp, 200, contextId);
3962
+ if (pageStatic) {
3963
+ for (const selector of selectors) {
3964
+ if (await checkSelector(selector)) {
3965
+ return { success: true, selector, waitedMs: Date.now() - startTime };
3966
+ }
3967
+ }
3968
+ return { success: false, waitedMs: Date.now() - startTime };
3241
3969
  }
3242
- const classMatch = selector.match(/\.([a-zA-Z0-9_-]+)/);
3243
- if (classMatch) {
3244
- patterns.push(classMatch[1]);
3970
+ }
3971
+ while (Date.now() < deadline) {
3972
+ await sleep4(pollInterval);
3973
+ for (const selector of selectors) {
3974
+ if (await checkSelector(selector)) {
3975
+ return { success: true, selector, waitedMs: Date.now() - startTime };
3976
+ }
3245
3977
  }
3246
3978
  }
3247
- patterns.sort((a, b) => b.length - a.length);
3248
- text = patterns[0] ?? selectors[0] ?? "";
3249
- return { text, patterns };
3250
- }
3251
- function getHintType(selector) {
3252
- if (selector.startsWith("ref:")) return "ref";
3253
- if (selector.includes("data-testid")) return "testid";
3254
- if (selector.includes("aria-label")) return "aria";
3255
- if (selector.startsWith("#")) return "id";
3256
- return "css";
3979
+ return { success: false, waitedMs: Date.now() - startTime };
3257
3980
  }
3258
- function getConfidence(score) {
3259
- if (score >= 0.8) return "high";
3260
- if (score >= 0.5) return "medium";
3261
- return "low";
3981
+ async function getCurrentUrl(cdp) {
3982
+ const result = await cdp.send("Runtime.evaluate", {
3983
+ expression: "location.href",
3984
+ returnByValue: true
3985
+ });
3986
+ return result.result.value;
3262
3987
  }
3263
- function diversifyHints(candidates, maxHints) {
3264
- const hints = [];
3265
- const usedTypes = /* @__PURE__ */ new Set();
3266
- for (const candidate of candidates) {
3267
- if (hints.length >= maxHints) break;
3268
- const refSelector = `ref:${candidate.element.ref}`;
3269
- const hintType = getHintType(refSelector);
3270
- if (!usedTypes.has(hintType)) {
3271
- hints.push({
3272
- selector: refSelector,
3273
- reason: candidate.matchReason,
3274
- confidence: getConfidence(candidate.score),
3275
- element: {
3276
- ref: candidate.element.ref,
3277
- role: candidate.element.role,
3278
- name: candidate.element.name,
3279
- disabled: candidate.element.disabled
3280
- }
3281
- });
3282
- usedTypes.add(hintType);
3283
- } else if (hints.length < maxHints) {
3284
- hints.push({
3285
- selector: refSelector,
3286
- reason: candidate.matchReason,
3287
- confidence: getConfidence(candidate.score),
3288
- element: {
3289
- ref: candidate.element.ref,
3290
- role: candidate.element.role,
3291
- name: candidate.element.name,
3292
- disabled: candidate.element.disabled
3293
- }
3294
- });
3295
- }
3988
+ async function waitForNavigation(cdp, options = {}) {
3989
+ const { timeout = 3e4, allowSameDocument = true } = options;
3990
+ const startTime = Date.now();
3991
+ let startUrl;
3992
+ try {
3993
+ startUrl = await getCurrentUrl(cdp);
3994
+ } catch {
3995
+ startUrl = "";
3296
3996
  }
3297
- return hints;
3997
+ return new Promise((resolve) => {
3998
+ let resolved = false;
3999
+ const cleanup = [];
4000
+ const done = (success) => {
4001
+ if (resolved) return;
4002
+ resolved = true;
4003
+ for (const fn of cleanup) fn();
4004
+ resolve({ success, waitedMs: Date.now() - startTime });
4005
+ };
4006
+ const timer = setTimeout(() => done(false), timeout);
4007
+ cleanup.push(() => clearTimeout(timer));
4008
+ const onLoad = () => done(true);
4009
+ cdp.on("Page.loadEventFired", onLoad);
4010
+ cleanup.push(() => cdp.off("Page.loadEventFired", onLoad));
4011
+ const onFrameNavigated = (params) => {
4012
+ const frame = params["frame"];
4013
+ if (frame && !frame.parentId && frame.url !== startUrl) {
4014
+ done(true);
4015
+ }
4016
+ };
4017
+ cdp.on("Page.frameNavigated", onFrameNavigated);
4018
+ cleanup.push(() => cdp.off("Page.frameNavigated", onFrameNavigated));
4019
+ if (allowSameDocument) {
4020
+ const onSameDoc = () => done(true);
4021
+ cdp.on("Page.navigatedWithinDocument", onSameDoc);
4022
+ cleanup.push(() => cdp.off("Page.navigatedWithinDocument", onSameDoc));
4023
+ }
4024
+ const onLifecycle = (params) => {
4025
+ if (params["name"] === "networkIdle") {
4026
+ done(true);
4027
+ }
4028
+ };
4029
+ cdp.on("Page.lifecycleEvent", onLifecycle);
4030
+ cleanup.push(() => cdp.off("Page.lifecycleEvent", onLifecycle));
4031
+ const pollUrl = async () => {
4032
+ while (!resolved && Date.now() < startTime + timeout) {
4033
+ await sleep4(100);
4034
+ if (resolved) return;
4035
+ try {
4036
+ const currentUrl = await getCurrentUrl(cdp);
4037
+ if (startUrl && currentUrl !== startUrl) {
4038
+ done(true);
4039
+ return;
4040
+ }
4041
+ } catch {
4042
+ }
4043
+ }
4044
+ };
4045
+ void pollUrl();
4046
+ });
4047
+ }
4048
+ async function waitForNetworkIdle(cdp, options = {}) {
4049
+ const { timeout = 3e4, idleTime = 500 } = options;
4050
+ const startTime = Date.now();
4051
+ await cdp.send("Network.enable");
4052
+ return new Promise((resolve) => {
4053
+ let inFlight = 0;
4054
+ let idleTimer = null;
4055
+ const timeoutTimer = setTimeout(() => {
4056
+ cleanup();
4057
+ resolve({ success: false, waitedMs: Date.now() - startTime });
4058
+ }, timeout);
4059
+ const checkIdle = () => {
4060
+ if (inFlight === 0) {
4061
+ if (idleTimer) clearTimeout(idleTimer);
4062
+ idleTimer = setTimeout(() => {
4063
+ cleanup();
4064
+ resolve({ success: true, waitedMs: Date.now() - startTime });
4065
+ }, idleTime);
4066
+ }
4067
+ };
4068
+ const onRequestStart = () => {
4069
+ inFlight++;
4070
+ if (idleTimer) {
4071
+ clearTimeout(idleTimer);
4072
+ idleTimer = null;
4073
+ }
4074
+ };
4075
+ const onRequestEnd = () => {
4076
+ inFlight = Math.max(0, inFlight - 1);
4077
+ checkIdle();
4078
+ };
4079
+ const cleanup = () => {
4080
+ clearTimeout(timeoutTimer);
4081
+ if (idleTimer) clearTimeout(idleTimer);
4082
+ cdp.off("Network.requestWillBeSent", onRequestStart);
4083
+ cdp.off("Network.loadingFinished", onRequestEnd);
4084
+ cdp.off("Network.loadingFailed", onRequestEnd);
4085
+ };
4086
+ cdp.on("Network.requestWillBeSent", onRequestStart);
4087
+ cdp.on("Network.loadingFinished", onRequestEnd);
4088
+ cdp.on("Network.loadingFailed", onRequestEnd);
4089
+ checkIdle();
4090
+ });
3298
4091
  }
3299
- async function generateHints(page, failedSelectors, actionType, maxHints = 3) {
3300
- let snapshot;
3301
- try {
3302
- snapshot = await page.snapshot();
3303
- } catch {
3304
- return [];
3305
- }
3306
- const intent = extractIntent(failedSelectors);
3307
- const roleFilter = ACTION_ROLE_MAP[actionType] ?? [];
3308
- let candidates = snapshot.interactiveElements;
3309
- if (roleFilter.length > 0) {
3310
- candidates = candidates.filter((el) => roleFilter.includes(el.role));
4092
+
4093
+ // src/browser/keyboard.ts
4094
+ var US_KEYBOARD = {
4095
+ // Letters (lowercase)
4096
+ a: { key: "a", code: "KeyA", keyCode: 65, text: "a" },
4097
+ b: { key: "b", code: "KeyB", keyCode: 66, text: "b" },
4098
+ c: { key: "c", code: "KeyC", keyCode: 67, text: "c" },
4099
+ d: { key: "d", code: "KeyD", keyCode: 68, text: "d" },
4100
+ e: { key: "e", code: "KeyE", keyCode: 69, text: "e" },
4101
+ f: { key: "f", code: "KeyF", keyCode: 70, text: "f" },
4102
+ g: { key: "g", code: "KeyG", keyCode: 71, text: "g" },
4103
+ h: { key: "h", code: "KeyH", keyCode: 72, text: "h" },
4104
+ i: { key: "i", code: "KeyI", keyCode: 73, text: "i" },
4105
+ j: { key: "j", code: "KeyJ", keyCode: 74, text: "j" },
4106
+ k: { key: "k", code: "KeyK", keyCode: 75, text: "k" },
4107
+ l: { key: "l", code: "KeyL", keyCode: 76, text: "l" },
4108
+ m: { key: "m", code: "KeyM", keyCode: 77, text: "m" },
4109
+ n: { key: "n", code: "KeyN", keyCode: 78, text: "n" },
4110
+ o: { key: "o", code: "KeyO", keyCode: 79, text: "o" },
4111
+ p: { key: "p", code: "KeyP", keyCode: 80, text: "p" },
4112
+ q: { key: "q", code: "KeyQ", keyCode: 81, text: "q" },
4113
+ r: { key: "r", code: "KeyR", keyCode: 82, text: "r" },
4114
+ s: { key: "s", code: "KeyS", keyCode: 83, text: "s" },
4115
+ t: { key: "t", code: "KeyT", keyCode: 84, text: "t" },
4116
+ u: { key: "u", code: "KeyU", keyCode: 85, text: "u" },
4117
+ v: { key: "v", code: "KeyV", keyCode: 86, text: "v" },
4118
+ w: { key: "w", code: "KeyW", keyCode: 87, text: "w" },
4119
+ x: { key: "x", code: "KeyX", keyCode: 88, text: "x" },
4120
+ y: { key: "y", code: "KeyY", keyCode: 89, text: "y" },
4121
+ z: { key: "z", code: "KeyZ", keyCode: 90, text: "z" },
4122
+ // Letters (uppercase)
4123
+ A: { key: "A", code: "KeyA", keyCode: 65, text: "A" },
4124
+ B: { key: "B", code: "KeyB", keyCode: 66, text: "B" },
4125
+ C: { key: "C", code: "KeyC", keyCode: 67, text: "C" },
4126
+ D: { key: "D", code: "KeyD", keyCode: 68, text: "D" },
4127
+ E: { key: "E", code: "KeyE", keyCode: 69, text: "E" },
4128
+ F: { key: "F", code: "KeyF", keyCode: 70, text: "F" },
4129
+ G: { key: "G", code: "KeyG", keyCode: 71, text: "G" },
4130
+ H: { key: "H", code: "KeyH", keyCode: 72, text: "H" },
4131
+ I: { key: "I", code: "KeyI", keyCode: 73, text: "I" },
4132
+ J: { key: "J", code: "KeyJ", keyCode: 74, text: "J" },
4133
+ K: { key: "K", code: "KeyK", keyCode: 75, text: "K" },
4134
+ L: { key: "L", code: "KeyL", keyCode: 76, text: "L" },
4135
+ M: { key: "M", code: "KeyM", keyCode: 77, text: "M" },
4136
+ N: { key: "N", code: "KeyN", keyCode: 78, text: "N" },
4137
+ O: { key: "O", code: "KeyO", keyCode: 79, text: "O" },
4138
+ P: { key: "P", code: "KeyP", keyCode: 80, text: "P" },
4139
+ Q: { key: "Q", code: "KeyQ", keyCode: 81, text: "Q" },
4140
+ R: { key: "R", code: "KeyR", keyCode: 82, text: "R" },
4141
+ S: { key: "S", code: "KeyS", keyCode: 83, text: "S" },
4142
+ T: { key: "T", code: "KeyT", keyCode: 84, text: "T" },
4143
+ U: { key: "U", code: "KeyU", keyCode: 85, text: "U" },
4144
+ V: { key: "V", code: "KeyV", keyCode: 86, text: "V" },
4145
+ W: { key: "W", code: "KeyW", keyCode: 87, text: "W" },
4146
+ X: { key: "X", code: "KeyX", keyCode: 88, text: "X" },
4147
+ Y: { key: "Y", code: "KeyY", keyCode: 89, text: "Y" },
4148
+ Z: { key: "Z", code: "KeyZ", keyCode: 90, text: "Z" },
4149
+ // Numbers
4150
+ "0": { key: "0", code: "Digit0", keyCode: 48, text: "0" },
4151
+ "1": { key: "1", code: "Digit1", keyCode: 49, text: "1" },
4152
+ "2": { key: "2", code: "Digit2", keyCode: 50, text: "2" },
4153
+ "3": { key: "3", code: "Digit3", keyCode: 51, text: "3" },
4154
+ "4": { key: "4", code: "Digit4", keyCode: 52, text: "4" },
4155
+ "5": { key: "5", code: "Digit5", keyCode: 53, text: "5" },
4156
+ "6": { key: "6", code: "Digit6", keyCode: 54, text: "6" },
4157
+ "7": { key: "7", code: "Digit7", keyCode: 55, text: "7" },
4158
+ "8": { key: "8", code: "Digit8", keyCode: 56, text: "8" },
4159
+ "9": { key: "9", code: "Digit9", keyCode: 57, text: "9" },
4160
+ // Punctuation
4161
+ " ": { key: " ", code: "Space", keyCode: 32, text: " " },
4162
+ ".": { key: ".", code: "Period", keyCode: 190, text: "." },
4163
+ ",": { key: ",", code: "Comma", keyCode: 188, text: "," },
4164
+ "/": { key: "/", code: "Slash", keyCode: 191, text: "/" },
4165
+ ";": { key: ";", code: "Semicolon", keyCode: 186, text: ";" },
4166
+ "'": { key: "'", code: "Quote", keyCode: 222, text: "'" },
4167
+ "[": { key: "[", code: "BracketLeft", keyCode: 219, text: "[" },
4168
+ "]": { key: "]", code: "BracketRight", keyCode: 221, text: "]" },
4169
+ "\\": { key: "\\", code: "Backslash", keyCode: 220, text: "\\" },
4170
+ "-": { key: "-", code: "Minus", keyCode: 189, text: "-" },
4171
+ "=": { key: "=", code: "Equal", keyCode: 187, text: "=" },
4172
+ "`": { key: "`", code: "Backquote", keyCode: 192, text: "`" },
4173
+ // Shifted punctuation
4174
+ "!": { key: "!", code: "Digit1", keyCode: 49, text: "!" },
4175
+ "@": { key: "@", code: "Digit2", keyCode: 50, text: "@" },
4176
+ "#": { key: "#", code: "Digit3", keyCode: 51, text: "#" },
4177
+ $: { key: "$", code: "Digit4", keyCode: 52, text: "$" },
4178
+ "%": { key: "%", code: "Digit5", keyCode: 53, text: "%" },
4179
+ "^": { key: "^", code: "Digit6", keyCode: 54, text: "^" },
4180
+ "&": { key: "&", code: "Digit7", keyCode: 55, text: "&" },
4181
+ "*": { key: "*", code: "Digit8", keyCode: 56, text: "*" },
4182
+ "(": { key: "(", code: "Digit9", keyCode: 57, text: "(" },
4183
+ ")": { key: ")", code: "Digit0", keyCode: 48, text: ")" },
4184
+ _: { key: "_", code: "Minus", keyCode: 189, text: "_" },
4185
+ "+": { key: "+", code: "Equal", keyCode: 187, text: "+" },
4186
+ "{": { key: "{", code: "BracketLeft", keyCode: 219, text: "{" },
4187
+ "}": { key: "}", code: "BracketRight", keyCode: 221, text: "}" },
4188
+ "|": { key: "|", code: "Backslash", keyCode: 220, text: "|" },
4189
+ ":": { key: ":", code: "Semicolon", keyCode: 186, text: ":" },
4190
+ '"': { key: '"', code: "Quote", keyCode: 222, text: '"' },
4191
+ "<": { key: "<", code: "Comma", keyCode: 188, text: "<" },
4192
+ ">": { key: ">", code: "Period", keyCode: 190, text: ">" },
4193
+ "?": { key: "?", code: "Slash", keyCode: 191, text: "?" },
4194
+ "~": { key: "~", code: "Backquote", keyCode: 192, text: "~" },
4195
+ // Special keys (non-text: use rawKeyDown, no text field)
4196
+ Enter: { key: "Enter", code: "Enter", keyCode: 13 },
4197
+ Tab: { key: "Tab", code: "Tab", keyCode: 9 },
4198
+ Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
4199
+ Delete: { key: "Delete", code: "Delete", keyCode: 46 },
4200
+ Escape: { key: "Escape", code: "Escape", keyCode: 27 },
4201
+ ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
4202
+ ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
4203
+ ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
4204
+ ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
4205
+ Home: { key: "Home", code: "Home", keyCode: 36 },
4206
+ End: { key: "End", code: "End", keyCode: 35 },
4207
+ PageUp: { key: "PageUp", code: "PageUp", keyCode: 33 },
4208
+ PageDown: { key: "PageDown", code: "PageDown", keyCode: 34 }
4209
+ };
4210
+ var MODIFIER_CODES = {
4211
+ Control: "ControlLeft",
4212
+ Shift: "ShiftLeft",
4213
+ Alt: "AltLeft",
4214
+ Meta: "MetaLeft"
4215
+ };
4216
+ var MODIFIER_KEY_CODES = {
4217
+ Control: 17,
4218
+ Shift: 16,
4219
+ Alt: 18,
4220
+ Meta: 91
4221
+ };
4222
+ function computeModifierBitmask(modifiers) {
4223
+ let mask = 0;
4224
+ if (modifiers.includes("Alt")) mask |= 1;
4225
+ if (modifiers.includes("Control")) mask |= 2;
4226
+ if (modifiers.includes("Meta")) mask |= 4;
4227
+ if (modifiers.includes("Shift")) mask |= 8;
4228
+ return mask;
4229
+ }
4230
+ function parseShortcut(combo) {
4231
+ const parts = combo.split("+");
4232
+ if (parts.length < 2) {
4233
+ throw new Error(
4234
+ `Invalid shortcut "${combo}": must contain at least one modifier and a key (e.g. "Control+a").`
4235
+ );
3311
4236
  }
3312
- const matches = fuzzyMatchElements(intent.text, candidates, maxHints * 2);
3313
- if (matches.length === 0) {
3314
- return [];
4237
+ const key = parts[parts.length - 1];
4238
+ const modifiers = [];
4239
+ const validModifiers = new Set(Object.keys(MODIFIER_CODES));
4240
+ for (let i = 0; i < parts.length - 1; i++) {
4241
+ const mod = parts[i];
4242
+ if (!validModifiers.has(mod)) {
4243
+ throw new Error(
4244
+ `Invalid modifier "${mod}" in shortcut "${combo}". Valid modifiers: ${[...validModifiers].join(", ")}`
4245
+ );
4246
+ }
4247
+ modifiers.push(mod);
3315
4248
  }
3316
- return diversifyHints(matches, maxHints);
4249
+ return { modifiers, key };
3317
4250
  }
3318
4251
 
3319
4252
  // src/browser/page.ts
3320
4253
  var DEFAULT_TIMEOUT2 = 3e4;
4254
+ var EVENT_LISTENER_TRACKER_SCRIPT = `(() => {
4255
+ if (globalThis.__bpEventListenerTrackerInstalled) return;
4256
+ Object.defineProperty(globalThis, '__bpEventListenerTrackerInstalled', {
4257
+ value: true,
4258
+ configurable: true,
4259
+ });
4260
+
4261
+ const storeKey = '__bpEventListeners';
4262
+ const originalAddEventListener = EventTarget.prototype.addEventListener;
4263
+ const originalRemoveEventListener = EventTarget.prototype.removeEventListener;
4264
+
4265
+ function ensureStore(target) {
4266
+ if (!Object.prototype.hasOwnProperty.call(target, storeKey)) {
4267
+ Object.defineProperty(target, storeKey, {
4268
+ value: Object.create(null),
4269
+ configurable: true,
4270
+ });
4271
+ }
4272
+ return target[storeKey];
4273
+ }
4274
+
4275
+ EventTarget.prototype.addEventListener = function(type, listener, options) {
4276
+ try {
4277
+ if (listener) {
4278
+ const store = ensureStore(this);
4279
+ const bucket = store[type] || (store[type] = []);
4280
+ const capture =
4281
+ typeof options === 'boolean' ? options : !!(options && options.capture);
4282
+ const exists = bucket.some((entry) => entry.listener === listener && entry.capture === capture);
4283
+ if (!exists) {
4284
+ bucket.push({ listener, capture });
4285
+ }
4286
+ }
4287
+ } catch {}
4288
+
4289
+ return originalAddEventListener.call(this, type, listener, options);
4290
+ };
4291
+
4292
+ EventTarget.prototype.removeEventListener = function(type, listener, options) {
4293
+ try {
4294
+ const store = this[storeKey];
4295
+ const bucket = store && store[type];
4296
+ const capture =
4297
+ typeof options === 'boolean' ? options : !!(options && options.capture);
4298
+ if (Array.isArray(bucket)) {
4299
+ store[type] = bucket.filter((entry) => {
4300
+ return !(entry.listener === listener && entry.capture === capture);
4301
+ });
4302
+ }
4303
+ } catch {}
4304
+
4305
+ return originalRemoveEventListener.call(this, type, listener, options);
4306
+ };
4307
+ })();`;
3321
4308
  var Page = class {
3322
4309
  cdp;
3323
4310
  _targetId;
@@ -3339,8 +4326,12 @@ var Page = class {
3339
4326
  frameExecutionContexts = /* @__PURE__ */ new Map();
3340
4327
  /** Current frame's execution context ID (null = main frame default) */
3341
4328
  currentFrameContextId = null;
4329
+ /** Frame selector if context acquisition failed (cross-origin/sandboxed) */
4330
+ brokenFrame = null;
3342
4331
  /** Last matched selector from findElement (for selectorUsed tracking) */
3343
4332
  _lastMatchedSelector;
4333
+ /** Last snapshot for stale ref recovery */
4334
+ lastSnapshot;
3344
4335
  /** Audio input controller (lazy-initialized) */
3345
4336
  _audioInput;
3346
4337
  /** Audio output controller (lazy-initialized) */
@@ -3385,17 +4376,34 @@ var Page = class {
3385
4376
  for (const [frameId, ctxId] of this.frameExecutionContexts.entries()) {
3386
4377
  if (ctxId === contextId) {
3387
4378
  this.frameExecutionContexts.delete(frameId);
4379
+ if (this.currentFrameContextId === contextId) {
4380
+ this.currentFrameContextId = null;
4381
+ }
3388
4382
  break;
3389
4383
  }
3390
4384
  }
3391
4385
  });
3392
- this.cdp.on("Page.javascriptDialogOpening", this.handleDialogOpening.bind(this));
4386
+ this.cdp.on("Page.javascriptDialogOpening", (params) => {
4387
+ void this.handleDialogOpening(params);
4388
+ });
3393
4389
  await Promise.all([
3394
4390
  this.cdp.send("Page.enable"),
3395
4391
  this.cdp.send("DOM.enable"),
3396
4392
  this.cdp.send("Runtime.enable"),
3397
4393
  this.cdp.send("Network.enable")
3398
4394
  ]);
4395
+ await this.installEventListenerTracker();
4396
+ }
4397
+ async installEventListenerTracker() {
4398
+ await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
4399
+ source: EVENT_LISTENER_TRACKER_SCRIPT
4400
+ });
4401
+ try {
4402
+ await this.cdp.send("Runtime.evaluate", {
4403
+ expression: EVENT_LISTENER_TRACKER_SCRIPT
4404
+ });
4405
+ } catch {
4406
+ }
3399
4407
  }
3400
4408
  // ============ Navigation ============
3401
4409
  /**
@@ -3411,6 +4419,9 @@ var Page = class {
3411
4419
  }
3412
4420
  this.rootNodeId = null;
3413
4421
  this.refMap.clear();
4422
+ this.currentFrame = null;
4423
+ this.currentFrameContextId = null;
4424
+ this.frameContexts.clear();
3414
4425
  }
3415
4426
  /**
3416
4427
  * Get the current URL
@@ -3481,8 +4492,9 @@ var Page = class {
3481
4492
  /**
3482
4493
  * Click an element (supports multi-selector)
3483
4494
  *
3484
- * Uses CDP mouse events for regular elements. For form submit buttons,
3485
- * uses dispatchEvent to reliably trigger form submission in headless Chrome.
4495
+ * Uses CDP mouse events (mouseMoved + mousePressed + mouseReleased) to
4496
+ * simulate a real click. Real mouse events on submit buttons naturally
4497
+ * trigger native form submission — no JS dispatch needed.
3486
4498
  */
3487
4499
  async click(selector, options = {}) {
3488
4500
  return this.withStaleNodeRetry(async () => {
@@ -3494,27 +4506,55 @@ var Page = class {
3494
4506
  throw new ElementNotFoundError(selector, hints);
3495
4507
  }
3496
4508
  await this.scrollIntoView(element.nodeId);
3497
- const submitResult = await this.evaluateInFrame(
3498
- `(() => {
3499
- const el = document.querySelector(${JSON.stringify(element.selector)});
3500
- if (!el) return { isSubmit: false };
3501
-
3502
- // Check if this is a form submit button
3503
- const isSubmitButton = (el instanceof HTMLButtonElement && (el.type === 'submit' || (el.form && el.type !== 'button'))) ||
3504
- (el instanceof HTMLInputElement && el.type === 'submit');
3505
-
3506
- if (isSubmitButton && el.form) {
3507
- // Dispatch submit event directly - works reliably in headless Chrome
3508
- el.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
3509
- return { isSubmit: true };
4509
+ const objectId = await this.resolveObjectId(element.nodeId);
4510
+ try {
4511
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled", "stable"], {
4512
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4513
+ });
4514
+ } catch (e) {
4515
+ if (options.optional) return false;
4516
+ throw e;
4517
+ }
4518
+ let clickX;
4519
+ let clickY;
4520
+ try {
4521
+ const { quads } = await this.cdp.send("DOM.getContentQuads", {
4522
+ objectId
4523
+ });
4524
+ if (quads?.length > 0) {
4525
+ const quad = quads[0];
4526
+ clickX = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
4527
+ clickY = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
4528
+ } else {
4529
+ throw new Error("No quads");
4530
+ }
4531
+ } catch {
4532
+ const box = await this.getBoxModel(element.nodeId);
4533
+ if (!box) throw new Error("Could not get element position");
4534
+ clickX = box.content[0] + box.width / 2;
4535
+ clickY = box.content[1] + box.height / 2;
4536
+ }
4537
+ const hitTargetCoordinates = this.currentFrame ? void 0 : { x: clickX, y: clickY };
4538
+ const HIT_TARGET_RETRIES = 3;
4539
+ const HIT_TARGET_DELAY = 100;
4540
+ for (let attempt = 0; attempt < HIT_TARGET_RETRIES; attempt++) {
4541
+ try {
4542
+ await ensureActionable(this.cdp, objectId, ["hitTarget"], {
4543
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2,
4544
+ coordinates: hitTargetCoordinates
4545
+ });
4546
+ break;
4547
+ } catch (e) {
4548
+ if (options.optional) return false;
4549
+ if (e instanceof ActionabilityError && e.failureType === "hitTarget" && attempt < HIT_TARGET_RETRIES - 1) {
4550
+ await sleep5(HIT_TARGET_DELAY);
4551
+ await this.cdp.send("DOM.scrollIntoViewIfNeeded", { nodeId: element.nodeId });
4552
+ continue;
3510
4553
  }
3511
- return { isSubmit: false };
3512
- })()`
3513
- );
3514
- const isSubmit = submitResult.result.value?.isSubmit;
3515
- if (!isSubmit) {
3516
- await this.clickElement(element.nodeId);
4554
+ throw e;
4555
+ }
3517
4556
  }
4557
+ await this.clickElement(element.nodeId);
3518
4558
  return true;
3519
4559
  });
3520
4560
  }
@@ -3522,7 +4562,7 @@ var Page = class {
3522
4562
  * Fill an input field (clears first by default)
3523
4563
  */
3524
4564
  async fill(selector, value, options = {}) {
3525
- const { clear = true, blur = false } = options;
4565
+ const { blur = false } = options;
3526
4566
  return this.withStaleNodeRetry(async () => {
3527
4567
  const element = await this.findElement(selector, options);
3528
4568
  if (!element) {
@@ -3531,71 +4571,158 @@ var Page = class {
3531
4571
  const hints = await generateHints(this, selectorList, "fill");
3532
4572
  throw new ElementNotFoundError(selector, hints);
3533
4573
  }
3534
- await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
3535
- if (clear) {
3536
- await this.evaluateInFrame(
3537
- `(() => {
3538
- const el = document.querySelector(${JSON.stringify(element.selector)});
3539
- if (el) {
3540
- el.value = '';
3541
- el.dispatchEvent(new InputEvent('input', {
3542
- bubbles: true,
3543
- cancelable: true,
3544
- inputType: 'deleteContent'
3545
- }));
3546
- }
3547
- })()`
3548
- );
4574
+ const { object } = await this.cdp.send("DOM.resolveNode", {
4575
+ nodeId: element.nodeId
4576
+ });
4577
+ const objectId = object.objectId;
4578
+ try {
4579
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled", "editable"], {
4580
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4581
+ });
4582
+ } catch (e) {
4583
+ if (options.optional) return false;
4584
+ throw e;
3549
4585
  }
3550
- await this.cdp.send("Input.insertText", { text: value });
3551
- await this.evaluateInFrame(
3552
- `(() => {
3553
- const el = document.querySelector(${JSON.stringify(element.selector)});
3554
- if (el) {
3555
- el.dispatchEvent(new InputEvent('input', {
3556
- bubbles: true,
3557
- cancelable: true,
3558
- inputType: 'insertText',
3559
- data: ${JSON.stringify(value)}
3560
- }));
3561
- el.dispatchEvent(new Event('change', { bubbles: true }));
4586
+ const tagInfo = await this.cdp.send("Runtime.callFunctionOn", {
4587
+ objectId,
4588
+ functionDeclaration: `function() {
4589
+ return { tagName: this.tagName?.toLowerCase() || '', inputType: (this.type || '').toLowerCase() };
4590
+ }`,
4591
+ returnByValue: true
4592
+ });
4593
+ const { tagName, inputType } = tagInfo.result.value;
4594
+ const specialInputTypes = /* @__PURE__ */ new Set([
4595
+ "date",
4596
+ "datetime-local",
4597
+ "month",
4598
+ "week",
4599
+ "time",
4600
+ "color",
4601
+ "range",
4602
+ "file"
4603
+ ]);
4604
+ const isSpecialInput = tagName === "input" && specialInputTypes.has(inputType);
4605
+ if (isSpecialInput) {
4606
+ await this.cdp.send("Runtime.callFunctionOn", {
4607
+ objectId,
4608
+ functionDeclaration: `function(val) {
4609
+ this.value = val;
4610
+ this.dispatchEvent(new Event('input', { bubbles: true }));
4611
+ this.dispatchEvent(new Event('change', { bubbles: true }));
4612
+ }`,
4613
+ arguments: [{ value }],
4614
+ returnByValue: true
4615
+ });
4616
+ } else {
4617
+ await this.selectEditableContent(objectId);
4618
+ if (value === "") {
4619
+ await this.dispatchKey("Delete");
4620
+ } else {
4621
+ await this.cdp.send("Input.insertText", { text: value });
4622
+ }
4623
+ }
4624
+ if (options.verify !== false) {
4625
+ let actualValue = await this.readEditableValue(objectId);
4626
+ if (actualValue !== value && !isSpecialInput) {
4627
+ if (value === "") {
4628
+ await this.clearEditableSelection(objectId, "Backspace");
4629
+ } else {
4630
+ await this.typeEditableFallback(element.nodeId, objectId, value);
3562
4631
  }
3563
- })()`
3564
- );
4632
+ actualValue = await this.readEditableValue(objectId);
4633
+ }
4634
+ if (actualValue !== value) {
4635
+ if (options.optional) return false;
4636
+ throw new Error(
4637
+ `Fill value did not stick. Expected ${JSON.stringify(value)} but got ${JSON.stringify(actualValue)}.`
4638
+ );
4639
+ }
4640
+ }
3565
4641
  if (blur) {
3566
- await this.evaluateInFrame(
3567
- `document.querySelector(${JSON.stringify(element.selector)})?.blur()`
3568
- );
4642
+ await this.cdp.send("Runtime.callFunctionOn", {
4643
+ objectId,
4644
+ functionDeclaration: "function() { this.blur(); }"
4645
+ });
3569
4646
  }
3570
4647
  return true;
3571
4648
  });
3572
4649
  }
3573
4650
  /**
3574
4651
  * Type text character by character (for autocomplete fields, etc.)
4652
+ *
4653
+ * Uses proper keyDown/rawKeyDown distinction with US keyboard layout.
4654
+ * Printable chars use 'keyDown' with text, non-text keys use 'rawKeyDown',
4655
+ * and non-layout chars (emoji, CJK) fall back to Input.insertText.
3575
4656
  */
3576
4657
  async type(selector, text, options = {}) {
3577
- const { delay = 50 } = options;
3578
- const element = await this.findElement(selector, options);
3579
- if (!element) {
3580
- if (options.optional) return false;
3581
- throw new ElementNotFoundError(selector);
3582
- }
3583
- await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
3584
- for (const char of text) {
3585
- await this.cdp.send("Input.dispatchKeyEvent", {
3586
- type: "keyDown",
3587
- key: char,
3588
- text: char
3589
- });
3590
- await this.cdp.send("Input.dispatchKeyEvent", {
3591
- type: "keyUp",
3592
- key: char
3593
- });
3594
- if (delay > 0) {
3595
- await sleep3(delay);
4658
+ return this.withStaleNodeRetry(async () => {
4659
+ const { delay = 50 } = options;
4660
+ const element = await this.findElement(selector, options);
4661
+ if (!element) {
4662
+ if (options.optional) return false;
4663
+ throw new ElementNotFoundError(selector);
3596
4664
  }
3597
- }
3598
- return true;
4665
+ const objectId = await this.resolveObjectId(element.nodeId);
4666
+ try {
4667
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled"], {
4668
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4669
+ });
4670
+ } catch (e) {
4671
+ if (options.optional) return false;
4672
+ throw e;
4673
+ }
4674
+ await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
4675
+ for (const char of text) {
4676
+ const def = US_KEYBOARD[char];
4677
+ if (def) {
4678
+ if (def.text !== void 0) {
4679
+ await this.cdp.send("Input.dispatchKeyEvent", {
4680
+ type: "keyDown",
4681
+ key: def.key,
4682
+ code: def.code,
4683
+ text: def.text,
4684
+ unmodifiedText: def.text,
4685
+ windowsVirtualKeyCode: def.keyCode,
4686
+ modifiers: 0,
4687
+ autoRepeat: false,
4688
+ location: def.location ?? 0,
4689
+ isKeypad: false
4690
+ });
4691
+ } else {
4692
+ await this.cdp.send("Input.dispatchKeyEvent", {
4693
+ type: "rawKeyDown",
4694
+ key: def.key,
4695
+ code: def.code,
4696
+ windowsVirtualKeyCode: def.keyCode,
4697
+ modifiers: 0,
4698
+ autoRepeat: false,
4699
+ location: def.location ?? 0,
4700
+ isKeypad: false
4701
+ });
4702
+ }
4703
+ await this.cdp.send("Input.dispatchKeyEvent", {
4704
+ type: "keyUp",
4705
+ key: def.key,
4706
+ code: def.code,
4707
+ windowsVirtualKeyCode: def.keyCode,
4708
+ modifiers: 0,
4709
+ location: def.location ?? 0
4710
+ });
4711
+ } else {
4712
+ await this.cdp.send("Input.insertText", { text: char });
4713
+ }
4714
+ if (delay > 0) {
4715
+ await sleep5(delay);
4716
+ }
4717
+ }
4718
+ if (options.blur) {
4719
+ await this.cdp.send("Runtime.callFunctionOn", {
4720
+ objectId,
4721
+ functionDeclaration: "function() { this.blur(); }"
4722
+ });
4723
+ }
4724
+ return true;
4725
+ });
3599
4726
  }
3600
4727
  async select(selectorOrConfig, valueOrOptions, maybeOptions) {
3601
4728
  if (typeof selectorOrConfig === "object" && !Array.isArray(selectorOrConfig) && "trigger" in selectorOrConfig) {
@@ -3604,108 +4731,231 @@ var Page = class {
3604
4731
  const selector = selectorOrConfig;
3605
4732
  const value = valueOrOptions;
3606
4733
  const options = maybeOptions ?? {};
3607
- const element = await this.findElement(selector, options);
3608
- if (!element) {
3609
- if (options.optional) return false;
3610
- const selectorList = Array.isArray(selector) ? selector : [selector];
3611
- const hints = await generateHints(this, selectorList, "select");
3612
- throw new ElementNotFoundError(selector, hints);
3613
- }
3614
- const values = Array.isArray(value) ? value : [value];
3615
- await this.cdp.send("Runtime.evaluate", {
3616
- expression: `(() => {
3617
- const el = document.querySelector(${JSON.stringify(element.selector)});
3618
- if (!el || el.tagName !== 'SELECT') return false;
3619
- const values = ${JSON.stringify(values)};
3620
- for (const opt of el.options) {
3621
- opt.selected = values.includes(opt.value) || values.includes(opt.text);
3622
- }
3623
- el.dispatchEvent(new Event('change', { bubbles: true }));
4734
+ return this.withStaleNodeRetry(async () => {
4735
+ const element = await this.findElement(selector, options);
4736
+ if (!element) {
4737
+ if (options.optional) return false;
4738
+ const selectorList = Array.isArray(selector) ? selector : [selector];
4739
+ const hints = await generateHints(this, selectorList, "select");
4740
+ throw new ElementNotFoundError(selector, hints);
4741
+ }
4742
+ const values = Array.isArray(value) ? value : [value];
4743
+ const objectId = await this.resolveObjectId(element.nodeId);
4744
+ try {
4745
+ await this.scrollIntoView(element.nodeId);
4746
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled"], {
4747
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4748
+ });
4749
+ } catch (e) {
4750
+ if (options.optional) return false;
4751
+ throw e;
4752
+ }
4753
+ const metadata = await this.getNativeSelectMetadata(objectId, values);
4754
+ if (!metadata.isSelect) {
4755
+ throw new Error("select() target must be a native <select> element");
4756
+ }
4757
+ if (metadata.missing.length > 0) {
4758
+ throw new Error(`No option found for: ${metadata.missing.join(", ")}`);
4759
+ }
4760
+ if (metadata.disabled.length > 0) {
4761
+ throw new Error(`Cannot select disabled option(s): ${metadata.disabled.join(", ")}`);
4762
+ }
4763
+ if (!metadata.multiple && metadata.targetIndexes.length > 1) {
4764
+ throw new Error("Cannot select multiple values on a single-select element");
4765
+ }
4766
+ const expectedValues = metadata.targetIndexes.map((idx) => metadata.options[idx].value);
4767
+ if (this.selectValuesMatch(metadata.selectedValues, expectedValues, metadata.multiple)) {
3624
4768
  return true;
3625
- })()`,
3626
- returnByValue: true
4769
+ }
4770
+ if (!metadata.multiple && metadata.targetIndexes.length === 1) {
4771
+ await this.applyNativeSelectByKeyboard(
4772
+ element.nodeId,
4773
+ objectId,
4774
+ metadata.currentIndex,
4775
+ metadata.targetIndexes[0]
4776
+ );
4777
+ }
4778
+ let selectedValues = await this.readNativeSelectValues(objectId);
4779
+ if (!this.selectValuesMatch(selectedValues, expectedValues, metadata.multiple)) {
4780
+ await this.applyNativeSelectFallback(objectId, metadata.targetIndexes);
4781
+ selectedValues = await this.readNativeSelectValues(objectId);
4782
+ }
4783
+ if (!this.selectValuesMatch(selectedValues, expectedValues, metadata.multiple)) {
4784
+ await this.applyRecordedSelectFallback(objectId, metadata.targetIndexes);
4785
+ selectedValues = await this.readNativeSelectValues(objectId);
4786
+ }
4787
+ if (!this.selectValuesMatch(selectedValues, expectedValues, metadata.multiple)) {
4788
+ if (options.optional) return false;
4789
+ throw new Error(
4790
+ `Select value did not stick. Expected ${expectedValues.join(", ") || "(empty)"} but got ${selectedValues.join(", ") || "(empty)"}.`
4791
+ );
4792
+ }
4793
+ return true;
3627
4794
  });
3628
- return true;
3629
4795
  }
3630
4796
  /**
3631
4797
  * Handle custom (non-native) select/dropdown components
3632
4798
  */
3633
4799
  async selectCustom(config, options = {}) {
3634
4800
  const { trigger, option, value, match = "text" } = config;
3635
- await this.click(trigger, options);
3636
- await sleep3(100);
3637
- let optionSelector;
3638
- const optionSelectors = Array.isArray(option) ? option : [option];
3639
- if (match === "contains") {
3640
- optionSelector = optionSelectors.map((s) => `${s}:has-text("${value}")`).join(", ");
3641
- } else if (match === "value") {
3642
- optionSelector = optionSelectors.map((s) => `${s}[data-value="${value}"], ${s}[value="${value}"]`).join(", ");
3643
- } else {
3644
- optionSelector = optionSelectors.map((s) => `${s}`).join(", ");
3645
- }
3646
- const result = await this.cdp.send("Runtime.evaluate", {
3647
- expression: `(() => {
3648
- const options = document.querySelectorAll(${JSON.stringify(optionSelector)});
3649
- for (const opt of options) {
3650
- const text = opt.textContent?.trim();
3651
- if (${match === "text" ? `text === ${JSON.stringify(value)}` : match === "contains" ? `text?.includes(${JSON.stringify(value)})` : "true"}) {
3652
- opt.click();
3653
- return true;
4801
+ return this.withStaleNodeRetry(async () => {
4802
+ await this.click(trigger, options);
4803
+ const optionSelectors = Array.isArray(option) ? option : [option];
4804
+ await waitForAnyElement(this.cdp, optionSelectors, {
4805
+ state: "visible",
4806
+ timeout: 500,
4807
+ contextId: this.currentFrameContextId ?? void 0
4808
+ }).catch(() => sleep5(100));
4809
+ const optionHandle = await this.evaluateInFrame(
4810
+ `(() => {
4811
+ const selectors = ${JSON.stringify(optionSelectors)};
4812
+ const wanted = ${JSON.stringify(value)};
4813
+ const mode = ${JSON.stringify(match)};
4814
+
4815
+ for (const selector of selectors) {
4816
+ const candidates = document.querySelectorAll(selector);
4817
+ for (const candidate of candidates) {
4818
+ const text = candidate.textContent?.trim() || '';
4819
+ const candidateValue =
4820
+ candidate.getAttribute?.('data-value') ??
4821
+ candidate.getAttribute?.('value') ??
4822
+ candidate.value ??
4823
+ '';
4824
+ const matches =
4825
+ mode === 'value'
4826
+ ? candidateValue === wanted
4827
+ : mode === 'contains'
4828
+ ? text.includes(wanted)
4829
+ : text === wanted;
4830
+
4831
+ if (matches) {
4832
+ return candidate;
4833
+ }
4834
+ }
3654
4835
  }
4836
+
4837
+ return null;
4838
+ })()`,
4839
+ { returnByValue: false }
4840
+ );
4841
+ if (!optionHandle.result.objectId) {
4842
+ if (options.optional) return false;
4843
+ throw new ElementNotFoundError(`Option with ${match} "${value}"`);
4844
+ }
4845
+ const nodeResult = await this.cdp.send("DOM.requestNode", {
4846
+ objectId: optionHandle.result.objectId
4847
+ });
4848
+ if (!nodeResult.nodeId) {
4849
+ if (options.optional) return false;
4850
+ throw new ElementNotFoundError(`Option with ${match} "${value}"`);
4851
+ }
4852
+ await this.scrollIntoView(nodeResult.nodeId);
4853
+ await ensureActionable(
4854
+ this.cdp,
4855
+ optionHandle.result.objectId,
4856
+ ["visible", "enabled", "stable"],
4857
+ {
4858
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
3655
4859
  }
3656
- return false;
3657
- })()`,
3658
- returnByValue: true
3659
- });
3660
- if (!result.result.value) {
3661
- if (options.optional) return false;
3662
- throw new ElementNotFoundError(`Option with ${match} "${value}"`);
3663
- }
3664
- return true;
3665
- }
3666
- /**
3667
- * Check a checkbox or radio button
3668
- */
3669
- async check(selector, options = {}) {
3670
- const element = await this.findElement(selector, options);
3671
- if (!element) {
3672
- if (options.optional) return false;
3673
- const selectorList = Array.isArray(selector) ? selector : [selector];
3674
- const hints = await generateHints(this, selectorList, "check");
3675
- throw new ElementNotFoundError(selector, hints);
3676
- }
3677
- const result = await this.cdp.send("Runtime.evaluate", {
3678
- expression: `(() => {
3679
- const el = document.querySelector(${JSON.stringify(element.selector)});
3680
- if (!el) return false;
3681
- if (!el.checked) el.click();
3682
- return true;
3683
- })()`,
3684
- returnByValue: true
4860
+ );
4861
+ await this.clickElement(nodeResult.nodeId);
4862
+ return true;
4863
+ });
4864
+ }
4865
+ /**
4866
+ * Check a checkbox or radio button using real mouse click.
4867
+ * No-op if already checked. Verifies state changed after click.
4868
+ */
4869
+ async check(selector, options = {}) {
4870
+ return this.withStaleNodeRetry(async () => {
4871
+ const element = await this.findElement(selector, options);
4872
+ if (!element) {
4873
+ if (options.optional) return false;
4874
+ const selectorList = Array.isArray(selector) ? selector : [selector];
4875
+ const hints = await generateHints(this, selectorList, "check");
4876
+ throw new ElementNotFoundError(selector, hints);
4877
+ }
4878
+ const { object } = await this.cdp.send("DOM.resolveNode", {
4879
+ nodeId: element.nodeId
4880
+ });
4881
+ try {
4882
+ await ensureActionable(this.cdp, object.objectId, ["visible", "enabled"], {
4883
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4884
+ });
4885
+ } catch (e) {
4886
+ if (options.optional) return false;
4887
+ throw e;
4888
+ }
4889
+ const before = await this.cdp.send("Runtime.callFunctionOn", {
4890
+ objectId: object.objectId,
4891
+ functionDeclaration: "function() { return !!this.checked; }",
4892
+ returnByValue: true
4893
+ });
4894
+ if (before.result.value) return true;
4895
+ await this.scrollIntoView(element.nodeId);
4896
+ await this.clickElement(element.nodeId);
4897
+ const after = await this.cdp.send("Runtime.callFunctionOn", {
4898
+ objectId: object.objectId,
4899
+ functionDeclaration: "function() { return !!this.checked; }",
4900
+ returnByValue: true
4901
+ });
4902
+ if (!after.result.value) {
4903
+ throw new Error("Clicking the checkbox did not change its state");
4904
+ }
4905
+ return true;
3685
4906
  });
3686
- return result.result.value;
3687
4907
  }
3688
4908
  /**
3689
- * Uncheck a checkbox
4909
+ * Uncheck a checkbox using real mouse click.
4910
+ * No-op if already unchecked. Radio buttons can't be unchecked (returns true).
3690
4911
  */
3691
4912
  async uncheck(selector, options = {}) {
3692
- const element = await this.findElement(selector, options);
3693
- if (!element) {
3694
- if (options.optional) return false;
3695
- const selectorList = Array.isArray(selector) ? selector : [selector];
3696
- const hints = await generateHints(this, selectorList, "uncheck");
3697
- throw new ElementNotFoundError(selector, hints);
3698
- }
3699
- const result = await this.cdp.send("Runtime.evaluate", {
3700
- expression: `(() => {
3701
- const el = document.querySelector(${JSON.stringify(element.selector)});
3702
- if (!el) return false;
3703
- if (el.checked) el.click();
3704
- return true;
3705
- })()`,
3706
- returnByValue: true
4913
+ return this.withStaleNodeRetry(async () => {
4914
+ const element = await this.findElement(selector, options);
4915
+ if (!element) {
4916
+ if (options.optional) return false;
4917
+ const selectorList = Array.isArray(selector) ? selector : [selector];
4918
+ const hints = await generateHints(this, selectorList, "uncheck");
4919
+ throw new ElementNotFoundError(selector, hints);
4920
+ }
4921
+ const { object } = await this.cdp.send("DOM.resolveNode", {
4922
+ nodeId: element.nodeId
4923
+ });
4924
+ try {
4925
+ await ensureActionable(this.cdp, object.objectId, ["visible", "enabled"], {
4926
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4927
+ });
4928
+ } catch (e) {
4929
+ if (options.optional) return false;
4930
+ throw e;
4931
+ }
4932
+ const isRadio = await this.cdp.send(
4933
+ "Runtime.callFunctionOn",
4934
+ {
4935
+ objectId: object.objectId,
4936
+ functionDeclaration: 'function() { return this.type === "radio"; }',
4937
+ returnByValue: true
4938
+ }
4939
+ );
4940
+ if (isRadio.result.value) return true;
4941
+ const before = await this.cdp.send("Runtime.callFunctionOn", {
4942
+ objectId: object.objectId,
4943
+ functionDeclaration: "function() { return !!this.checked; }",
4944
+ returnByValue: true
4945
+ });
4946
+ if (!before.result.value) return true;
4947
+ await this.scrollIntoView(element.nodeId);
4948
+ await this.clickElement(element.nodeId);
4949
+ const after = await this.cdp.send("Runtime.callFunctionOn", {
4950
+ objectId: object.objectId,
4951
+ functionDeclaration: "function() { return !!this.checked; }",
4952
+ returnByValue: true
4953
+ });
4954
+ if (after.result.value) {
4955
+ throw new Error("Clicking the checkbox did not change its state");
4956
+ }
4957
+ return true;
3707
4958
  });
3708
- return result.result.value;
3709
4959
  }
3710
4960
  /**
3711
4961
  * Submit a form (tries Enter key first, then click)
@@ -3719,97 +4969,100 @@ var Page = class {
3719
4969
  * the submit event and triggers HTML5 validation.
3720
4970
  */
3721
4971
  async submit(selector, options = {}) {
3722
- const { method = "enter+click", waitForNavigation: shouldWait = "auto" } = options;
3723
- const element = await this.findElement(selector, options);
3724
- if (!element) {
3725
- if (options.optional) return false;
3726
- const selectorList = Array.isArray(selector) ? selector : [selector];
3727
- const hints = await generateHints(this, selectorList, "submit");
3728
- throw new ElementNotFoundError(selector, hints);
3729
- }
3730
- const isFormElement = await this.evaluateInFrame(
3731
- `(() => {
3732
- const el = document.querySelector(${JSON.stringify(element.selector)});
3733
- return el instanceof HTMLFormElement;
3734
- })()`
3735
- );
3736
- if (isFormElement.result.value) {
3737
- await this.evaluateInFrame(
3738
- `(() => {
3739
- const form = document.querySelector(${JSON.stringify(element.selector)});
3740
- if (form && form instanceof HTMLFormElement) {
3741
- form.requestSubmit();
3742
- }
3743
- })()`
3744
- );
3745
- if (shouldWait === true) {
3746
- await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3747
- } else if (shouldWait === "auto") {
3748
- await Promise.race([this.waitForNavigation({ timeout: 1e3, optional: true }), sleep3(500)]);
4972
+ return this.withStaleNodeRetry(async () => {
4973
+ const { method = "enter+click", waitForNavigation: shouldWait = "auto" } = options;
4974
+ const element = await this.findElement(selector, options);
4975
+ if (!element) {
4976
+ if (options.optional) return false;
4977
+ const selectorList = Array.isArray(selector) ? selector : [selector];
4978
+ const hints = await generateHints(this, selectorList, "submit");
4979
+ throw new ElementNotFoundError(selector, hints);
3749
4980
  }
3750
- return true;
3751
- }
3752
- await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
3753
- if (method.includes("enter")) {
3754
- await this.press("Enter");
3755
- if (shouldWait === true) {
3756
- try {
4981
+ const objectId = await this.resolveObjectId(element.nodeId);
4982
+ const isFormElement = await this.cdp.send(
4983
+ "Runtime.callFunctionOn",
4984
+ {
4985
+ objectId,
4986
+ functionDeclaration: "function() { return this instanceof HTMLFormElement; }",
4987
+ returnByValue: true
4988
+ }
4989
+ );
4990
+ if (isFormElement.result.value) {
4991
+ await this.cdp.send("Runtime.callFunctionOn", {
4992
+ objectId,
4993
+ functionDeclaration: `function() {
4994
+ if (typeof this.requestSubmit === 'function') {
4995
+ this.requestSubmit();
4996
+ } else {
4997
+ this.submit();
4998
+ }
4999
+ }`
5000
+ });
5001
+ if (shouldWait === true) {
3757
5002
  await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3758
- return true;
3759
- } catch {
5003
+ } else if (shouldWait === "auto") {
5004
+ await Promise.race([
5005
+ this.waitForNavigation({ timeout: 2e3, optional: true }).then(
5006
+ () => "navigation"
5007
+ ),
5008
+ this.waitForDOMMutation({ timeout: 1e3 }).then(() => "mutation"),
5009
+ sleep5(1500).then(() => "timeout")
5010
+ ]);
3760
5011
  }
3761
- } else if (shouldWait === "auto") {
3762
- const navigationDetected = await Promise.race([
3763
- this.waitForNavigation({ timeout: 1e3, optional: true }).then(
3764
- (success) => success ? "nav" : null
3765
- ),
3766
- sleep3(500).then(() => "timeout")
3767
- ]);
3768
- if (navigationDetected === "nav") {
5012
+ return true;
5013
+ }
5014
+ await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
5015
+ if (method.includes("enter")) {
5016
+ await this.press("Enter");
5017
+ if (shouldWait === true) {
5018
+ try {
5019
+ await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
5020
+ return true;
5021
+ } catch {
5022
+ }
5023
+ } else if (shouldWait === "auto") {
5024
+ const navigationDetected = await Promise.race([
5025
+ this.waitForNavigation({ timeout: 2e3, optional: true }).then(
5026
+ (success) => success ? "nav" : null
5027
+ ),
5028
+ this.waitForDOMMutation({ timeout: 1e3 }).then(() => "mutation"),
5029
+ sleep5(1500).then(() => "timeout")
5030
+ ]);
5031
+ if (navigationDetected === "nav") {
5032
+ return true;
5033
+ }
5034
+ } else if (method === "enter") {
3769
5035
  return true;
3770
5036
  }
3771
- } else {
3772
- if (method === "enter") return true;
3773
5037
  }
3774
- }
3775
- if (method.includes("click")) {
3776
- await this.click(element.selector, { ...options, optional: false });
3777
- if (shouldWait === true) {
3778
- await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3779
- } else if (shouldWait === "auto") {
3780
- await sleep3(100);
5038
+ if (method.includes("click")) {
5039
+ await this.click(element.selector, { ...options, optional: false });
5040
+ if (shouldWait === true) {
5041
+ await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
5042
+ } else if (shouldWait === "auto") {
5043
+ await sleep5(100);
5044
+ }
3781
5045
  }
5046
+ return true;
5047
+ });
5048
+ }
5049
+ /**
5050
+ * Press a key, optionally with modifier keys held down
5051
+ */
5052
+ async press(key, options) {
5053
+ const modifiers = options?.modifiers;
5054
+ if (modifiers && modifiers.length > 0) {
5055
+ await this.dispatchKeyWithModifiers(key, modifiers);
5056
+ } else {
5057
+ await this.dispatchKey(key);
3782
5058
  }
3783
- return true;
3784
5059
  }
3785
5060
  /**
3786
- * Press a key
5061
+ * Execute a keyboard shortcut (e.g. "Control+a", "Meta+Shift+z")
3787
5062
  */
3788
- async press(key) {
3789
- const keyMap = {
3790
- Enter: { key: "Enter", code: "Enter", keyCode: 13 },
3791
- Tab: { key: "Tab", code: "Tab", keyCode: 9 },
3792
- Escape: { key: "Escape", code: "Escape", keyCode: 27 },
3793
- Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
3794
- Delete: { key: "Delete", code: "Delete", keyCode: 46 },
3795
- ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
3796
- ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
3797
- ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
3798
- ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 }
3799
- };
3800
- const keyInfo = keyMap[key] ?? { key, code: key, keyCode: 0 };
3801
- await this.cdp.send("Input.dispatchKeyEvent", {
3802
- type: "keyDown",
3803
- key: keyInfo.key,
3804
- code: keyInfo.code,
3805
- windowsVirtualKeyCode: keyInfo.keyCode
3806
- });
3807
- await this.cdp.send("Input.dispatchKeyEvent", {
3808
- type: "keyUp",
3809
- key: keyInfo.key,
3810
- code: keyInfo.code,
3811
- windowsVirtualKeyCode: keyInfo.keyCode
3812
- });
5063
+ async shortcut(combo) {
5064
+ const { modifiers, key } = parseShortcut(combo);
5065
+ await this.dispatchKeyWithModifiers(key, modifiers);
3813
5066
  }
3814
5067
  /**
3815
5068
  * Focus an element
@@ -3838,13 +5091,37 @@ var Page = class {
3838
5091
  throw new ElementNotFoundError(selector, hints);
3839
5092
  }
3840
5093
  await this.scrollIntoView(element.nodeId);
3841
- const box = await this.getBoxModel(element.nodeId);
3842
- if (!box) {
5094
+ const objectId = await this.resolveObjectId(element.nodeId);
5095
+ try {
5096
+ await ensureActionable(this.cdp, objectId, ["visible", "stable"], {
5097
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
5098
+ });
5099
+ } catch (e) {
3843
5100
  if (options.optional) return false;
3844
- throw new Error("Could not get element box model");
5101
+ throw e;
5102
+ }
5103
+ let x;
5104
+ let y;
5105
+ try {
5106
+ const { quads } = await this.cdp.send("DOM.getContentQuads", {
5107
+ objectId
5108
+ });
5109
+ if (quads?.length > 0) {
5110
+ const quad = quads[0];
5111
+ x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
5112
+ y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
5113
+ } else {
5114
+ throw new Error("No quads");
5115
+ }
5116
+ } catch {
5117
+ const box = await this.getBoxModel(element.nodeId);
5118
+ if (!box) {
5119
+ if (options.optional) return false;
5120
+ throw new Error("Could not get element position");
5121
+ }
5122
+ x = box.content[0] + box.width / 2;
5123
+ y = box.content[1] + box.height / 2;
3845
5124
  }
3846
- const x = box.content[0] + box.width / 2;
3847
- const y = box.content[1] + box.height / 2;
3848
5125
  await this.cdp.send("Input.dispatchMouseEvent", {
3849
5126
  type: "mouseMoved",
3850
5127
  x,
@@ -3902,15 +5179,19 @@ var Page = class {
3902
5179
  if (descResult.node.frameId) {
3903
5180
  const frameId = descResult.node.frameId;
3904
5181
  const { timeout = DEFAULT_TIMEOUT2 } = options;
3905
- const pollInterval = 50;
3906
- const deadline = Date.now() + timeout;
3907
5182
  let contextId = this.frameExecutionContexts.get(frameId);
3908
- while (!contextId && Date.now() < deadline) {
3909
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
3910
- contextId = this.frameExecutionContexts.get(frameId);
5183
+ if (!contextId) {
5184
+ contextId = await this.waitForFrameContext(frameId, Math.min(timeout, 2e3));
3911
5185
  }
3912
5186
  if (contextId) {
3913
5187
  this.currentFrameContextId = contextId;
5188
+ this.brokenFrame = null;
5189
+ } else {
5190
+ const frameKey2 = Array.isArray(selector) ? selector[0] : selector;
5191
+ this.brokenFrame = frameKey2;
5192
+ console.warn(
5193
+ `[browser-pilot] Frame "${frameKey2}" execution context unavailable. JS evaluation will fail in this frame. DOM operations may still work.`
5194
+ );
3914
5195
  }
3915
5196
  }
3916
5197
  this.refMap.clear();
@@ -3923,6 +5204,7 @@ var Page = class {
3923
5204
  this.currentFrame = null;
3924
5205
  this.rootNodeId = null;
3925
5206
  this.currentFrameContextId = null;
5207
+ this.brokenFrame = null;
3926
5208
  this.refMap.clear();
3927
5209
  }
3928
5210
  /**
@@ -3972,109 +5254,491 @@ var Page = class {
3972
5254
  }
3973
5255
  return result.success;
3974
5256
  }
3975
- // ============ JavaScript Execution ============
3976
- /**
3977
- * Evaluate JavaScript in the page context (or current frame context if in iframe)
3978
- */
3979
- async evaluate(expression, ...args) {
3980
- let script;
3981
- if (typeof expression === "function") {
3982
- const argString = args.map((a) => JSON.stringify(a)).join(", ");
3983
- script = `(${expression.toString()})(${argString})`;
3984
- } else {
3985
- script = expression;
3986
- }
3987
- const params = {
3988
- expression: script,
3989
- returnByValue: true,
3990
- awaitPromise: true
3991
- };
3992
- if (this.currentFrameContextId !== null) {
3993
- params["contextId"] = this.currentFrameContextId;
5257
+ // ============ JavaScript Execution ============
5258
+ /**
5259
+ * Evaluate JavaScript in the page context (or current frame context if in iframe)
5260
+ */
5261
+ async evaluate(expression, ...args) {
5262
+ let script;
5263
+ if (typeof expression === "function") {
5264
+ const argString = args.map((a) => JSON.stringify(a)).join(", ");
5265
+ script = `(${expression.toString()})(${argString})`;
5266
+ } else {
5267
+ script = expression;
5268
+ }
5269
+ const params = {
5270
+ expression: script,
5271
+ returnByValue: true,
5272
+ awaitPromise: true
5273
+ };
5274
+ if (this.currentFrameContextId !== null) {
5275
+ params["contextId"] = this.currentFrameContextId;
5276
+ }
5277
+ const result = await this.cdp.send("Runtime.evaluate", params);
5278
+ if (result.exceptionDetails) {
5279
+ throw new Error(`Evaluation failed: ${result.exceptionDetails.text}`);
5280
+ }
5281
+ return result.result.value;
5282
+ }
5283
+ // ============ Screenshots ============
5284
+ /**
5285
+ * Take a screenshot
5286
+ */
5287
+ async screenshot(options = {}) {
5288
+ const { format = "png", quality, fullPage = false } = options;
5289
+ let clip;
5290
+ if (fullPage) {
5291
+ const metrics = await this.cdp.send("Page.getLayoutMetrics");
5292
+ clip = {
5293
+ x: 0,
5294
+ y: 0,
5295
+ width: metrics.contentSize.width,
5296
+ height: metrics.contentSize.height,
5297
+ scale: 1
5298
+ };
5299
+ }
5300
+ const result = await this.cdp.send("Page.captureScreenshot", {
5301
+ format,
5302
+ quality: format === "png" ? void 0 : quality,
5303
+ clip,
5304
+ captureBeyondViewport: fullPage
5305
+ });
5306
+ return result.data;
5307
+ }
5308
+ // ============ Text Extraction ============
5309
+ /**
5310
+ * Get text content from the page or a specific element
5311
+ */
5312
+ async text(selector) {
5313
+ if (!selector) {
5314
+ const result = await this.evaluateInFrame(
5315
+ "document.body.innerText"
5316
+ );
5317
+ return result.result.value ?? "";
5318
+ }
5319
+ return this.withStaleNodeRetry(async () => {
5320
+ const element = await this.findElement(selector, { timeout: DEFAULT_TIMEOUT2 });
5321
+ if (!element) return "";
5322
+ const objectId = await this.resolveObjectId(element.nodeId);
5323
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
5324
+ objectId,
5325
+ functionDeclaration: 'function() { return this.innerText || this.textContent || ""; }',
5326
+ returnByValue: true
5327
+ });
5328
+ return result.result.value ?? "";
5329
+ });
5330
+ }
5331
+ // ============ File Handling ============
5332
+ /**
5333
+ * Set files on a file input
5334
+ */
5335
+ async setInputFiles(selector, files, options = {}) {
5336
+ return this.withStaleNodeRetry(async () => {
5337
+ const element = await this.findElement(selector, options);
5338
+ if (!element) {
5339
+ if (options.optional) return false;
5340
+ throw new ElementNotFoundError(selector);
5341
+ }
5342
+ const fileData = await Promise.all(
5343
+ files.map(async (f) => {
5344
+ let base64;
5345
+ if (typeof f.buffer === "string") {
5346
+ base64 = f.buffer;
5347
+ } else {
5348
+ const bytes = new Uint8Array(f.buffer);
5349
+ base64 = btoa(String.fromCharCode(...bytes));
5350
+ }
5351
+ return { name: f.name, mimeType: f.mimeType, data: base64 };
5352
+ })
5353
+ );
5354
+ const objectId = await this.resolveObjectId(element.nodeId);
5355
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
5356
+ objectId,
5357
+ functionDeclaration: `function(files) {
5358
+ if (!(this instanceof HTMLInputElement) || this.type !== 'file') {
5359
+ return { ok: false, fileCount: 0 };
5360
+ }
5361
+
5362
+ const dt = new DataTransfer();
5363
+ for (const f of files) {
5364
+ const bytes = Uint8Array.from(atob(f.data), function(c) { return c.charCodeAt(0); });
5365
+ const file = new File([bytes], f.name, { type: f.mimeType });
5366
+ dt.items.add(file);
5367
+ }
5368
+
5369
+ var descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'files');
5370
+ if (descriptor && descriptor.set) {
5371
+ descriptor.set.call(this, dt.files);
5372
+ } else {
5373
+ this.files = dt.files;
5374
+ }
5375
+
5376
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
5377
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
5378
+ return {
5379
+ ok: (this.files && this.files.length === files.length) || files.length === 0,
5380
+ fileCount: this.files ? this.files.length : 0
5381
+ };
5382
+ }`,
5383
+ arguments: [{ value: fileData }],
5384
+ returnByValue: true
5385
+ });
5386
+ if (!result.result.value.ok) {
5387
+ if (options.optional) return false;
5388
+ throw new Error("Failed to set files on input");
5389
+ }
5390
+ return true;
5391
+ });
5392
+ }
5393
+ async getNativeSelectMetadata(objectId, targets) {
5394
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
5395
+ objectId,
5396
+ functionDeclaration: `function(targetValues) {
5397
+ if (!(this instanceof HTMLSelectElement)) {
5398
+ return {
5399
+ currentIndex: -1,
5400
+ currentValue: '',
5401
+ disabled: [],
5402
+ isSelect: false,
5403
+ missing: Array.isArray(targetValues) ? targetValues.map(String) : [],
5404
+ multiple: false,
5405
+ options: [],
5406
+ selectedValues: [],
5407
+ targetIndexes: []
5408
+ };
5409
+ }
5410
+
5411
+ var allOptions = Array.from(this.options).map(function(opt, index) {
5412
+ return { index: index, label: opt.label || opt.text || '', value: opt.value || '' };
5413
+ });
5414
+ var targetIndexes = [];
5415
+ var missing = [];
5416
+ var disabled = [];
5417
+
5418
+ for (var i = 0; i < targetValues.length; i++) {
5419
+ var target = String(targetValues[i]);
5420
+ var idx = -1;
5421
+
5422
+ for (var j = 0; j < this.options.length; j++) {
5423
+ var opt = this.options[j];
5424
+ if (opt.value === target || opt.text === target || opt.label === target) {
5425
+ idx = j;
5426
+ break;
5427
+ }
5428
+ }
5429
+
5430
+ if (idx === -1 && /^\\d+$/.test(target)) {
5431
+ var numericIndex = parseInt(target, 10);
5432
+ if (numericIndex >= 0 && numericIndex < this.options.length) {
5433
+ idx = numericIndex;
5434
+ }
5435
+ }
5436
+
5437
+ if (idx === -1) {
5438
+ missing.push(target);
5439
+ continue;
5440
+ }
5441
+
5442
+ if (this.options[idx] && this.options[idx].disabled) {
5443
+ disabled.push(target);
5444
+ continue;
5445
+ }
5446
+
5447
+ if (targetIndexes.indexOf(idx) === -1) {
5448
+ targetIndexes.push(idx);
5449
+ }
5450
+ }
5451
+
5452
+ return {
5453
+ currentIndex: this.selectedIndex,
5454
+ currentValue: this.value || '',
5455
+ disabled: disabled,
5456
+ isSelect: true,
5457
+ missing: missing,
5458
+ multiple: !!this.multiple,
5459
+ options: allOptions,
5460
+ selectedValues: Array.from(this.selectedOptions).map(function(opt) { return opt.value || ''; }),
5461
+ targetIndexes: targetIndexes
5462
+ };
5463
+ }`,
5464
+ arguments: [{ value: targets }],
5465
+ returnByValue: true
5466
+ });
5467
+ return result.result.value;
5468
+ }
5469
+ async readNativeSelectValues(objectId) {
5470
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
5471
+ objectId,
5472
+ functionDeclaration: 'function() { return this instanceof HTMLSelectElement ? Array.from(this.selectedOptions).map(function(opt) { return opt.value || ""; }) : []; }',
5473
+ returnByValue: true
5474
+ });
5475
+ return result.result.value ?? [];
5476
+ }
5477
+ selectValuesMatch(actual, expected, multiple) {
5478
+ if (!multiple) {
5479
+ return (actual[0] ?? "") === (expected[0] ?? "");
3994
5480
  }
3995
- const result = await this.cdp.send("Runtime.evaluate", params);
3996
- if (result.exceptionDetails) {
3997
- throw new Error(`Evaluation failed: ${result.exceptionDetails.text}`);
5481
+ if (actual.length !== expected.length) {
5482
+ return false;
3998
5483
  }
3999
- return result.result.value;
5484
+ const actualSorted = [...actual].sort();
5485
+ const expectedSorted = [...expected].sort();
5486
+ return actualSorted.every((value, index) => value === expectedSorted[index]);
4000
5487
  }
4001
- // ============ Screenshots ============
4002
- /**
4003
- * Take a screenshot
4004
- */
4005
- async screenshot(options = {}) {
4006
- const { format = "png", quality, fullPage = false } = options;
4007
- let clip;
4008
- if (fullPage) {
4009
- const metrics = await this.cdp.send("Page.getLayoutMetrics");
4010
- clip = {
4011
- x: 0,
4012
- y: 0,
4013
- width: metrics.contentSize.width,
4014
- height: metrics.contentSize.height,
4015
- scale: 1
4016
- };
5488
+ async applyNativeSelectByKeyboard(nodeId, objectId, currentIndex, targetIndex) {
5489
+ await this.cdp.send("DOM.focus", { nodeId });
5490
+ if (targetIndex !== currentIndex) {
5491
+ let effectiveIndex = currentIndex;
5492
+ if (effectiveIndex < 0 || targetIndex < effectiveIndex) {
5493
+ await this.dispatchKey("Home");
5494
+ effectiveIndex = 0;
5495
+ }
5496
+ const steps = targetIndex - effectiveIndex;
5497
+ const direction = steps >= 0 ? "ArrowDown" : "ArrowUp";
5498
+ for (let i = 0; i < Math.abs(steps); i++) {
5499
+ await this.dispatchKey(direction);
5500
+ }
4017
5501
  }
4018
- const result = await this.cdp.send("Page.captureScreenshot", {
4019
- format,
4020
- quality: format === "png" ? void 0 : quality,
4021
- clip,
4022
- captureBeyondViewport: fullPage
5502
+ const selectedValues = await this.readNativeSelectValues(objectId);
5503
+ return selectedValues[0] !== void 0;
5504
+ }
5505
+ async applyNativeSelectFallback(objectId, targetIndexes) {
5506
+ await this.cdp.send("Runtime.callFunctionOn", {
5507
+ objectId,
5508
+ functionDeclaration: `function(indexes) {
5509
+ if (!(this instanceof HTMLSelectElement)) return false;
5510
+
5511
+ var wanted = new Set(indexes.map(function(index) { return Number(index); }));
5512
+ for (var i = 0; i < this.options.length; i++) {
5513
+ this.options[i].selected = wanted.has(i);
5514
+ }
5515
+ if (!this.multiple && indexes.length === 1) {
5516
+ this.selectedIndex = indexes[0];
5517
+ }
5518
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
5519
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
5520
+ return true;
5521
+ }`,
5522
+ arguments: [{ value: targetIndexes }],
5523
+ returnByValue: true
4023
5524
  });
4024
- return result.data;
4025
5525
  }
4026
- // ============ Text Extraction ============
4027
- /**
4028
- * Get text content from the page or a specific element
4029
- */
4030
- async text(selector) {
4031
- const expression = selector ? `document.querySelector(${JSON.stringify(selector)})?.innerText ?? ''` : "document.body.innerText";
4032
- const result = await this.evaluateInFrame(expression);
5526
+ async selectEditableContent(objectId) {
5527
+ await this.cdp.send("Runtime.callFunctionOn", {
5528
+ objectId,
5529
+ functionDeclaration: `function() {
5530
+ if (this.isContentEditable) {
5531
+ this.focus();
5532
+ const range = document.createRange();
5533
+ range.selectNodeContents(this);
5534
+ const selection = window.getSelection();
5535
+ if (selection) {
5536
+ selection.removeAllRanges();
5537
+ selection.addRange(range);
5538
+ }
5539
+ return;
5540
+ }
5541
+
5542
+ if (this.tagName === 'TEXTAREA') {
5543
+ this.selectionStart = 0;
5544
+ this.selectionEnd = this.value.length;
5545
+ this.focus();
5546
+ return;
5547
+ }
5548
+
5549
+ if (typeof this.select === 'function') {
5550
+ this.select();
5551
+ }
5552
+ this.focus();
5553
+ }`
5554
+ });
5555
+ }
5556
+ async clearEditableSelection(objectId, key) {
5557
+ await this.selectEditableContent(objectId);
5558
+ await this.dispatchKey(key);
5559
+ }
5560
+ async readEditableValue(objectId) {
5561
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
5562
+ objectId,
5563
+ functionDeclaration: `function() {
5564
+ if (this.isContentEditable) {
5565
+ return this.textContent || '';
5566
+ }
5567
+ return this.value || '';
5568
+ }`,
5569
+ returnByValue: true
5570
+ });
4033
5571
  return result.result.value ?? "";
4034
5572
  }
4035
- // ============ File Handling ============
4036
- /**
4037
- * Set files on a file input
4038
- */
4039
- async setInputFiles(selector, files, options = {}) {
4040
- const element = await this.findElement(selector, options);
4041
- if (!element) {
4042
- if (options.optional) return false;
4043
- throw new ElementNotFoundError(selector);
5573
+ async typeEditableFallback(nodeId, objectId, value) {
5574
+ await this.selectEditableContent(objectId);
5575
+ await this.cdp.send("DOM.focus", { nodeId });
5576
+ for (const char of value) {
5577
+ await this.dispatchKey(char);
4044
5578
  }
4045
- const fileData = await Promise.all(
4046
- files.map(async (f) => {
4047
- let base64;
4048
- if (typeof f.buffer === "string") {
4049
- base64 = f.buffer;
4050
- } else {
4051
- const bytes = new Uint8Array(f.buffer);
4052
- base64 = btoa(String.fromCharCode(...bytes));
5579
+ }
5580
+ async applyRecordedSelectFallback(objectId, targetIndexes) {
5581
+ await this.cdp.send("Runtime.callFunctionOn", {
5582
+ objectId,
5583
+ functionDeclaration: `function(indexes) {
5584
+ if (!(this instanceof HTMLSelectElement)) return false;
5585
+
5586
+ var wanted = new Set(indexes.map(function(index) { return Number(index); }));
5587
+ for (var i = 0; i < this.options.length; i++) {
5588
+ this.options[i].selected = wanted.has(i);
4053
5589
  }
4054
- return { name: f.name, mimeType: f.mimeType, data: base64 };
4055
- })
4056
- );
4057
- await this.cdp.send("Runtime.evaluate", {
4058
- expression: `(() => {
4059
- const input = document.querySelector(${JSON.stringify(element.selector)});
4060
- if (!input) return false;
5590
+ if (!this.multiple && indexes.length === 1) {
5591
+ this.selectedIndex = indexes[0];
5592
+ }
5593
+ return true;
5594
+ }`,
5595
+ arguments: [{ value: targetIndexes }],
5596
+ returnByValue: true
5597
+ });
5598
+ return this.invokeRecordedEventListeners(objectId, ["input", "change"]);
5599
+ }
5600
+ async invokeRecordedEventListeners(objectId, eventTypes) {
5601
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
5602
+ objectId,
5603
+ functionDeclaration: `function(types) {
5604
+ function buildPath(target) {
5605
+ var path = [];
5606
+ var node = target;
5607
+
5608
+ while (node) {
5609
+ path.push(node);
5610
+
5611
+ if (node.parentElement) {
5612
+ node = node.parentElement;
5613
+ continue;
5614
+ }
5615
+
5616
+ if (node === document) {
5617
+ node = window;
5618
+ continue;
5619
+ }
5620
+
5621
+ if (node.defaultView && node !== node.defaultView) {
5622
+ node = node.defaultView;
5623
+ continue;
5624
+ }
5625
+
5626
+ if (node.ownerDocument && node !== node.ownerDocument) {
5627
+ node = node.ownerDocument;
5628
+ continue;
5629
+ }
5630
+
5631
+ var root = node.getRootNode && node.getRootNode();
5632
+ if (root && root !== node && root.host) {
5633
+ node = root.host;
5634
+ continue;
5635
+ }
4061
5636
 
4062
- const files = ${JSON.stringify(fileData)};
4063
- const dt = new DataTransfer();
5637
+ node = null;
5638
+ }
4064
5639
 
4065
- for (const f of files) {
4066
- const bytes = Uint8Array.from(atob(f.data), c => c.charCodeAt(0));
4067
- const file = new File([bytes], f.name, { type: f.mimeType });
4068
- dt.items.add(file);
5640
+ return path;
4069
5641
  }
4070
5642
 
4071
- input.files = dt.files;
4072
- input.dispatchEvent(new Event('change', { bubbles: true }));
4073
- return true;
4074
- })()`,
5643
+ function createEvent(type, target, currentTarget, path, phase) {
5644
+ return {
5645
+ type: type,
5646
+ target: target,
5647
+ currentTarget: currentTarget,
5648
+ srcElement: target,
5649
+ isTrusted: true,
5650
+ bubbles: true,
5651
+ cancelable: true,
5652
+ composed: true,
5653
+ defaultPrevented: false,
5654
+ eventPhase: phase,
5655
+ timeStamp: Date.now(),
5656
+ preventDefault: function() {
5657
+ this.defaultPrevented = true;
5658
+ },
5659
+ stopPropagation: function() {
5660
+ this.__stopped = true;
5661
+ },
5662
+ stopImmediatePropagation: function() {
5663
+ this.__stopped = true;
5664
+ this.__immediateStopped = true;
5665
+ },
5666
+ composedPath: function() {
5667
+ return path.slice();
5668
+ }
5669
+ };
5670
+ }
5671
+
5672
+ function invokePhase(type, nodes, capture, target, path) {
5673
+ var invoked = false;
5674
+
5675
+ for (var i = 0; i < nodes.length; i++) {
5676
+ var currentTarget = nodes[i];
5677
+
5678
+ var phase = currentTarget === target ? 2 : capture ? 1 : 3;
5679
+
5680
+ // Invoke inline handler if present (e.g. onclick, oninput)
5681
+ var inlineHandler = currentTarget['on' + type];
5682
+ if (typeof inlineHandler === 'function') {
5683
+ var inlineEvent = createEvent(type, target, currentTarget, path, phase);
5684
+ inlineHandler.call(currentTarget, inlineEvent);
5685
+ invoked = true;
5686
+ if (inlineEvent.__stopped) break;
5687
+ }
5688
+
5689
+ var store = currentTarget && currentTarget.__bpEventListeners;
5690
+ var entries = store && store[type];
5691
+ if (!Array.isArray(entries) || entries.length === 0) continue;
5692
+
5693
+ var event = createEvent(type, target, currentTarget, path, phase);
5694
+
5695
+ for (var j = 0; j < entries.length; j++) {
5696
+ var entry = entries[j];
5697
+ if (!!entry.capture !== capture) continue;
5698
+
5699
+ var listener = entry.listener;
5700
+ if (typeof listener === 'function') {
5701
+ listener.call(currentTarget, event);
5702
+ invoked = true;
5703
+ } else if (listener && typeof listener.handleEvent === 'function') {
5704
+ listener.handleEvent(event);
5705
+ invoked = true;
5706
+ }
5707
+
5708
+ if (event.__immediateStopped) {
5709
+ break;
5710
+ }
5711
+ }
5712
+
5713
+ if (event.__stopped) {
5714
+ break;
5715
+ }
5716
+ }
5717
+
5718
+ return invoked;
5719
+ }
5720
+
5721
+ var path = buildPath(this);
5722
+ var capturePath = path.slice().reverse();
5723
+ var bubblePath = path.slice();
5724
+ var invokedAny = false;
5725
+
5726
+ for (var i = 0; i < types.length; i++) {
5727
+ var type = String(types[i]);
5728
+ if (invokePhase(type, capturePath, true, this, path)) {
5729
+ invokedAny = true;
5730
+ }
5731
+ if (invokePhase(type, bubblePath, false, this, path)) {
5732
+ invokedAny = true;
5733
+ }
5734
+ }
5735
+
5736
+ return invokedAny;
5737
+ }`,
5738
+ arguments: [{ value: eventTypes }],
4075
5739
  returnByValue: true
4076
5740
  });
4077
- return true;
5741
+ return result.result.value ?? false;
4078
5742
  }
4079
5743
  /**
4080
5744
  * Wait for a download to complete, triggered by an action
@@ -4234,7 +5898,7 @@ var Page = class {
4234
5898
  return lines.join("\n");
4235
5899
  };
4236
5900
  const text = formatTree(accessibilityTree);
4237
- return {
5901
+ const result = {
4238
5902
  url,
4239
5903
  title,
4240
5904
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -4242,6 +5906,8 @@ var Page = class {
4242
5906
  interactiveElements,
4243
5907
  text
4244
5908
  };
5909
+ this.lastSnapshot = result;
5910
+ return result;
4245
5911
  }
4246
5912
  /**
4247
5913
  * Export the current ref map for cross-exec reuse (CLI).
@@ -4655,8 +6321,15 @@ var Page = class {
4655
6321
  }
4656
6322
  };
4657
6323
  if (this.dialogHandler) {
6324
+ const DIALOG_TIMEOUT = 5e3;
4658
6325
  try {
4659
- await this.dialogHandler(dialog);
6326
+ await Promise.race([
6327
+ this.dialogHandler(dialog),
6328
+ sleep5(DIALOG_TIMEOUT).then(() => {
6329
+ console.warn("[browser-pilot] Dialog handler timed out after 5s, auto-dismissing");
6330
+ return dialog.dismiss();
6331
+ })
6332
+ ]);
4660
6333
  } catch (e) {
4661
6334
  console.error("[Dialog handler error]", e);
4662
6335
  await dialog.dismiss();
@@ -4736,6 +6409,7 @@ var Page = class {
4736
6409
  this.refMap.clear();
4737
6410
  this.currentFrame = null;
4738
6411
  this.currentFrameContextId = null;
6412
+ this.brokenFrame = null;
4739
6413
  this.frameContexts.clear();
4740
6414
  this.dialogHandler = null;
4741
6415
  try {
@@ -4770,11 +6444,13 @@ var Page = class {
4770
6444
  try {
4771
6445
  return await fn();
4772
6446
  } catch (e) {
4773
- if (e instanceof Error && (e.message.includes("Could not find node with given id") || e.message.includes("Node with given id does not belong to the document") || e.message.includes("No node with given id found"))) {
6447
+ const message = e instanceof Error ? e.message : "";
6448
+ if (e instanceof Error && (message.includes("Could not find node with given id") || message.includes("Node with given id does not belong to the document") || message.includes("No node with given id found") || message.includes("Could not find object with given id") || message.includes("Cannot find context with specified id") || message.includes("Cannot find context with given id") || message.includes("Execution context was destroyed") || message.includes("No execution context with given id") || message.includes("Argument should belong to the same JavaScript world"))) {
4774
6449
  lastError = e;
4775
6450
  if (attempt < retries) {
4776
6451
  this.rootNodeId = null;
4777
- await sleep3(delay);
6452
+ this.currentFrameContextId = null;
6453
+ await sleep5(delay);
4778
6454
  continue;
4779
6455
  }
4780
6456
  }
@@ -4819,6 +6495,39 @@ var Page = class {
4819
6495
  }
4820
6496
  }
4821
6497
  }
6498
+ if (selectorList.every((s) => s.startsWith("ref:")) && this.lastSnapshot) {
6499
+ for (const selector of selectorList) {
6500
+ const ref = selector.slice(4);
6501
+ const originalElement = this.lastSnapshot.interactiveElements.find((e) => e.ref === ref);
6502
+ if (!originalElement) continue;
6503
+ const freshSnapshot = await this.snapshot();
6504
+ const match = freshSnapshot.interactiveElements.find(
6505
+ (e) => e.role === originalElement.role && e.name === originalElement.name
6506
+ );
6507
+ if (match) {
6508
+ const newBackendNodeId = this.refMap.get(match.ref);
6509
+ if (newBackendNodeId) {
6510
+ try {
6511
+ await this.ensureRootNode();
6512
+ const pushResult = await this.cdp.send(
6513
+ "DOM.pushNodesByBackendIdsToFrontend",
6514
+ { backendNodeIds: [newBackendNodeId] }
6515
+ );
6516
+ if (pushResult.nodeIds?.[0]) {
6517
+ this._lastMatchedSelector = `ref:${match.ref}`;
6518
+ return {
6519
+ nodeId: pushResult.nodeIds[0],
6520
+ backendNodeId: newBackendNodeId,
6521
+ selector: `ref:${match.ref}`,
6522
+ waitedMs: 0
6523
+ };
6524
+ }
6525
+ } catch {
6526
+ }
6527
+ }
6528
+ }
6529
+ }
6530
+ }
4822
6531
  const cssSelectors = selectorList.filter((s) => !s.startsWith("ref:"));
4823
6532
  if (cssSelectors.length === 0) {
4824
6533
  return null;
@@ -4882,6 +6591,34 @@ var Page = class {
4882
6591
  */
4883
6592
  async ensureRootNode() {
4884
6593
  if (this.rootNodeId) return;
6594
+ if (this.currentFrame) {
6595
+ const mainDocument = await this.cdp.send("DOM.getDocument", {
6596
+ depth: 0
6597
+ });
6598
+ const iframeNode = await this.cdp.send("DOM.querySelector", {
6599
+ nodeId: mainDocument.root.nodeId,
6600
+ selector: this.currentFrame
6601
+ });
6602
+ if (iframeNode.nodeId) {
6603
+ const frameResult = await this.cdp.send("DOM.describeNode", {
6604
+ nodeId: iframeNode.nodeId,
6605
+ depth: 1
6606
+ });
6607
+ if (frameResult.node.contentDocument?.nodeId) {
6608
+ this.rootNodeId = frameResult.node.contentDocument.nodeId;
6609
+ if (frameResult.node.frameId) {
6610
+ let contextId = this.frameExecutionContexts.get(frameResult.node.frameId);
6611
+ if (!contextId) {
6612
+ contextId = await this.waitForFrameContext(frameResult.node.frameId, 1e3);
6613
+ }
6614
+ this.currentFrameContextId = contextId ?? null;
6615
+ }
6616
+ return;
6617
+ }
6618
+ }
6619
+ this.currentFrame = null;
6620
+ this.currentFrameContextId = null;
6621
+ }
4885
6622
  const doc = await this.cdp.send("DOM.getDocument", {
4886
6623
  depth: 0
4887
6624
  });
@@ -4892,6 +6629,11 @@ var Page = class {
4892
6629
  * Automatically injects contextId when in an iframe
4893
6630
  */
4894
6631
  async evaluateInFrame(expression, options = {}) {
6632
+ if (this.brokenFrame && this.currentFrame) {
6633
+ throw new Error(
6634
+ `Cannot evaluate JavaScript in frame "${this.brokenFrame}": execution context is unavailable (cross-origin or sandboxed iframe). DOM operations (click, fill, etc.) may still work via CDP.`
6635
+ );
6636
+ }
4895
6637
  const params = {
4896
6638
  expression,
4897
6639
  returnByValue: options.returnByValue ?? true,
@@ -4903,10 +6645,43 @@ var Page = class {
4903
6645
  return this.cdp.send("Runtime.evaluate", params);
4904
6646
  }
4905
6647
  /**
4906
- * Scroll an element into view
6648
+ * Scroll an element into view, with fallback to center-scroll if clipped by fixed headers
4907
6649
  */
4908
6650
  async scrollIntoView(nodeId) {
4909
6651
  await this.cdp.send("DOM.scrollIntoViewIfNeeded", { nodeId });
6652
+ if (!await this.isInViewport(nodeId)) {
6653
+ const objectId = await this.resolveObjectId(nodeId);
6654
+ await this.cdp.send("Runtime.callFunctionOn", {
6655
+ objectId,
6656
+ functionDeclaration: `function() { this.scrollIntoView({ block: 'center', inline: 'center' }); }`
6657
+ });
6658
+ }
6659
+ }
6660
+ /**
6661
+ * Check if element is within the visible viewport
6662
+ */
6663
+ async isInViewport(nodeId) {
6664
+ try {
6665
+ const objectId = await this.resolveObjectId(nodeId);
6666
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
6667
+ objectId,
6668
+ functionDeclaration: `function() {
6669
+ var rect = this.getBoundingClientRect();
6670
+ return (
6671
+ rect.top >= 0 &&
6672
+ rect.left >= 0 &&
6673
+ rect.bottom <= window.innerHeight &&
6674
+ rect.right <= window.innerWidth &&
6675
+ rect.width > 0 &&
6676
+ rect.height > 0
6677
+ );
6678
+ }`,
6679
+ returnByValue: true
6680
+ });
6681
+ return result?.result?.value === true;
6682
+ } catch {
6683
+ return true;
6684
+ }
4910
6685
  }
4911
6686
  /**
4912
6687
  * Get element box model (position and dimensions)
@@ -4922,30 +6697,147 @@ var Page = class {
4922
6697
  }
4923
6698
  }
4924
6699
  /**
4925
- * Click an element by node ID
6700
+ * Click an element by node ID using Playwright's 3-event sequence:
6701
+ * mouseMoved → mousePressed → mouseReleased (sequential).
6702
+ * Uses DOM.getContentQuads for accurate coordinates (handles CSS transforms).
6703
+ * Falls back to JS this.click() if CDP mouse dispatch fails.
4926
6704
  */
4927
6705
  async clickElement(nodeId) {
4928
- const box = await this.getBoxModel(nodeId);
4929
- if (!box) {
4930
- throw new Error("Could not get element box model for click");
4931
- }
4932
- const x = box.content[0] + box.width / 2;
4933
- const y = box.content[1] + box.height / 2;
4934
- await this.cdp.send("Input.dispatchMouseEvent", {
4935
- type: "mousePressed",
4936
- x,
4937
- y,
4938
- button: "left",
4939
- clickCount: 1
6706
+ const { object } = await this.cdp.send("DOM.resolveNode", {
6707
+ nodeId
6708
+ });
6709
+ let x;
6710
+ let y;
6711
+ try {
6712
+ const { quads } = await this.cdp.send("DOM.getContentQuads", {
6713
+ objectId: object.objectId
6714
+ });
6715
+ if (quads && quads.length > 0) {
6716
+ const quad = quads[0];
6717
+ x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
6718
+ y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
6719
+ } else {
6720
+ throw new Error("No quads");
6721
+ }
6722
+ } catch {
6723
+ const box = await this.getBoxModel(nodeId);
6724
+ if (!box) throw new Error("Could not get element position for click");
6725
+ x = box.content[0] + box.width / 2;
6726
+ y = box.content[1] + box.height / 2;
6727
+ }
6728
+ try {
6729
+ await this.cdp.send("Input.dispatchMouseEvent", {
6730
+ type: "mouseMoved",
6731
+ x,
6732
+ y,
6733
+ button: "none",
6734
+ buttons: 0,
6735
+ modifiers: 0
6736
+ });
6737
+ await this.cdp.send("Input.dispatchMouseEvent", {
6738
+ type: "mousePressed",
6739
+ x,
6740
+ y,
6741
+ button: "left",
6742
+ buttons: 1,
6743
+ clickCount: 1,
6744
+ modifiers: 0
6745
+ });
6746
+ await this.cdp.send("Input.dispatchMouseEvent", {
6747
+ type: "mouseReleased",
6748
+ x,
6749
+ y,
6750
+ button: "left",
6751
+ buttons: 0,
6752
+ clickCount: 1,
6753
+ modifiers: 0
6754
+ });
6755
+ } catch {
6756
+ await this.cdp.send("Runtime.callFunctionOn", {
6757
+ objectId: object.objectId,
6758
+ functionDeclaration: "function() { this.click(); }"
6759
+ });
6760
+ }
6761
+ await this.cdp.send("Runtime.evaluate", { expression: "0" });
6762
+ }
6763
+ /**
6764
+ * Resolve a nodeId to a Remote Object ID for use with Runtime.callFunctionOn
6765
+ */
6766
+ async resolveObjectId(nodeId) {
6767
+ const { object } = await this.cdp.send("DOM.resolveNode", {
6768
+ nodeId
4940
6769
  });
4941
- await this.cdp.send("Input.dispatchMouseEvent", {
4942
- type: "mouseReleased",
4943
- x,
4944
- y,
4945
- button: "left",
4946
- clickCount: 1
6770
+ return object.objectId;
6771
+ }
6772
+ async dispatchKeyDefinition(def, modifierBitmask = 0) {
6773
+ const downParams = {
6774
+ type: def.text !== void 0 ? "keyDown" : "rawKeyDown",
6775
+ key: def.key,
6776
+ code: def.code,
6777
+ windowsVirtualKeyCode: def.keyCode,
6778
+ modifiers: modifierBitmask,
6779
+ autoRepeat: false,
6780
+ location: def.location ?? 0,
6781
+ isKeypad: false
6782
+ };
6783
+ if (def.text !== void 0) {
6784
+ downParams["text"] = def.text;
6785
+ downParams["unmodifiedText"] = def.text;
6786
+ }
6787
+ await this.cdp.send("Input.dispatchKeyEvent", downParams);
6788
+ await this.cdp.send("Input.dispatchKeyEvent", {
6789
+ type: "keyUp",
6790
+ key: def.key,
6791
+ code: def.code,
6792
+ windowsVirtualKeyCode: def.keyCode,
6793
+ modifiers: modifierBitmask,
6794
+ location: def.location ?? 0
4947
6795
  });
4948
6796
  }
6797
+ async dispatchKey(key) {
6798
+ const def = US_KEYBOARD[key];
6799
+ if (def) {
6800
+ await this.dispatchKeyDefinition(def);
6801
+ return;
6802
+ }
6803
+ if (key.length === 1) {
6804
+ await this.cdp.send("Input.insertText", { text: key });
6805
+ return;
6806
+ }
6807
+ await this.dispatchKeyDefinition({ key, code: key, keyCode: 0 });
6808
+ }
6809
+ async dispatchKeyWithModifiers(key, modifiers) {
6810
+ const mask = computeModifierBitmask(modifiers);
6811
+ for (const mod of modifiers) {
6812
+ await this.cdp.send("Input.dispatchKeyEvent", {
6813
+ type: "rawKeyDown",
6814
+ key: mod,
6815
+ code: MODIFIER_CODES[mod],
6816
+ windowsVirtualKeyCode: MODIFIER_KEY_CODES[mod],
6817
+ modifiers: mask,
6818
+ location: 1
6819
+ });
6820
+ }
6821
+ const def = US_KEYBOARD[key];
6822
+ if (def) {
6823
+ await this.dispatchKeyDefinition(def, mask);
6824
+ } else if (key.length === 1) {
6825
+ await this.dispatchKeyDefinition({ key, code: key, keyCode: 0, text: key }, mask);
6826
+ } else {
6827
+ await this.dispatchKeyDefinition({ key, code: key, keyCode: 0 }, mask);
6828
+ }
6829
+ for (let i = modifiers.length - 1; i >= 0; i--) {
6830
+ const mod = modifiers[i];
6831
+ await this.cdp.send("Input.dispatchKeyEvent", {
6832
+ type: "keyUp",
6833
+ key: mod,
6834
+ code: MODIFIER_CODES[mod],
6835
+ windowsVirtualKeyCode: MODIFIER_KEY_CODES[mod],
6836
+ modifiers: 0,
6837
+ location: 1
6838
+ });
6839
+ }
6840
+ }
4949
6841
  // ============ Audio I/O ============
4950
6842
  /**
4951
6843
  * Audio input controller (fake microphone).
@@ -5018,7 +6910,7 @@ var Page = class {
5018
6910
  const start = Date.now();
5019
6911
  await this.audioOutput.start();
5020
6912
  if (options.preDelay && options.preDelay > 0) {
5021
- await sleep3(options.preDelay);
6913
+ await sleep5(options.preDelay);
5022
6914
  }
5023
6915
  const inputDone = this.audioInput.play(options.input, {
5024
6916
  waitForEnd: !!options.sendSelector
@@ -5045,12 +6937,68 @@ var Page = class {
5045
6937
  totalMs: Date.now() - start
5046
6938
  };
5047
6939
  }
6940
+ /**
6941
+ * Wait for a DOM mutation in the current frame (used for detecting client-side form handling)
6942
+ */
6943
+ async waitForDOMMutation(options) {
6944
+ await this.evaluateInFrame(
6945
+ `new Promise((resolve) => {
6946
+ var observer = new MutationObserver(function() {
6947
+ observer.disconnect();
6948
+ resolve();
6949
+ });
6950
+ observer.observe(document.body, { childList: true, subtree: true });
6951
+ setTimeout(function() { observer.disconnect(); resolve(); }, ${options.timeout});
6952
+ })`
6953
+ );
6954
+ }
6955
+ /**
6956
+ * Wait for a frame execution context via Runtime.executionContextCreated event
6957
+ */
6958
+ async waitForFrameContext(frameId, timeout) {
6959
+ const existing = this.frameExecutionContexts.get(frameId);
6960
+ if (existing) return existing;
6961
+ return new Promise((resolve) => {
6962
+ const timer = setTimeout(() => {
6963
+ cleanup();
6964
+ resolve(void 0);
6965
+ }, timeout);
6966
+ const handler = (params) => {
6967
+ const context = params["context"];
6968
+ if (context.auxData?.frameId === frameId && context.auxData?.isDefault !== false) {
6969
+ cleanup();
6970
+ resolve(context.id);
6971
+ }
6972
+ };
6973
+ const cleanup = () => {
6974
+ clearTimeout(timer);
6975
+ this.cdp.off("Runtime.executionContextCreated", handler);
6976
+ };
6977
+ this.cdp.on("Runtime.executionContextCreated", handler);
6978
+ });
6979
+ }
5048
6980
  };
5049
- function sleep3(ms) {
6981
+ function sleep5(ms) {
5050
6982
  return new Promise((resolve) => setTimeout(resolve, ms));
5051
6983
  }
5052
6984
 
5053
6985
  // src/browser/browser.ts
6986
+ function scoreTarget(t) {
6987
+ let score = 0;
6988
+ if (t.url.startsWith("http://") || t.url.startsWith("https://")) score += 10;
6989
+ if (t.url.startsWith("chrome://")) score -= 20;
6990
+ if (t.url.startsWith("chrome-extension://")) score -= 15;
6991
+ if (t.url.startsWith("devtools://")) score -= 25;
6992
+ if (t.url === "about:blank") score -= 5;
6993
+ if (!t.attached) score += 3;
6994
+ if (t.title && t.title.length > 0) score += 2;
6995
+ return score;
6996
+ }
6997
+ function pickBestTarget(targets) {
6998
+ if (targets.length === 0) return void 0;
6999
+ const sorted = [...targets].sort((a, b) => scoreTarget(b) - scoreTarget(a));
7000
+ return sorted[0].targetId;
7001
+ }
5054
7002
  var Browser = class _Browser {
5055
7003
  cdp;
5056
7004
  providerSession;
@@ -5072,28 +7020,46 @@ var Browser = class _Browser {
5072
7020
  return new _Browser(cdp, provider, session, options);
5073
7021
  }
5074
7022
  /**
5075
- * Get or create a page by name
5076
- * If no name is provided, returns the first available page or creates a new one
7023
+ * Get or create a page by name.
7024
+ * If no name is provided, returns the first available page or creates a new one.
7025
+ *
7026
+ * Target selection heuristics (when no targetId is specified):
7027
+ * - Prefer http/https URLs over chrome://, devtools://, about:blank
7028
+ * - Prefer unattached targets (not already controlled by another client)
7029
+ * - Filter by targetUrl if provided
5077
7030
  */
5078
7031
  async page(name, options) {
5079
7032
  const pageName = name ?? "default";
5080
7033
  const cached = this.pages.get(pageName);
5081
7034
  if (cached) return cached;
5082
7035
  const targets = await this.cdp.send("Target.getTargets");
5083
- const pageTargets = targets.targetInfos.filter((t) => t.type === "page");
7036
+ let pageTargets = targets.targetInfos.filter((t) => t.type === "page");
7037
+ if (options?.targetUrl) {
7038
+ const urlFilter = options.targetUrl;
7039
+ const filtered = pageTargets.filter((t) => t.url.includes(urlFilter));
7040
+ if (filtered.length > 0) {
7041
+ pageTargets = filtered;
7042
+ } else {
7043
+ console.warn(
7044
+ `[browser-pilot] No targets match URL filter "${urlFilter}", falling back to all page targets`
7045
+ );
7046
+ }
7047
+ }
5084
7048
  let targetId;
5085
7049
  if (options?.targetId) {
5086
- const targetExists = pageTargets.some((t) => t.targetId === options.targetId);
7050
+ const targetExists = targets.targetInfos.some(
7051
+ (t) => t.type === "page" && t.targetId === options.targetId
7052
+ );
5087
7053
  if (targetExists) {
5088
7054
  targetId = options.targetId;
5089
7055
  } else {
5090
7056
  console.warn(`[browser-pilot] Target ${options.targetId} no longer exists, falling back`);
5091
- targetId = pageTargets.length > 0 ? pageTargets[0].targetId : (await this.cdp.send("Target.createTarget", {
7057
+ targetId = pickBestTarget(pageTargets) ?? (await this.cdp.send("Target.createTarget", {
5092
7058
  url: "about:blank"
5093
7059
  })).targetId;
5094
7060
  }
5095
7061
  } else if (pageTargets.length > 0) {
5096
- targetId = pageTargets[0].targetId;
7062
+ targetId = pickBestTarget(pageTargets);
5097
7063
  } else {
5098
7064
  const result = await this.cdp.send("Target.createTarget", {
5099
7065
  url: "about:blank"
@@ -5103,6 +7069,21 @@ var Browser = class _Browser {
5103
7069
  await this.cdp.attachToTarget(targetId);
5104
7070
  const page = new Page(this.cdp, targetId);
5105
7071
  await page.init();
7072
+ const minViewport = options?.minViewport !== void 0 ? options.minViewport : { width: 200, height: 200 };
7073
+ if (minViewport !== false) {
7074
+ try {
7075
+ const viewport = await page.evaluate(
7076
+ "({ w: window.innerWidth, h: window.innerHeight })"
7077
+ );
7078
+ if (viewport.w < minViewport.width || viewport.h < minViewport.height) {
7079
+ console.warn(
7080
+ `[browser-pilot] Attached target has small viewport (${viewport.w}x${viewport.h}). Applying default viewport override (1280x720). Use { minViewport: false } to disable this check.`
7081
+ );
7082
+ await page.setViewport({ width: 1280, height: 720 });
7083
+ }
7084
+ } catch {
7085
+ }
7086
+ }
5106
7087
  this.pages.set(pageName, page);
5107
7088
  return page;
5108
7089
  }