browser-pilot 0.0.9 → 0.0.11

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/browser.cjs CHANGED
@@ -463,7 +463,12 @@ var ElementNotFoundError = class extends Error {
463
463
  hints;
464
464
  constructor(selectors, hints) {
465
465
  const selectorList = Array.isArray(selectors) ? selectors : [selectors];
466
- super(`Element not found: ${selectorList.join(", ")}`);
466
+ let msg = `Element not found: ${selectorList.join(", ")}`;
467
+ if (hints?.length) {
468
+ msg += `. Did you mean: ${hints.slice(0, 3).map((h) => `${h.element.ref} (${h.element.role} "${h.element.name}")`).join(", ")}`;
469
+ }
470
+ msg += `. Run 'bp snapshot' to see available elements.`;
471
+ super(msg);
467
472
  this.name = "ElementNotFoundError";
468
473
  this.selectors = selectorList;
469
474
  this.hints = hints;
@@ -471,7 +476,8 @@ var ElementNotFoundError = class extends Error {
471
476
  };
472
477
  var TimeoutError = class extends Error {
473
478
  constructor(message = "Operation timed out") {
474
- super(message);
479
+ const msg = message.includes("bp snapshot") ? message : `${message}. Run 'bp snapshot' to check current page state.`;
480
+ super(msg);
475
481
  this.name = "TimeoutError";
476
482
  }
477
483
  };
@@ -554,7 +560,7 @@ var BatchExecutor = class {
554
560
  }
555
561
  case "click": {
556
562
  if (!step.selector) throw new Error("click requires selector");
557
- if (step.waitForNavigation) {
563
+ if (step.waitForNavigation === true) {
558
564
  const navPromise = this.page.waitForNavigation({ timeout, optional });
559
565
  await this.page.click(step.selector, { timeout, optional });
560
566
  await navPromise;
@@ -569,7 +575,6 @@ var BatchExecutor = class {
569
575
  await this.page.fill(step.selector, step.value, {
570
576
  timeout,
571
577
  optional,
572
- clear: step.clear ?? true,
573
578
  blur: step.blur
574
579
  });
575
580
  return { selectorUsed: this.getUsedSelector(step.selector) };
@@ -617,7 +622,8 @@ var BatchExecutor = class {
617
622
  await this.page.submit(step.selector, {
618
623
  timeout,
619
624
  optional,
620
- method: step.method ?? "enter+click"
625
+ method: step.method ?? "enter+click",
626
+ waitForNavigation: step.waitForNavigation
621
627
  });
622
628
  return { selectorUsed: this.getUsedSelector(step.selector) };
623
629
  }
@@ -2347,6 +2353,335 @@ async function waitForNetworkIdle(cdp, options = {}) {
2347
2353
  });
2348
2354
  }
2349
2355
 
2356
+ // src/browser/actionability.ts
2357
+ var ActionabilityError = class extends Error {
2358
+ failureType;
2359
+ coveringElement;
2360
+ constructor(message, failureType, coveringElement) {
2361
+ super(message);
2362
+ this.name = "ActionabilityError";
2363
+ this.failureType = failureType;
2364
+ this.coveringElement = coveringElement;
2365
+ }
2366
+ };
2367
+ var CHECK_VISIBLE = `function() {
2368
+ // checkVisibility handles display:none, visibility:hidden, content-visibility up the tree
2369
+ if (typeof this.checkVisibility === 'function' && !this.checkVisibility()) {
2370
+ return { actionable: false, reason: 'Element is not visible (checkVisibility failed). Try scrolling or check if a prior action is needed to reveal it.' };
2371
+ }
2372
+
2373
+ var style = getComputedStyle(this);
2374
+
2375
+ if (style.visibility !== 'visible') {
2376
+ return { actionable: false, reason: 'Element has visibility: ' + style.visibility + '. Try scrolling or check if a prior action is needed to reveal it.' };
2377
+ }
2378
+
2379
+ // display:contents elements have no box themselves \u2014 check children
2380
+ if (style.display === 'contents') {
2381
+ var children = this.children;
2382
+ if (children.length === 0) {
2383
+ return { actionable: false, reason: 'Element has display:contents with no children. Try scrolling or check if a prior action is needed to reveal it.' };
2384
+ }
2385
+ for (var i = 0; i < children.length; i++) {
2386
+ var childRect = children[i].getBoundingClientRect();
2387
+ if (childRect.width > 0 && childRect.height > 0) {
2388
+ return { actionable: true };
2389
+ }
2390
+ }
2391
+ 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.' };
2392
+ }
2393
+
2394
+ var rect = this.getBoundingClientRect();
2395
+ if (rect.width <= 0 || rect.height <= 0) {
2396
+ 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.' };
2397
+ }
2398
+
2399
+ return { actionable: true };
2400
+ }`;
2401
+ var CHECK_ENABLED = `function() {
2402
+ // Native disabled property
2403
+ var disableable = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'];
2404
+ if (disableable.indexOf(this.tagName) !== -1 && this.disabled) {
2405
+ return { actionable: false, reason: 'Element is disabled. Check if a prerequisite field needs to be filled first.' };
2406
+ }
2407
+
2408
+ // Check ancestor FIELDSET[disabled]
2409
+ var parent = this.parentElement;
2410
+ while (parent) {
2411
+ if (parent.tagName === 'FIELDSET' && parent.disabled) {
2412
+ // Exception: elements inside the first <legend> of a disabled fieldset are NOT disabled
2413
+ var legend = parent.querySelector(':scope > legend');
2414
+ if (!legend || !legend.contains(this)) {
2415
+ return { actionable: false, reason: 'Element is inside a disabled fieldset. Check if a prerequisite field needs to be filled first.' };
2416
+ }
2417
+ }
2418
+ parent = parent.parentElement;
2419
+ }
2420
+
2421
+ // aria-disabled="true" walking up ancestor chain (crosses shadow DOM)
2422
+ var node = this;
2423
+ while (node) {
2424
+ if (node.nodeType === 1 && node.getAttribute && node.getAttribute('aria-disabled') === 'true') {
2425
+ return { actionable: false, reason: 'Element or ancestor has aria-disabled="true". Check if a prerequisite field needs to be filled first.' };
2426
+ }
2427
+ if (node.parentElement) {
2428
+ node = node.parentElement;
2429
+ } else if (node.getRootNode && node.getRootNode() !== node) {
2430
+ // Cross shadow DOM boundary
2431
+ var root = node.getRootNode();
2432
+ node = root.host || null;
2433
+ } else {
2434
+ break;
2435
+ }
2436
+ }
2437
+
2438
+ return { actionable: true };
2439
+ }`;
2440
+ var CHECK_STABLE = `function() {
2441
+ var self = this;
2442
+ return new Promise(function(resolve) {
2443
+ var maxFrames = 30;
2444
+ var prev = null;
2445
+ var frame = 0;
2446
+ var resolved = false;
2447
+
2448
+ var fallbackTimer = setTimeout(function() {
2449
+ if (!resolved) {
2450
+ resolved = true;
2451
+ resolve({ actionable: false, reason: 'Element stability check timed out (tab may be backgrounded)' });
2452
+ }
2453
+ }, 2000);
2454
+
2455
+ function check() {
2456
+ if (resolved) return;
2457
+ frame++;
2458
+ if (frame > maxFrames) {
2459
+ resolved = true;
2460
+ clearTimeout(fallbackTimer);
2461
+ resolve({ actionable: false, reason: 'Element position not stable after ' + maxFrames + ' frames' });
2462
+ return;
2463
+ }
2464
+
2465
+ var rect = self.getBoundingClientRect();
2466
+ var cur = { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
2467
+
2468
+ if (prev !== null &&
2469
+ prev.x === cur.x && prev.y === cur.y &&
2470
+ prev.w === cur.w && prev.h === cur.h) {
2471
+ resolved = true;
2472
+ clearTimeout(fallbackTimer);
2473
+ resolve({ actionable: true });
2474
+ return;
2475
+ }
2476
+
2477
+ prev = cur;
2478
+ requestAnimationFrame(check);
2479
+ }
2480
+
2481
+ requestAnimationFrame(check);
2482
+ });
2483
+ }`;
2484
+ var CHECK_HIT_TARGET = `function(x, y) {
2485
+ // Compute click center if coordinates not provided
2486
+ if (x === undefined || y === undefined) {
2487
+ var rect = this.getBoundingClientRect();
2488
+ x = rect.x + rect.width / 2;
2489
+ y = rect.y + rect.height / 2;
2490
+ }
2491
+
2492
+ function checkPoint(root, px, py) {
2493
+ var method = root.elementsFromPoint || root.msElementsFromPoint;
2494
+ if (!method) return [];
2495
+ return method.call(root, px, py) || [];
2496
+ }
2497
+
2498
+ // Follow only the top-most hit through nested shadow roots.
2499
+ // Accepting any hit in the stack creates false positives for covered elements.
2500
+ var root = document;
2501
+ var topHits = [];
2502
+ var seenRoots = [];
2503
+ while (root && seenRoots.indexOf(root) === -1) {
2504
+ seenRoots.push(root);
2505
+ var hits = checkPoint(root, x, y);
2506
+ if (!hits.length) break;
2507
+ var top = hits[0];
2508
+ topHits.push(top);
2509
+ if (top && top.shadowRoot) {
2510
+ root = top.shadowRoot;
2511
+ continue;
2512
+ }
2513
+ break;
2514
+ }
2515
+
2516
+ // Target must be the top-most hit element or an ancestor/descendant
2517
+ for (var j = 0; j < topHits.length; j++) {
2518
+ var hit = topHits[j];
2519
+ if (hit === this || this.contains(hit) || hit.contains(this)) {
2520
+ return { actionable: true };
2521
+ }
2522
+ }
2523
+
2524
+ // Report the covering element
2525
+ var top = topHits.length > 0 ? topHits[topHits.length - 1] : null;
2526
+ if (top) {
2527
+ return {
2528
+ actionable: false,
2529
+ reason: 'Element is covered by <' + top.tagName.toLowerCase() + '>' +
2530
+ (top.id ? '#' + top.id : '') +
2531
+ (top.className && typeof top.className === 'string' ? '.' + top.className.split(' ').join('.') : '') +
2532
+ '. Try dismissing overlays first.',
2533
+ coveringElement: {
2534
+ tag: top.tagName.toLowerCase(),
2535
+ id: top.id || undefined,
2536
+ className: (typeof top.className === 'string' && top.className) || undefined
2537
+ }
2538
+ };
2539
+ }
2540
+
2541
+ return { actionable: false, reason: 'No element found at click point (' + x + ', ' + y + '). Try scrolling the element into view first.' };
2542
+ }`;
2543
+ var CHECK_EDITABLE = `function() {
2544
+ // Must be an editable element type
2545
+ var tag = this.tagName;
2546
+ var isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' ||
2547
+ this.isContentEditable;
2548
+ if (!isEditable) {
2549
+ return { actionable: false, reason: 'Element is not an editable type (<' + tag.toLowerCase() + '>). Target an <input>, <textarea>, <select>, or [contenteditable] element instead.' };
2550
+ }
2551
+
2552
+ // Check disabled
2553
+ var disableable = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'];
2554
+ if (disableable.indexOf(tag) !== -1 && this.disabled) {
2555
+ return { actionable: false, reason: 'Element is disabled. Check if a prerequisite field needs to be filled first.' };
2556
+ }
2557
+
2558
+ // Check ancestor FIELDSET[disabled]
2559
+ var parent = this.parentElement;
2560
+ while (parent) {
2561
+ if (parent.tagName === 'FIELDSET' && parent.disabled) {
2562
+ var legend = parent.querySelector(':scope > legend');
2563
+ if (!legend || !legend.contains(this)) {
2564
+ return { actionable: false, reason: 'Element is inside a disabled fieldset. Check if a prerequisite field needs to be filled first.' };
2565
+ }
2566
+ }
2567
+ parent = parent.parentElement;
2568
+ }
2569
+
2570
+ // aria-disabled walking up (crosses shadow DOM)
2571
+ var node = this;
2572
+ while (node) {
2573
+ if (node.nodeType === 1 && node.getAttribute && node.getAttribute('aria-disabled') === 'true') {
2574
+ return { actionable: false, reason: 'Element or ancestor has aria-disabled="true". Check if a prerequisite field needs to be filled first.' };
2575
+ }
2576
+ if (node.parentElement) {
2577
+ node = node.parentElement;
2578
+ } else if (node.getRootNode && node.getRootNode() !== node) {
2579
+ var root = node.getRootNode();
2580
+ node = root.host || null;
2581
+ } else {
2582
+ break;
2583
+ }
2584
+ }
2585
+
2586
+ // Check readonly
2587
+ if (this.hasAttribute && this.hasAttribute('readonly')) {
2588
+ return { actionable: false, reason: 'Cannot fill a readonly input. Remove the readonly attribute or target a different element.' };
2589
+ }
2590
+ if (this.getAttribute && this.getAttribute('aria-readonly') === 'true') {
2591
+ return { actionable: false, reason: 'Cannot fill a readonly input (aria-readonly="true"). Remove the attribute or target a different element.' };
2592
+ }
2593
+
2594
+ return { actionable: true };
2595
+ }`;
2596
+ function sleep3(ms) {
2597
+ return new Promise((resolve) => setTimeout(resolve, ms));
2598
+ }
2599
+ var BACKOFF = [0, 20, 100, 100];
2600
+ async function runCheck(cdp, objectId, check, options) {
2601
+ let script;
2602
+ let awaitPromise = false;
2603
+ const args = [];
2604
+ switch (check) {
2605
+ case "visible":
2606
+ script = CHECK_VISIBLE;
2607
+ break;
2608
+ case "enabled":
2609
+ script = CHECK_ENABLED;
2610
+ break;
2611
+ case "stable":
2612
+ script = CHECK_STABLE;
2613
+ awaitPromise = true;
2614
+ break;
2615
+ case "hitTarget":
2616
+ script = CHECK_HIT_TARGET;
2617
+ if (options?.coordinates) {
2618
+ args.push({ value: options.coordinates.x });
2619
+ args.push({ value: options.coordinates.y });
2620
+ } else {
2621
+ args.push({ value: void 0 });
2622
+ args.push({ value: void 0 });
2623
+ }
2624
+ break;
2625
+ case "editable":
2626
+ script = CHECK_EDITABLE;
2627
+ break;
2628
+ default: {
2629
+ const _exhaustive = check;
2630
+ throw new Error(`Unknown actionability check: ${_exhaustive}`);
2631
+ }
2632
+ }
2633
+ const params = {
2634
+ functionDeclaration: script,
2635
+ objectId,
2636
+ returnByValue: true,
2637
+ arguments: args
2638
+ };
2639
+ if (awaitPromise) {
2640
+ params["awaitPromise"] = true;
2641
+ }
2642
+ const response = await cdp.send("Runtime.callFunctionOn", params);
2643
+ if (response.exceptionDetails) {
2644
+ return {
2645
+ actionable: false,
2646
+ reason: `Check "${check}" threw: ${response.exceptionDetails.text}`,
2647
+ failureType: check
2648
+ };
2649
+ }
2650
+ const result = response.result.value;
2651
+ if (!result.actionable) {
2652
+ result.failureType = check;
2653
+ }
2654
+ return result;
2655
+ }
2656
+ async function runChecks(cdp, objectId, checks, options) {
2657
+ for (const check of checks) {
2658
+ const result = await runCheck(cdp, objectId, check, options);
2659
+ if (!result.actionable) {
2660
+ return result;
2661
+ }
2662
+ }
2663
+ return { actionable: true };
2664
+ }
2665
+ async function ensureActionable(cdp, objectId, checks, options) {
2666
+ const timeout = options?.timeout ?? 3e4;
2667
+ const start = Date.now();
2668
+ let attempt = 0;
2669
+ while (true) {
2670
+ const result = await runChecks(cdp, objectId, checks, options);
2671
+ if (result.actionable) return;
2672
+ if (Date.now() - start >= timeout) {
2673
+ throw new ActionabilityError(
2674
+ `Element not actionable: ${result.reason}`,
2675
+ result.failureType,
2676
+ result.coveringElement
2677
+ );
2678
+ }
2679
+ const delay = attempt < BACKOFF.length ? BACKOFF[attempt] ?? 0 : 500;
2680
+ if (delay > 0) await sleep3(delay);
2681
+ attempt++;
2682
+ }
2683
+ }
2684
+
2350
2685
  // src/browser/fuzzy-match.ts
2351
2686
  function jaroWinkler(a, b) {
2352
2687
  if (a.length === 0 && b.length === 0) return 0;
@@ -2592,8 +2927,180 @@ async function generateHints(page, failedSelectors, actionType, maxHints = 3) {
2592
2927
  return diversifyHints(matches, maxHints);
2593
2928
  }
2594
2929
 
2930
+ // src/browser/keyboard.ts
2931
+ var US_KEYBOARD = {
2932
+ // Letters (lowercase)
2933
+ a: { key: "a", code: "KeyA", keyCode: 65, text: "a" },
2934
+ b: { key: "b", code: "KeyB", keyCode: 66, text: "b" },
2935
+ c: { key: "c", code: "KeyC", keyCode: 67, text: "c" },
2936
+ d: { key: "d", code: "KeyD", keyCode: 68, text: "d" },
2937
+ e: { key: "e", code: "KeyE", keyCode: 69, text: "e" },
2938
+ f: { key: "f", code: "KeyF", keyCode: 70, text: "f" },
2939
+ g: { key: "g", code: "KeyG", keyCode: 71, text: "g" },
2940
+ h: { key: "h", code: "KeyH", keyCode: 72, text: "h" },
2941
+ i: { key: "i", code: "KeyI", keyCode: 73, text: "i" },
2942
+ j: { key: "j", code: "KeyJ", keyCode: 74, text: "j" },
2943
+ k: { key: "k", code: "KeyK", keyCode: 75, text: "k" },
2944
+ l: { key: "l", code: "KeyL", keyCode: 76, text: "l" },
2945
+ m: { key: "m", code: "KeyM", keyCode: 77, text: "m" },
2946
+ n: { key: "n", code: "KeyN", keyCode: 78, text: "n" },
2947
+ o: { key: "o", code: "KeyO", keyCode: 79, text: "o" },
2948
+ p: { key: "p", code: "KeyP", keyCode: 80, text: "p" },
2949
+ q: { key: "q", code: "KeyQ", keyCode: 81, text: "q" },
2950
+ r: { key: "r", code: "KeyR", keyCode: 82, text: "r" },
2951
+ s: { key: "s", code: "KeyS", keyCode: 83, text: "s" },
2952
+ t: { key: "t", code: "KeyT", keyCode: 84, text: "t" },
2953
+ u: { key: "u", code: "KeyU", keyCode: 85, text: "u" },
2954
+ v: { key: "v", code: "KeyV", keyCode: 86, text: "v" },
2955
+ w: { key: "w", code: "KeyW", keyCode: 87, text: "w" },
2956
+ x: { key: "x", code: "KeyX", keyCode: 88, text: "x" },
2957
+ y: { key: "y", code: "KeyY", keyCode: 89, text: "y" },
2958
+ z: { key: "z", code: "KeyZ", keyCode: 90, text: "z" },
2959
+ // Letters (uppercase)
2960
+ A: { key: "A", code: "KeyA", keyCode: 65, text: "A" },
2961
+ B: { key: "B", code: "KeyB", keyCode: 66, text: "B" },
2962
+ C: { key: "C", code: "KeyC", keyCode: 67, text: "C" },
2963
+ D: { key: "D", code: "KeyD", keyCode: 68, text: "D" },
2964
+ E: { key: "E", code: "KeyE", keyCode: 69, text: "E" },
2965
+ F: { key: "F", code: "KeyF", keyCode: 70, text: "F" },
2966
+ G: { key: "G", code: "KeyG", keyCode: 71, text: "G" },
2967
+ H: { key: "H", code: "KeyH", keyCode: 72, text: "H" },
2968
+ I: { key: "I", code: "KeyI", keyCode: 73, text: "I" },
2969
+ J: { key: "J", code: "KeyJ", keyCode: 74, text: "J" },
2970
+ K: { key: "K", code: "KeyK", keyCode: 75, text: "K" },
2971
+ L: { key: "L", code: "KeyL", keyCode: 76, text: "L" },
2972
+ M: { key: "M", code: "KeyM", keyCode: 77, text: "M" },
2973
+ N: { key: "N", code: "KeyN", keyCode: 78, text: "N" },
2974
+ O: { key: "O", code: "KeyO", keyCode: 79, text: "O" },
2975
+ P: { key: "P", code: "KeyP", keyCode: 80, text: "P" },
2976
+ Q: { key: "Q", code: "KeyQ", keyCode: 81, text: "Q" },
2977
+ R: { key: "R", code: "KeyR", keyCode: 82, text: "R" },
2978
+ S: { key: "S", code: "KeyS", keyCode: 83, text: "S" },
2979
+ T: { key: "T", code: "KeyT", keyCode: 84, text: "T" },
2980
+ U: { key: "U", code: "KeyU", keyCode: 85, text: "U" },
2981
+ V: { key: "V", code: "KeyV", keyCode: 86, text: "V" },
2982
+ W: { key: "W", code: "KeyW", keyCode: 87, text: "W" },
2983
+ X: { key: "X", code: "KeyX", keyCode: 88, text: "X" },
2984
+ Y: { key: "Y", code: "KeyY", keyCode: 89, text: "Y" },
2985
+ Z: { key: "Z", code: "KeyZ", keyCode: 90, text: "Z" },
2986
+ // Numbers
2987
+ "0": { key: "0", code: "Digit0", keyCode: 48, text: "0" },
2988
+ "1": { key: "1", code: "Digit1", keyCode: 49, text: "1" },
2989
+ "2": { key: "2", code: "Digit2", keyCode: 50, text: "2" },
2990
+ "3": { key: "3", code: "Digit3", keyCode: 51, text: "3" },
2991
+ "4": { key: "4", code: "Digit4", keyCode: 52, text: "4" },
2992
+ "5": { key: "5", code: "Digit5", keyCode: 53, text: "5" },
2993
+ "6": { key: "6", code: "Digit6", keyCode: 54, text: "6" },
2994
+ "7": { key: "7", code: "Digit7", keyCode: 55, text: "7" },
2995
+ "8": { key: "8", code: "Digit8", keyCode: 56, text: "8" },
2996
+ "9": { key: "9", code: "Digit9", keyCode: 57, text: "9" },
2997
+ // Punctuation
2998
+ " ": { key: " ", code: "Space", keyCode: 32, text: " " },
2999
+ ".": { key: ".", code: "Period", keyCode: 190, text: "." },
3000
+ ",": { key: ",", code: "Comma", keyCode: 188, text: "," },
3001
+ "/": { key: "/", code: "Slash", keyCode: 191, text: "/" },
3002
+ ";": { key: ";", code: "Semicolon", keyCode: 186, text: ";" },
3003
+ "'": { key: "'", code: "Quote", keyCode: 222, text: "'" },
3004
+ "[": { key: "[", code: "BracketLeft", keyCode: 219, text: "[" },
3005
+ "]": { key: "]", code: "BracketRight", keyCode: 221, text: "]" },
3006
+ "\\": { key: "\\", code: "Backslash", keyCode: 220, text: "\\" },
3007
+ "-": { key: "-", code: "Minus", keyCode: 189, text: "-" },
3008
+ "=": { key: "=", code: "Equal", keyCode: 187, text: "=" },
3009
+ "`": { key: "`", code: "Backquote", keyCode: 192, text: "`" },
3010
+ // Shifted punctuation
3011
+ "!": { key: "!", code: "Digit1", keyCode: 49, text: "!" },
3012
+ "@": { key: "@", code: "Digit2", keyCode: 50, text: "@" },
3013
+ "#": { key: "#", code: "Digit3", keyCode: 51, text: "#" },
3014
+ $: { key: "$", code: "Digit4", keyCode: 52, text: "$" },
3015
+ "%": { key: "%", code: "Digit5", keyCode: 53, text: "%" },
3016
+ "^": { key: "^", code: "Digit6", keyCode: 54, text: "^" },
3017
+ "&": { key: "&", code: "Digit7", keyCode: 55, text: "&" },
3018
+ "*": { key: "*", code: "Digit8", keyCode: 56, text: "*" },
3019
+ "(": { key: "(", code: "Digit9", keyCode: 57, text: "(" },
3020
+ ")": { key: ")", code: "Digit0", keyCode: 48, text: ")" },
3021
+ _: { key: "_", code: "Minus", keyCode: 189, text: "_" },
3022
+ "+": { key: "+", code: "Equal", keyCode: 187, text: "+" },
3023
+ "{": { key: "{", code: "BracketLeft", keyCode: 219, text: "{" },
3024
+ "}": { key: "}", code: "BracketRight", keyCode: 221, text: "}" },
3025
+ "|": { key: "|", code: "Backslash", keyCode: 220, text: "|" },
3026
+ ":": { key: ":", code: "Semicolon", keyCode: 186, text: ":" },
3027
+ '"': { key: '"', code: "Quote", keyCode: 222, text: '"' },
3028
+ "<": { key: "<", code: "Comma", keyCode: 188, text: "<" },
3029
+ ">": { key: ">", code: "Period", keyCode: 190, text: ">" },
3030
+ "?": { key: "?", code: "Slash", keyCode: 191, text: "?" },
3031
+ "~": { key: "~", code: "Backquote", keyCode: 192, text: "~" },
3032
+ // Special keys (non-text: use rawKeyDown, no text field)
3033
+ Enter: { key: "Enter", code: "Enter", keyCode: 13 },
3034
+ Tab: { key: "Tab", code: "Tab", keyCode: 9 },
3035
+ Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
3036
+ Delete: { key: "Delete", code: "Delete", keyCode: 46 },
3037
+ Escape: { key: "Escape", code: "Escape", keyCode: 27 },
3038
+ ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
3039
+ ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
3040
+ ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
3041
+ ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
3042
+ Home: { key: "Home", code: "Home", keyCode: 36 },
3043
+ End: { key: "End", code: "End", keyCode: 35 },
3044
+ PageUp: { key: "PageUp", code: "PageUp", keyCode: 33 },
3045
+ PageDown: { key: "PageDown", code: "PageDown", keyCode: 34 }
3046
+ };
3047
+
2595
3048
  // src/browser/page.ts
2596
3049
  var DEFAULT_TIMEOUT2 = 3e4;
3050
+ var EVENT_LISTENER_TRACKER_SCRIPT = `(() => {
3051
+ if (globalThis.__bpEventListenerTrackerInstalled) return;
3052
+ Object.defineProperty(globalThis, '__bpEventListenerTrackerInstalled', {
3053
+ value: true,
3054
+ configurable: true,
3055
+ });
3056
+
3057
+ const storeKey = '__bpEventListeners';
3058
+ const originalAddEventListener = EventTarget.prototype.addEventListener;
3059
+ const originalRemoveEventListener = EventTarget.prototype.removeEventListener;
3060
+
3061
+ function ensureStore(target) {
3062
+ if (!Object.prototype.hasOwnProperty.call(target, storeKey)) {
3063
+ Object.defineProperty(target, storeKey, {
3064
+ value: Object.create(null),
3065
+ configurable: true,
3066
+ });
3067
+ }
3068
+ return target[storeKey];
3069
+ }
3070
+
3071
+ EventTarget.prototype.addEventListener = function(type, listener, options) {
3072
+ try {
3073
+ if (listener) {
3074
+ const store = ensureStore(this);
3075
+ const bucket = store[type] || (store[type] = []);
3076
+ const capture =
3077
+ typeof options === 'boolean' ? options : !!(options && options.capture);
3078
+ const exists = bucket.some((entry) => entry.listener === listener && entry.capture === capture);
3079
+ if (!exists) {
3080
+ bucket.push({ listener, capture });
3081
+ }
3082
+ }
3083
+ } catch {}
3084
+
3085
+ return originalAddEventListener.call(this, type, listener, options);
3086
+ };
3087
+
3088
+ EventTarget.prototype.removeEventListener = function(type, listener, options) {
3089
+ try {
3090
+ const store = this[storeKey];
3091
+ const bucket = store && store[type];
3092
+ const capture =
3093
+ typeof options === 'boolean' ? options : !!(options && options.capture);
3094
+ if (Array.isArray(bucket)) {
3095
+ store[type] = bucket.filter((entry) => {
3096
+ return !(entry.listener === listener && entry.capture === capture);
3097
+ });
3098
+ }
3099
+ } catch {}
3100
+
3101
+ return originalRemoveEventListener.call(this, type, listener, options);
3102
+ };
3103
+ })();`;
2597
3104
  var Page = class {
2598
3105
  cdp;
2599
3106
  _targetId;
@@ -2617,6 +3124,8 @@ var Page = class {
2617
3124
  currentFrameContextId = null;
2618
3125
  /** Last matched selector from findElement (for selectorUsed tracking) */
2619
3126
  _lastMatchedSelector;
3127
+ /** Last snapshot for stale ref recovery */
3128
+ lastSnapshot;
2620
3129
  /** Audio input controller (lazy-initialized) */
2621
3130
  _audioInput;
2622
3131
  /** Audio output controller (lazy-initialized) */
@@ -2661,6 +3170,9 @@ var Page = class {
2661
3170
  for (const [frameId, ctxId] of this.frameExecutionContexts.entries()) {
2662
3171
  if (ctxId === contextId) {
2663
3172
  this.frameExecutionContexts.delete(frameId);
3173
+ if (this.currentFrameContextId === contextId) {
3174
+ this.currentFrameContextId = null;
3175
+ }
2664
3176
  break;
2665
3177
  }
2666
3178
  }
@@ -2672,6 +3184,18 @@ var Page = class {
2672
3184
  this.cdp.send("Runtime.enable"),
2673
3185
  this.cdp.send("Network.enable")
2674
3186
  ]);
3187
+ await this.installEventListenerTracker();
3188
+ }
3189
+ async installEventListenerTracker() {
3190
+ await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
3191
+ source: EVENT_LISTENER_TRACKER_SCRIPT
3192
+ });
3193
+ try {
3194
+ await this.cdp.send("Runtime.evaluate", {
3195
+ expression: EVENT_LISTENER_TRACKER_SCRIPT
3196
+ });
3197
+ } catch {
3198
+ }
2675
3199
  }
2676
3200
  // ============ Navigation ============
2677
3201
  /**
@@ -2687,6 +3211,9 @@ var Page = class {
2687
3211
  }
2688
3212
  this.rootNodeId = null;
2689
3213
  this.refMap.clear();
3214
+ this.currentFrame = null;
3215
+ this.currentFrameContextId = null;
3216
+ this.frameContexts.clear();
2690
3217
  }
2691
3218
  /**
2692
3219
  * Get the current URL
@@ -2757,8 +3284,9 @@ var Page = class {
2757
3284
  /**
2758
3285
  * Click an element (supports multi-selector)
2759
3286
  *
2760
- * Uses CDP mouse events for regular elements. For form submit buttons,
2761
- * uses dispatchEvent to reliably trigger form submission in headless Chrome.
3287
+ * Uses CDP mouse events (mouseMoved + mousePressed + mouseReleased) to
3288
+ * simulate a real click. Real mouse events on submit buttons naturally
3289
+ * trigger native form submission — no JS dispatch needed.
2762
3290
  */
2763
3291
  async click(selector, options = {}) {
2764
3292
  return this.withStaleNodeRetry(async () => {
@@ -2770,27 +3298,45 @@ var Page = class {
2770
3298
  throw new ElementNotFoundError(selector, hints);
2771
3299
  }
2772
3300
  await this.scrollIntoView(element.nodeId);
2773
- const submitResult = await this.evaluateInFrame(
2774
- `(() => {
2775
- const el = document.querySelector(${JSON.stringify(element.selector)});
2776
- if (!el) return { isSubmit: false };
2777
-
2778
- // Check if this is a form submit button
2779
- const isSubmitButton = (el instanceof HTMLButtonElement && (el.type === 'submit' || (el.form && el.type !== 'button'))) ||
2780
- (el instanceof HTMLInputElement && el.type === 'submit');
2781
-
2782
- if (isSubmitButton && el.form) {
2783
- // Dispatch submit event directly - works reliably in headless Chrome
2784
- el.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
2785
- return { isSubmit: true };
2786
- }
2787
- return { isSubmit: false };
2788
- })()`
2789
- );
2790
- const isSubmit = submitResult.result.value?.isSubmit;
2791
- if (!isSubmit) {
2792
- await this.clickElement(element.nodeId);
3301
+ const objectId = await this.resolveObjectId(element.nodeId);
3302
+ try {
3303
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled", "stable"], {
3304
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
3305
+ });
3306
+ } catch (e) {
3307
+ if (options.optional) return false;
3308
+ throw e;
3309
+ }
3310
+ let clickX;
3311
+ let clickY;
3312
+ try {
3313
+ const { quads } = await this.cdp.send("DOM.getContentQuads", {
3314
+ objectId
3315
+ });
3316
+ if (quads?.length > 0) {
3317
+ const quad = quads[0];
3318
+ clickX = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
3319
+ clickY = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
3320
+ } else {
3321
+ throw new Error("No quads");
3322
+ }
3323
+ } catch {
3324
+ const box = await this.getBoxModel(element.nodeId);
3325
+ if (!box) throw new Error("Could not get element position");
3326
+ clickX = box.content[0] + box.width / 2;
3327
+ clickY = box.content[1] + box.height / 2;
3328
+ }
3329
+ const hitTargetCoordinates = this.currentFrame ? void 0 : { x: clickX, y: clickY };
3330
+ try {
3331
+ await ensureActionable(this.cdp, objectId, ["hitTarget"], {
3332
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2,
3333
+ coordinates: hitTargetCoordinates
3334
+ });
3335
+ } catch (e) {
3336
+ if (options.optional) return false;
3337
+ throw e;
2793
3338
  }
3339
+ await this.clickElement(element.nodeId);
2794
3340
  return true;
2795
3341
  });
2796
3342
  }
@@ -2798,7 +3344,7 @@ var Page = class {
2798
3344
  * Fill an input field (clears first by default)
2799
3345
  */
2800
3346
  async fill(selector, value, options = {}) {
2801
- const { clear = true, blur = false } = options;
3347
+ const { blur = false } = options;
2802
3348
  return this.withStaleNodeRetry(async () => {
2803
3349
  const element = await this.findElement(selector, options);
2804
3350
  if (!element) {
@@ -2807,71 +3353,152 @@ var Page = class {
2807
3353
  const hints = await generateHints(this, selectorList, "fill");
2808
3354
  throw new ElementNotFoundError(selector, hints);
2809
3355
  }
2810
- await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
2811
- if (clear) {
2812
- await this.evaluateInFrame(
2813
- `(() => {
2814
- const el = document.querySelector(${JSON.stringify(element.selector)});
2815
- if (el) {
2816
- el.value = '';
2817
- el.dispatchEvent(new InputEvent('input', {
2818
- bubbles: true,
2819
- cancelable: true,
2820
- inputType: 'deleteContent'
2821
- }));
2822
- }
2823
- })()`
2824
- );
3356
+ const { object } = await this.cdp.send("DOM.resolveNode", {
3357
+ nodeId: element.nodeId
3358
+ });
3359
+ const objectId = object.objectId;
3360
+ try {
3361
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled", "editable"], {
3362
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
3363
+ });
3364
+ } catch (e) {
3365
+ if (options.optional) return false;
3366
+ throw e;
2825
3367
  }
2826
- await this.cdp.send("Input.insertText", { text: value });
2827
- await this.evaluateInFrame(
2828
- `(() => {
2829
- const el = document.querySelector(${JSON.stringify(element.selector)});
2830
- if (el) {
2831
- el.dispatchEvent(new InputEvent('input', {
2832
- bubbles: true,
2833
- cancelable: true,
2834
- inputType: 'insertText',
2835
- data: ${JSON.stringify(value)}
2836
- }));
2837
- el.dispatchEvent(new Event('change', { bubbles: true }));
3368
+ const tagInfo = await this.cdp.send("Runtime.callFunctionOn", {
3369
+ objectId,
3370
+ functionDeclaration: `function() {
3371
+ return { tagName: this.tagName?.toLowerCase() || '', inputType: (this.type || '').toLowerCase() };
3372
+ }`,
3373
+ returnByValue: true
3374
+ });
3375
+ const { tagName, inputType } = tagInfo.result.value;
3376
+ const specialInputTypes = /* @__PURE__ */ new Set([
3377
+ "date",
3378
+ "datetime-local",
3379
+ "month",
3380
+ "week",
3381
+ "time",
3382
+ "color",
3383
+ "range",
3384
+ "file"
3385
+ ]);
3386
+ const isSpecialInput = tagName === "input" && specialInputTypes.has(inputType);
3387
+ if (isSpecialInput) {
3388
+ await this.cdp.send("Runtime.callFunctionOn", {
3389
+ objectId,
3390
+ functionDeclaration: `function(val) {
3391
+ this.value = val;
3392
+ this.dispatchEvent(new Event('input', { bubbles: true }));
3393
+ this.dispatchEvent(new Event('change', { bubbles: true }));
3394
+ }`,
3395
+ arguments: [{ value }],
3396
+ returnByValue: true
3397
+ });
3398
+ } else {
3399
+ await this.selectEditableContent(objectId);
3400
+ if (value === "") {
3401
+ await this.dispatchKey("Delete");
3402
+ } else {
3403
+ await this.cdp.send("Input.insertText", { text: value });
3404
+ }
3405
+ }
3406
+ if (options.verify !== false) {
3407
+ let actualValue = await this.readEditableValue(objectId);
3408
+ if (actualValue !== value && !isSpecialInput) {
3409
+ if (value === "") {
3410
+ await this.clearEditableSelection(objectId, "Backspace");
3411
+ } else {
3412
+ await this.typeEditableFallback(element.nodeId, objectId, value);
2838
3413
  }
2839
- })()`
2840
- );
3414
+ actualValue = await this.readEditableValue(objectId);
3415
+ }
3416
+ if (actualValue !== value) {
3417
+ if (options.optional) return false;
3418
+ throw new Error(
3419
+ `Fill value did not stick. Expected ${JSON.stringify(value)} but got ${JSON.stringify(actualValue)}.`
3420
+ );
3421
+ }
3422
+ }
2841
3423
  if (blur) {
2842
- await this.evaluateInFrame(
2843
- `document.querySelector(${JSON.stringify(element.selector)})?.blur()`
2844
- );
3424
+ await this.cdp.send("Runtime.callFunctionOn", {
3425
+ objectId,
3426
+ functionDeclaration: "function() { this.blur(); }"
3427
+ });
2845
3428
  }
2846
3429
  return true;
2847
3430
  });
2848
3431
  }
2849
3432
  /**
2850
3433
  * Type text character by character (for autocomplete fields, etc.)
3434
+ *
3435
+ * Uses proper keyDown/rawKeyDown distinction with US keyboard layout.
3436
+ * Printable chars use 'keyDown' with text, non-text keys use 'rawKeyDown',
3437
+ * and non-layout chars (emoji, CJK) fall back to Input.insertText.
2851
3438
  */
2852
3439
  async type(selector, text, options = {}) {
2853
- const { delay = 50 } = options;
2854
- const element = await this.findElement(selector, options);
2855
- if (!element) {
2856
- if (options.optional) return false;
2857
- throw new ElementNotFoundError(selector);
2858
- }
2859
- await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
2860
- for (const char of text) {
2861
- await this.cdp.send("Input.dispatchKeyEvent", {
2862
- type: "keyDown",
2863
- key: char,
2864
- text: char
2865
- });
2866
- await this.cdp.send("Input.dispatchKeyEvent", {
2867
- type: "keyUp",
2868
- key: char
2869
- });
2870
- if (delay > 0) {
2871
- await sleep3(delay);
3440
+ return this.withStaleNodeRetry(async () => {
3441
+ const { delay = 50 } = options;
3442
+ const element = await this.findElement(selector, options);
3443
+ if (!element) {
3444
+ if (options.optional) return false;
3445
+ throw new ElementNotFoundError(selector);
2872
3446
  }
2873
- }
2874
- return true;
3447
+ try {
3448
+ const objectId = await this.resolveObjectId(element.nodeId);
3449
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled"], {
3450
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
3451
+ });
3452
+ } catch (e) {
3453
+ if (options.optional) return false;
3454
+ throw e;
3455
+ }
3456
+ await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
3457
+ for (const char of text) {
3458
+ const def = US_KEYBOARD[char];
3459
+ if (def) {
3460
+ if (def.text !== void 0) {
3461
+ await this.cdp.send("Input.dispatchKeyEvent", {
3462
+ type: "keyDown",
3463
+ key: def.key,
3464
+ code: def.code,
3465
+ text: def.text,
3466
+ unmodifiedText: def.text,
3467
+ windowsVirtualKeyCode: def.keyCode,
3468
+ modifiers: 0,
3469
+ autoRepeat: false,
3470
+ location: def.location ?? 0,
3471
+ isKeypad: false
3472
+ });
3473
+ } else {
3474
+ await this.cdp.send("Input.dispatchKeyEvent", {
3475
+ type: "rawKeyDown",
3476
+ key: def.key,
3477
+ code: def.code,
3478
+ windowsVirtualKeyCode: def.keyCode,
3479
+ modifiers: 0,
3480
+ autoRepeat: false,
3481
+ location: def.location ?? 0,
3482
+ isKeypad: false
3483
+ });
3484
+ }
3485
+ await this.cdp.send("Input.dispatchKeyEvent", {
3486
+ type: "keyUp",
3487
+ key: def.key,
3488
+ code: def.code,
3489
+ windowsVirtualKeyCode: def.keyCode,
3490
+ modifiers: 0,
3491
+ location: def.location ?? 0
3492
+ });
3493
+ } else {
3494
+ await this.cdp.send("Input.insertText", { text: char });
3495
+ }
3496
+ if (delay > 0) {
3497
+ await sleep4(delay);
3498
+ }
3499
+ }
3500
+ return true;
3501
+ });
2875
3502
  }
2876
3503
  async select(selectorOrConfig, valueOrOptions, maybeOptions) {
2877
3504
  if (typeof selectorOrConfig === "object" && !Array.isArray(selectorOrConfig) && "trigger" in selectorOrConfig) {
@@ -2880,108 +3507,227 @@ var Page = class {
2880
3507
  const selector = selectorOrConfig;
2881
3508
  const value = valueOrOptions;
2882
3509
  const options = maybeOptions ?? {};
2883
- const element = await this.findElement(selector, options);
2884
- if (!element) {
2885
- if (options.optional) return false;
2886
- const selectorList = Array.isArray(selector) ? selector : [selector];
2887
- const hints = await generateHints(this, selectorList, "select");
2888
- throw new ElementNotFoundError(selector, hints);
2889
- }
2890
- const values = Array.isArray(value) ? value : [value];
2891
- await this.cdp.send("Runtime.evaluate", {
2892
- expression: `(() => {
2893
- const el = document.querySelector(${JSON.stringify(element.selector)});
2894
- if (!el || el.tagName !== 'SELECT') return false;
2895
- const values = ${JSON.stringify(values)};
2896
- for (const opt of el.options) {
2897
- opt.selected = values.includes(opt.value) || values.includes(opt.text);
2898
- }
2899
- el.dispatchEvent(new Event('change', { bubbles: true }));
3510
+ return this.withStaleNodeRetry(async () => {
3511
+ const element = await this.findElement(selector, options);
3512
+ if (!element) {
3513
+ if (options.optional) return false;
3514
+ const selectorList = Array.isArray(selector) ? selector : [selector];
3515
+ const hints = await generateHints(this, selectorList, "select");
3516
+ throw new ElementNotFoundError(selector, hints);
3517
+ }
3518
+ const values = Array.isArray(value) ? value : [value];
3519
+ const objectId = await this.resolveObjectId(element.nodeId);
3520
+ try {
3521
+ await this.scrollIntoView(element.nodeId);
3522
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled"], {
3523
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
3524
+ });
3525
+ } catch (e) {
3526
+ if (options.optional) return false;
3527
+ throw e;
3528
+ }
3529
+ const metadata = await this.getNativeSelectMetadata(objectId, values);
3530
+ if (!metadata.isSelect) {
3531
+ throw new Error("select() target must be a native <select> element");
3532
+ }
3533
+ if (metadata.missing.length > 0) {
3534
+ throw new Error(`No option found for: ${metadata.missing.join(", ")}`);
3535
+ }
3536
+ if (metadata.disabled.length > 0) {
3537
+ throw new Error(`Cannot select disabled option(s): ${metadata.disabled.join(", ")}`);
3538
+ }
3539
+ if (!metadata.multiple && metadata.targetIndexes.length > 1) {
3540
+ throw new Error("Cannot select multiple values on a single-select element");
3541
+ }
3542
+ const expectedValues = metadata.targetIndexes.map((idx) => metadata.options[idx].value);
3543
+ if (this.selectValuesMatch(metadata.selectedValues, expectedValues, metadata.multiple)) {
2900
3544
  return true;
2901
- })()`,
2902
- returnByValue: true
3545
+ }
3546
+ if (!metadata.multiple && metadata.targetIndexes.length === 1) {
3547
+ await this.applyNativeSelectByKeyboard(
3548
+ element.nodeId,
3549
+ objectId,
3550
+ metadata.currentIndex,
3551
+ metadata.targetIndexes[0]
3552
+ );
3553
+ }
3554
+ let selectedValues = await this.readNativeSelectValues(objectId);
3555
+ if (!this.selectValuesMatch(selectedValues, expectedValues, metadata.multiple)) {
3556
+ await this.applyNativeSelectFallback(objectId, metadata.targetIndexes);
3557
+ selectedValues = await this.readNativeSelectValues(objectId);
3558
+ }
3559
+ if (!this.selectValuesMatch(selectedValues, expectedValues, metadata.multiple)) {
3560
+ await this.applyRecordedSelectFallback(objectId, metadata.targetIndexes);
3561
+ selectedValues = await this.readNativeSelectValues(objectId);
3562
+ }
3563
+ if (!this.selectValuesMatch(selectedValues, expectedValues, metadata.multiple)) {
3564
+ if (options.optional) return false;
3565
+ throw new Error(
3566
+ `Select value did not stick. Expected ${expectedValues.join(", ") || "(empty)"} but got ${selectedValues.join(", ") || "(empty)"}.`
3567
+ );
3568
+ }
3569
+ return true;
2903
3570
  });
2904
- return true;
2905
3571
  }
2906
3572
  /**
2907
3573
  * Handle custom (non-native) select/dropdown components
2908
3574
  */
2909
3575
  async selectCustom(config, options = {}) {
2910
3576
  const { trigger, option, value, match = "text" } = config;
2911
- await this.click(trigger, options);
2912
- await sleep3(100);
2913
- let optionSelector;
2914
- const optionSelectors = Array.isArray(option) ? option : [option];
2915
- if (match === "contains") {
2916
- optionSelector = optionSelectors.map((s) => `${s}:has-text("${value}")`).join(", ");
2917
- } else if (match === "value") {
2918
- optionSelector = optionSelectors.map((s) => `${s}[data-value="${value}"], ${s}[value="${value}"]`).join(", ");
2919
- } else {
2920
- optionSelector = optionSelectors.map((s) => `${s}`).join(", ");
2921
- }
2922
- const result = await this.cdp.send("Runtime.evaluate", {
2923
- expression: `(() => {
2924
- const options = document.querySelectorAll(${JSON.stringify(optionSelector)});
2925
- for (const opt of options) {
2926
- const text = opt.textContent?.trim();
2927
- if (${match === "text" ? `text === ${JSON.stringify(value)}` : match === "contains" ? `text?.includes(${JSON.stringify(value)})` : "true"}) {
2928
- opt.click();
2929
- return true;
3577
+ return this.withStaleNodeRetry(async () => {
3578
+ await this.click(trigger, options);
3579
+ await sleep4(100);
3580
+ const optionSelectors = Array.isArray(option) ? option : [option];
3581
+ const optionHandle = await this.evaluateInFrame(
3582
+ `(() => {
3583
+ const selectors = ${JSON.stringify(optionSelectors)};
3584
+ const wanted = ${JSON.stringify(value)};
3585
+ const mode = ${JSON.stringify(match)};
3586
+
3587
+ for (const selector of selectors) {
3588
+ const candidates = document.querySelectorAll(selector);
3589
+ for (const candidate of candidates) {
3590
+ const text = candidate.textContent?.trim() || '';
3591
+ const candidateValue =
3592
+ candidate.getAttribute?.('data-value') ??
3593
+ candidate.getAttribute?.('value') ??
3594
+ candidate.value ??
3595
+ '';
3596
+ const matches =
3597
+ mode === 'value'
3598
+ ? candidateValue === wanted
3599
+ : mode === 'contains'
3600
+ ? text.includes(wanted)
3601
+ : text === wanted;
3602
+
3603
+ if (matches) {
3604
+ return candidate;
3605
+ }
3606
+ }
2930
3607
  }
3608
+
3609
+ return null;
3610
+ })()`,
3611
+ { returnByValue: false }
3612
+ );
3613
+ if (!optionHandle.result.objectId) {
3614
+ if (options.optional) return false;
3615
+ throw new ElementNotFoundError(`Option with ${match} "${value}"`);
3616
+ }
3617
+ const nodeResult = await this.cdp.send("DOM.requestNode", {
3618
+ objectId: optionHandle.result.objectId
3619
+ });
3620
+ if (!nodeResult.nodeId) {
3621
+ if (options.optional) return false;
3622
+ throw new ElementNotFoundError(`Option with ${match} "${value}"`);
3623
+ }
3624
+ await this.scrollIntoView(nodeResult.nodeId);
3625
+ await ensureActionable(
3626
+ this.cdp,
3627
+ optionHandle.result.objectId,
3628
+ ["visible", "enabled", "stable"],
3629
+ {
3630
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
2931
3631
  }
2932
- return false;
2933
- })()`,
2934
- returnByValue: true
3632
+ );
3633
+ await this.clickElement(nodeResult.nodeId);
3634
+ return true;
2935
3635
  });
2936
- if (!result.result.value) {
2937
- if (options.optional) return false;
2938
- throw new ElementNotFoundError(`Option with ${match} "${value}"`);
2939
- }
2940
- return true;
2941
3636
  }
2942
3637
  /**
2943
- * Check a checkbox or radio button
3638
+ * Check a checkbox or radio button using real mouse click.
3639
+ * No-op if already checked. Verifies state changed after click.
2944
3640
  */
2945
3641
  async check(selector, options = {}) {
2946
- const element = await this.findElement(selector, options);
2947
- if (!element) {
2948
- if (options.optional) return false;
2949
- const selectorList = Array.isArray(selector) ? selector : [selector];
2950
- const hints = await generateHints(this, selectorList, "check");
2951
- throw new ElementNotFoundError(selector, hints);
2952
- }
2953
- const result = await this.cdp.send("Runtime.evaluate", {
2954
- expression: `(() => {
2955
- const el = document.querySelector(${JSON.stringify(element.selector)});
2956
- if (!el) return false;
2957
- if (!el.checked) el.click();
2958
- return true;
2959
- })()`,
2960
- returnByValue: true
3642
+ return this.withStaleNodeRetry(async () => {
3643
+ const element = await this.findElement(selector, options);
3644
+ if (!element) {
3645
+ if (options.optional) return false;
3646
+ const selectorList = Array.isArray(selector) ? selector : [selector];
3647
+ const hints = await generateHints(this, selectorList, "check");
3648
+ throw new ElementNotFoundError(selector, hints);
3649
+ }
3650
+ const { object } = await this.cdp.send("DOM.resolveNode", {
3651
+ nodeId: element.nodeId
3652
+ });
3653
+ try {
3654
+ await ensureActionable(this.cdp, object.objectId, ["visible", "enabled"], {
3655
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
3656
+ });
3657
+ } catch (e) {
3658
+ if (options.optional) return false;
3659
+ throw e;
3660
+ }
3661
+ const before = await this.cdp.send("Runtime.callFunctionOn", {
3662
+ objectId: object.objectId,
3663
+ functionDeclaration: "function() { return !!this.checked; }",
3664
+ returnByValue: true
3665
+ });
3666
+ if (before.result.value) return true;
3667
+ await this.scrollIntoView(element.nodeId);
3668
+ await this.clickElement(element.nodeId);
3669
+ const after = await this.cdp.send("Runtime.callFunctionOn", {
3670
+ objectId: object.objectId,
3671
+ functionDeclaration: "function() { return !!this.checked; }",
3672
+ returnByValue: true
3673
+ });
3674
+ if (!after.result.value) {
3675
+ throw new Error("Clicking the checkbox did not change its state");
3676
+ }
3677
+ return true;
2961
3678
  });
2962
- return result.result.value;
2963
3679
  }
2964
3680
  /**
2965
- * Uncheck a checkbox
3681
+ * Uncheck a checkbox using real mouse click.
3682
+ * No-op if already unchecked. Radio buttons can't be unchecked (returns true).
2966
3683
  */
2967
3684
  async uncheck(selector, options = {}) {
2968
- const element = await this.findElement(selector, options);
2969
- if (!element) {
2970
- if (options.optional) return false;
2971
- const selectorList = Array.isArray(selector) ? selector : [selector];
2972
- const hints = await generateHints(this, selectorList, "uncheck");
2973
- throw new ElementNotFoundError(selector, hints);
2974
- }
2975
- const result = await this.cdp.send("Runtime.evaluate", {
2976
- expression: `(() => {
2977
- const el = document.querySelector(${JSON.stringify(element.selector)});
2978
- if (!el) return false;
2979
- if (el.checked) el.click();
2980
- return true;
2981
- })()`,
2982
- returnByValue: true
3685
+ return this.withStaleNodeRetry(async () => {
3686
+ const element = await this.findElement(selector, options);
3687
+ if (!element) {
3688
+ if (options.optional) return false;
3689
+ const selectorList = Array.isArray(selector) ? selector : [selector];
3690
+ const hints = await generateHints(this, selectorList, "uncheck");
3691
+ throw new ElementNotFoundError(selector, hints);
3692
+ }
3693
+ const { object } = await this.cdp.send("DOM.resolveNode", {
3694
+ nodeId: element.nodeId
3695
+ });
3696
+ try {
3697
+ await ensureActionable(this.cdp, object.objectId, ["visible", "enabled"], {
3698
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
3699
+ });
3700
+ } catch (e) {
3701
+ if (options.optional) return false;
3702
+ throw e;
3703
+ }
3704
+ const isRadio = await this.cdp.send(
3705
+ "Runtime.callFunctionOn",
3706
+ {
3707
+ objectId: object.objectId,
3708
+ functionDeclaration: 'function() { return this.type === "radio"; }',
3709
+ returnByValue: true
3710
+ }
3711
+ );
3712
+ if (isRadio.result.value) return true;
3713
+ const before = await this.cdp.send("Runtime.callFunctionOn", {
3714
+ objectId: object.objectId,
3715
+ functionDeclaration: "function() { return !!this.checked; }",
3716
+ returnByValue: true
3717
+ });
3718
+ if (!before.result.value) return true;
3719
+ await this.scrollIntoView(element.nodeId);
3720
+ await this.clickElement(element.nodeId);
3721
+ const after = await this.cdp.send("Runtime.callFunctionOn", {
3722
+ objectId: object.objectId,
3723
+ functionDeclaration: "function() { return !!this.checked; }",
3724
+ returnByValue: true
3725
+ });
3726
+ if (after.result.value) {
3727
+ throw new Error("Clicking the checkbox did not change its state");
3728
+ }
3729
+ return true;
2983
3730
  });
2984
- return result.result.value;
2985
3731
  }
2986
3732
  /**
2987
3733
  * Submit a form (tries Enter key first, then click)
@@ -2995,97 +3741,84 @@ var Page = class {
2995
3741
  * the submit event and triggers HTML5 validation.
2996
3742
  */
2997
3743
  async submit(selector, options = {}) {
2998
- const { method = "enter+click", waitForNavigation: shouldWait = "auto" } = options;
2999
- const element = await this.findElement(selector, options);
3000
- if (!element) {
3001
- if (options.optional) return false;
3002
- const selectorList = Array.isArray(selector) ? selector : [selector];
3003
- const hints = await generateHints(this, selectorList, "submit");
3004
- throw new ElementNotFoundError(selector, hints);
3005
- }
3006
- const isFormElement = await this.evaluateInFrame(
3007
- `(() => {
3008
- const el = document.querySelector(${JSON.stringify(element.selector)});
3009
- return el instanceof HTMLFormElement;
3010
- })()`
3011
- );
3012
- if (isFormElement.result.value) {
3013
- await this.evaluateInFrame(
3014
- `(() => {
3015
- const form = document.querySelector(${JSON.stringify(element.selector)});
3016
- if (form && form instanceof HTMLFormElement) {
3017
- form.requestSubmit();
3018
- }
3019
- })()`
3020
- );
3021
- if (shouldWait === true) {
3022
- await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3023
- } else if (shouldWait === "auto") {
3024
- await Promise.race([this.waitForNavigation({ timeout: 1e3, optional: true }), sleep3(500)]);
3744
+ return this.withStaleNodeRetry(async () => {
3745
+ const { method = "enter+click", waitForNavigation: shouldWait = "auto" } = options;
3746
+ const element = await this.findElement(selector, options);
3747
+ if (!element) {
3748
+ if (options.optional) return false;
3749
+ const selectorList = Array.isArray(selector) ? selector : [selector];
3750
+ const hints = await generateHints(this, selectorList, "submit");
3751
+ throw new ElementNotFoundError(selector, hints);
3025
3752
  }
3026
- return true;
3027
- }
3028
- await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
3029
- if (method.includes("enter")) {
3030
- await this.press("Enter");
3031
- if (shouldWait === true) {
3032
- try {
3753
+ const objectId = await this.resolveObjectId(element.nodeId);
3754
+ const isFormElement = await this.cdp.send(
3755
+ "Runtime.callFunctionOn",
3756
+ {
3757
+ objectId,
3758
+ functionDeclaration: "function() { return this instanceof HTMLFormElement; }",
3759
+ returnByValue: true
3760
+ }
3761
+ );
3762
+ if (isFormElement.result.value) {
3763
+ await this.cdp.send("Runtime.callFunctionOn", {
3764
+ objectId,
3765
+ functionDeclaration: `function() {
3766
+ if (typeof this.requestSubmit === 'function') {
3767
+ this.requestSubmit();
3768
+ } else {
3769
+ this.submit();
3770
+ }
3771
+ }`
3772
+ });
3773
+ if (shouldWait === true) {
3033
3774
  await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3034
- return true;
3035
- } catch {
3775
+ } else if (shouldWait === "auto") {
3776
+ await Promise.race([
3777
+ this.waitForNavigation({ timeout: 1e3, optional: true }),
3778
+ sleep4(500)
3779
+ ]);
3036
3780
  }
3037
- } else if (shouldWait === "auto") {
3038
- const navigationDetected = await Promise.race([
3039
- this.waitForNavigation({ timeout: 1e3, optional: true }).then(
3040
- (success) => success ? "nav" : null
3041
- ),
3042
- sleep3(500).then(() => "timeout")
3043
- ]);
3044
- if (navigationDetected === "nav") {
3781
+ return true;
3782
+ }
3783
+ await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
3784
+ if (method.includes("enter")) {
3785
+ await this.press("Enter");
3786
+ if (shouldWait === true) {
3787
+ try {
3788
+ await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3789
+ return true;
3790
+ } catch {
3791
+ }
3792
+ } else if (shouldWait === "auto") {
3793
+ const navigationDetected = await Promise.race([
3794
+ this.waitForNavigation({ timeout: 1e3, optional: true }).then(
3795
+ (success) => success ? "nav" : null
3796
+ ),
3797
+ sleep4(500).then(() => "timeout")
3798
+ ]);
3799
+ if (navigationDetected === "nav") {
3800
+ return true;
3801
+ }
3802
+ } else if (method === "enter") {
3045
3803
  return true;
3046
3804
  }
3047
- } else {
3048
- if (method === "enter") return true;
3049
3805
  }
3050
- }
3051
- if (method.includes("click")) {
3052
- await this.click(element.selector, { ...options, optional: false });
3053
- if (shouldWait === true) {
3054
- await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3055
- } else if (shouldWait === "auto") {
3056
- await sleep3(100);
3806
+ if (method.includes("click")) {
3807
+ await this.click(element.selector, { ...options, optional: false });
3808
+ if (shouldWait === true) {
3809
+ await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3810
+ } else if (shouldWait === "auto") {
3811
+ await sleep4(100);
3812
+ }
3057
3813
  }
3058
- }
3059
- return true;
3814
+ return true;
3815
+ });
3060
3816
  }
3061
3817
  /**
3062
3818
  * Press a key
3063
3819
  */
3064
3820
  async press(key) {
3065
- const keyMap = {
3066
- Enter: { key: "Enter", code: "Enter", keyCode: 13 },
3067
- Tab: { key: "Tab", code: "Tab", keyCode: 9 },
3068
- Escape: { key: "Escape", code: "Escape", keyCode: 27 },
3069
- Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
3070
- Delete: { key: "Delete", code: "Delete", keyCode: 46 },
3071
- ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
3072
- ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
3073
- ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
3074
- ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 }
3075
- };
3076
- const keyInfo = keyMap[key] ?? { key, code: key, keyCode: 0 };
3077
- await this.cdp.send("Input.dispatchKeyEvent", {
3078
- type: "keyDown",
3079
- key: keyInfo.key,
3080
- code: keyInfo.code,
3081
- windowsVirtualKeyCode: keyInfo.keyCode
3082
- });
3083
- await this.cdp.send("Input.dispatchKeyEvent", {
3084
- type: "keyUp",
3085
- key: keyInfo.key,
3086
- code: keyInfo.code,
3087
- windowsVirtualKeyCode: keyInfo.keyCode
3088
- });
3821
+ await this.dispatchKey(key);
3089
3822
  }
3090
3823
  /**
3091
3824
  * Focus an element
@@ -3114,6 +3847,15 @@ var Page = class {
3114
3847
  throw new ElementNotFoundError(selector, hints);
3115
3848
  }
3116
3849
  await this.scrollIntoView(element.nodeId);
3850
+ try {
3851
+ const objectId = await this.resolveObjectId(element.nodeId);
3852
+ await ensureActionable(this.cdp, objectId, ["visible", "stable"], {
3853
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
3854
+ });
3855
+ } catch (e) {
3856
+ if (options.optional) return false;
3857
+ throw e;
3858
+ }
3117
3859
  const box = await this.getBoxModel(element.nodeId);
3118
3860
  if (!box) {
3119
3861
  if (options.optional) return false;
@@ -3304,53 +4046,424 @@ var Page = class {
3304
4046
  * Get text content from the page or a specific element
3305
4047
  */
3306
4048
  async text(selector) {
3307
- const expression = selector ? `document.querySelector(${JSON.stringify(selector)})?.innerText ?? ''` : "document.body.innerText";
3308
- const result = await this.evaluateInFrame(expression);
3309
- return result.result.value ?? "";
4049
+ if (!selector) {
4050
+ const result = await this.evaluateInFrame(
4051
+ "document.body.innerText"
4052
+ );
4053
+ return result.result.value ?? "";
4054
+ }
4055
+ return this.withStaleNodeRetry(async () => {
4056
+ const element = await this.findElement(selector, { timeout: DEFAULT_TIMEOUT2 });
4057
+ if (!element) return "";
4058
+ const objectId = await this.resolveObjectId(element.nodeId);
4059
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
4060
+ objectId,
4061
+ functionDeclaration: 'function() { return this.innerText || this.textContent || ""; }',
4062
+ returnByValue: true
4063
+ });
4064
+ return result.result.value ?? "";
4065
+ });
3310
4066
  }
3311
4067
  // ============ File Handling ============
3312
4068
  /**
3313
4069
  * Set files on a file input
3314
4070
  */
3315
4071
  async setInputFiles(selector, files, options = {}) {
3316
- const element = await this.findElement(selector, options);
3317
- if (!element) {
3318
- if (options.optional) return false;
3319
- throw new ElementNotFoundError(selector);
4072
+ return this.withStaleNodeRetry(async () => {
4073
+ const element = await this.findElement(selector, options);
4074
+ if (!element) {
4075
+ if (options.optional) return false;
4076
+ throw new ElementNotFoundError(selector);
4077
+ }
4078
+ const fileData = await Promise.all(
4079
+ files.map(async (f) => {
4080
+ let base64;
4081
+ if (typeof f.buffer === "string") {
4082
+ base64 = f.buffer;
4083
+ } else {
4084
+ const bytes = new Uint8Array(f.buffer);
4085
+ base64 = btoa(String.fromCharCode(...bytes));
4086
+ }
4087
+ return { name: f.name, mimeType: f.mimeType, data: base64 };
4088
+ })
4089
+ );
4090
+ const objectId = await this.resolveObjectId(element.nodeId);
4091
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
4092
+ objectId,
4093
+ functionDeclaration: `function(files) {
4094
+ if (!(this instanceof HTMLInputElement) || this.type !== 'file') {
4095
+ return { ok: false, fileCount: 0 };
4096
+ }
4097
+
4098
+ const dt = new DataTransfer();
4099
+ for (const f of files) {
4100
+ const bytes = Uint8Array.from(atob(f.data), function(c) { return c.charCodeAt(0); });
4101
+ const file = new File([bytes], f.name, { type: f.mimeType });
4102
+ dt.items.add(file);
4103
+ }
4104
+
4105
+ var descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'files');
4106
+ if (descriptor && descriptor.set) {
4107
+ descriptor.set.call(this, dt.files);
4108
+ } else {
4109
+ this.files = dt.files;
4110
+ }
4111
+
4112
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
4113
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
4114
+ return {
4115
+ ok: (this.files && this.files.length === files.length) || files.length === 0,
4116
+ fileCount: this.files ? this.files.length : 0
4117
+ };
4118
+ }`,
4119
+ arguments: [{ value: fileData }],
4120
+ returnByValue: true
4121
+ });
4122
+ if (!result.result.value.ok) {
4123
+ if (options.optional) return false;
4124
+ throw new Error("Failed to set files on input");
4125
+ }
4126
+ return true;
4127
+ });
4128
+ }
4129
+ async getNativeSelectMetadata(objectId, targets) {
4130
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
4131
+ objectId,
4132
+ functionDeclaration: `function(targetValues) {
4133
+ if (!(this instanceof HTMLSelectElement)) {
4134
+ return {
4135
+ currentIndex: -1,
4136
+ currentValue: '',
4137
+ disabled: [],
4138
+ isSelect: false,
4139
+ missing: Array.isArray(targetValues) ? targetValues.map(String) : [],
4140
+ multiple: false,
4141
+ options: [],
4142
+ selectedValues: [],
4143
+ targetIndexes: []
4144
+ };
4145
+ }
4146
+
4147
+ var allOptions = Array.from(this.options).map(function(opt, index) {
4148
+ return { index: index, label: opt.label || opt.text || '', value: opt.value || '' };
4149
+ });
4150
+ var targetIndexes = [];
4151
+ var missing = [];
4152
+ var disabled = [];
4153
+
4154
+ for (var i = 0; i < targetValues.length; i++) {
4155
+ var target = String(targetValues[i]);
4156
+ var idx = -1;
4157
+
4158
+ for (var j = 0; j < this.options.length; j++) {
4159
+ var opt = this.options[j];
4160
+ if (opt.value === target || opt.text === target || opt.label === target) {
4161
+ idx = j;
4162
+ break;
4163
+ }
4164
+ }
4165
+
4166
+ if (idx === -1 && /^\\d+$/.test(target)) {
4167
+ var numericIndex = parseInt(target, 10);
4168
+ if (numericIndex >= 0 && numericIndex < this.options.length) {
4169
+ idx = numericIndex;
4170
+ }
4171
+ }
4172
+
4173
+ if (idx === -1) {
4174
+ missing.push(target);
4175
+ continue;
4176
+ }
4177
+
4178
+ if (this.options[idx] && this.options[idx].disabled) {
4179
+ disabled.push(target);
4180
+ continue;
4181
+ }
4182
+
4183
+ if (targetIndexes.indexOf(idx) === -1) {
4184
+ targetIndexes.push(idx);
4185
+ }
4186
+ }
4187
+
4188
+ return {
4189
+ currentIndex: this.selectedIndex,
4190
+ currentValue: this.value || '',
4191
+ disabled: disabled,
4192
+ isSelect: true,
4193
+ missing: missing,
4194
+ multiple: !!this.multiple,
4195
+ options: allOptions,
4196
+ selectedValues: Array.from(this.selectedOptions).map(function(opt) { return opt.value || ''; }),
4197
+ targetIndexes: targetIndexes
4198
+ };
4199
+ }`,
4200
+ arguments: [{ value: targets }],
4201
+ returnByValue: true
4202
+ });
4203
+ return result.result.value;
4204
+ }
4205
+ async readNativeSelectValues(objectId) {
4206
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
4207
+ objectId,
4208
+ functionDeclaration: 'function() { return this instanceof HTMLSelectElement ? Array.from(this.selectedOptions).map(function(opt) { return opt.value || ""; }) : []; }',
4209
+ returnByValue: true
4210
+ });
4211
+ return result.result.value ?? [];
4212
+ }
4213
+ selectValuesMatch(actual, expected, multiple) {
4214
+ if (!multiple) {
4215
+ return (actual[0] ?? "") === (expected[0] ?? "");
3320
4216
  }
3321
- const fileData = await Promise.all(
3322
- files.map(async (f) => {
3323
- let base64;
3324
- if (typeof f.buffer === "string") {
3325
- base64 = f.buffer;
3326
- } else {
3327
- const bytes = new Uint8Array(f.buffer);
3328
- base64 = btoa(String.fromCharCode(...bytes));
4217
+ if (actual.length !== expected.length) {
4218
+ return false;
4219
+ }
4220
+ const actualSorted = [...actual].sort();
4221
+ const expectedSorted = [...expected].sort();
4222
+ return actualSorted.every((value, index) => value === expectedSorted[index]);
4223
+ }
4224
+ async applyNativeSelectByKeyboard(nodeId, objectId, currentIndex, targetIndex) {
4225
+ await this.cdp.send("DOM.focus", { nodeId });
4226
+ if (targetIndex !== currentIndex) {
4227
+ let effectiveIndex = currentIndex;
4228
+ if (effectiveIndex < 0 || targetIndex < effectiveIndex) {
4229
+ await this.dispatchKey("Home");
4230
+ effectiveIndex = 0;
4231
+ }
4232
+ const steps = targetIndex - effectiveIndex;
4233
+ const direction = steps >= 0 ? "ArrowDown" : "ArrowUp";
4234
+ for (let i = 0; i < Math.abs(steps); i++) {
4235
+ await this.dispatchKey(direction);
4236
+ }
4237
+ }
4238
+ const selectedValues = await this.readNativeSelectValues(objectId);
4239
+ return selectedValues[0] !== void 0;
4240
+ }
4241
+ async applyNativeSelectFallback(objectId, targetIndexes) {
4242
+ await this.cdp.send("Runtime.callFunctionOn", {
4243
+ objectId,
4244
+ functionDeclaration: `function(indexes) {
4245
+ if (!(this instanceof HTMLSelectElement)) return false;
4246
+
4247
+ var wanted = new Set(indexes.map(function(index) { return Number(index); }));
4248
+ for (var i = 0; i < this.options.length; i++) {
4249
+ this.options[i].selected = wanted.has(i);
4250
+ }
4251
+ if (!this.multiple && indexes.length === 1) {
4252
+ this.selectedIndex = indexes[0];
4253
+ }
4254
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
4255
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
4256
+ return true;
4257
+ }`,
4258
+ arguments: [{ value: targetIndexes }],
4259
+ returnByValue: true
4260
+ });
4261
+ }
4262
+ async selectEditableContent(objectId) {
4263
+ await this.cdp.send("Runtime.callFunctionOn", {
4264
+ objectId,
4265
+ functionDeclaration: `function() {
4266
+ if (this.isContentEditable) {
4267
+ this.focus();
4268
+ const range = document.createRange();
4269
+ range.selectNodeContents(this);
4270
+ const selection = window.getSelection();
4271
+ if (selection) {
4272
+ selection.removeAllRanges();
4273
+ selection.addRange(range);
4274
+ }
4275
+ return;
3329
4276
  }
3330
- return { name: f.name, mimeType: f.mimeType, data: base64 };
3331
- })
3332
- );
3333
- await this.cdp.send("Runtime.evaluate", {
3334
- expression: `(() => {
3335
- const input = document.querySelector(${JSON.stringify(element.selector)});
3336
- if (!input) return false;
3337
4277
 
3338
- const files = ${JSON.stringify(fileData)};
3339
- const dt = new DataTransfer();
4278
+ if (this.tagName === 'TEXTAREA') {
4279
+ this.selectionStart = 0;
4280
+ this.selectionEnd = this.value.length;
4281
+ this.focus();
4282
+ return;
4283
+ }
3340
4284
 
3341
- for (const f of files) {
3342
- const bytes = Uint8Array.from(atob(f.data), c => c.charCodeAt(0));
3343
- const file = new File([bytes], f.name, { type: f.mimeType });
3344
- dt.items.add(file);
4285
+ if (typeof this.select === 'function') {
4286
+ this.select();
4287
+ }
4288
+ this.focus();
4289
+ }`
4290
+ });
4291
+ }
4292
+ async clearEditableSelection(objectId, key) {
4293
+ await this.selectEditableContent(objectId);
4294
+ await this.dispatchKey(key);
4295
+ }
4296
+ async readEditableValue(objectId) {
4297
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
4298
+ objectId,
4299
+ functionDeclaration: `function() {
4300
+ if (this.isContentEditable) {
4301
+ return this.textContent || '';
3345
4302
  }
4303
+ return this.value || '';
4304
+ }`,
4305
+ returnByValue: true
4306
+ });
4307
+ return result.result.value ?? "";
4308
+ }
4309
+ async typeEditableFallback(nodeId, objectId, value) {
4310
+ await this.selectEditableContent(objectId);
4311
+ await this.cdp.send("DOM.focus", { nodeId });
4312
+ for (const char of value) {
4313
+ await this.dispatchKey(char);
4314
+ }
4315
+ }
4316
+ async applyRecordedSelectFallback(objectId, targetIndexes) {
4317
+ await this.cdp.send("Runtime.callFunctionOn", {
4318
+ objectId,
4319
+ functionDeclaration: `function(indexes) {
4320
+ if (!(this instanceof HTMLSelectElement)) return false;
3346
4321
 
3347
- input.files = dt.files;
3348
- input.dispatchEvent(new Event('change', { bubbles: true }));
4322
+ var wanted = new Set(indexes.map(function(index) { return Number(index); }));
4323
+ for (var i = 0; i < this.options.length; i++) {
4324
+ this.options[i].selected = wanted.has(i);
4325
+ }
4326
+ if (!this.multiple && indexes.length === 1) {
4327
+ this.selectedIndex = indexes[0];
4328
+ }
3349
4329
  return true;
3350
- })()`,
4330
+ }`,
4331
+ arguments: [{ value: targetIndexes }],
3351
4332
  returnByValue: true
3352
4333
  });
3353
- return true;
4334
+ return this.invokeRecordedEventListeners(objectId, ["input", "change"]);
4335
+ }
4336
+ async invokeRecordedEventListeners(objectId, eventTypes) {
4337
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
4338
+ objectId,
4339
+ functionDeclaration: `function(types) {
4340
+ function buildPath(target) {
4341
+ var path = [];
4342
+ var node = target;
4343
+
4344
+ while (node) {
4345
+ path.push(node);
4346
+
4347
+ if (node.parentElement) {
4348
+ node = node.parentElement;
4349
+ continue;
4350
+ }
4351
+
4352
+ if (node === document) {
4353
+ node = window;
4354
+ continue;
4355
+ }
4356
+
4357
+ if (node.defaultView && node !== node.defaultView) {
4358
+ node = node.defaultView;
4359
+ continue;
4360
+ }
4361
+
4362
+ if (node.ownerDocument && node !== node.ownerDocument) {
4363
+ node = node.ownerDocument;
4364
+ continue;
4365
+ }
4366
+
4367
+ var root = node.getRootNode && node.getRootNode();
4368
+ if (root && root !== node && root.host) {
4369
+ node = root.host;
4370
+ continue;
4371
+ }
4372
+
4373
+ node = null;
4374
+ }
4375
+
4376
+ return path;
4377
+ }
4378
+
4379
+ function createEvent(type, target, currentTarget, path, phase) {
4380
+ return {
4381
+ type: type,
4382
+ target: target,
4383
+ currentTarget: currentTarget,
4384
+ srcElement: target,
4385
+ isTrusted: true,
4386
+ bubbles: true,
4387
+ cancelable: true,
4388
+ composed: true,
4389
+ defaultPrevented: false,
4390
+ eventPhase: phase,
4391
+ timeStamp: Date.now(),
4392
+ preventDefault: function() {
4393
+ this.defaultPrevented = true;
4394
+ },
4395
+ stopPropagation: function() {
4396
+ this.__stopped = true;
4397
+ },
4398
+ stopImmediatePropagation: function() {
4399
+ this.__stopped = true;
4400
+ this.__immediateStopped = true;
4401
+ },
4402
+ composedPath: function() {
4403
+ return path.slice();
4404
+ }
4405
+ };
4406
+ }
4407
+
4408
+ function invokePhase(type, nodes, capture, target, path) {
4409
+ var invoked = false;
4410
+
4411
+ for (var i = 0; i < nodes.length; i++) {
4412
+ var currentTarget = nodes[i];
4413
+ var store = currentTarget && currentTarget.__bpEventListeners;
4414
+ var entries = store && store[type];
4415
+ if (!Array.isArray(entries) || entries.length === 0) continue;
4416
+
4417
+ var phase = currentTarget === target ? 2 : capture ? 1 : 3;
4418
+ var event = createEvent(type, target, currentTarget, path, phase);
4419
+
4420
+ for (var j = 0; j < entries.length; j++) {
4421
+ var entry = entries[j];
4422
+ if (!!entry.capture !== capture) continue;
4423
+
4424
+ var listener = entry.listener;
4425
+ if (typeof listener === 'function') {
4426
+ listener.call(currentTarget, event);
4427
+ invoked = true;
4428
+ } else if (listener && typeof listener.handleEvent === 'function') {
4429
+ listener.handleEvent(event);
4430
+ invoked = true;
4431
+ }
4432
+
4433
+ if (event.__immediateStopped) {
4434
+ break;
4435
+ }
4436
+ }
4437
+
4438
+ if (event.__stopped) {
4439
+ break;
4440
+ }
4441
+ }
4442
+
4443
+ return invoked;
4444
+ }
4445
+
4446
+ var path = buildPath(this);
4447
+ var capturePath = path.slice().reverse();
4448
+ var bubblePath = path.slice();
4449
+ var invokedAny = false;
4450
+
4451
+ for (var i = 0; i < types.length; i++) {
4452
+ var type = String(types[i]);
4453
+ if (invokePhase(type, capturePath, true, this, path)) {
4454
+ invokedAny = true;
4455
+ }
4456
+ if (invokePhase(type, bubblePath, false, this, path)) {
4457
+ invokedAny = true;
4458
+ }
4459
+ }
4460
+
4461
+ return invokedAny;
4462
+ }`,
4463
+ arguments: [{ value: eventTypes }],
4464
+ returnByValue: true
4465
+ });
4466
+ return result.result.value ?? false;
3354
4467
  }
3355
4468
  /**
3356
4469
  * Wait for a download to complete, triggered by an action
@@ -3510,7 +4623,7 @@ var Page = class {
3510
4623
  return lines.join("\n");
3511
4624
  };
3512
4625
  const text = formatTree(accessibilityTree);
3513
- return {
4626
+ const result = {
3514
4627
  url,
3515
4628
  title,
3516
4629
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3518,6 +4631,8 @@ var Page = class {
3518
4631
  interactiveElements,
3519
4632
  text
3520
4633
  };
4634
+ this.lastSnapshot = result;
4635
+ return result;
3521
4636
  }
3522
4637
  /**
3523
4638
  * Export the current ref map for cross-exec reuse (CLI).
@@ -4046,11 +5161,13 @@ var Page = class {
4046
5161
  try {
4047
5162
  return await fn();
4048
5163
  } catch (e) {
4049
- 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"))) {
5164
+ const message = e instanceof Error ? e.message : "";
5165
+ 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"))) {
4050
5166
  lastError = e;
4051
5167
  if (attempt < retries) {
4052
5168
  this.rootNodeId = null;
4053
- await sleep3(delay);
5169
+ this.currentFrameContextId = null;
5170
+ await sleep4(delay);
4054
5171
  continue;
4055
5172
  }
4056
5173
  }
@@ -4095,6 +5212,39 @@ var Page = class {
4095
5212
  }
4096
5213
  }
4097
5214
  }
5215
+ if (selectorList.every((s) => s.startsWith("ref:")) && this.lastSnapshot) {
5216
+ for (const selector of selectorList) {
5217
+ const ref = selector.slice(4);
5218
+ const originalElement = this.lastSnapshot.interactiveElements.find((e) => e.ref === ref);
5219
+ if (!originalElement) continue;
5220
+ const freshSnapshot = await this.snapshot();
5221
+ const match = freshSnapshot.interactiveElements.find(
5222
+ (e) => e.role === originalElement.role && e.name === originalElement.name
5223
+ );
5224
+ if (match) {
5225
+ const newBackendNodeId = this.refMap.get(match.ref);
5226
+ if (newBackendNodeId) {
5227
+ try {
5228
+ await this.ensureRootNode();
5229
+ const pushResult = await this.cdp.send(
5230
+ "DOM.pushNodesByBackendIdsToFrontend",
5231
+ { backendNodeIds: [newBackendNodeId] }
5232
+ );
5233
+ if (pushResult.nodeIds?.[0]) {
5234
+ this._lastMatchedSelector = `ref:${match.ref}`;
5235
+ return {
5236
+ nodeId: pushResult.nodeIds[0],
5237
+ backendNodeId: newBackendNodeId,
5238
+ selector: `ref:${match.ref}`,
5239
+ waitedMs: 0
5240
+ };
5241
+ }
5242
+ } catch {
5243
+ }
5244
+ }
5245
+ }
5246
+ }
5247
+ }
4098
5248
  const cssSelectors = selectorList.filter((s) => !s.startsWith("ref:"));
4099
5249
  if (cssSelectors.length === 0) {
4100
5250
  return null;
@@ -4158,6 +5308,38 @@ var Page = class {
4158
5308
  */
4159
5309
  async ensureRootNode() {
4160
5310
  if (this.rootNodeId) return;
5311
+ if (this.currentFrame) {
5312
+ const mainDocument = await this.cdp.send("DOM.getDocument", {
5313
+ depth: 0
5314
+ });
5315
+ const iframeNode = await this.cdp.send("DOM.querySelector", {
5316
+ nodeId: mainDocument.root.nodeId,
5317
+ selector: this.currentFrame
5318
+ });
5319
+ if (iframeNode.nodeId) {
5320
+ const frameResult = await this.cdp.send("DOM.describeNode", {
5321
+ nodeId: iframeNode.nodeId,
5322
+ depth: 1
5323
+ });
5324
+ if (frameResult.node.contentDocument?.nodeId) {
5325
+ this.rootNodeId = frameResult.node.contentDocument.nodeId;
5326
+ if (frameResult.node.frameId) {
5327
+ let contextId = this.frameExecutionContexts.get(frameResult.node.frameId);
5328
+ if (!contextId) {
5329
+ for (let i = 0; i < 10; i++) {
5330
+ await sleep4(100);
5331
+ contextId = this.frameExecutionContexts.get(frameResult.node.frameId);
5332
+ if (contextId) break;
5333
+ }
5334
+ }
5335
+ this.currentFrameContextId = contextId ?? null;
5336
+ }
5337
+ return;
5338
+ }
5339
+ }
5340
+ this.currentFrame = null;
5341
+ this.currentFrameContextId = null;
5342
+ }
4161
5343
  const doc = await this.cdp.send("DOM.getDocument", {
4162
5344
  depth: 0
4163
5345
  });
@@ -4198,30 +5380,115 @@ var Page = class {
4198
5380
  }
4199
5381
  }
4200
5382
  /**
4201
- * Click an element by node ID
5383
+ * Click an element by node ID using Playwright's 3-event sequence:
5384
+ * mouseMoved → mousePressed → mouseReleased (sequential).
5385
+ * Uses DOM.getContentQuads for accurate coordinates (handles CSS transforms).
5386
+ * Falls back to JS this.click() if CDP mouse dispatch fails.
4202
5387
  */
4203
5388
  async clickElement(nodeId) {
4204
- const box = await this.getBoxModel(nodeId);
4205
- if (!box) {
4206
- throw new Error("Could not get element box model for click");
4207
- }
4208
- const x = box.content[0] + box.width / 2;
4209
- const y = box.content[1] + box.height / 2;
4210
- await this.cdp.send("Input.dispatchMouseEvent", {
4211
- type: "mousePressed",
4212
- x,
4213
- y,
4214
- button: "left",
4215
- clickCount: 1
5389
+ const { object } = await this.cdp.send("DOM.resolveNode", {
5390
+ nodeId
5391
+ });
5392
+ let x;
5393
+ let y;
5394
+ try {
5395
+ const { quads } = await this.cdp.send("DOM.getContentQuads", {
5396
+ objectId: object.objectId
5397
+ });
5398
+ if (quads && quads.length > 0) {
5399
+ const quad = quads[0];
5400
+ x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
5401
+ y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
5402
+ } else {
5403
+ throw new Error("No quads");
5404
+ }
5405
+ } catch {
5406
+ const box = await this.getBoxModel(nodeId);
5407
+ if (!box) throw new Error("Could not get element position for click");
5408
+ x = box.content[0] + box.width / 2;
5409
+ y = box.content[1] + box.height / 2;
5410
+ }
5411
+ try {
5412
+ await this.cdp.send("Input.dispatchMouseEvent", {
5413
+ type: "mouseMoved",
5414
+ x,
5415
+ y,
5416
+ button: "none",
5417
+ buttons: 0,
5418
+ modifiers: 0
5419
+ });
5420
+ await this.cdp.send("Input.dispatchMouseEvent", {
5421
+ type: "mousePressed",
5422
+ x,
5423
+ y,
5424
+ button: "left",
5425
+ buttons: 1,
5426
+ clickCount: 1,
5427
+ modifiers: 0
5428
+ });
5429
+ await this.cdp.send("Input.dispatchMouseEvent", {
5430
+ type: "mouseReleased",
5431
+ x,
5432
+ y,
5433
+ button: "left",
5434
+ buttons: 0,
5435
+ clickCount: 1,
5436
+ modifiers: 0
5437
+ });
5438
+ } catch {
5439
+ await this.cdp.send("Runtime.callFunctionOn", {
5440
+ objectId: object.objectId,
5441
+ functionDeclaration: "function() { this.click(); }"
5442
+ });
5443
+ }
5444
+ await this.cdp.send("Runtime.evaluate", { expression: "0" });
5445
+ }
5446
+ /**
5447
+ * Resolve a nodeId to a Remote Object ID for use with Runtime.callFunctionOn
5448
+ */
5449
+ async resolveObjectId(nodeId) {
5450
+ const { object } = await this.cdp.send("DOM.resolveNode", {
5451
+ nodeId
4216
5452
  });
4217
- await this.cdp.send("Input.dispatchMouseEvent", {
4218
- type: "mouseReleased",
4219
- x,
4220
- y,
4221
- button: "left",
4222
- clickCount: 1
5453
+ return object.objectId;
5454
+ }
5455
+ async dispatchKeyDefinition(def) {
5456
+ const downParams = {
5457
+ type: def.text !== void 0 ? "keyDown" : "rawKeyDown",
5458
+ key: def.key,
5459
+ code: def.code,
5460
+ windowsVirtualKeyCode: def.keyCode,
5461
+ modifiers: 0,
5462
+ autoRepeat: false,
5463
+ location: def.location ?? 0,
5464
+ isKeypad: false
5465
+ };
5466
+ if (def.text !== void 0) {
5467
+ downParams["text"] = def.text;
5468
+ downParams["unmodifiedText"] = def.text;
5469
+ }
5470
+ await this.cdp.send("Input.dispatchKeyEvent", downParams);
5471
+ await this.cdp.send("Input.dispatchKeyEvent", {
5472
+ type: "keyUp",
5473
+ key: def.key,
5474
+ code: def.code,
5475
+ windowsVirtualKeyCode: def.keyCode,
5476
+ modifiers: 0,
5477
+ location: def.location ?? 0
4223
5478
  });
4224
5479
  }
5480
+ async dispatchKey(key) {
5481
+ const def = US_KEYBOARD[key];
5482
+ if (def) {
5483
+ await this.dispatchKeyDefinition(def);
5484
+ return;
5485
+ }
5486
+ if ([...key].length === 1) {
5487
+ await this.cdp.send("Input.insertText", { text: key });
5488
+ return;
5489
+ }
5490
+ await this.dispatchKeyDefinition({ key, code: key, keyCode: 0 });
5491
+ }
4225
5492
  // ============ Audio I/O ============
4226
5493
  /**
4227
5494
  * Audio input controller (fake microphone).
@@ -4294,7 +5561,7 @@ var Page = class {
4294
5561
  const start = Date.now();
4295
5562
  await this.audioOutput.start();
4296
5563
  if (options.preDelay && options.preDelay > 0) {
4297
- await sleep3(options.preDelay);
5564
+ await sleep4(options.preDelay);
4298
5565
  }
4299
5566
  const inputDone = this.audioInput.play(options.input, {
4300
5567
  waitForEnd: !!options.sendSelector
@@ -4322,11 +5589,27 @@ var Page = class {
4322
5589
  };
4323
5590
  }
4324
5591
  };
4325
- function sleep3(ms) {
5592
+ function sleep4(ms) {
4326
5593
  return new Promise((resolve) => setTimeout(resolve, ms));
4327
5594
  }
4328
5595
 
4329
5596
  // src/browser/browser.ts
5597
+ function scoreTarget(t) {
5598
+ let score = 0;
5599
+ if (t.url.startsWith("http://") || t.url.startsWith("https://")) score += 10;
5600
+ if (t.url.startsWith("chrome://")) score -= 20;
5601
+ if (t.url.startsWith("chrome-extension://")) score -= 15;
5602
+ if (t.url.startsWith("devtools://")) score -= 25;
5603
+ if (t.url === "about:blank") score -= 5;
5604
+ if (!t.attached) score += 3;
5605
+ if (t.title && t.title.length > 0) score += 2;
5606
+ return score;
5607
+ }
5608
+ function pickBestTarget(targets) {
5609
+ if (targets.length === 0) return void 0;
5610
+ const sorted = [...targets].sort((a, b) => scoreTarget(b) - scoreTarget(a));
5611
+ return sorted[0].targetId;
5612
+ }
4330
5613
  var Browser = class _Browser {
4331
5614
  cdp;
4332
5615
  providerSession;
@@ -4348,28 +5631,46 @@ var Browser = class _Browser {
4348
5631
  return new _Browser(cdp, provider, session, options);
4349
5632
  }
4350
5633
  /**
4351
- * Get or create a page by name
4352
- * If no name is provided, returns the first available page or creates a new one
5634
+ * Get or create a page by name.
5635
+ * If no name is provided, returns the first available page or creates a new one.
5636
+ *
5637
+ * Target selection heuristics (when no targetId is specified):
5638
+ * - Prefer http/https URLs over chrome://, devtools://, about:blank
5639
+ * - Prefer unattached targets (not already controlled by another client)
5640
+ * - Filter by targetUrl if provided
4353
5641
  */
4354
5642
  async page(name, options) {
4355
5643
  const pageName = name ?? "default";
4356
5644
  const cached = this.pages.get(pageName);
4357
5645
  if (cached) return cached;
4358
5646
  const targets = await this.cdp.send("Target.getTargets");
4359
- const pageTargets = targets.targetInfos.filter((t) => t.type === "page");
5647
+ let pageTargets = targets.targetInfos.filter((t) => t.type === "page");
5648
+ if (options?.targetUrl) {
5649
+ const urlFilter = options.targetUrl;
5650
+ const filtered = pageTargets.filter((t) => t.url.includes(urlFilter));
5651
+ if (filtered.length > 0) {
5652
+ pageTargets = filtered;
5653
+ } else {
5654
+ console.warn(
5655
+ `[browser-pilot] No targets match URL filter "${urlFilter}", falling back to all page targets`
5656
+ );
5657
+ }
5658
+ }
4360
5659
  let targetId;
4361
5660
  if (options?.targetId) {
4362
- const targetExists = pageTargets.some((t) => t.targetId === options.targetId);
5661
+ const targetExists = targets.targetInfos.some(
5662
+ (t) => t.type === "page" && t.targetId === options.targetId
5663
+ );
4363
5664
  if (targetExists) {
4364
5665
  targetId = options.targetId;
4365
5666
  } else {
4366
5667
  console.warn(`[browser-pilot] Target ${options.targetId} no longer exists, falling back`);
4367
- targetId = pageTargets.length > 0 ? pageTargets[0].targetId : (await this.cdp.send("Target.createTarget", {
5668
+ targetId = pickBestTarget(pageTargets) ?? (await this.cdp.send("Target.createTarget", {
4368
5669
  url: "about:blank"
4369
5670
  })).targetId;
4370
5671
  }
4371
5672
  } else if (pageTargets.length > 0) {
4372
- targetId = pageTargets[0].targetId;
5673
+ targetId = pickBestTarget(pageTargets);
4373
5674
  } else {
4374
5675
  const result = await this.cdp.send("Target.createTarget", {
4375
5676
  url: "about:blank"
@@ -4379,6 +5680,21 @@ var Browser = class _Browser {
4379
5680
  await this.cdp.attachToTarget(targetId);
4380
5681
  const page = new Page(this.cdp, targetId);
4381
5682
  await page.init();
5683
+ const minViewport = options?.minViewport !== void 0 ? options.minViewport : { width: 200, height: 200 };
5684
+ if (minViewport !== false) {
5685
+ try {
5686
+ const viewport = await page.evaluate(
5687
+ "({ w: window.innerWidth, h: window.innerHeight })"
5688
+ );
5689
+ if (viewport.w < minViewport.width || viewport.h < minViewport.height) {
5690
+ console.warn(
5691
+ `[browser-pilot] Attached target has small viewport (${viewport.w}x${viewport.h}). Applying default viewport override (1280x720). Use { minViewport: false } to disable this check.`
5692
+ );
5693
+ await page.setViewport({ width: 1280, height: 720 });
5694
+ }
5695
+ } catch {
5696
+ }
5697
+ }
4382
5698
  this.pages.set(pageName, page);
4383
5699
  return page;
4384
5700
  }