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.
- package/SKILL.md +8 -4
- package/package.json +1 -1
- package/src/aria.js +14 -7
- package/src/cdp-skill.js +2 -1
- package/src/dom/LazyResolver.js +634 -0
- package/src/dom/click-executor.js +162 -54
- package/src/dom/fill-executor.js +32 -27
- package/src/dom/index.js +3 -0
- package/src/page/page-controller.js +46 -0
- package/src/runner/execute-interaction.js +6 -6
- package/src/runner/execute-navigation.js +3 -3
- package/src/runner/execute-query.js +9 -6
- package/src/runner/step-registry.js +4 -4
- package/src/tests/Aria.test.js +5 -5
- package/src/tests/ClickExecutor.test.js +170 -50
- package/src/tests/ContextHelpers.test.js +2 -2
- package/src/tests/ExecuteInteraction.test.js +2 -2
- package/src/tests/ExecuteQuery.test.js +33 -33
- package/src/tests/FillExecutor.test.js +87 -35
- package/src/tests/LazyResolver.test.js +383 -0
- package/src/tests/StepValidator.test.js +2 -2
- package/src/tests/TestRunner.test.js +2 -2
|
@@ -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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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.
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
|
664
|
-
if (
|
|
665
|
-
refInfo.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": "
|
|
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
|
}
|
package/src/dom/fill-executor.js
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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": "
|
|
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., "
|
|
79
|
-
// This allows {"hover": "
|
|
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., "
|
|
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., "
|
|
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
|
|
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
|
|
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
|
|
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., "
|
|
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
|
}
|
package/src/tests/Aria.test.js
CHANGED
|
@@ -820,7 +820,7 @@ describe('ARIA Module', () => {
|
|
|
820
820
|
return {};
|
|
821
821
|
});
|
|
822
822
|
|
|
823
|
-
const refInfo = await snapshot.getElementByRef('
|
|
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('
|
|
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: '
|
|
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
|
-
['
|
|
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('
|
|
1021
|
+
const refInfo = await snapshot.getElementByRef('f0s1e1');
|
|
1022
1022
|
assert.ok(refInfo === null || typeof refInfo === 'object');
|
|
1023
1023
|
});
|
|
1024
1024
|
});
|