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.
- package/README.md +80 -35
- package/SKILL.md +157 -241
- package/install.js +1 -0
- package/package.json +1 -1
- package/src/aria/index.js +8 -0
- package/src/aria/output-processor.js +173 -0
- package/src/aria/role-query.js +1229 -0
- package/src/aria/snapshot.js +459 -0
- package/src/aria.js +251 -50
- package/src/cdp/browser.js +22 -4
- package/src/cdp-skill.js +246 -69
- package/src/dom/LazyResolver.js +634 -0
- package/src/dom/click-executor.js +366 -94
- package/src/dom/element-locator.js +34 -25
- package/src/dom/fill-executor.js +83 -50
- package/src/dom/index.js +3 -0
- package/src/page/dialog-handler.js +119 -0
- package/src/page/page-controller.js +236 -3
- package/src/runner/context-helpers.js +33 -55
- package/src/runner/execute-dynamic.js +8 -7
- package/src/runner/execute-form.js +11 -11
- package/src/runner/execute-input.js +2 -2
- package/src/runner/execute-interaction.js +105 -126
- package/src/runner/execute-navigation.js +14 -29
- package/src/runner/execute-query.js +17 -11
- package/src/runner/step-executors.js +225 -84
- package/src/runner/step-registry.js +1064 -0
- package/src/runner/step-validator.js +16 -754
- package/src/tests/Aria.test.js +1025 -0
- package/src/tests/ClickExecutor.test.js +170 -50
- package/src/tests/ContextHelpers.test.js +41 -30
- package/src/tests/ExecuteBrowser.test.js +572 -0
- package/src/tests/ExecuteDynamic.test.js +2 -457
- package/src/tests/ExecuteForm.test.js +700 -0
- package/src/tests/ExecuteInput.test.js +540 -0
- package/src/tests/ExecuteInteraction.test.js +319 -0
- package/src/tests/ExecuteQuery.test.js +820 -0
- package/src/tests/FillExecutor.test.js +89 -37
- package/src/tests/LazyResolver.test.js +383 -0
- package/src/tests/StepValidator.test.js +224 -78
- package/src/tests/TestRunner.test.js +38 -27
- package/src/tests/integration.test.js +2 -1
- package/src/types.js +9 -9
- package/src/utils/backoff.js +118 -0
- package/src/utils/cdp-helpers.js +130 -0
- package/src/utils/devices.js +140 -0
- package/src/utils/errors.js +242 -0
- package/src/utils/index.js +65 -0
- package/src/utils/temp.js +75 -0
- package/src/utils/validators.js +433 -0
- 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
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
424
|
+
${LAZY_RESOLVE_SCRIPT}
|
|
425
|
+
|
|
426
|
+
const el = lazyResolveRef(${JSON.stringify(ref)});
|
|
427
|
+
|
|
309
428
|
if (!el) {
|
|
310
|
-
return { success: false, reason: 'ref not
|
|
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
|
-
|
|
338
|
-
this.
|
|
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
|
-
|
|
343
|
-
|
|
473
|
+
try {
|
|
474
|
+
await inputEmulator.click(x, y);
|
|
475
|
+
await sleep(50);
|
|
344
476
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
501
|
+
const targetReceived = verifyResult.result.value === true;
|
|
502
|
+
const result = { targetReceived };
|
|
359
503
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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.
|
|
447
|
-
el.addEventListener('
|
|
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
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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": "
|
|
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
|
}
|