cdp-skill 1.0.14 → 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.
@@ -14,6 +14,7 @@
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,
@@ -40,6 +41,7 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
40
41
  const getFrameContext = elementLocator.getFrameContext || null;
41
42
  const actionabilityChecker = createActionabilityChecker(session);
42
43
  const elementValidator = createElementValidator(session);
44
+ const lazyResolver = createLazyResolver(session, { getFrameContext });
43
45
 
44
46
  /** Build Runtime.evaluate params with frame context when in an iframe. */
45
47
  function frameEvalParams(expression, returnByValue = true) {
@@ -306,27 +308,125 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
306
308
  return { targetReceived: true };
307
309
  }
308
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
+
309
421
  async function executeJsClickOnRef(ref) {
310
422
  const result = await session.send('Runtime.evaluate', frameEvalParams(`
311
423
  (function() {
312
- let el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
313
-
314
- // Re-resolve if element is missing or stale
315
- if (!el || !el.isConnected) {
316
- const meta = window.__ariaRefMeta && window.__ariaRefMeta.get(${JSON.stringify(ref)});
317
- if (meta && meta.selector) {
318
- try {
319
- const candidate = document.querySelector(meta.selector);
320
- if (candidate && candidate.isConnected) {
321
- el = candidate;
322
- if (window.__ariaRefs) window.__ariaRefs.set(${JSON.stringify(ref)}, el);
323
- }
324
- } catch (e) {}
325
- }
326
- }
424
+ ${LAZY_RESOLVE_SCRIPT}
425
+
426
+ const el = lazyResolveRef(${JSON.stringify(ref)});
327
427
 
328
428
  if (!el) {
329
- return { success: false, reason: 'ref not found in __ariaRefs' };
429
+ return { success: false, reason: 'ref could not be resolved - element not found' };
330
430
  }
331
431
  if (!el.isConnected) {
332
432
  return { success: false, reason: 'element is no longer attached to DOM' };
@@ -502,22 +602,15 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
502
602
  // React re-renders between mousedown and click, destroying the original DOM node.
503
603
  // pointerdown fires synchronously before any re-render.
504
604
  // Also uses document-level capture as fallback for descendant hits.
605
+ // LAZY RESOLUTION: Always resolve ref from metadata, never rely on cached element.
505
606
  await session.send('Runtime.evaluate', frameEvalParams(`
506
607
  (function() {
507
- let el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
508
- if ((!el || !el.isConnected) && window.__ariaRefMeta) {
509
- const meta = window.__ariaRefMeta.get(${JSON.stringify(ref)});
510
- if (meta && meta.selector) {
511
- try {
512
- const candidate = document.querySelector(meta.selector);
513
- if (candidate && candidate.isConnected) {
514
- el = candidate;
515
- if (window.__ariaRefs) window.__ariaRefs.set(${JSON.stringify(ref)}, el);
516
- }
517
- } catch (e) {}
518
- }
519
- }
608
+ ${LAZY_RESOLVE_SCRIPT}
609
+
610
+ const el = lazyResolveRef(${JSON.stringify(ref)});
520
611
  if (el && el.isConnected) {
612
+ // Store resolved element for verification phase
613
+ window.__clickVerifyEl = el;
521
614
  el.__clickReceived = false;
522
615
  el.__ptrHandler = () => { el.__clickReceived = true; };
523
616
  el.addEventListener('pointerdown', el.__ptrHandler, { once: true });
@@ -539,7 +632,8 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
539
632
  try {
540
633
  verifyResult = await session.send('Runtime.evaluate', frameEvalParams(`
541
634
  (function() {
542
- const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
635
+ const el = window.__clickVerifyEl;
636
+ delete window.__clickVerifyEl;
543
637
  if (!el) return { targetReceived: false, reason: 'element not found' };
544
638
  if (el.__ptrHandler) el.removeEventListener('pointerdown', el.__ptrHandler);
545
639
  if (el.__docHandler) document.removeEventListener('pointerdown', el.__docHandler, { capture: true });
@@ -565,29 +659,41 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
565
659
  async function clickByRef(ref, jsClick = false, opts = {}) {
566
660
  const { force = false, debug = false, nativeOnly = false, waitForNavigation, navigationTimeout = 100 } = opts;
567
661
 
568
- if (!ariaSnapshot) {
569
- throw new Error('ariaSnapshot is required for ref-based clicks');
570
- }
571
-
572
- const refInfo = await ariaSnapshot.getElementByRef(ref);
573
- 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) {
574
666
  throw elementNotFoundError(`ref:${ref}`, 0);
575
667
  }
576
668
 
577
- if (refInfo.stale) {
578
- return {
579
- clicked: false,
580
- stale: true,
581
- warning: `Element ref:${ref} is no longer attached to the DOM. Page content may have changed. Run 'snapshot' again to get fresh refs.`
582
- };
583
- }
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
+ };
584
688
 
585
689
  if (!force && refInfo.isVisible === false) {
586
690
  // Special case: hidden radio/checkbox inputs — try to click associated label
691
+ // LAZY RESOLUTION: Always resolve ref from metadata
587
692
  const labelResult = await session.send('Runtime.evaluate', frameEvalParams(`
588
693
  (function() {
589
- const ref = ${JSON.stringify(ref)};
590
- const el = window.__ariaRefs && window.__ariaRefs.get(ref);
694
+ ${LAZY_RESOLVE_SCRIPT}
695
+
696
+ const el = lazyResolveRef(${JSON.stringify(ref)});
591
697
  if (!el) return { found: false };
592
698
 
593
699
  const tag = el.tagName.toUpperCase();
@@ -649,20 +755,22 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
649
755
  }
650
756
 
651
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
652
759
  const box = refInfo.box;
653
760
  if (box && (box.x < 0 || box.y < 0 || box.x + box.width > 1920 || box.y + box.height > 1080)) {
654
761
  try {
655
762
  await session.send('Runtime.evaluate', frameEvalParams(`
656
763
  (function() {
657
- const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
764
+ ${LAZY_RESOLVE_SCRIPT}
765
+ const el = lazyResolveRef(${JSON.stringify(ref)});
658
766
  if (el) el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
659
767
  })()
660
768
  `, true));
661
769
  await sleep(100);
662
- // Re-fetch element info after scroll
663
- const updatedInfo = await ariaSnapshot.getElementByRef(ref);
664
- if (updatedInfo && updatedInfo.box) {
665
- refInfo.box = updatedInfo.box;
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;
666
774
  }
667
775
  } catch {
668
776
  // Scroll failed — proceed with original coordinates
@@ -1050,12 +1158,12 @@ export function createClickExecutor(session, elementLocator, inputEmulator, aria
1050
1158
  const scrollUntilVisible = typeof params === 'object' && params.scrollUntilVisible === true;
1051
1159
  const scrollOptions = typeof params === 'object' ? params.scrollOptions : {};
1052
1160
 
1053
- // Detect if string selector looks like a versioned ref (s{N}e{M})
1054
- // 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"}}
1055
1163
  if (!ref && selector) {
1056
- if (/^s\d+e\d+$/.test(selector)) {
1164
+ if (/^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(selector)) {
1057
1165
  ref = selector;
1058
- } else if (/^ref=s\d+e\d+$/i.test(selector)) {
1166
+ } else if (/^ref=f(\d+|\[[^\]]+\])s\d+e\d+$/i.test(selector)) {
1059
1167
  ref = selector.slice(4); // Remove "ref=" prefix
1060
1168
  }
1061
1169
  }
@@ -16,6 +16,7 @@
16
16
  import { createActionabilityChecker } from './actionability.js';
17
17
  import { createElementValidator } from './element-validator.js';
18
18
  import { createReactInputFiller } from './react-filler.js';
19
+ import { createLazyResolver } from './LazyResolver.js';
19
20
  import {
20
21
  sleep,
21
22
  elementNotFoundError,
@@ -44,6 +45,7 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
44
45
  const actionabilityChecker = createActionabilityChecker(session);
45
46
  const elementValidator = createElementValidator(session);
46
47
  const reactInputFiller = createReactInputFiller(session);
48
+ const lazyResolver = createLazyResolver(session, { getFrameContext });
47
49
 
48
50
  /**
49
51
  * Build Runtime.evaluate params, injecting contextId when in an iframe.
@@ -87,36 +89,39 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
87
89
  async function fillByRef(ref, value, opts = {}) {
88
90
  const { clear = true, react = false } = opts;
89
91
 
90
- if (!ariaSnapshot) {
91
- throw new Error('ariaSnapshot is required for ref-based fills');
92
- }
93
-
94
- const refInfo = await ariaSnapshot.getElementByRef(ref);
95
- if (!refInfo) {
92
+ // LAZY RESOLUTION: Always resolve ref from metadata, never rely on cached element
93
+ // This eliminates stale element errors entirely
94
+ const resolved = await lazyResolver.resolveRef(ref);
95
+ if (!resolved) {
96
96
  throw elementNotFoundError(`ref:${ref}`, 0);
97
97
  }
98
98
 
99
- if (refInfo.stale) {
100
- throw new Error(`Element ref:${ref} is no longer attached to the DOM. Page content may have changed. Run 'snapshot' again to get fresh refs.`);
101
- }
99
+ const objectId = resolved.objectId;
100
+
101
+ // Get visibility info using the resolved element
102
+ const visibilityResult = await session.send('Runtime.callFunctionOn', {
103
+ objectId,
104
+ functionDeclaration: `function() {
105
+ const style = window.getComputedStyle(this);
106
+ const rect = this.getBoundingClientRect();
107
+ return {
108
+ isVisible: style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0,
109
+ box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
110
+ };
111
+ }`,
112
+ returnByValue: true
113
+ });
114
+
115
+ const refInfo = {
116
+ box: visibilityResult.result?.value?.box || resolved.box,
117
+ isVisible: visibilityResult.result?.value?.isVisible ?? true
118
+ };
102
119
 
103
120
  if (refInfo.isVisible === false) {
121
+ await releaseObject(session, objectId);
104
122
  throw new Error(`Element ref:${ref} exists but is not visible. It may be hidden or have zero dimensions.`);
105
123
  }
106
124
 
107
- const elementResult = await session.send('Runtime.evaluate',
108
- evalParams(`(function() {
109
- const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
110
- return el;
111
- })()`, false)
112
- );
113
-
114
- if (!elementResult.result.objectId) {
115
- throw elementNotFoundError(`ref:${ref}`, 0);
116
- }
117
-
118
- const objectId = elementResult.result.objectId;
119
-
120
125
  const editableCheck = await elementValidator.isEditable(objectId);
121
126
  if (!editableCheck.editable) {
122
127
  await releaseObject(session, objectId);
@@ -433,9 +438,9 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
433
438
  throw new Error('Fill requires value');
434
439
  }
435
440
 
436
- // Detect if selector looks like a versioned ref (s{N}e{M})
437
- // This allows {"fill": {"selector": "s1e1", "value": "..."}} to work like {"fill": {"ref": "s1e1", "value": "..."}}
438
- if (!ref && selector && /^s\d+e\d+$/.test(selector)) {
441
+ // Detect if selector looks like a versioned ref (f{frameId}s{N}e{M})
442
+ // This allows {"fill": {"selector": "f0s1e1", "value": "..."}} to work like {"fill": {"ref": "f0s1e1", "value": "..."}}
443
+ if (!ref && selector && /^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(selector)) {
439
444
  ref = selector;
440
445
  }
441
446
 
@@ -486,8 +491,8 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
486
491
 
487
492
  for (const [selector, value] of entries) {
488
493
  try {
489
- // Match versioned ref format s{N}e{M}
490
- const isRef = /^s\d+e\d+$/.test(selector);
494
+ // Match versioned ref format f{frameId}s{N}e{M}
495
+ const isRef = /^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(selector);
491
496
 
492
497
  if (isRef) {
493
498
  await fillByRef(selector, value, { clear: true, react: useReact });
package/src/dom/index.js CHANGED
@@ -76,6 +76,9 @@ export { createKeyboardExecutor } from './keyboard-executor.js';
76
76
  // Wait executor (waiting operations)
77
77
  export { createWaitExecutor } from './wait-executor.js';
78
78
 
79
+ // Lazy resolver (stateless element resolution)
80
+ export { createLazyResolver } from './LazyResolver.js';
81
+
79
82
  // ============================================================================
80
83
  // Convenience Functions
81
84
  // ============================================================================
@@ -1035,6 +1035,51 @@ export function createPageController(cdpClient, options = {}) {
1035
1035
  return null;
1036
1036
  }
1037
1037
 
1038
+ /**
1039
+ * Get the current frame identifier for ref generation.
1040
+ * Returns 'f0' for main frame, 'f1', 'f2', etc. for iframes by index.
1041
+ * Uses frame name if available for better stability.
1042
+ * @returns {Promise<string>} Frame identifier (e.g., 'f0', 'f1', 'f[frame-name]')
1043
+ */
1044
+ async function getFrameIdentifier() {
1045
+ // Main frame is always f0
1046
+ if (currentFrameId === mainFrameId || !currentFrameId) {
1047
+ return 'f0';
1048
+ }
1049
+
1050
+ // Get frame tree to find index or name
1051
+ const { frameTree } = await cdpClient.send('Page.getFrameTree');
1052
+
1053
+ function findAllChildFrames(node) {
1054
+ const frames = [];
1055
+ if (node.childFrames) {
1056
+ for (const child of node.childFrames) {
1057
+ frames.push(child);
1058
+ frames.push(...findAllChildFrames(child));
1059
+ }
1060
+ }
1061
+ return frames;
1062
+ }
1063
+
1064
+ const childFrames = findAllChildFrames(frameTree);
1065
+
1066
+ // Find current frame
1067
+ for (let i = 0; i < childFrames.length; i++) {
1068
+ if (childFrames[i].frame.id === currentFrameId) {
1069
+ // Prefer name if available (more stable than index)
1070
+ const frameName = childFrames[i].frame.name;
1071
+ if (frameName) {
1072
+ return `f[${frameName}]`;
1073
+ }
1074
+ // Fall back to index (1-based for iframes)
1075
+ return `f${i + 1}`;
1076
+ }
1077
+ }
1078
+
1079
+ // Fallback: unknown frame, use hash of frameId
1080
+ return `f[${currentFrameId.substring(0, 8)}]`;
1081
+ }
1082
+
1038
1083
  /**
1039
1084
  * Execute code in the current frame context
1040
1085
  * @param {string} expression - JavaScript expression
@@ -1295,6 +1340,7 @@ export function createPageController(cdpClient, options = {}) {
1295
1340
  getNetworkStatus,
1296
1341
  searchAllFrames,
1297
1342
  getFrameContext,
1343
+ getFrameIdentifier,
1298
1344
  dispose,
1299
1345
  get mainFrameId() { return mainFrameId; },
1300
1346
  get currentFrameId() { return currentFrameId; },
@@ -75,9 +75,9 @@ export async function executeHover(elementLocator, inputEmulator, ariaSnapshot,
75
75
  const text = typeof params === 'object' ? params.text : null;
76
76
  const duration = typeof params === 'object' ? (params.duration || 0) : 0;
77
77
 
78
- // Detect if string selector looks like a ref (e.g., "s1e1", "s2e12")
79
- // This allows {"hover": "s1e1"} to work the same as {"hover": {"ref": "s1e1"}}
80
- if (!ref && selector && /^s\d+e\d+$/.test(selector)) {
78
+ // Detect if string selector looks like a ref (e.g., "f0s1e1", "f[frame-top]s2e12")
79
+ // This allows {"hover": "f0s1e1"} to work the same as {"hover": {"ref": "f0s1e1"}}
80
+ if (!ref && selector && /^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(selector)) {
81
81
  ref = selector;
82
82
  }
83
83
  const force = typeof params === 'object' && params.force === true;
@@ -383,8 +383,8 @@ export async function executeDrag(elementLocator, inputEmulator, pageController,
383
383
  // String - could be selector or ref
384
384
  const selectorOrRef = spec;
385
385
 
386
- // Check if it looks like a ref (e.g., "s1e1", "s2e12")
387
- if (/^s\d+e\d+$/.test(selectorOrRef)) {
386
+ // Check if it looks like a ref (e.g., "f0s1e1", "f[frame-top]s2e12")
387
+ if (/^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(selectorOrRef)) {
388
388
  const box = await getRefBox(selectorOrRef);
389
389
  return { ...box, offsetX: 0, offsetY: 0 };
390
390
  }
@@ -400,7 +400,7 @@ export async function executeDrag(elementLocator, inputEmulator, pageController,
400
400
  // Helper to resolve selector string for JS execution
401
401
  function getSelectorExpression(spec) {
402
402
  if (typeof spec === 'string') {
403
- if (/^s\d+e\d+$/.test(spec)) {
403
+ if (/^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(spec)) {
404
404
  return `window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(spec)})`;
405
405
  }
406
406
  return `document.querySelector(${JSON.stringify(spec)})`;
@@ -121,8 +121,8 @@ export async function executeScroll(elementLocator, inputEmulator, pageControlle
121
121
  await pageController.evaluateInFrame('window.scrollBy(0, 300)');
122
122
  break;
123
123
  default:
124
- // Check if it looks like a ref (e.g., "s1e1", "s2e12")
125
- if (/^s\d+e\d+$/.test(params)) {
124
+ // Check if it looks like a ref (e.g., "f0s1e1", "f[frame-top]s2e12")
125
+ if (/^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(params)) {
126
126
  await scrollToRef(params);
127
127
  } else {
128
128
  // Treat as selector - scroll element into view
@@ -136,7 +136,7 @@ export async function executeScroll(elementLocator, inputEmulator, pageControlle
136
136
  }
137
137
  } else if (params && typeof params === 'object') {
138
138
  // Check for ref first
139
- const ref = params.ref || (params.selector && /^s\d+e\d+$/.test(params.selector) ? params.selector : null);
139
+ const ref = params.ref || (params.selector && /^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(params.selector) ? params.selector : null);
140
140
  if (ref) {
141
141
  await scrollToRef(ref);
142
142
  } else if (params.selector) {
@@ -328,9 +328,10 @@ export async function executeRefAt(session, params) {
328
328
  }
329
329
  }
330
330
 
331
- // Create new versioned ref: s{snapshotId}e{counter}
331
+ // Create new versioned ref: f{frameId}s{snapshotId}e{counter}
332
332
  window.__ariaRefCounter++;
333
- const ref = 's' + window.__ariaSnapshotId + 'e' + window.__ariaRefCounter;
333
+ const frameId = window.__ariaFrameIdentifier || 'f0';
334
+ const ref = frameId + 's' + window.__ariaSnapshotId + 'e' + window.__ariaRefCounter;
334
335
  window.__ariaRefs.set(ref, el);
335
336
 
336
337
  const rect = el.getBoundingClientRect();
@@ -396,9 +397,10 @@ export async function executeElementsAt(session, coords) {
396
397
  }
397
398
  }
398
399
 
399
- // Create new versioned ref: s{snapshotId}e{counter}
400
+ // Create new versioned ref: f{frameId}s{snapshotId}e{counter}
400
401
  window.__ariaRefCounter++;
401
- const ref = 's' + window.__ariaSnapshotId + 'e' + window.__ariaRefCounter;
402
+ const frameId = window.__ariaFrameIdentifier || 'f0';
403
+ const ref = frameId + 's' + window.__ariaSnapshotId + 'e' + window.__ariaRefCounter;
402
404
  window.__ariaRefs.set(ref, el);
403
405
  return { ref, existing: false };
404
406
  }
@@ -540,9 +542,10 @@ export async function executeElementsNear(session, params) {
540
542
  }
541
543
  }
542
544
 
543
- // Create new versioned ref: s{snapshotId}e{counter}
545
+ // Create new versioned ref: f{frameId}s{snapshotId}e{counter}
544
546
  window.__ariaRefCounter++;
545
- const ref = 's' + window.__ariaSnapshotId + 'e' + window.__ariaRefCounter;
547
+ const frameId = window.__ariaFrameIdentifier || 'f0';
548
+ const ref = frameId + 's' + window.__ariaSnapshotId + 'e' + window.__ariaRefCounter;
546
549
  window.__ariaRefs.set(ref, el);
547
550
  return { ref, existing: false };
548
551
  }
@@ -792,16 +792,16 @@ export const STEP_CONFIG = {
792
792
  validate: (params) => {
793
793
  const errors = [];
794
794
  if (typeof params === 'string') {
795
- if (!/^s\d+e\d+$/.test(params)) {
796
- errors.push('getBox ref must be in format "s{N}e{M}" (e.g., "s1e1", "s2e34")');
795
+ if (!/^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(params)) {
796
+ errors.push('getBox ref must be in format "f{frameId}s{N}e{M}" (e.g., "f0s1e1", "f[frame-top]s2e34")');
797
797
  }
798
798
  } else if (Array.isArray(params)) {
799
799
  if (params.length === 0) {
800
800
  errors.push('getBox refs array cannot be empty');
801
801
  }
802
802
  for (const ref of params) {
803
- if (typeof ref !== 'string' || !/^s\d+e\d+$/.test(ref)) {
804
- errors.push('getBox refs must be strings in format "s{N}e{M}"');
803
+ if (typeof ref !== 'string' || !/^f(\d+|\[[^\]]+\])s\d+e\d+$/.test(ref)) {
804
+ errors.push('getBox refs must be strings in format "f{frameId}s{N}e{M}"');
805
805
  break;
806
806
  }
807
807
  }
@@ -820,7 +820,7 @@ describe('ARIA Module', () => {
820
820
  return {};
821
821
  });
822
822
 
823
- const refInfo = await snapshot.getElementByRef('s1e1');
823
+ const refInfo = await snapshot.getElementByRef('f0s1e1');
824
824
 
825
825
  // Should return ref info with box, isConnected, etc
826
826
  assert.ok(refInfo);
@@ -848,7 +848,7 @@ describe('ARIA Module', () => {
848
848
  }
849
849
  }));
850
850
 
851
- const refInfo = await snapshot.getElementByRef('s1e1');
851
+ const refInfo = await snapshot.getElementByRef('f0s1e1');
852
852
 
853
853
  // Stale refs return object with stale flag
854
854
  if (refInfo) {
@@ -988,12 +988,12 @@ describe('ARIA Module', () => {
988
988
  tree: {
989
989
  role: 'document',
990
990
  children: [
991
- { role: 'button', name: 'Click me', ref: 's1e1' }
991
+ { role: 'button', name: 'Click me', ref: 'f0s1e1' }
992
992
  ]
993
993
  },
994
994
  yaml: 'document:\n button: Click me [s1e1]\n',
995
995
  refs: new Map([
996
- ['s1e1', { ref: 's1e1', role: 'button', name: 'Click me' }]
996
+ ['f0s1e1', { ref: 'f0s1e1', role: 'button', name: 'Click me' }]
997
997
  ]),
998
998
  snapshotId: 's1'
999
999
  }
@@ -1018,7 +1018,7 @@ describe('ARIA Module', () => {
1018
1018
  assert.ok(result.snapshotId);
1019
1019
 
1020
1020
  // Refs should be accessible via getElementByRef
1021
- const refInfo = await snapshot.getElementByRef('s1e1');
1021
+ const refInfo = await snapshot.getElementByRef('f0s1e1');
1022
1022
  assert.ok(refInfo === null || typeof refInfo === 'object');
1023
1023
  });
1024
1024
  });