cdp-skill 1.0.8 → 1.0.15

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 (51) hide show
  1. package/README.md +80 -35
  2. package/SKILL.md +157 -241
  3. package/install.js +1 -0
  4. package/package.json +1 -1
  5. package/src/aria/index.js +8 -0
  6. package/src/aria/output-processor.js +173 -0
  7. package/src/aria/role-query.js +1229 -0
  8. package/src/aria/snapshot.js +459 -0
  9. package/src/aria.js +251 -50
  10. package/src/cdp/browser.js +22 -4
  11. package/src/cdp-skill.js +246 -69
  12. package/src/dom/LazyResolver.js +634 -0
  13. package/src/dom/click-executor.js +366 -94
  14. package/src/dom/element-locator.js +34 -25
  15. package/src/dom/fill-executor.js +83 -50
  16. package/src/dom/index.js +3 -0
  17. package/src/page/dialog-handler.js +119 -0
  18. package/src/page/page-controller.js +236 -3
  19. package/src/runner/context-helpers.js +33 -55
  20. package/src/runner/execute-dynamic.js +8 -7
  21. package/src/runner/execute-form.js +11 -11
  22. package/src/runner/execute-input.js +2 -2
  23. package/src/runner/execute-interaction.js +105 -126
  24. package/src/runner/execute-navigation.js +14 -29
  25. package/src/runner/execute-query.js +17 -11
  26. package/src/runner/step-executors.js +225 -84
  27. package/src/runner/step-registry.js +1064 -0
  28. package/src/runner/step-validator.js +16 -754
  29. package/src/tests/Aria.test.js +1025 -0
  30. package/src/tests/ClickExecutor.test.js +170 -50
  31. package/src/tests/ContextHelpers.test.js +41 -30
  32. package/src/tests/ExecuteBrowser.test.js +572 -0
  33. package/src/tests/ExecuteDynamic.test.js +2 -457
  34. package/src/tests/ExecuteForm.test.js +700 -0
  35. package/src/tests/ExecuteInput.test.js +540 -0
  36. package/src/tests/ExecuteInteraction.test.js +319 -0
  37. package/src/tests/ExecuteQuery.test.js +820 -0
  38. package/src/tests/FillExecutor.test.js +89 -37
  39. package/src/tests/LazyResolver.test.js +383 -0
  40. package/src/tests/StepValidator.test.js +224 -78
  41. package/src/tests/TestRunner.test.js +38 -27
  42. package/src/tests/integration.test.js +2 -1
  43. package/src/types.js +9 -9
  44. package/src/utils/backoff.js +118 -0
  45. package/src/utils/cdp-helpers.js +130 -0
  46. package/src/utils/devices.js +140 -0
  47. package/src/utils/errors.js +242 -0
  48. package/src/utils/index.js +65 -0
  49. package/src/utils/temp.js +75 -0
  50. package/src/utils/validators.js +433 -0
  51. package/src/utils.js +14 -1142
@@ -14,13 +14,15 @@
14
14
 
15
15
  import { createActionabilityChecker } from './actionability.js';
16
16
  import { createElementValidator } from './element-validator.js';
17
+ import { createLazyResolver } from './LazyResolver.js';
17
18
  import {
18
19
  sleep,
19
20
  elementNotFoundError,
20
21
  getCurrentUrl,
21
22
  getElementAtPoint,
22
23
  detectNavigation,
23
- releaseObject
24
+ releaseObject,
25
+ isContextDestroyed
24
26
  } from '../utils.js';
25
27
 
26
28
  /**
@@ -36,8 +38,20 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
36
38
  if (!elementLocator) throw new Error('Element locator is required');
37
39
  if (!inputEmulator) throw new Error('Input emulator is required');
38
40
 
41
+ const getFrameContext = elementLocator.getFrameContext || null;
39
42
  const actionabilityChecker = createActionabilityChecker(session);
40
43
  const elementValidator = createElementValidator(session);
44
+ const lazyResolver = createLazyResolver(session, { getFrameContext });
45
+
46
+ /** Build Runtime.evaluate params with frame context when in an iframe. */
47
+ function frameEvalParams(expression, returnByValue = true) {
48
+ const params = { expression, returnByValue };
49
+ if (getFrameContext) {
50
+ const contextId = getFrameContext();
51
+ if (contextId) params.contextId = contextId;
52
+ }
53
+ return params;
54
+ }
41
55
 
42
56
  function calculateVisibleCenter(box, viewport = null) {
43
57
  let visibleBox = { ...box };
@@ -58,13 +72,10 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
58
72
  }
59
73
 
60
74
  async function getViewportBounds() {
61
- const result = await session.send('Runtime.evaluate', {
62
- expression: `({
75
+ const result = await session.send('Runtime.evaluate', frameEvalParams(`({
63
76
  width: window.innerWidth || document.documentElement.clientWidth,
64
77
  height: window.innerHeight || document.documentElement.clientHeight
65
- })`,
66
- returnByValue: true
67
- });
78
+ })`, true));
68
79
  return result.result.value;
69
80
  }
70
81
 
@@ -85,8 +96,7 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
85
96
 
86
97
  const urlBefore = checkNavigation ? await getCurrentUrl(session) : null;
87
98
 
88
- const result = await session.send('Runtime.evaluate', {
89
- expression: `
99
+ const detectExpr = `
90
100
  (function() {
91
101
  return new Promise((resolve) => {
92
102
  const timeout = ${timeout};
@@ -142,10 +152,10 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
142
152
  }, stableTime);
143
153
  });
144
154
  })()
145
- `,
146
- returnByValue: true,
147
- awaitPromise: true
148
- });
155
+ `;
156
+ const detectParams = frameEvalParams(detectExpr, true);
157
+ detectParams.awaitPromise = true;
158
+ const result = await session.send('Runtime.evaluate', detectParams);
149
159
 
150
160
  const changeResult = result.result.value || { type: 'none', changeCount: 0 };
151
161
 
@@ -245,10 +255,7 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
245
255
  })()
246
256
  `;
247
257
 
248
- const result = await session.send('Runtime.evaluate', {
249
- expression,
250
- returnByValue: true
251
- });
258
+ const result = await session.send('Runtime.evaluate', frameEvalParams(expression, true));
252
259
 
253
260
  if (result.exceptionDetails || !result.result.value) {
254
261
  return null;
@@ -301,13 +308,125 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
301
308
  return { targetReceived: true };
302
309
  }
303
310
 
311
+ /**
312
+ * Browser-side lazy resolution script that always re-resolves refs from metadata.
313
+ * This eliminates stale element errors by never relying on cached DOM references.
314
+ */
315
+ const LAZY_RESOLVE_SCRIPT = `
316
+ function lazyResolveRef(ref) {
317
+ const meta = window.__ariaRefMeta && window.__ariaRefMeta.get(ref);
318
+ if (!meta) return null;
319
+
320
+ // Helper: check if candidate matches role+name from metadata
321
+ function matchesRoleAndName(candidate) {
322
+ if (!candidate || !candidate.isConnected) return false;
323
+ if (!meta.role) return true;
324
+
325
+ // Get element's role
326
+ const explicit = candidate.getAttribute('role');
327
+ let candidateRole = explicit ? explicit.split(/\\s+/)[0] : null;
328
+ if (!candidateRole) {
329
+ const tag = candidate.tagName.toUpperCase();
330
+ const implicitMap = {
331
+ 'A': 'link', 'BUTTON': 'button', 'SELECT': 'combobox', 'TEXTAREA': 'textbox',
332
+ 'H1': 'heading', 'H2': 'heading', 'H3': 'heading', 'H4': 'heading', 'H5': 'heading', 'H6': 'heading',
333
+ 'NAV': 'navigation', 'MAIN': 'main', 'LI': 'listitem', 'OPTION': 'option'
334
+ };
335
+ if (tag === 'INPUT') {
336
+ const type = (candidate.type || 'text').toLowerCase();
337
+ const typeMap = { 'checkbox': 'checkbox', 'radio': 'radio', 'range': 'slider', 'number': 'spinbutton', 'search': 'searchbox' };
338
+ candidateRole = typeMap[type] || 'textbox';
339
+ } else {
340
+ candidateRole = implicitMap[tag] || null;
341
+ }
342
+ }
343
+
344
+ const roleMatch = !meta.role || candidateRole === meta.role;
345
+ if (!roleMatch) return false;
346
+ if (!meta.name) return true;
347
+
348
+ // Check accessible name
349
+ const candidateName = (
350
+ candidate.getAttribute('aria-label') ||
351
+ candidate.getAttribute('title') ||
352
+ candidate.getAttribute('placeholder') ||
353
+ (candidate.textContent || '').replace(/\\s+/g, ' ').trim().substring(0, 200) ||
354
+ ''
355
+ );
356
+ return candidateName.toLowerCase().includes(meta.name.toLowerCase().substring(0, 100));
357
+ }
358
+
359
+ // Helper: resolve through shadow DOM
360
+ function queryShadow(shadowHostPath, selector) {
361
+ let root = document;
362
+ for (const hostSel of shadowHostPath) {
363
+ try {
364
+ const host = root.querySelector(hostSel);
365
+ if (!host || !host.shadowRoot) return null;
366
+ root = host.shadowRoot;
367
+ } catch (e) { return null; }
368
+ }
369
+ try { return root.querySelector(selector); } catch (e) { return null; }
370
+ }
371
+
372
+ // Strategy 1: Try selector (with shadow path if applicable)
373
+ if (meta.selector) {
374
+ const hasShadow = meta.shadowHostPath && meta.shadowHostPath.length > 0;
375
+ const candidate = hasShadow
376
+ ? queryShadow(meta.shadowHostPath, meta.selector)
377
+ : document.querySelector(meta.selector);
378
+ if (matchesRoleAndName(candidate)) return candidate;
379
+ }
380
+
381
+ // Strategy 2: Role+name search
382
+ if (meta.role) {
383
+ const ROLE_SELECTORS = {
384
+ button: 'button, input[type="button"], input[type="submit"], input[type="reset"], [role="button"]',
385
+ textbox: 'input:not([type]), input[type="text"], input[type="email"], input[type="password"], input[type="search"], input[type="tel"], input[type="url"], textarea, [role="textbox"]',
386
+ checkbox: 'input[type="checkbox"], [role="checkbox"]',
387
+ link: 'a[href], [role="link"]',
388
+ heading: 'h1, h2, h3, h4, h5, h6, [role="heading"]',
389
+ combobox: 'select, [role="combobox"]',
390
+ radio: 'input[type="radio"], [role="radio"]',
391
+ tab: '[role="tab"]',
392
+ menuitem: '[role="menuitem"]',
393
+ option: 'option, [role="option"]',
394
+ slider: 'input[type="range"], [role="slider"]',
395
+ spinbutton: 'input[type="number"], [role="spinbutton"]',
396
+ searchbox: 'input[type="search"], [role="searchbox"]',
397
+ switch: '[role="switch"]'
398
+ };
399
+ const selectorString = ROLE_SELECTORS[meta.role] || '[role="' + meta.role + '"]';
400
+ const elements = document.querySelectorAll(selectorString);
401
+ for (const el of elements) {
402
+ if (matchesRoleAndName(el)) return el;
403
+ }
404
+
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);
410
+ for (const el of els) {
411
+ if (matchesRoleAndName(el)) return el;
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ return null;
418
+ }
419
+ `;
420
+
304
421
  async function executeJsClickOnRef(ref) {
305
- const result = await session.send('Runtime.evaluate', {
306
- expression: `
422
+ const result = await session.send('Runtime.evaluate', frameEvalParams(`
307
423
  (function() {
308
- const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
424
+ ${LAZY_RESOLVE_SCRIPT}
425
+
426
+ const el = lazyResolveRef(${JSON.stringify(ref)});
427
+
309
428
  if (!el) {
310
- return { success: false, reason: 'ref not found in __ariaRefs' };
429
+ return { success: false, reason: 'ref could not be resolved - element not found' };
311
430
  }
312
431
  if (!el.isConnected) {
313
432
  return { success: false, reason: 'element is no longer attached to DOM' };
@@ -319,9 +438,7 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
319
438
  el.click();
320
439
  return { success: true };
321
440
  })()
322
- `,
323
- returnByValue: true
324
- });
441
+ `, true));
325
442
 
326
443
  const value = result.result.value || {};
327
444
  if (!value.success) {
@@ -330,42 +447,87 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
330
447
  }
331
448
 
332
449
  async function clickWithVerification(x, y, targetObjectId) {
450
+ // Use pointerdown for verification instead of click.
451
+ // React (and similar frameworks) re-render elements between mousedown and click,
452
+ // destroying the original DOM node and its event listeners. pointerdown fires
453
+ // synchronously at the start of the interaction, before any re-render.
454
+ // Also listen on document (capture phase) as a fallback — if the click target
455
+ // is the element or a descendant, count it as received.
333
456
  await session.send('Runtime.callFunctionOn', {
334
457
  objectId: targetObjectId,
335
458
  functionDeclaration: `function() {
336
459
  this.__clickReceived = false;
337
- this.__clickHandler = () => { this.__clickReceived = true; };
338
- this.addEventListener('click', this.__clickHandler, { once: true });
460
+ const self = this;
461
+ this.__ptrHandler = (e) => { self.__clickReceived = true; };
462
+ this.addEventListener('pointerdown', this.__ptrHandler, { once: true });
463
+ // Document-level capture fallback: catch clicks that bubble from descendants
464
+ this.__docHandler = (e) => {
465
+ if (self.contains(e.target) || e.target === self) {
466
+ self.__clickReceived = true;
467
+ }
468
+ };
469
+ document.addEventListener('pointerdown', this.__docHandler, { capture: true, once: true });
339
470
  }`
340
471
  });
341
472
 
342
- await inputEmulator.click(x, y);
343
- await sleep(50);
473
+ try {
474
+ await inputEmulator.click(x, y);
475
+ await sleep(50);
344
476
 
345
- const verifyResult = await session.send('Runtime.callFunctionOn', {
346
- objectId: targetObjectId,
347
- functionDeclaration: `function() {
348
- this.removeEventListener('click', this.__clickHandler);
349
- const received = this.__clickReceived;
350
- delete this.__clickReceived;
351
- delete this.__clickHandler;
352
- return received;
353
- }`,
354
- returnByValue: true
355
- });
477
+ let verifyResult;
478
+ try {
479
+ verifyResult = await session.send('Runtime.callFunctionOn', {
480
+ objectId: targetObjectId,
481
+ functionDeclaration: `function() {
482
+ this.removeEventListener('pointerdown', this.__ptrHandler);
483
+ document.removeEventListener('pointerdown', this.__docHandler, { capture: true });
484
+ const received = this.__clickReceived;
485
+ delete this.__clickReceived;
486
+ delete this.__ptrHandler;
487
+ delete this.__docHandler;
488
+ return received;
489
+ }`,
490
+ returnByValue: true
491
+ });
492
+ } catch (verifyError) {
493
+ // Context destroyed during verification means click likely triggered navigation
494
+ // Treat as successful click with navigation
495
+ if (isContextDestroyed(null, verifyError)) {
496
+ return { targetReceived: true, contextDestroyed: true };
497
+ }
498
+ throw verifyError;
499
+ }
356
500
 
357
- const targetReceived = verifyResult.result.value === true;
358
- const result = { targetReceived };
501
+ const targetReceived = verifyResult.result.value === true;
502
+ const result = { targetReceived };
359
503
 
360
- // If click didn't reach target, get interceptor info
361
- if (!targetReceived) {
362
- const interceptor = await getInterceptorInfo(x, y, targetObjectId);
363
- if (interceptor) {
364
- result.interceptedBy = interceptor;
504
+ // If click didn't reach target, get interceptor info
505
+ if (!targetReceived) {
506
+ const interceptor = await getInterceptorInfo(x, y, targetObjectId);
507
+ if (interceptor) {
508
+ result.interceptedBy = interceptor;
509
+ }
365
510
  }
366
- }
367
511
 
368
- return result;
512
+ return result;
513
+ } finally {
514
+ // Always cleanup event listeners, even if click fails
515
+ try {
516
+ await session.send('Runtime.callFunctionOn', {
517
+ objectId: targetObjectId,
518
+ functionDeclaration: `function() {
519
+ this.removeEventListener('pointerdown', this.__ptrHandler);
520
+ document.removeEventListener('pointerdown', this.__docHandler, { capture: true });
521
+ delete this.__clickReceived;
522
+ delete this.__ptrHandler;
523
+ delete this.__docHandler;
524
+ }`,
525
+ returnByValue: true
526
+ });
527
+ } catch (cleanupError) {
528
+ // Ignore cleanup errors (element may be gone)
529
+ }
530
+ }
369
531
  }
370
532
 
371
533
  async function addNavigationAndDebugInfo(result, urlBeforeClick, debugData, opts) {
@@ -436,38 +598,60 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
436
598
  }
437
599
 
438
600
  async function clickWithVerificationByRef(ref, x, y) {
439
- // Set up click listener on the ref element
440
- await session.send('Runtime.evaluate', {
441
- expression: `
601
+ // Use pointerdown for verification instead of click.
602
+ // React re-renders between mousedown and click, destroying the original DOM node.
603
+ // pointerdown fires synchronously before any re-render.
604
+ // Also uses document-level capture as fallback for descendant hits.
605
+ // LAZY RESOLUTION: Always resolve ref from metadata, never rely on cached element.
606
+ await session.send('Runtime.evaluate', frameEvalParams(`
442
607
  (function() {
443
- const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
444
- if (el) {
608
+ ${LAZY_RESOLVE_SCRIPT}
609
+
610
+ const el = lazyResolveRef(${JSON.stringify(ref)});
611
+ if (el && el.isConnected) {
612
+ // Store resolved element for verification phase
613
+ window.__clickVerifyEl = el;
445
614
  el.__clickReceived = false;
446
- el.__clickHandler = () => { el.__clickReceived = true; };
447
- el.addEventListener('click', el.__clickHandler, { once: true });
615
+ el.__ptrHandler = () => { el.__clickReceived = true; };
616
+ el.addEventListener('pointerdown', el.__ptrHandler, { once: true });
617
+ el.__docHandler = (e) => {
618
+ if (el.contains(e.target) || e.target === el) {
619
+ el.__clickReceived = true;
620
+ }
621
+ };
622
+ document.addEventListener('pointerdown', el.__docHandler, { capture: true, once: true });
448
623
  }
449
624
  })()
450
- `
451
- });
625
+ `, false));
452
626
 
453
627
  await inputEmulator.click(x, y);
454
628
  await sleep(50);
455
629
 
456
- // Check if click was received
457
- const verifyResult = await session.send('Runtime.evaluate', {
458
- expression: `
459
- (function() {
460
- const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
461
- if (!el) return { targetReceived: false, reason: 'element not found' };
462
- el.removeEventListener('click', el.__clickHandler);
463
- const received = el.__clickReceived;
464
- delete el.__clickReceived;
465
- delete el.__clickHandler;
466
- return { targetReceived: received };
467
- })()
468
- `,
469
- returnByValue: true
470
- });
630
+ // Check if pointerdown was received
631
+ let verifyResult;
632
+ try {
633
+ verifyResult = await session.send('Runtime.evaluate', frameEvalParams(`
634
+ (function() {
635
+ const el = window.__clickVerifyEl;
636
+ delete window.__clickVerifyEl;
637
+ if (!el) return { targetReceived: false, reason: 'element not found' };
638
+ if (el.__ptrHandler) el.removeEventListener('pointerdown', el.__ptrHandler);
639
+ if (el.__docHandler) document.removeEventListener('pointerdown', el.__docHandler, { capture: true });
640
+ const received = el.__clickReceived;
641
+ delete el.__clickReceived;
642
+ delete el.__ptrHandler;
643
+ delete el.__docHandler;
644
+ return { targetReceived: received };
645
+ })()
646
+ `, true));
647
+ } catch (verifyError) {
648
+ // Context destroyed during verification means click likely triggered navigation
649
+ // Treat as successful click with navigation
650
+ if (isContextDestroyed(null, verifyError)) {
651
+ return { targetReceived: true, contextDestroyed: true };
652
+ }
653
+ throw verifyError;
654
+ }
471
655
 
472
656
  return verifyResult.result.value || { targetReceived: false };
473
657
  }
@@ -475,30 +659,124 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
475
659
  async function clickByRef(ref, jsClick = false, opts = {}) {
476
660
  const { force = false, debug = false, nativeOnly = false, waitForNavigation, navigationTimeout = 100 } = opts;
477
661
 
478
- if (!ariaSnapshot) {
479
- throw new Error('ariaSnapshot is required for ref-based clicks');
480
- }
481
-
482
- const refInfo = await ariaSnapshot.getElementByRef(ref);
483
- if (!refInfo) {
662
+ // LAZY RESOLUTION: Always resolve ref from metadata, never rely on cached element
663
+ // This eliminates stale element errors entirely
664
+ const resolved = await lazyResolver.resolveRef(ref);
665
+ if (!resolved) {
484
666
  throw elementNotFoundError(`ref:${ref}`, 0);
485
667
  }
486
668
 
487
- if (refInfo.stale) {
488
- return {
489
- clicked: false,
490
- stale: true,
491
- warning: `Element ref:${ref} is no longer attached to the DOM. Page content may have changed. Run 'snapshot' again to get fresh refs.`
492
- };
493
- }
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
+ });
682
+
683
+ const refInfo = {
684
+ box: visibilityResult.result?.value?.box || resolved.box,
685
+ isVisible: visibilityResult.result?.value?.isVisible ?? true,
686
+ resolvedBy: resolved.resolvedBy
687
+ };
494
688
 
495
689
  if (!force && refInfo.isVisible === false) {
690
+ // Special case: hidden radio/checkbox inputs — try to click associated label
691
+ // LAZY RESOLUTION: Always resolve ref from metadata
692
+ const labelResult = await session.send('Runtime.evaluate', frameEvalParams(`
693
+ (function() {
694
+ ${LAZY_RESOLVE_SCRIPT}
695
+
696
+ const el = lazyResolveRef(${JSON.stringify(ref)});
697
+ if (!el) return { found: false };
698
+
699
+ const tag = el.tagName.toUpperCase();
700
+ const type = (el.type || '').toLowerCase();
701
+ if (tag === 'INPUT' && (type === 'radio' || type === 'checkbox')) {
702
+ // Look for associated label
703
+ let label = null;
704
+ if (el.id) {
705
+ label = document.querySelector('label[for="' + el.id + '"]');
706
+ }
707
+ if (!label) {
708
+ label = el.closest('label');
709
+ }
710
+
711
+ if (label) {
712
+ const rect = label.getBoundingClientRect();
713
+ const style = window.getComputedStyle(label);
714
+ const isVisible = style.display !== 'none' &&
715
+ style.visibility !== 'hidden' &&
716
+ style.opacity !== '0' &&
717
+ rect.width > 0 && rect.height > 0;
718
+
719
+ if (isVisible) {
720
+ return {
721
+ found: true,
722
+ clickedLabel: true,
723
+ box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
724
+ };
725
+ }
726
+ }
727
+ }
728
+ return { found: false };
729
+ })()
730
+ `, true));
731
+
732
+ const labelInfo = labelResult.result?.value || { found: false };
733
+ if (labelInfo.found && labelInfo.clickedLabel) {
734
+ // Click the label instead
735
+ const labelCenter = calculateVisibleCenter(labelInfo.box);
736
+ const urlBefore = await getCurrentUrl(session);
737
+ await inputEmulator.click(labelCenter.x, labelCenter.y);
738
+ const urlAfter = await getCurrentUrl(session);
739
+ const navigated = urlAfter !== urlBefore;
740
+
741
+ return {
742
+ clicked: true,
743
+ method: 'label-proxy',
744
+ ref,
745
+ warning: `Element ref:${ref} is a hidden radio/checkbox input. Clicked associated label instead.`,
746
+ navigated
747
+ };
748
+ }
749
+
750
+ // No label found or element isn't radio/checkbox — return original error
496
751
  return {
497
752
  clicked: false,
498
753
  warning: `Element ref:${ref} exists but is not visible. It may be hidden or have zero dimensions.`
499
754
  };
500
755
  }
501
756
 
757
+ // If element is outside viewport (e.g., inside an unscrolled container), scroll it into view first
758
+ // LAZY RESOLUTION: Always resolve ref from metadata for scroll
759
+ const box = refInfo.box;
760
+ if (box && (box.x < 0 || box.y < 0 || box.x + box.width > 1920 || box.y + box.height > 1080)) {
761
+ try {
762
+ await session.send('Runtime.evaluate', frameEvalParams(`
763
+ (function() {
764
+ ${LAZY_RESOLVE_SCRIPT}
765
+ const el = lazyResolveRef(${JSON.stringify(ref)});
766
+ if (el) el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
767
+ })()
768
+ `, true));
769
+ await sleep(100);
770
+ // Re-fetch element info after scroll using lazy resolution
771
+ const updatedResult = await lazyResolver.resolveRef(ref);
772
+ if (updatedResult && updatedResult.box) {
773
+ refInfo.box = updatedResult.box;
774
+ }
775
+ } catch {
776
+ // Scroll failed — proceed with original coordinates
777
+ }
778
+ }
779
+
502
780
  const urlBeforeClick = await getCurrentUrl(session);
503
781
 
504
782
  const point = calculateVisibleCenter(refInfo.box);
@@ -880,12 +1158,12 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
880
1158
  const scrollUntilVisible = typeof params === 'object' && params.scrollUntilVisible === true;
881
1159
  const scrollOptions = typeof params === 'object' ? params.scrollOptions : {};
882
1160
 
883
- // Detect if string selector looks like a versioned ref (s{N}e{M})
884
- // This allows {"click": "s1e1"} to work the same as {"click": {"ref": "s1e1"}}
1161
+ // Detect if string selector looks like a versioned ref (f{frameId}s{N}e{M})
1162
+ // This allows {"click": "f0s1e1"} to work the same as {"click": {"ref": "f0s1e1"}}
885
1163
  if (!ref && selector) {
886
- if (/^s\d+e\d+$/.test(selector)) {
1164
+ if (/^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(selector)) {
887
1165
  ref = selector;
888
- } else if (/^ref=s\d+e\d+$/i.test(selector)) {
1166
+ } else if (/^ref=f(\d+|\[[^\]]+\])s\d+e\d+$/i.test(selector)) {
889
1167
  ref = selector.slice(4); // Remove "ref=" prefix
890
1168
  }
891
1169
  }
@@ -916,12 +1194,6 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
916
1194
  if (!scrollResult.found) {
917
1195
  throw elementNotFoundError(selector, scrollOptions.timeout || 30000);
918
1196
  }
919
- // Release the objectId from scroll search since clickBySelector will find it again
920
- if (scrollResult.objectId) {
921
- try {
922
- await releaseObject(session, scrollResult.objectId);
923
- } catch { /* ignore cleanup errors */ }
924
- }
925
1197
  // Element found, now proceed with normal click
926
1198
  // The scrollUntilVisible already scrolled it into view, so the actionability check should pass
927
1199
  }