cdp-skill 1.0.14 → 1.0.16
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
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LazyResolver
|
|
3
|
+
* Stateless element resolution - always re-resolves refs from metadata instead of caching DOM elements.
|
|
4
|
+
* This eliminates stale element errors entirely.
|
|
5
|
+
*
|
|
6
|
+
* EXPORTS:
|
|
7
|
+
* - createLazyResolver(session, options?) → LazyResolver
|
|
8
|
+
* Methods: resolveRef, resolveSelector, resolveText
|
|
9
|
+
*
|
|
10
|
+
* DEPENDENCIES:
|
|
11
|
+
* - ../utils.js: releaseObject
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { releaseObject } from '../utils.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a lazy resolver for stateless element resolution
|
|
18
|
+
* @param {Object} session - CDP session
|
|
19
|
+
* @param {Object} [options] - Configuration options
|
|
20
|
+
* @param {Function} [options.getFrameContext] - Returns contextId when in a non-main frame
|
|
21
|
+
* @returns {Object} Lazy resolver interface
|
|
22
|
+
*/
|
|
23
|
+
export function createLazyResolver(session, options = {}) {
|
|
24
|
+
if (!session) throw new Error('CDP session is required');
|
|
25
|
+
|
|
26
|
+
const getFrameContext = options.getFrameContext || null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build Runtime.evaluate params with frame context when in an iframe.
|
|
30
|
+
*/
|
|
31
|
+
function evalParams(expression, returnByValue = false) {
|
|
32
|
+
const params = { expression, returnByValue };
|
|
33
|
+
if (getFrameContext) {
|
|
34
|
+
const contextId = getFrameContext();
|
|
35
|
+
if (contextId) params.contextId = contextId;
|
|
36
|
+
}
|
|
37
|
+
return params;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve an element by CSS selector - always fresh resolution
|
|
42
|
+
* @param {string} selector - CSS selector
|
|
43
|
+
* @returns {Promise<{objectId: string, box: Object}|null>} Element with objectId and bounding box, or null
|
|
44
|
+
*/
|
|
45
|
+
async function resolveSelector(selector) {
|
|
46
|
+
if (!selector || typeof selector !== 'string') return null;
|
|
47
|
+
|
|
48
|
+
const expression = `
|
|
49
|
+
(function() {
|
|
50
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
51
|
+
if (!el) return null;
|
|
52
|
+
const rect = el.getBoundingClientRect();
|
|
53
|
+
return {
|
|
54
|
+
found: true,
|
|
55
|
+
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
|
56
|
+
};
|
|
57
|
+
})()
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// First check if element exists and get box
|
|
62
|
+
const checkResult = await session.send('Runtime.evaluate', evalParams(expression, true));
|
|
63
|
+
if (!checkResult.result.value?.found) return null;
|
|
64
|
+
|
|
65
|
+
// Now get the actual objectId
|
|
66
|
+
const objResult = await session.send('Runtime.evaluate',
|
|
67
|
+
evalParams(`document.querySelector(${JSON.stringify(selector)})`, false)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (objResult.result.subtype === 'null' || !objResult.result.objectId) return null;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
objectId: objResult.result.objectId,
|
|
74
|
+
box: checkResult.result.value.box,
|
|
75
|
+
resolvedBy: 'selector',
|
|
76
|
+
selector
|
|
77
|
+
};
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve an element by role and name - always fresh resolution
|
|
85
|
+
* @param {string} role - ARIA role
|
|
86
|
+
* @param {string} name - Accessible name
|
|
87
|
+
* @returns {Promise<{objectId: string, box: Object}|null>} Element with objectId and bounding box, or null
|
|
88
|
+
*/
|
|
89
|
+
async function resolveByRoleAndName(role, name) {
|
|
90
|
+
if (!role) return null;
|
|
91
|
+
|
|
92
|
+
const expression = `
|
|
93
|
+
(function() {
|
|
94
|
+
const role = ${JSON.stringify(role)};
|
|
95
|
+
const name = ${JSON.stringify(name || '')};
|
|
96
|
+
|
|
97
|
+
// Role to selector mappings
|
|
98
|
+
const ROLE_SELECTORS = {
|
|
99
|
+
button: ['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]', '[role="button"]'],
|
|
100
|
+
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"]'],
|
|
101
|
+
checkbox: ['input[type="checkbox"]', '[role="checkbox"]'],
|
|
102
|
+
link: ['a[href]', '[role="link"]'],
|
|
103
|
+
heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', '[role="heading"]'],
|
|
104
|
+
listitem: ['li', '[role="listitem"]'],
|
|
105
|
+
option: ['option', '[role="option"]'],
|
|
106
|
+
combobox: ['select', '[role="combobox"]'],
|
|
107
|
+
radio: ['input[type="radio"]', '[role="radio"]'],
|
|
108
|
+
img: ['img[alt]', '[role="img"]'],
|
|
109
|
+
tab: ['[role="tab"]'],
|
|
110
|
+
menuitem: ['[role="menuitem"]'],
|
|
111
|
+
slider: ['input[type="range"]', '[role="slider"]'],
|
|
112
|
+
spinbutton: ['input[type="number"]', '[role="spinbutton"]'],
|
|
113
|
+
searchbox: ['input[type="search"]', '[role="searchbox"]'],
|
|
114
|
+
switch: ['[role="switch"]']
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const selectors = ROLE_SELECTORS[role] || ['[role="' + role + '"]'];
|
|
118
|
+
const selectorString = selectors.join(', ');
|
|
119
|
+
const elements = document.querySelectorAll(selectorString);
|
|
120
|
+
|
|
121
|
+
function getAccessibleName(el) {
|
|
122
|
+
return (
|
|
123
|
+
el.getAttribute('aria-label') ||
|
|
124
|
+
el.textContent?.trim() ||
|
|
125
|
+
el.getAttribute('title') ||
|
|
126
|
+
el.getAttribute('placeholder') ||
|
|
127
|
+
el.value ||
|
|
128
|
+
''
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isVisible(el) {
|
|
133
|
+
if (!el.isConnected) return false;
|
|
134
|
+
const style = window.getComputedStyle(el);
|
|
135
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
136
|
+
const rect = el.getBoundingClientRect();
|
|
137
|
+
return rect.width > 0 && rect.height > 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Find element matching role and name
|
|
141
|
+
for (const el of elements) {
|
|
142
|
+
if (!isVisible(el)) continue;
|
|
143
|
+
const elName = getAccessibleName(el);
|
|
144
|
+
// Match by name (case-insensitive contains)
|
|
145
|
+
if (name && !elName.toLowerCase().includes(name.toLowerCase())) continue;
|
|
146
|
+
|
|
147
|
+
const rect = el.getBoundingClientRect();
|
|
148
|
+
return {
|
|
149
|
+
found: true,
|
|
150
|
+
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
151
|
+
index: Array.from(elements).indexOf(el)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
})()
|
|
157
|
+
`;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const checkResult = await session.send('Runtime.evaluate', evalParams(expression, true));
|
|
161
|
+
if (!checkResult.result.value?.found) return null;
|
|
162
|
+
|
|
163
|
+
const index = checkResult.result.value.index;
|
|
164
|
+
|
|
165
|
+
// Get the actual objectId
|
|
166
|
+
const objExpression = `
|
|
167
|
+
(function() {
|
|
168
|
+
const role = ${JSON.stringify(role)};
|
|
169
|
+
const ROLE_SELECTORS = {
|
|
170
|
+
button: ['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]', '[role="button"]'],
|
|
171
|
+
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"]'],
|
|
172
|
+
checkbox: ['input[type="checkbox"]', '[role="checkbox"]'],
|
|
173
|
+
link: ['a[href]', '[role="link"]'],
|
|
174
|
+
heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', '[role="heading"]'],
|
|
175
|
+
listitem: ['li', '[role="listitem"]'],
|
|
176
|
+
option: ['option', '[role="option"]'],
|
|
177
|
+
combobox: ['select', '[role="combobox"]'],
|
|
178
|
+
radio: ['input[type="radio"]', '[role="radio"]'],
|
|
179
|
+
img: ['img[alt]', '[role="img"]'],
|
|
180
|
+
tab: ['[role="tab"]'],
|
|
181
|
+
menuitem: ['[role="menuitem"]'],
|
|
182
|
+
slider: ['input[type="range"]', '[role="slider"]'],
|
|
183
|
+
spinbutton: ['input[type="number"]', '[role="spinbutton"]'],
|
|
184
|
+
searchbox: ['input[type="search"]', '[role="searchbox"]'],
|
|
185
|
+
switch: ['[role="switch"]']
|
|
186
|
+
};
|
|
187
|
+
const selectors = ROLE_SELECTORS[role] || ['[role="' + role + '"]'];
|
|
188
|
+
const elements = document.querySelectorAll(selectors.join(', '));
|
|
189
|
+
return elements[${index}] || null;
|
|
190
|
+
})()
|
|
191
|
+
`;
|
|
192
|
+
|
|
193
|
+
const objResult = await session.send('Runtime.evaluate', evalParams(objExpression, false));
|
|
194
|
+
if (objResult.result.subtype === 'null' || !objResult.result.objectId) return null;
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
objectId: objResult.result.objectId,
|
|
198
|
+
box: checkResult.result.value.box,
|
|
199
|
+
resolvedBy: 'role+name',
|
|
200
|
+
role,
|
|
201
|
+
name
|
|
202
|
+
};
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve an element through shadow DOM using the host path
|
|
210
|
+
* @param {string[]} shadowHostPath - Array of selectors for shadow hosts
|
|
211
|
+
* @param {string} selector - Final selector within the shadow root
|
|
212
|
+
* @returns {Promise<{objectId: string, box: Object}|null>} Element with objectId and bounding box, or null
|
|
213
|
+
*/
|
|
214
|
+
async function resolveThroughShadowDOM(shadowHostPath, selector) {
|
|
215
|
+
if (!shadowHostPath || shadowHostPath.length === 0) return null;
|
|
216
|
+
|
|
217
|
+
const expression = `
|
|
218
|
+
(function() {
|
|
219
|
+
const hostPath = ${JSON.stringify(shadowHostPath)};
|
|
220
|
+
const selector = ${JSON.stringify(selector)};
|
|
221
|
+
|
|
222
|
+
let root = document;
|
|
223
|
+
for (const hostSelector of hostPath) {
|
|
224
|
+
const host = root.querySelector(hostSelector);
|
|
225
|
+
if (!host || !host.shadowRoot) return null;
|
|
226
|
+
root = host.shadowRoot;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const el = root.querySelector(selector);
|
|
230
|
+
if (!el) return null;
|
|
231
|
+
|
|
232
|
+
const rect = el.getBoundingClientRect();
|
|
233
|
+
return {
|
|
234
|
+
found: true,
|
|
235
|
+
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
|
236
|
+
};
|
|
237
|
+
})()
|
|
238
|
+
`;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const checkResult = await session.send('Runtime.evaluate', evalParams(expression, true));
|
|
242
|
+
if (!checkResult.result.value?.found) return null;
|
|
243
|
+
|
|
244
|
+
// Get objectId
|
|
245
|
+
const objExpression = `
|
|
246
|
+
(function() {
|
|
247
|
+
const hostPath = ${JSON.stringify(shadowHostPath)};
|
|
248
|
+
const selector = ${JSON.stringify(selector)};
|
|
249
|
+
let root = document;
|
|
250
|
+
for (const hostSelector of hostPath) {
|
|
251
|
+
const host = root.querySelector(hostSelector);
|
|
252
|
+
if (!host || !host.shadowRoot) return null;
|
|
253
|
+
root = host.shadowRoot;
|
|
254
|
+
}
|
|
255
|
+
return root.querySelector(selector);
|
|
256
|
+
})()
|
|
257
|
+
`;
|
|
258
|
+
|
|
259
|
+
const objResult = await session.send('Runtime.evaluate', evalParams(objExpression, false));
|
|
260
|
+
if (objResult.result.subtype === 'null' || !objResult.result.objectId) return null;
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
objectId: objResult.result.objectId,
|
|
264
|
+
box: checkResult.result.value.box,
|
|
265
|
+
resolvedBy: 'shadow-dom',
|
|
266
|
+
shadowHostPath,
|
|
267
|
+
selector
|
|
268
|
+
};
|
|
269
|
+
} catch (err) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolve an element ref using stored metadata - ALWAYS fresh resolution
|
|
276
|
+
* This is the core of lazy resolution - never uses cached element references
|
|
277
|
+
*
|
|
278
|
+
* Resolution order:
|
|
279
|
+
* 1. Try selector from metadata
|
|
280
|
+
* 2. Try role+name search if selector fails
|
|
281
|
+
* 3. Try shadow DOM traversal if shadowHostPath exists
|
|
282
|
+
*
|
|
283
|
+
* @param {string} ref - Element ref (e.g., "s1e5")
|
|
284
|
+
* @returns {Promise<{objectId: string, box: Object, resolvedBy: string}|null>} Resolved element or null
|
|
285
|
+
*/
|
|
286
|
+
async function resolveRef(ref) {
|
|
287
|
+
if (!ref || typeof ref !== 'string') return null;
|
|
288
|
+
|
|
289
|
+
// Get metadata from browser
|
|
290
|
+
const metaExpression = `
|
|
291
|
+
(function() {
|
|
292
|
+
const meta = window.__ariaRefMeta && window.__ariaRefMeta.get(${JSON.stringify(ref)});
|
|
293
|
+
if (!meta) return null;
|
|
294
|
+
return {
|
|
295
|
+
selector: meta.selector || null,
|
|
296
|
+
role: meta.role || null,
|
|
297
|
+
name: meta.name || null,
|
|
298
|
+
shadowHostPath: meta.shadowHostPath || null
|
|
299
|
+
};
|
|
300
|
+
})()
|
|
301
|
+
`;
|
|
302
|
+
|
|
303
|
+
let metadata;
|
|
304
|
+
try {
|
|
305
|
+
const metaResult = await session.send('Runtime.evaluate', evalParams(metaExpression, true));
|
|
306
|
+
metadata = metaResult.result.value;
|
|
307
|
+
} catch (err) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!metadata) {
|
|
312
|
+
// No metadata stored - ref doesn't exist
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Strategy 1: Try selector first (most specific)
|
|
317
|
+
if (metadata.selector) {
|
|
318
|
+
// If there's a shadow host path, use shadow DOM resolution
|
|
319
|
+
if (metadata.shadowHostPath && metadata.shadowHostPath.length > 0) {
|
|
320
|
+
const shadowResult = await resolveThroughShadowDOM(metadata.shadowHostPath, metadata.selector);
|
|
321
|
+
if (shadowResult) {
|
|
322
|
+
shadowResult.ref = ref;
|
|
323
|
+
return shadowResult;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Try regular selector
|
|
328
|
+
const selectorResult = await resolveSelector(metadata.selector);
|
|
329
|
+
if (selectorResult) {
|
|
330
|
+
selectorResult.ref = ref;
|
|
331
|
+
return selectorResult;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Strategy 2: Try role+name search (works even if selector changed)
|
|
336
|
+
if (metadata.role) {
|
|
337
|
+
const roleResult = await resolveByRoleAndName(metadata.role, metadata.name);
|
|
338
|
+
if (roleResult) {
|
|
339
|
+
roleResult.ref = ref;
|
|
340
|
+
return roleResult;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Strategy 3: Last resort - scan all shadow roots for role+name
|
|
345
|
+
if (metadata.role) {
|
|
346
|
+
const shadowScanResult = await scanShadowRootsForRoleAndName(metadata.role, metadata.name);
|
|
347
|
+
if (shadowScanResult) {
|
|
348
|
+
shadowScanResult.ref = ref;
|
|
349
|
+
return shadowScanResult;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Scan all shadow roots for an element matching role and name
|
|
358
|
+
* @param {string} role - ARIA role
|
|
359
|
+
* @param {string} name - Accessible name
|
|
360
|
+
* @returns {Promise<{objectId: string, box: Object}|null>}
|
|
361
|
+
*/
|
|
362
|
+
async function scanShadowRootsForRoleAndName(role, name) {
|
|
363
|
+
const expression = `
|
|
364
|
+
(function() {
|
|
365
|
+
const targetRole = ${JSON.stringify(role)};
|
|
366
|
+
const targetName = ${JSON.stringify(name || '')};
|
|
367
|
+
|
|
368
|
+
const ROLE_SELECTORS = {
|
|
369
|
+
button: ['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]', '[role="button"]'],
|
|
370
|
+
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"]'],
|
|
371
|
+
checkbox: ['input[type="checkbox"]', '[role="checkbox"]'],
|
|
372
|
+
link: ['a[href]', '[role="link"]'],
|
|
373
|
+
heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', '[role="heading"]'],
|
|
374
|
+
listitem: ['li', '[role="listitem"]'],
|
|
375
|
+
option: ['option', '[role="option"]'],
|
|
376
|
+
combobox: ['select', '[role="combobox"]'],
|
|
377
|
+
radio: ['input[type="radio"]', '[role="radio"]'],
|
|
378
|
+
tab: ['[role="tab"]'],
|
|
379
|
+
menuitem: ['[role="menuitem"]']
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
function getAccessibleName(el) {
|
|
383
|
+
return (
|
|
384
|
+
el.getAttribute('aria-label') ||
|
|
385
|
+
el.textContent?.trim() ||
|
|
386
|
+
el.getAttribute('title') ||
|
|
387
|
+
el.getAttribute('placeholder') ||
|
|
388
|
+
el.value ||
|
|
389
|
+
''
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function isVisible(el) {
|
|
394
|
+
if (!el.isConnected) return false;
|
|
395
|
+
const style = window.getComputedStyle(el);
|
|
396
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
397
|
+
const rect = el.getBoundingClientRect();
|
|
398
|
+
return rect.width > 0 && rect.height > 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function searchInRoot(root, path) {
|
|
402
|
+
const selectors = ROLE_SELECTORS[targetRole] || ['[role="' + targetRole + '"]'];
|
|
403
|
+
const elements = root.querySelectorAll(selectors.join(', '));
|
|
404
|
+
|
|
405
|
+
for (const el of elements) {
|
|
406
|
+
if (!isVisible(el)) continue;
|
|
407
|
+
const elName = getAccessibleName(el);
|
|
408
|
+
if (targetName && !elName.toLowerCase().includes(targetName.toLowerCase())) continue;
|
|
409
|
+
|
|
410
|
+
const rect = el.getBoundingClientRect();
|
|
411
|
+
return {
|
|
412
|
+
found: true,
|
|
413
|
+
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
414
|
+
path: path
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Collect all shadow roots
|
|
421
|
+
const shadowHosts = document.querySelectorAll('*');
|
|
422
|
+
for (const host of shadowHosts) {
|
|
423
|
+
if (host.shadowRoot) {
|
|
424
|
+
const result = searchInRoot(host.shadowRoot, []);
|
|
425
|
+
if (result) return result;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return null;
|
|
430
|
+
})()
|
|
431
|
+
`;
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const checkResult = await session.send('Runtime.evaluate', evalParams(expression, true));
|
|
435
|
+
if (!checkResult.result.value?.found) return null;
|
|
436
|
+
|
|
437
|
+
// For simplicity, we'll re-run to get the objectId
|
|
438
|
+
// This is acceptable because lazy resolution is already making fresh queries
|
|
439
|
+
const objExpression = `
|
|
440
|
+
(function() {
|
|
441
|
+
const targetRole = ${JSON.stringify(role)};
|
|
442
|
+
const targetName = ${JSON.stringify(name || '')};
|
|
443
|
+
|
|
444
|
+
const ROLE_SELECTORS = {
|
|
445
|
+
button: ['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]', '[role="button"]'],
|
|
446
|
+
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"]'],
|
|
447
|
+
checkbox: ['input[type="checkbox"]', '[role="checkbox"]'],
|
|
448
|
+
link: ['a[href]', '[role="link"]'],
|
|
449
|
+
heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', '[role="heading"]'],
|
|
450
|
+
listitem: ['li', '[role="listitem"]'],
|
|
451
|
+
option: ['option', '[role="option"]'],
|
|
452
|
+
combobox: ['select', '[role="combobox"]'],
|
|
453
|
+
radio: ['input[type="radio"]', '[role="radio"]'],
|
|
454
|
+
tab: ['[role="tab"]'],
|
|
455
|
+
menuitem: ['[role="menuitem"]']
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
function getAccessibleName(el) {
|
|
459
|
+
return (
|
|
460
|
+
el.getAttribute('aria-label') ||
|
|
461
|
+
el.textContent?.trim() ||
|
|
462
|
+
el.getAttribute('title') ||
|
|
463
|
+
el.getAttribute('placeholder') ||
|
|
464
|
+
el.value ||
|
|
465
|
+
''
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isVisible(el) {
|
|
470
|
+
if (!el.isConnected) return false;
|
|
471
|
+
const style = window.getComputedStyle(el);
|
|
472
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
473
|
+
const rect = el.getBoundingClientRect();
|
|
474
|
+
return rect.width > 0 && rect.height > 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const shadowHosts = document.querySelectorAll('*');
|
|
478
|
+
for (const host of shadowHosts) {
|
|
479
|
+
if (host.shadowRoot) {
|
|
480
|
+
const selectors = ROLE_SELECTORS[targetRole] || ['[role="' + targetRole + '"]'];
|
|
481
|
+
const elements = host.shadowRoot.querySelectorAll(selectors.join(', '));
|
|
482
|
+
for (const el of elements) {
|
|
483
|
+
if (!isVisible(el)) continue;
|
|
484
|
+
const elName = getAccessibleName(el);
|
|
485
|
+
if (targetName && !elName.toLowerCase().includes(targetName.toLowerCase())) continue;
|
|
486
|
+
return el;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return null;
|
|
491
|
+
})()
|
|
492
|
+
`;
|
|
493
|
+
|
|
494
|
+
const objResult = await session.send('Runtime.evaluate', evalParams(objExpression, false));
|
|
495
|
+
if (objResult.result.subtype === 'null' || !objResult.result.objectId) return null;
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
objectId: objResult.result.objectId,
|
|
499
|
+
box: checkResult.result.value.box,
|
|
500
|
+
resolvedBy: 'shadow-scan',
|
|
501
|
+
role,
|
|
502
|
+
name
|
|
503
|
+
};
|
|
504
|
+
} catch (err) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Resolve an element by text content - always fresh resolution
|
|
511
|
+
* @param {string} text - Text to search for
|
|
512
|
+
* @param {Object} [opts] - Options
|
|
513
|
+
* @param {boolean} [opts.exact=false] - Require exact match
|
|
514
|
+
* @returns {Promise<{objectId: string, box: Object}|null>} Element with objectId and bounding box, or null
|
|
515
|
+
*/
|
|
516
|
+
async function resolveText(text, opts = {}) {
|
|
517
|
+
if (!text || typeof text !== 'string') return null;
|
|
518
|
+
|
|
519
|
+
const { exact = false } = opts;
|
|
520
|
+
const expression = `
|
|
521
|
+
(function() {
|
|
522
|
+
const text = ${JSON.stringify(text)};
|
|
523
|
+
const exact = ${exact};
|
|
524
|
+
|
|
525
|
+
function getElementText(el) {
|
|
526
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
527
|
+
if (ariaLabel) return ariaLabel;
|
|
528
|
+
if (el.tagName === 'INPUT') return el.value || el.placeholder || '';
|
|
529
|
+
return el.textContent || '';
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function matchesText(elText) {
|
|
533
|
+
if (exact) return elText.trim() === text;
|
|
534
|
+
return elText.toLowerCase().includes(text.toLowerCase());
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function isVisible(el) {
|
|
538
|
+
if (!el.isConnected) return false;
|
|
539
|
+
const style = window.getComputedStyle(el);
|
|
540
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
541
|
+
const rect = el.getBoundingClientRect();
|
|
542
|
+
return rect.width > 0 && rect.height > 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Priority: buttons → links → role buttons → other clickable
|
|
546
|
+
const selectorGroups = [
|
|
547
|
+
['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]'],
|
|
548
|
+
['a[href]'],
|
|
549
|
+
['[role="button"]'],
|
|
550
|
+
['[onclick]', '[tabindex]', 'label', 'summary']
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
for (const selectors of selectorGroups) {
|
|
554
|
+
const elements = document.querySelectorAll(selectors.join(', '));
|
|
555
|
+
for (const el of elements) {
|
|
556
|
+
if (!isVisible(el)) continue;
|
|
557
|
+
if (matchesText(getElementText(el))) {
|
|
558
|
+
const rect = el.getBoundingClientRect();
|
|
559
|
+
return {
|
|
560
|
+
found: true,
|
|
561
|
+
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
562
|
+
selectors: selectors.join(', ')
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
})()
|
|
569
|
+
`;
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
const checkResult = await session.send('Runtime.evaluate', evalParams(expression, true));
|
|
573
|
+
if (!checkResult.result.value?.found) return null;
|
|
574
|
+
|
|
575
|
+
const matchedSelectors = checkResult.result.value.selectors;
|
|
576
|
+
|
|
577
|
+
// Get objectId
|
|
578
|
+
const objExpression = `
|
|
579
|
+
(function() {
|
|
580
|
+
const text = ${JSON.stringify(text)};
|
|
581
|
+
const exact = ${exact};
|
|
582
|
+
const selectors = ${JSON.stringify(matchedSelectors)};
|
|
583
|
+
|
|
584
|
+
function getElementText(el) {
|
|
585
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
586
|
+
if (ariaLabel) return ariaLabel;
|
|
587
|
+
if (el.tagName === 'INPUT') return el.value || el.placeholder || '';
|
|
588
|
+
return el.textContent || '';
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function matchesText(elText) {
|
|
592
|
+
if (exact) return elText.trim() === text;
|
|
593
|
+
return elText.toLowerCase().includes(text.toLowerCase());
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function isVisible(el) {
|
|
597
|
+
if (!el.isConnected) return false;
|
|
598
|
+
const style = window.getComputedStyle(el);
|
|
599
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
600
|
+
const rect = el.getBoundingClientRect();
|
|
601
|
+
return rect.width > 0 && rect.height > 0;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const elements = document.querySelectorAll(selectors);
|
|
605
|
+
for (const el of elements) {
|
|
606
|
+
if (!isVisible(el)) continue;
|
|
607
|
+
if (matchesText(getElementText(el))) return el;
|
|
608
|
+
}
|
|
609
|
+
return null;
|
|
610
|
+
})()
|
|
611
|
+
`;
|
|
612
|
+
|
|
613
|
+
const objResult = await session.send('Runtime.evaluate', evalParams(objExpression, false));
|
|
614
|
+
if (objResult.result.subtype === 'null' || !objResult.result.objectId) return null;
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
objectId: objResult.result.objectId,
|
|
618
|
+
box: checkResult.result.value.box,
|
|
619
|
+
resolvedBy: 'text',
|
|
620
|
+
text
|
|
621
|
+
};
|
|
622
|
+
} catch (err) {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
resolveRef,
|
|
629
|
+
resolveSelector,
|
|
630
|
+
resolveText,
|
|
631
|
+
resolveByRoleAndName,
|
|
632
|
+
resolveThroughShadowDOM
|
|
633
|
+
};
|
|
634
|
+
}
|