cdp-skill 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +4 -4
  2. package/SKILL.md +276 -170
  3. package/package.json +9 -8
  4. package/{src → scripts}/aria/index.js +1 -1
  5. package/scripts/aria/role-query.js +295 -0
  6. package/{src → scripts}/aria.js +11 -5
  7. package/{src → scripts}/capture/console-capture.js +11 -9
  8. package/{src → scripts}/capture/screenshot-capture.js +8 -9
  9. package/{src → scripts}/cdp/connection.js +30 -6
  10. package/{src → scripts}/cdp-skill.js +7 -6
  11. package/{src → scripts}/diff.js +7 -6
  12. package/{src → scripts}/dom/LazyResolver.js +23 -12
  13. package/{src → scripts}/dom/actionability.js +39 -22
  14. package/{src → scripts}/dom/click-executor.js +90 -53
  15. package/{src → scripts}/dom/element-locator.js +4 -4
  16. package/{src → scripts}/dom/fill-executor.js +8 -4
  17. package/{src → scripts}/dom/input-emulator.js +47 -9
  18. package/{src → scripts}/dom/react-filler.js +11 -3
  19. package/{src → scripts}/dom/wait-executor.js +10 -2
  20. package/{src → scripts}/page/dialog-handler.js +7 -3
  21. package/{src → scripts}/page/dom-stability.js +17 -10
  22. package/{src → scripts}/page/page-controller.js +41 -34
  23. package/{src → scripts}/runner/context-helpers.js +7 -0
  24. package/{src → scripts}/runner/execute-browser.js +3 -118
  25. package/{src → scripts}/runner/execute-dynamic.js +46 -11
  26. package/{src → scripts}/runner/execute-form.js +6 -4
  27. package/{src → scripts}/runner/execute-input.js +127 -100
  28. package/{src → scripts}/runner/execute-interaction.js +31 -46
  29. package/{src → scripts}/runner/execute-navigation.js +14 -12
  30. package/{src → scripts}/runner/step-executors.js +28 -9
  31. package/{src → scripts}/runner/step-registry.js +57 -8
  32. package/{src → scripts}/runner/step-validator.js +13 -3
  33. package/{src → scripts}/tests/ExecuteInput.test.js +58 -188
  34. package/src/aria/role-query.js +0 -1229
  35. package/src/aria/snapshot.js +0 -459
  36. /package/{src → scripts}/aria/output-processor.js +0 -0
  37. /package/{src → scripts}/capture/debug-capture.js +0 -0
  38. /package/{src → scripts}/capture/error-aggregator.js +0 -0
  39. /package/{src → scripts}/capture/eval-serializer.js +0 -0
  40. /package/{src → scripts}/capture/index.js +0 -0
  41. /package/{src → scripts}/capture/network-capture.js +0 -0
  42. /package/{src → scripts}/capture/pdf-capture.js +0 -0
  43. /package/{src → scripts}/cdp/browser.js +0 -0
  44. /package/{src → scripts}/cdp/discovery.js +0 -0
  45. /package/{src → scripts}/cdp/index.js +0 -0
  46. /package/{src → scripts}/cdp/target-and-session.js +0 -0
  47. /package/{src → scripts}/constants.js +0 -0
  48. /package/{src → scripts}/dom/element-handle.js +0 -0
  49. /package/{src → scripts}/dom/element-validator.js +0 -0
  50. /package/{src → scripts}/dom/index.js +0 -0
  51. /package/{src → scripts}/dom/keyboard-executor.js +0 -0
  52. /package/{src → scripts}/dom/quad-helpers.js +0 -0
  53. /package/{src → scripts}/index.js +0 -0
  54. /package/{src → scripts}/page/cookie-manager.js +0 -0
  55. /package/{src → scripts}/page/index.js +0 -0
  56. /package/{src → scripts}/page/wait-utilities.js +0 -0
  57. /package/{src → scripts}/page/web-storage-manager.js +0 -0
  58. /package/{src → scripts}/runner/execute-query.js +0 -0
  59. /package/{src → scripts}/runner/index.js +0 -0
  60. /package/{src → scripts}/tests/Actionability.test.js +0 -0
  61. /package/{src → scripts}/tests/Aria.test.js +0 -0
  62. /package/{src → scripts}/tests/BrowserClient.test.js +0 -0
  63. /package/{src → scripts}/tests/CDPConnection.test.js +0 -0
  64. /package/{src → scripts}/tests/ChromeDiscovery.test.js +0 -0
  65. /package/{src → scripts}/tests/ClickExecutor.test.js +0 -0
  66. /package/{src → scripts}/tests/ConsoleCapture.test.js +0 -0
  67. /package/{src → scripts}/tests/ContextHelpers.test.js +0 -0
  68. /package/{src → scripts}/tests/CookieManager.test.js +0 -0
  69. /package/{src → scripts}/tests/DebugCapture.test.js +0 -0
  70. /package/{src → scripts}/tests/ElementHandle.test.js +0 -0
  71. /package/{src → scripts}/tests/ElementLocator.test.js +0 -0
  72. /package/{src → scripts}/tests/ErrorAggregator.test.js +0 -0
  73. /package/{src → scripts}/tests/EvalSerializer.test.js +0 -0
  74. /package/{src → scripts}/tests/ExecuteBrowser.test.js +0 -0
  75. /package/{src → scripts}/tests/ExecuteDynamic.test.js +0 -0
  76. /package/{src → scripts}/tests/ExecuteForm.test.js +0 -0
  77. /package/{src → scripts}/tests/ExecuteInteraction.test.js +0 -0
  78. /package/{src → scripts}/tests/ExecuteQuery.test.js +0 -0
  79. /package/{src → scripts}/tests/FillExecutor.test.js +0 -0
  80. /package/{src → scripts}/tests/InputEmulator.test.js +0 -0
  81. /package/{src → scripts}/tests/KeyboardExecutor.test.js +0 -0
  82. /package/{src → scripts}/tests/LazyResolver.test.js +0 -0
  83. /package/{src → scripts}/tests/NetworkErrorCapture.test.js +0 -0
  84. /package/{src → scripts}/tests/PageController.test.js +0 -0
  85. /package/{src → scripts}/tests/PdfCapture.test.js +0 -0
  86. /package/{src → scripts}/tests/ScreenshotCapture.test.js +0 -0
  87. /package/{src → scripts}/tests/SessionRegistry.test.js +0 -0
  88. /package/{src → scripts}/tests/StepValidator.test.js +0 -0
  89. /package/{src → scripts}/tests/TargetManager.test.js +0 -0
  90. /package/{src → scripts}/tests/TestRunner.test.js +0 -0
  91. /package/{src → scripts}/tests/WaitStrategy.test.js +0 -0
  92. /package/{src → scripts}/tests/WaitUtilities.test.js +0 -0
  93. /package/{src → scripts}/tests/WebStorageManager.test.js +0 -0
  94. /package/{src → scripts}/tests/integration.test.js +0 -0
  95. /package/{src → scripts}/types.js +0 -0
  96. /package/{src → scripts}/utils/backoff.js +0 -0
  97. /package/{src → scripts}/utils/cdp-helpers.js +0 -0
  98. /package/{src → scripts}/utils/devices.js +0 -0
  99. /package/{src → scripts}/utils/errors.js +0 -0
  100. /package/{src → scripts}/utils/index.js +0 -0
  101. /package/{src → scripts}/utils/temp.js +0 -0
  102. /package/{src → scripts}/utils/validators.js +0 -0
  103. /package/{src → scripts}/utils.js +0 -0
@@ -22,9 +22,22 @@ const stableFrameCount = 3;
22
22
  /**
23
23
  * Create an actionability checker for Playwright-style auto-waiting
24
24
  * @param {Object} session - CDP session
25
+ * @param {Object} [options] - Options
26
+ * @param {function(): number|null} [options.getFrameContext] - Returns contextId for current frame
25
27
  * @returns {Object} Actionability checker interface
26
28
  */
27
- export function createActionabilityChecker(session) {
29
+ export function createActionabilityChecker(session, options = {}) {
30
+ const { getFrameContext } = options;
31
+
32
+ /** Build Runtime.evaluate params with frame context when in an iframe. */
33
+ function evalParams(expression, returnByValue = true) {
34
+ const params = { expression, returnByValue };
35
+ if (getFrameContext) {
36
+ const contextId = getFrameContext();
37
+ if (contextId) params.contextId = contextId;
38
+ }
39
+ return params;
40
+ }
28
41
  // Simplified: removed stability check, shorter retry delays
29
42
  const retryDelays = [0, 50, 100, 200];
30
43
 
@@ -48,10 +61,18 @@ export function createActionabilityChecker(session) {
48
61
 
49
62
  async function findElementInternal(selector) {
50
63
  try {
51
- const result = await session.send('Runtime.evaluate', {
52
- expression: `document.querySelector(${JSON.stringify(selector)})`,
53
- returnByValue: false
54
- });
64
+ const result = await session.send('Runtime.evaluate',
65
+ evalParams(`document.querySelector(${JSON.stringify(selector)})`, false)
66
+ );
67
+
68
+ // Check for selector syntax errors (e.g., invalid CSS selectors)
69
+ if (result.exceptionDetails) {
70
+ const msg = result.exceptionDetails.exception?.description ||
71
+ result.exceptionDetails.exception?.value ||
72
+ result.exceptionDetails.text ||
73
+ 'Unknown selector error';
74
+ return { success: false, error: `Selector error: ${msg}`, immediate: true };
75
+ }
55
76
 
56
77
  if (result.result.subtype === 'null' || !result.result.objectId) {
57
78
  return { success: false, error: `Element not found: ${selector}` };
@@ -302,6 +323,10 @@ export function createActionabilityChecker(session) {
302
323
  const element = await findElementInternal(selector);
303
324
  if (!element.success) {
304
325
  lastError = element.error;
326
+ // Immediate failures (syntax errors) should not retry
327
+ if (element.immediate) {
328
+ return { success: false, error: element.error };
329
+ }
305
330
  retry++;
306
331
  continue;
307
332
  }
@@ -456,14 +481,9 @@ export function createActionabilityChecker(session) {
456
481
  // Check if the hit element is a child of our target (also valid)
457
482
  const isChild = await session.send('Runtime.callFunctionOn', {
458
483
  objectId,
459
- functionDeclaration: `function(hitNodeId) {
460
- // We need to find if the hit element is inside this element
461
- // This is tricky because we only have backend node IDs
462
- // Use elementFromPoint as a fallback check
463
- const rect = this.getBoundingClientRect();
464
- const centerX = rect.left + rect.width / 2;
465
- const centerY = rect.top + rect.height / 2;
466
- const hitEl = document.elementFromPoint(centerX, centerY);
484
+ functionDeclaration: `function(pt) {
485
+ // Use the provided point coordinates for elementFromPoint check
486
+ const hitEl = document.elementFromPoint(pt.x, pt.y);
467
487
 
468
488
  if (!hitEl) return { isChild: false, coverInfo: 'no-element' };
469
489
 
@@ -481,6 +501,7 @@ export function createActionabilityChecker(session) {
481
501
 
482
502
  return { isChild: false, coverInfo: desc };
483
503
  }`,
504
+ arguments: [{ value: point }],
484
505
  returnByValue: true
485
506
  });
486
507
 
@@ -499,19 +520,17 @@ export function createActionabilityChecker(session) {
499
520
  try {
500
521
  const fallbackResult = await session.send('Runtime.callFunctionOn', {
501
522
  objectId,
502
- functionDeclaration: `function() {
503
- const rect = this.getBoundingClientRect();
504
- const centerX = rect.left + rect.width / 2;
505
- const centerY = rect.top + rect.height / 2;
506
- const hitEl = document.elementFromPoint(centerX, centerY);
523
+ functionDeclaration: `function(pt) {
524
+ const hitEl = document.elementFromPoint(pt.x, pt.y);
507
525
 
508
- if (!hitEl) return { covered: true, coverInfo: 'no-element-at-center' };
526
+ if (!hitEl) return { covered: true, coverInfo: 'no-element-at-point' };
509
527
  if (hitEl === this || this.contains(hitEl)) return { covered: false };
510
528
 
511
529
  let desc = hitEl.tagName.toLowerCase();
512
530
  if (hitEl.id) desc += '#' + hitEl.id;
513
531
  return { covered: true, coverInfo: desc };
514
532
  }`,
533
+ arguments: [{ value: point }],
515
534
  returnByValue: true
516
535
  });
517
536
  return {
@@ -592,9 +611,7 @@ export function createActionabilityChecker(session) {
592
611
 
593
612
  // Scroll the page
594
613
  const scrollDir = direction === 'up' ? -scrollAmount : scrollAmount;
595
- await session.send('Runtime.evaluate', {
596
- expression: `window.scrollBy(0, ${scrollDir})`
597
- });
614
+ await session.send('Runtime.evaluate', evalParams(`window.scrollBy(0, ${scrollDir})`));
598
615
 
599
616
  scrollCount++;
600
617
  await sleep(200); // Wait for content to load/render
@@ -8,12 +8,10 @@
8
8
  *
9
9
  * DEPENDENCIES:
10
10
  * - ./actionability.js: createActionabilityChecker
11
- * - ./element-validator.js: createElementValidator
12
11
  * - ../utils.js: sleep, elementNotFoundError, getCurrentUrl, getElementAtPoint, detectNavigation, releaseObject
13
12
  */
14
13
 
15
14
  import { createActionabilityChecker } from './actionability.js';
16
- import { createElementValidator } from './element-validator.js';
17
15
  import { createLazyResolver } from './LazyResolver.js';
18
16
  import {
19
17
  sleep,
@@ -39,8 +37,7 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
39
37
  if (!inputEmulator) throw new Error('Input emulator is required');
40
38
 
41
39
  const getFrameContext = elementLocator.getFrameContext || null;
42
- const actionabilityChecker = createActionabilityChecker(session);
43
- const elementValidator = createElementValidator(session);
40
+ const actionabilityChecker = createActionabilityChecker(session, { getFrameContext });
44
41
  const lazyResolver = createLazyResolver(session, { getFrameContext });
45
42
 
46
43
  /** Build Runtime.evaluate params with frame context when in an iframe. */
@@ -61,8 +58,8 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
61
58
  visibleBox.y = Math.max(box.y, 0);
62
59
  const right = Math.min(box.x + box.width, viewport.width);
63
60
  const bottom = Math.min(box.y + box.height, viewport.height);
64
- visibleBox.width = right - visibleBox.x;
65
- visibleBox.height = bottom - visibleBox.y;
61
+ visibleBox.width = Math.max(0, right - visibleBox.x);
62
+ visibleBox.height = Math.max(0, bottom - visibleBox.y);
66
63
  }
67
64
 
68
65
  return {
@@ -402,16 +399,25 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
402
399
  if (matchesRoleAndName(el)) return el;
403
400
  }
404
401
 
405
- // Strategy 3: Search in shadow roots
406
- const shadowHosts = document.querySelectorAll('*');
407
- for (const host of shadowHosts) {
408
- if (host.shadowRoot) {
409
- const els = host.shadowRoot.querySelectorAll(selectorString);
402
+ // Strategy 3: Search in shadow roots via tree walk (avoids querySelectorAll('*'))
403
+ function searchShadowRoots(node) {
404
+ if (node.shadowRoot) {
405
+ const els = node.shadowRoot.querySelectorAll(selectorString);
410
406
  for (const el of els) {
411
407
  if (matchesRoleAndName(el)) return el;
412
408
  }
409
+ const found = searchShadowRoots(node.shadowRoot);
410
+ if (found) return found;
413
411
  }
412
+ const children = node.children || [];
413
+ for (const child of children) {
414
+ const found = searchShadowRoots(child);
415
+ if (found) return found;
416
+ }
417
+ return null;
414
418
  }
419
+ const shadowResult = searchShadowRoots(document.body);
420
+ if (shadowResult) return shadowResult;
415
421
  }
416
422
 
417
423
  return null;
@@ -562,7 +568,7 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
562
568
  }
563
569
 
564
570
  async function clickAtCoordinates(x, y, opts = {}) {
565
- const { debug = false, waitForNavigation = false, navigationTimeout = 100 } = opts;
571
+ const { debug = false, waitForNavigation = false, navigationTimeout = 100, waitAfter = false, waitAfterOptions = {} } = opts;
566
572
 
567
573
  const urlBeforeClick = await getCurrentUrl(session);
568
574
 
@@ -579,38 +585,27 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
579
585
  coordinates: { x, y }
580
586
  };
581
587
 
582
- if (waitForNavigation) {
583
- const navResult = await detectNavigation(session, urlBeforeClick, navigationTimeout);
584
- result.navigated = navResult.navigated;
585
- if (navResult.newUrl) {
586
- result.newUrl = navResult.newUrl;
587
- }
588
- }
589
-
590
- if (debug) {
591
- result.debug = {
592
- clickedAt: { x, y },
593
- elementHit: elementAtPoint
594
- };
595
- }
596
-
597
- return result;
588
+ return addNavigationAndDebugInfo(result, urlBeforeClick, { point: { x, y }, elementAtPoint }, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
598
589
  }
599
590
 
591
+ let clickVerifyCounter = 0;
592
+
600
593
  async function clickWithVerificationByRef(ref, x, y) {
601
594
  // Use pointerdown for verification instead of click.
602
595
  // React re-renders between mousedown and click, destroying the original DOM node.
603
596
  // pointerdown fires synchronously before any re-render.
604
597
  // Also uses document-level capture as fallback for descendant hits.
605
598
  // LAZY RESOLUTION: Always resolve ref from metadata, never rely on cached element.
599
+ const verifyKey = `__clickVerify_${++clickVerifyCounter}`;
600
+
606
601
  await session.send('Runtime.evaluate', frameEvalParams(`
607
602
  (function() {
608
603
  ${LAZY_RESOLVE_SCRIPT}
609
604
 
610
605
  const el = lazyResolveRef(${JSON.stringify(ref)});
611
606
  if (el && el.isConnected) {
612
- // Store resolved element for verification phase
613
- window.__clickVerifyEl = el;
607
+ // Store resolved element for verification phase (unique key per click)
608
+ window[${JSON.stringify(verifyKey)}] = el;
614
609
  el.__clickReceived = false;
615
610
  el.__ptrHandler = () => { el.__clickReceived = true; };
616
611
  el.addEventListener('pointerdown', el.__ptrHandler, { once: true });
@@ -624,16 +619,35 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
624
619
  })()
625
620
  `, false));
626
621
 
627
- await inputEmulator.click(x, y);
628
- await sleep(50);
622
+ try {
623
+ await inputEmulator.click(x, y);
624
+ await sleep(50);
625
+ } catch (clickError) {
626
+ // Cleanup listeners on click failure
627
+ try {
628
+ await session.send('Runtime.evaluate', frameEvalParams(`
629
+ (function() {
630
+ const el = window[${JSON.stringify(verifyKey)}];
631
+ delete window[${JSON.stringify(verifyKey)}];
632
+ if (!el) return;
633
+ if (el.__ptrHandler) el.removeEventListener('pointerdown', el.__ptrHandler);
634
+ if (el.__docHandler) document.removeEventListener('pointerdown', el.__docHandler, { capture: true });
635
+ delete el.__clickReceived;
636
+ delete el.__ptrHandler;
637
+ delete el.__docHandler;
638
+ })()
639
+ `, true));
640
+ } catch { /* ignore cleanup errors */ }
641
+ throw clickError;
642
+ }
629
643
 
630
644
  // Check if pointerdown was received
631
645
  let verifyResult;
632
646
  try {
633
647
  verifyResult = await session.send('Runtime.evaluate', frameEvalParams(`
634
648
  (function() {
635
- const el = window.__clickVerifyEl;
636
- delete window.__clickVerifyEl;
649
+ const el = window[${JSON.stringify(verifyKey)}];
650
+ delete window[${JSON.stringify(verifyKey)}];
637
651
  if (!el) return { targetReceived: false, reason: 'element not found' };
638
652
  if (el.__ptrHandler) el.removeEventListener('pointerdown', el.__ptrHandler);
639
653
  if (el.__docHandler) document.removeEventListener('pointerdown', el.__docHandler, { capture: true });
@@ -657,7 +671,7 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
657
671
  }
658
672
 
659
673
  async function clickByRef(ref, jsClick = false, opts = {}) {
660
- const { force = false, debug = false, nativeOnly = false, waitForNavigation, navigationTimeout = 100 } = opts;
674
+ const { force = false, debug = false, nativeOnly = false, waitForNavigation, navigationTimeout = 100, waitAfter = false, waitAfterOptions = {} } = opts;
661
675
 
662
676
  // LAZY RESOLUTION: Always resolve ref from metadata, never rely on cached element
663
677
  // This eliminates stale element errors entirely
@@ -666,19 +680,24 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
666
680
  throw elementNotFoundError(`ref:${ref}`, 0);
667
681
  }
668
682
 
669
- // Get visibility info using the resolved element
670
- const visibilityResult = await session.send('Runtime.callFunctionOn', {
671
- objectId: resolved.objectId,
672
- functionDeclaration: `function() {
673
- const style = window.getComputedStyle(this);
674
- const rect = this.getBoundingClientRect();
675
- return {
676
- isVisible: style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0,
677
- box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
678
- };
679
- }`,
680
- returnByValue: true
681
- });
683
+ // Get visibility info using the resolved element, then release the objectId
684
+ let visibilityResult;
685
+ try {
686
+ visibilityResult = await session.send('Runtime.callFunctionOn', {
687
+ objectId: resolved.objectId,
688
+ functionDeclaration: `function() {
689
+ const style = window.getComputedStyle(this);
690
+ const rect = this.getBoundingClientRect();
691
+ return {
692
+ isVisible: style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0,
693
+ box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
694
+ };
695
+ }`,
696
+ returnByValue: true
697
+ });
698
+ } finally {
699
+ await releaseObject(session, resolved.objectId);
700
+ }
682
701
 
683
702
  const refInfo = {
684
703
  box: visibilityResult.result?.value?.box || resolved.box,
@@ -757,7 +776,8 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
757
776
  // If element is outside viewport (e.g., inside an unscrolled container), scroll it into view first
758
777
  // LAZY RESOLUTION: Always resolve ref from metadata for scroll
759
778
  const box = refInfo.box;
760
- if (box && (box.x < 0 || box.y < 0 || box.x + box.width > 1920 || box.y + box.height > 1080)) {
779
+ const vp = await getViewportBounds().catch(() => null) || { width: 1280, height: 720 };
780
+ if (box && (box.x < 0 || box.y < 0 || box.x + box.width > vp.width || box.y + box.height > vp.height)) {
761
781
  try {
762
782
  await session.send('Runtime.evaluate', frameEvalParams(`
763
783
  (function() {
@@ -769,8 +789,13 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
769
789
  await sleep(100);
770
790
  // Re-fetch element info after scroll using lazy resolution
771
791
  const updatedResult = await lazyResolver.resolveRef(ref);
772
- if (updatedResult && updatedResult.box) {
773
- refInfo.box = updatedResult.box;
792
+ if (updatedResult) {
793
+ if (updatedResult.box) {
794
+ refInfo.box = updatedResult.box;
795
+ }
796
+ if (updatedResult.objectId) {
797
+ await releaseObject(session, updatedResult.objectId);
798
+ }
774
799
  }
775
800
  } catch {
776
801
  // Scroll failed — proceed with original coordinates
@@ -877,6 +902,16 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
877
902
  }
878
903
  }
879
904
 
905
+ // Auto-wait after click
906
+ if (waitAfter) {
907
+ const changeResult = await detectContentChange({
908
+ timeout: waitAfterOptions.timeout || 5000,
909
+ stableTime: waitAfterOptions.stableTime || 500,
910
+ checkNavigation: true
911
+ });
912
+ result.waitResult = changeResult;
913
+ }
914
+
880
915
  if (debug) {
881
916
  result.debug = {
882
917
  clickedAt: point,
@@ -1194,8 +1229,10 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
1194
1229
  if (!scrollResult.found) {
1195
1230
  throw elementNotFoundError(selector, scrollOptions.timeout || 30000);
1196
1231
  }
1197
- // Element found, now proceed with normal click
1198
- // The scrollUntilVisible already scrolled it into view, so the actionability check should pass
1232
+ // Release the objectId from scrollUntilVisible clickBySelector will re-find the element
1233
+ if (scrollResult.objectId) {
1234
+ await releaseObject(session, scrollResult.objectId);
1235
+ }
1199
1236
  }
1200
1237
 
1201
1238
  return clickBySelector(selector, { jsClick, nativeOnly, force, debug, waitForNavigation, navigationTimeout, waitAfter, waitAfterOptions });
@@ -205,16 +205,16 @@ export function createElementLocator(session, options = {}) {
205
205
  async function findElement(selector) {
206
206
  const element = await querySelector(selector);
207
207
  if (!element) return null;
208
- return { nodeId: element.objectId, _handle: element };
208
+ return { objectId: element.objectId, _handle: element };
209
209
  }
210
210
 
211
- async function getBoundingBox(nodeId) {
212
- if (!nodeId) return null;
211
+ async function getBoundingBox(objectId) {
212
+ if (!objectId) return null;
213
213
 
214
214
  let result;
215
215
  try {
216
216
  result = await session.send('Runtime.callFunctionOn', {
217
- objectId: nodeId,
217
+ objectId,
218
218
  functionDeclaration: `function() {
219
219
  const rect = this.getBoundingClientRect();
220
220
  return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
@@ -134,17 +134,21 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
134
134
  return { filled: true, ref, method: 'react' };
135
135
  }
136
136
 
137
- await session.send('Runtime.callFunctionOn', {
137
+ const boxAfterScroll = await session.send('Runtime.callFunctionOn', {
138
138
  objectId,
139
139
  functionDeclaration: `function() {
140
140
  this.scrollIntoView({ block: 'center', behavior: 'instant' });
141
- }`
141
+ const rect = this.getBoundingClientRect();
142
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
143
+ }`,
144
+ returnByValue: true
142
145
  });
143
146
 
144
147
  await sleep(100);
145
148
 
146
- const x = refInfo.box.x + refInfo.box.width / 2;
147
- const y = refInfo.box.y + refInfo.box.height / 2;
149
+ const box = boxAfterScroll.result?.value || refInfo.box;
150
+ const x = box.x + box.width / 2;
151
+ const y = box.y + box.height / 2;
148
152
  await inputEmulator.click(x, y);
149
153
 
150
154
  await session.send('Runtime.callFunctionOn', {
@@ -21,9 +21,11 @@ import { sleep } from '../utils.js';
21
21
  * @param {Object} session - CDP session
22
22
  * @returns {Object} Input emulator interface
23
23
  */
24
- export function createInputEmulator(session) {
24
+ export function createInputEmulator(session, options = {}) {
25
25
  if (!session) throw new Error('CDP session is required');
26
26
 
27
+ const { getFrameContext } = options;
28
+
27
29
  // Transaction-based mouse state
28
30
  // Inspired by Puppeteer's CdpMouse
29
31
  const mouseState = {
@@ -251,7 +253,7 @@ export function createInputEmulator(session) {
251
253
 
252
254
  // Trigger synthetic input event for framework bindings (React, Vue, etc.)
253
255
  if (dispatchEvents) {
254
- await session.send('Runtime.evaluate', {
256
+ const evalParams = {
255
257
  expression: `
256
258
  (function() {
257
259
  const el = document.activeElement;
@@ -261,7 +263,12 @@ export function createInputEmulator(session) {
261
263
  }
262
264
  })()
263
265
  `
264
- });
266
+ };
267
+ if (getFrameContext) {
268
+ const contextId = getFrameContext();
269
+ if (contextId) evalParams.contextId = contextId;
270
+ }
271
+ await session.send('Runtime.evaluate', evalParams);
265
272
  }
266
273
  }
267
274
 
@@ -277,20 +284,42 @@ export function createInputEmulator(session) {
277
284
  await type(text, opts);
278
285
  }
279
286
 
287
+ // Mapping of Meta+key combos to macOS editing commands (sent via CDP commands param)
288
+ const MAC_COMMANDS = {
289
+ 'a': ['selectAll'], 'c': ['copy'], 'v': ['paste'], 'x': ['cut'],
290
+ 'z': ['undo'],
291
+ };
292
+ const MAC_SHIFT_COMMANDS = {
293
+ 'z': ['redo'],
294
+ };
295
+
280
296
  async function press(key, opts = {}) {
281
- const { modifiers = {}, delay = 0 } = opts;
297
+ const { modifiers = {}, delay = 0, commands } = opts;
282
298
  const keyDef = KEY_DEFINITIONS[key] || getKeyDefinition(key);
283
299
  const modifierFlags = calculateModifiers(modifiers);
284
300
 
285
- await session.send('Input.dispatchKeyEvent', {
301
+ // Resolve commands: explicit > auto-detect for Meta combos on macOS
302
+ const resolvedCommands = commands
303
+ || (modifiers.meta && modifiers.shift && MAC_SHIFT_COMMANDS[keyDef.key])
304
+ || (modifiers.meta && MAC_COMMANDS[keyDef.key])
305
+ || undefined;
306
+
307
+ const keyDown = {
286
308
  type: 'rawKeyDown',
287
309
  key: keyDef.key,
288
310
  code: keyDef.code,
289
311
  windowsVirtualKeyCode: keyDef.keyCode,
312
+ nativeVirtualKeyCode: keyDef.keyCode,
290
313
  modifiers: modifierFlags
291
- });
314
+ };
315
+ if (resolvedCommands) keyDown.commands = resolvedCommands;
316
+
317
+ await session.send('Input.dispatchKeyEvent', keyDown);
292
318
 
293
- if (keyDef.text) {
319
+ // Skip char event when command modifiers are held (shortcuts shouldn't produce text)
320
+ // Shift alone still produces text (e.g., Shift+a → "A")
321
+ const hasCommandModifier = modifiers.ctrl || modifiers.meta || modifiers.alt;
322
+ if (keyDef.text && !hasCommandModifier) {
294
323
  await session.send('Input.dispatchKeyEvent', {
295
324
  type: 'char',
296
325
  text: keyDef.text,
@@ -306,12 +335,13 @@ export function createInputEmulator(session) {
306
335
  key: keyDef.key,
307
336
  code: keyDef.code,
308
337
  windowsVirtualKeyCode: keyDef.keyCode,
338
+ nativeVirtualKeyCode: keyDef.keyCode,
309
339
  modifiers: modifierFlags
310
340
  });
311
341
  }
312
342
 
313
343
  async function selectAll() {
314
- await session.send('Runtime.evaluate', {
344
+ const evalParams = {
315
345
  expression: `
316
346
  (function() {
317
347
  const el = document.activeElement;
@@ -322,7 +352,12 @@ export function createInputEmulator(session) {
322
352
  }
323
353
  })()
324
354
  `
325
- });
355
+ };
356
+ if (getFrameContext) {
357
+ const contextId = getFrameContext();
358
+ if (contextId) evalParams.contextId = contextId;
359
+ }
360
+ await session.send('Runtime.evaluate', evalParams);
326
361
  }
327
362
 
328
363
  async function moveMouse(x, y) {
@@ -342,6 +377,9 @@ export function createInputEmulator(session) {
342
377
  y
343
378
  });
344
379
 
380
+ mouseState.x = x;
381
+ mouseState.y = y;
382
+
345
383
  if (duration > 0) {
346
384
  await sleep(duration);
347
385
  }
@@ -27,8 +27,11 @@ export function createReactInputFiller(session) {
27
27
  const prototype = el.tagName === 'TEXTAREA'
28
28
  ? window.HTMLTextAreaElement.prototype
29
29
  : window.HTMLInputElement.prototype;
30
- const nativeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set;
31
- nativeValueSetter.call(el, newValue);
30
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
31
+ if (!descriptor || !descriptor.set) {
32
+ return { success: false, error: 'Cannot get native value setter for ' + el.tagName };
33
+ }
34
+ descriptor.set.call(el, newValue);
32
35
  el.dispatchEvent(new Event('input', { bubbles: true }));
33
36
  el.dispatchEvent(new Event('change', { bubbles: true }));
34
37
  return { success: true, value: el.value };
@@ -44,7 +47,12 @@ export function createReactInputFiller(session) {
44
47
  throw new Error(`React fill failed: ${errorText}`);
45
48
  }
46
49
 
47
- return result.result.value;
50
+ const fillResult = result.result.value;
51
+ if (fillResult && fillResult.success === false) {
52
+ throw new Error(`React fill failed: ${fillResult.error}`);
53
+ }
54
+
55
+ return fillResult;
48
56
  }
49
57
 
50
58
  async function fillBySelector(selector, value) {
@@ -197,6 +197,7 @@ export function createWaitExecutor(session, elementLocator) {
197
197
  async function waitForText(text, opts = {}) {
198
198
  const { timeout = DEFAULT_TIMEOUT, caseSensitive = false } = opts;
199
199
  const validatedTimeout = validateTimeout(timeout);
200
+ const waitStartTime = Date.now();
200
201
 
201
202
  try {
202
203
  // Use browser-side polling with MutationObserver
@@ -270,13 +271,20 @@ export function createWaitExecutor(session, elementLocator) {
270
271
 
271
272
  return result.result.value;
272
273
  } catch (error) {
273
- // Fall back to original Node.js polling
274
+ // Fall back to original Node.js polling using remaining time from overall timeout
275
+ const elapsed = Date.now() - waitStartTime;
276
+ const remaining = validatedTimeout - elapsed;
277
+ if (remaining <= 0) {
278
+ throw timeoutError(
279
+ `Timeout (${validatedTimeout}ms) waiting for text: "${text}"${caseSensitive ? ' (case-sensitive)' : ''}`
280
+ );
281
+ }
274
282
  const startTime = Date.now();
275
283
  const checkExpr = caseSensitive
276
284
  ? `document.body.innerText.includes(${JSON.stringify(text)})`
277
285
  : `document.body.innerText.toLowerCase().includes(${JSON.stringify(text.toLowerCase())})`;
278
286
 
279
- while (Date.now() - startTime < validatedTimeout) {
287
+ while (Date.now() - startTime < remaining) {
280
288
  try {
281
289
  const result = await session.send('Runtime.evaluate', {
282
290
  expression: checkExpr,
@@ -32,9 +32,13 @@ export function createDialogHandler(session) {
32
32
  promptText = queued.promptText;
33
33
  } else if (dialogCallback) {
34
34
  // If custom callback is set, use it
35
- const result = dialogCallback({ type, message, defaultPrompt });
36
- accept = result.accept !== false;
37
- promptText = result.promptText;
35
+ try {
36
+ const result = dialogCallback({ type, message, defaultPrompt });
37
+ accept = result.accept !== false;
38
+ promptText = result.promptText;
39
+ } catch {
40
+ // Callback threw — fall through to default accept behavior
41
+ }
38
42
  } else {
39
43
  // Auto-accept with reasonable defaults for prompts
40
44
  if (type === 'prompt') {
@@ -24,16 +24,23 @@ export function lcsLength(a, b) {
24
24
  const m = a.length;
25
25
  const n = b.length;
26
26
 
27
- // Use space-optimized version for large arrays
28
- if (m > 1000 || n > 1000) {
29
- // For very large arrays, use a simpler similarity metric
30
- const setA = new Set(a);
31
- const setB = new Set(b);
32
- let common = 0;
33
- for (const item of setA) {
34
- if (setB.has(item)) common++;
35
- }
36
- return common;
27
+ // For large arrays, sample evenly to keep DP tractable while preserving order
28
+ const MAX_DP = 1000;
29
+ if (m > MAX_DP || n > MAX_DP) {
30
+ const sampleEvenly = (arr, size) => {
31
+ if (arr.length <= size) return arr;
32
+ const step = arr.length / size;
33
+ const sampled = [];
34
+ for (let i = 0; i < size; i++) {
35
+ sampled.push(arr[Math.floor(i * step)]);
36
+ }
37
+ return sampled;
38
+ };
39
+ const sampledA = sampleEvenly(a, MAX_DP);
40
+ const sampledB = sampleEvenly(b, MAX_DP);
41
+ const lcs = lcsLength(sampledA, sampledB);
42
+ // Scale result back to original array size
43
+ return Math.round(lcs * Math.max(m, n) / MAX_DP);
37
44
  }
38
45
 
39
46
  // Standard DP solution