cdp-skill 1.0.0
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 +543 -0
- package/install.js +92 -0
- package/package.json +47 -0
- package/src/aria.js +1302 -0
- package/src/capture.js +1359 -0
- package/src/cdp.js +905 -0
- package/src/cli.js +244 -0
- package/src/dom.js +3525 -0
- package/src/index.js +155 -0
- package/src/page.js +1720 -0
- package/src/runner.js +2111 -0
- package/src/tests/BrowserClient.test.js +588 -0
- package/src/tests/CDPConnection.test.js +598 -0
- package/src/tests/ChromeDiscovery.test.js +181 -0
- package/src/tests/ConsoleCapture.test.js +302 -0
- package/src/tests/ElementHandle.test.js +586 -0
- package/src/tests/ElementLocator.test.js +586 -0
- package/src/tests/ErrorAggregator.test.js +327 -0
- package/src/tests/InputEmulator.test.js +641 -0
- package/src/tests/NetworkErrorCapture.test.js +458 -0
- package/src/tests/PageController.test.js +822 -0
- package/src/tests/ScreenshotCapture.test.js +356 -0
- package/src/tests/SessionRegistry.test.js +257 -0
- package/src/tests/TargetManager.test.js +274 -0
- package/src/tests/TestRunner.test.js +1529 -0
- package/src/tests/WaitStrategy.test.js +406 -0
- package/src/tests/integration.test.js +431 -0
- package/src/utils.js +1034 -0
- package/uninstall.js +44 -0
package/src/dom.js
ADDED
|
@@ -0,0 +1,3525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM Operations and Input Emulation
|
|
3
|
+
* Element location, handling, state checking, input simulation, and step executors
|
|
4
|
+
*
|
|
5
|
+
* Consolidated: ActionabilityChecker, ElementValidator, ClickExecutor, FillExecutor,
|
|
6
|
+
* ReactInputFiller, KeyboardStepExecutor, WaitExecutor
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
timeoutError,
|
|
11
|
+
elementNotFoundError,
|
|
12
|
+
elementNotEditableError,
|
|
13
|
+
staleElementError,
|
|
14
|
+
connectionError,
|
|
15
|
+
isStaleElementError,
|
|
16
|
+
sleep,
|
|
17
|
+
sleepWithBackoff,
|
|
18
|
+
createBackoffSleeper,
|
|
19
|
+
releaseObject,
|
|
20
|
+
resetInputState,
|
|
21
|
+
getCurrentUrl,
|
|
22
|
+
getElementAtPoint,
|
|
23
|
+
detectNavigation
|
|
24
|
+
} from './utils.js';
|
|
25
|
+
|
|
26
|
+
const MAX_TIMEOUT = 300000; // 5 minutes max timeout
|
|
27
|
+
const DEFAULT_TIMEOUT = 30000;
|
|
28
|
+
const POLL_INTERVAL = 100;
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Key Definitions
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
const KEY_DEFINITIONS = {
|
|
35
|
+
Enter: { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' },
|
|
36
|
+
Tab: { key: 'Tab', code: 'Tab', keyCode: 9 },
|
|
37
|
+
Escape: { key: 'Escape', code: 'Escape', keyCode: 27 },
|
|
38
|
+
Backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 },
|
|
39
|
+
Delete: { key: 'Delete', code: 'Delete', keyCode: 46 },
|
|
40
|
+
Space: { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
|
|
41
|
+
ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
|
42
|
+
ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
|
43
|
+
ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
|
44
|
+
ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
|
|
45
|
+
Shift: { key: 'Shift', code: 'ShiftLeft', keyCode: 16 },
|
|
46
|
+
Control: { key: 'Control', code: 'ControlLeft', keyCode: 17 },
|
|
47
|
+
Alt: { key: 'Alt', code: 'AltLeft', keyCode: 18 },
|
|
48
|
+
Meta: { key: 'Meta', code: 'MetaLeft', keyCode: 91 },
|
|
49
|
+
F1: { key: 'F1', code: 'F1', keyCode: 112 },
|
|
50
|
+
F2: { key: 'F2', code: 'F2', keyCode: 113 },
|
|
51
|
+
F3: { key: 'F3', code: 'F3', keyCode: 114 },
|
|
52
|
+
F4: { key: 'F4', code: 'F4', keyCode: 115 },
|
|
53
|
+
F5: { key: 'F5', code: 'F5', keyCode: 116 },
|
|
54
|
+
F6: { key: 'F6', code: 'F6', keyCode: 117 },
|
|
55
|
+
F7: { key: 'F7', code: 'F7', keyCode: 118 },
|
|
56
|
+
F8: { key: 'F8', code: 'F8', keyCode: 119 },
|
|
57
|
+
F9: { key: 'F9', code: 'F9', keyCode: 120 },
|
|
58
|
+
F10: { key: 'F10', code: 'F10', keyCode: 121 },
|
|
59
|
+
F11: { key: 'F11', code: 'F11', keyCode: 122 },
|
|
60
|
+
F12: { key: 'F12', code: 'F12', keyCode: 123 },
|
|
61
|
+
Home: { key: 'Home', code: 'Home', keyCode: 36 },
|
|
62
|
+
End: { key: 'End', code: 'End', keyCode: 35 },
|
|
63
|
+
PageUp: { key: 'PageUp', code: 'PageUp', keyCode: 33 },
|
|
64
|
+
PageDown: { key: 'PageDown', code: 'PageDown', keyCode: 34 },
|
|
65
|
+
Insert: { key: 'Insert', code: 'Insert', keyCode: 45 }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Non-editable input types
|
|
70
|
+
*/
|
|
71
|
+
const NON_EDITABLE_INPUT_TYPES = [
|
|
72
|
+
'button', 'checkbox', 'color', 'file', 'hidden',
|
|
73
|
+
'image', 'radio', 'range', 'reset', 'submit'
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Content Quads Helpers (inspired by Chromedp)
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Calculate center point of a quad
|
|
82
|
+
* Quads are arrays of 8 numbers: [x1,y1, x2,y2, x3,y3, x4,y4]
|
|
83
|
+
* @param {number[]} quad - Quad coordinates
|
|
84
|
+
* @returns {{x: number, y: number}}
|
|
85
|
+
*/
|
|
86
|
+
function calculateQuadCenter(quad) {
|
|
87
|
+
let x = 0, y = 0;
|
|
88
|
+
for (let i = 0; i < 8; i += 2) {
|
|
89
|
+
x += quad[i];
|
|
90
|
+
y += quad[i + 1];
|
|
91
|
+
}
|
|
92
|
+
return { x: x / 4, y: y / 4 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Calculate area of a quad
|
|
97
|
+
* @param {number[]} quad - Quad coordinates
|
|
98
|
+
* @returns {number}
|
|
99
|
+
*/
|
|
100
|
+
function calculateQuadArea(quad) {
|
|
101
|
+
// Shoelace formula for polygon area
|
|
102
|
+
let area = 0;
|
|
103
|
+
for (let i = 0; i < 4; i++) {
|
|
104
|
+
const j = (i + 1) % 4;
|
|
105
|
+
area += quad[i * 2] * quad[j * 2 + 1];
|
|
106
|
+
area -= quad[j * 2] * quad[i * 2 + 1];
|
|
107
|
+
}
|
|
108
|
+
return Math.abs(area) / 2;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if a point is inside a quad
|
|
113
|
+
* @param {number[]} quad - Quad coordinates
|
|
114
|
+
* @param {number} x - Point x
|
|
115
|
+
* @param {number} y - Point y
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
function isPointInQuad(quad, x, y) {
|
|
119
|
+
// Using ray casting algorithm
|
|
120
|
+
const points = [];
|
|
121
|
+
for (let i = 0; i < 8; i += 2) {
|
|
122
|
+
points.push({ x: quad[i], y: quad[i + 1] });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let inside = false;
|
|
126
|
+
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
127
|
+
const xi = points[i].x, yi = points[i].y;
|
|
128
|
+
const xj = points[j].x, yj = points[j].y;
|
|
129
|
+
|
|
130
|
+
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
|
|
131
|
+
inside = !inside;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return inside;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get the largest quad from an array (most likely the visible content area)
|
|
139
|
+
* @param {number[][]} quads - Array of quads
|
|
140
|
+
* @returns {number[]|null}
|
|
141
|
+
*/
|
|
142
|
+
function getLargestQuad(quads) {
|
|
143
|
+
if (!quads || quads.length === 0) return null;
|
|
144
|
+
if (quads.length === 1) return quads[0];
|
|
145
|
+
|
|
146
|
+
let largest = quads[0];
|
|
147
|
+
let largestArea = calculateQuadArea(quads[0]);
|
|
148
|
+
|
|
149
|
+
for (let i = 1; i < quads.length; i++) {
|
|
150
|
+
const area = calculateQuadArea(quads[i]);
|
|
151
|
+
if (area > largestArea) {
|
|
152
|
+
largestArea = area;
|
|
153
|
+
largest = quads[i];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return largest;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// Element Handle
|
|
161
|
+
// ============================================================================
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Create an element handle for a remote object
|
|
165
|
+
* @param {Object} session - CDP session
|
|
166
|
+
* @param {string} objectId - Remote object ID from CDP
|
|
167
|
+
* @param {Object} [options] - Additional options
|
|
168
|
+
* @param {string} [options.selector] - Selector used to find this element
|
|
169
|
+
* @returns {Object} Element handle interface
|
|
170
|
+
*/
|
|
171
|
+
export function createElementHandle(session, objectId, options = {}) {
|
|
172
|
+
if (!session) throw new Error('CDP session is required');
|
|
173
|
+
if (!objectId) throw new Error('objectId is required');
|
|
174
|
+
|
|
175
|
+
let disposed = false;
|
|
176
|
+
const selector = options.selector || null;
|
|
177
|
+
|
|
178
|
+
function ensureNotDisposed() {
|
|
179
|
+
if (disposed) throw new Error('ElementHandle has been disposed');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function wrapCDPOperation(operation, fn) {
|
|
183
|
+
try {
|
|
184
|
+
return await fn();
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (isStaleElementError(error)) {
|
|
187
|
+
throw staleElementError(objectId, { operation, selector, cause: error });
|
|
188
|
+
}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function getBoundingBox() {
|
|
194
|
+
ensureNotDisposed();
|
|
195
|
+
return wrapCDPOperation('getBoundingBox', async () => {
|
|
196
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
197
|
+
objectId,
|
|
198
|
+
functionDeclaration: `function() {
|
|
199
|
+
const rect = this.getBoundingClientRect();
|
|
200
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
201
|
+
}`,
|
|
202
|
+
returnByValue: true
|
|
203
|
+
});
|
|
204
|
+
return result.result.value;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get content quads for the element (handles CSS transforms)
|
|
210
|
+
* Content quads give the actual renderable area accounting for transforms
|
|
211
|
+
* @returns {Promise<{quads: number[][], center: {x: number, y: number}}|null>}
|
|
212
|
+
*/
|
|
213
|
+
async function getContentQuads() {
|
|
214
|
+
ensureNotDisposed();
|
|
215
|
+
return wrapCDPOperation('getContentQuads', async () => {
|
|
216
|
+
try {
|
|
217
|
+
// First get the backend node ID
|
|
218
|
+
const nodeResult = await session.send('DOM.describeNode', { objectId });
|
|
219
|
+
const backendNodeId = nodeResult.node.backendNodeId;
|
|
220
|
+
|
|
221
|
+
// Get content quads using CDP
|
|
222
|
+
const quadsResult = await session.send('DOM.getContentQuads', { backendNodeId });
|
|
223
|
+
const quads = quadsResult.quads;
|
|
224
|
+
|
|
225
|
+
if (!quads || quads.length === 0) {
|
|
226
|
+
// Fall back to bounding box if no quads
|
|
227
|
+
const box = await getBoundingBox();
|
|
228
|
+
return {
|
|
229
|
+
quads: [[box.x, box.y, box.x + box.width, box.y,
|
|
230
|
+
box.x + box.width, box.y + box.height, box.x, box.y + box.height]],
|
|
231
|
+
center: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
|
|
232
|
+
fallback: true
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Calculate center of first quad (8 numbers: 4 points * 2 coords)
|
|
237
|
+
const quad = quads[0];
|
|
238
|
+
const center = calculateQuadCenter(quad);
|
|
239
|
+
|
|
240
|
+
return { quads, center, fallback: false };
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// If getContentQuads fails, fall back to bounding box
|
|
243
|
+
const box = await getBoundingBox();
|
|
244
|
+
if (!box) return null;
|
|
245
|
+
return {
|
|
246
|
+
quads: [[box.x, box.y, box.x + box.width, box.y,
|
|
247
|
+
box.x + box.width, box.y + box.height, box.x, box.y + box.height]],
|
|
248
|
+
center: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
|
|
249
|
+
fallback: true
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function getClickPoint(useQuads = true) {
|
|
256
|
+
ensureNotDisposed();
|
|
257
|
+
|
|
258
|
+
// Try content quads first for accurate positioning with transforms
|
|
259
|
+
if (useQuads) {
|
|
260
|
+
try {
|
|
261
|
+
const quadsResult = await getContentQuads();
|
|
262
|
+
if (quadsResult && quadsResult.center) {
|
|
263
|
+
return quadsResult.center;
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// Fall back to bounding box
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const box = await getBoundingBox();
|
|
271
|
+
return {
|
|
272
|
+
x: box.x + box.width / 2,
|
|
273
|
+
y: box.y + box.height / 2
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function isConnectedToDOM() {
|
|
278
|
+
ensureNotDisposed();
|
|
279
|
+
try {
|
|
280
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
281
|
+
objectId,
|
|
282
|
+
functionDeclaration: `function() { return this.isConnected; }`,
|
|
283
|
+
returnByValue: true
|
|
284
|
+
});
|
|
285
|
+
return result.result.value === true;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (isStaleElementError(error)) return false;
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function ensureConnected(operation = null) {
|
|
293
|
+
const connected = await isConnectedToDOM();
|
|
294
|
+
if (!connected) {
|
|
295
|
+
throw staleElementError(objectId, operation);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function isVisible() {
|
|
300
|
+
ensureNotDisposed();
|
|
301
|
+
return wrapCDPOperation('isVisible', async () => {
|
|
302
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
303
|
+
objectId,
|
|
304
|
+
functionDeclaration: `function() {
|
|
305
|
+
const el = this;
|
|
306
|
+
let current = el;
|
|
307
|
+
while (current) {
|
|
308
|
+
const style = window.getComputedStyle(current);
|
|
309
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
current = current.parentElement;
|
|
313
|
+
}
|
|
314
|
+
const rect = el.getBoundingClientRect();
|
|
315
|
+
if (rect.width === 0 || rect.height === 0) return false;
|
|
316
|
+
return true;
|
|
317
|
+
}`,
|
|
318
|
+
returnByValue: true
|
|
319
|
+
});
|
|
320
|
+
return result.result.value;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Check if element is in viewport using IntersectionObserver (improvement #11)
|
|
326
|
+
* More efficient than manual rect calculations for determining visibility
|
|
327
|
+
* @param {Object} [options] - Options
|
|
328
|
+
* @param {number} [options.threshold=0] - Minimum intersection ratio (0-1)
|
|
329
|
+
* @param {number} [options.timeout=5000] - Timeout in ms
|
|
330
|
+
* @returns {Promise<{inViewport: boolean, intersectionRatio: number, boundingRect: Object}>}
|
|
331
|
+
*/
|
|
332
|
+
async function isInViewport(options = {}) {
|
|
333
|
+
ensureNotDisposed();
|
|
334
|
+
const { threshold = 0, timeout = 5000 } = options;
|
|
335
|
+
|
|
336
|
+
return wrapCDPOperation('isInViewport', async () => {
|
|
337
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
338
|
+
objectId,
|
|
339
|
+
functionDeclaration: `function(threshold, timeout) {
|
|
340
|
+
return new Promise((resolve) => {
|
|
341
|
+
const el = this;
|
|
342
|
+
|
|
343
|
+
// Quick check first using getBoundingClientRect
|
|
344
|
+
const rect = el.getBoundingClientRect();
|
|
345
|
+
const viewHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
346
|
+
const viewWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
347
|
+
|
|
348
|
+
// Calculate intersection manually as a quick check
|
|
349
|
+
const visibleTop = Math.max(0, rect.top);
|
|
350
|
+
const visibleLeft = Math.max(0, rect.left);
|
|
351
|
+
const visibleBottom = Math.min(viewHeight, rect.bottom);
|
|
352
|
+
const visibleRight = Math.min(viewWidth, rect.right);
|
|
353
|
+
|
|
354
|
+
const visibleWidth = Math.max(0, visibleRight - visibleLeft);
|
|
355
|
+
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
|
|
356
|
+
const visibleArea = visibleWidth * visibleHeight;
|
|
357
|
+
const totalArea = rect.width * rect.height;
|
|
358
|
+
const ratio = totalArea > 0 ? visibleArea / totalArea : 0;
|
|
359
|
+
|
|
360
|
+
// If no IntersectionObserver support, use manual calculation
|
|
361
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
362
|
+
resolve({
|
|
363
|
+
inViewport: ratio > threshold,
|
|
364
|
+
intersectionRatio: ratio,
|
|
365
|
+
boundingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
366
|
+
method: 'manual'
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Use IntersectionObserver for accurate detection
|
|
372
|
+
let resolved = false;
|
|
373
|
+
const timeoutId = setTimeout(() => {
|
|
374
|
+
if (!resolved) {
|
|
375
|
+
resolved = true;
|
|
376
|
+
observer.disconnect();
|
|
377
|
+
// Fall back to manual calculation on timeout
|
|
378
|
+
resolve({
|
|
379
|
+
inViewport: ratio > threshold,
|
|
380
|
+
intersectionRatio: ratio,
|
|
381
|
+
boundingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
382
|
+
method: 'timeout-fallback'
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}, timeout);
|
|
386
|
+
|
|
387
|
+
const observer = new IntersectionObserver((entries) => {
|
|
388
|
+
if (resolved) return;
|
|
389
|
+
resolved = true;
|
|
390
|
+
clearTimeout(timeoutId);
|
|
391
|
+
observer.disconnect();
|
|
392
|
+
|
|
393
|
+
const entry = entries[0];
|
|
394
|
+
if (!entry) {
|
|
395
|
+
resolve({
|
|
396
|
+
inViewport: ratio > threshold,
|
|
397
|
+
intersectionRatio: ratio,
|
|
398
|
+
boundingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
399
|
+
method: 'no-entry'
|
|
400
|
+
});
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
resolve({
|
|
405
|
+
inViewport: entry.isIntersecting && entry.intersectionRatio > threshold,
|
|
406
|
+
intersectionRatio: entry.intersectionRatio,
|
|
407
|
+
boundingRect: {
|
|
408
|
+
x: entry.boundingClientRect.x,
|
|
409
|
+
y: entry.boundingClientRect.y,
|
|
410
|
+
width: entry.boundingClientRect.width,
|
|
411
|
+
height: entry.boundingClientRect.height
|
|
412
|
+
},
|
|
413
|
+
rootBounds: entry.rootBounds ? {
|
|
414
|
+
width: entry.rootBounds.width,
|
|
415
|
+
height: entry.rootBounds.height
|
|
416
|
+
} : null,
|
|
417
|
+
method: 'intersectionObserver'
|
|
418
|
+
});
|
|
419
|
+
}, { threshold: [0, threshold, 1] });
|
|
420
|
+
|
|
421
|
+
observer.observe(el);
|
|
422
|
+
});
|
|
423
|
+
}`,
|
|
424
|
+
arguments: [{ value: threshold }, { value: timeout }],
|
|
425
|
+
returnByValue: true,
|
|
426
|
+
awaitPromise: true
|
|
427
|
+
});
|
|
428
|
+
return result.result.value;
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Wait for element to enter viewport using IntersectionObserver
|
|
434
|
+
* @param {Object} [options] - Options
|
|
435
|
+
* @param {number} [options.threshold=0.1] - Minimum visibility ratio
|
|
436
|
+
* @param {number} [options.timeout=30000] - Timeout in ms
|
|
437
|
+
* @returns {Promise<{inViewport: boolean, intersectionRatio: number}>}
|
|
438
|
+
*/
|
|
439
|
+
async function waitForInViewport(options = {}) {
|
|
440
|
+
ensureNotDisposed();
|
|
441
|
+
const { threshold = 0.1, timeout = 30000 } = options;
|
|
442
|
+
|
|
443
|
+
return wrapCDPOperation('waitForInViewport', async () => {
|
|
444
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
445
|
+
objectId,
|
|
446
|
+
functionDeclaration: `function(threshold, timeout) {
|
|
447
|
+
return new Promise((resolve, reject) => {
|
|
448
|
+
const el = this;
|
|
449
|
+
|
|
450
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
451
|
+
// Fall back to scroll into view
|
|
452
|
+
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
453
|
+
resolve({ inViewport: true, method: 'scrolled' });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let resolved = false;
|
|
458
|
+
const timeoutId = setTimeout(() => {
|
|
459
|
+
if (!resolved) {
|
|
460
|
+
resolved = true;
|
|
461
|
+
observer.disconnect();
|
|
462
|
+
reject(new Error('Timeout waiting for element to enter viewport'));
|
|
463
|
+
}
|
|
464
|
+
}, timeout);
|
|
465
|
+
|
|
466
|
+
const observer = new IntersectionObserver((entries) => {
|
|
467
|
+
if (resolved) return;
|
|
468
|
+
const entry = entries[0];
|
|
469
|
+
if (entry && entry.isIntersecting && entry.intersectionRatio >= threshold) {
|
|
470
|
+
resolved = true;
|
|
471
|
+
clearTimeout(timeoutId);
|
|
472
|
+
observer.disconnect();
|
|
473
|
+
resolve({
|
|
474
|
+
inViewport: true,
|
|
475
|
+
intersectionRatio: entry.intersectionRatio,
|
|
476
|
+
method: 'intersectionObserver'
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}, { threshold: [threshold] });
|
|
480
|
+
|
|
481
|
+
observer.observe(el);
|
|
482
|
+
});
|
|
483
|
+
}`,
|
|
484
|
+
arguments: [{ value: threshold }, { value: timeout }],
|
|
485
|
+
returnByValue: true,
|
|
486
|
+
awaitPromise: true
|
|
487
|
+
});
|
|
488
|
+
return result.result.value;
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function isActionable() {
|
|
493
|
+
ensureNotDisposed();
|
|
494
|
+
return wrapCDPOperation('isActionable', async () => {
|
|
495
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
496
|
+
objectId,
|
|
497
|
+
functionDeclaration: `function() {
|
|
498
|
+
const el = this;
|
|
499
|
+
if (!el.isConnected) return { actionable: false, reason: 'element not connected to DOM' };
|
|
500
|
+
|
|
501
|
+
let current = el;
|
|
502
|
+
while (current) {
|
|
503
|
+
const style = window.getComputedStyle(current);
|
|
504
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
505
|
+
return { actionable: false, reason: 'hidden by CSS' };
|
|
506
|
+
}
|
|
507
|
+
current = current.parentElement;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const rect = el.getBoundingClientRect();
|
|
511
|
+
if (rect.width === 0 || rect.height === 0) return { actionable: false, reason: 'zero dimensions' };
|
|
512
|
+
|
|
513
|
+
const viewHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
514
|
+
const viewWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
515
|
+
if (rect.bottom < 0 || rect.top > viewHeight || rect.right < 0 || rect.left > viewWidth) {
|
|
516
|
+
return { actionable: false, reason: 'outside viewport' };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const centerX = rect.left + rect.width / 2;
|
|
520
|
+
const centerY = rect.top + rect.height / 2;
|
|
521
|
+
const topElement = document.elementFromPoint(centerX, centerY);
|
|
522
|
+
if (topElement === null) return { actionable: false, reason: 'element center not hittable' };
|
|
523
|
+
if (topElement !== el && !el.contains(topElement)) {
|
|
524
|
+
return { actionable: false, reason: 'occluded by another element' };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (el.disabled) return { actionable: false, reason: 'element is disabled' };
|
|
528
|
+
|
|
529
|
+
return { actionable: true, reason: null };
|
|
530
|
+
}`,
|
|
531
|
+
returnByValue: true
|
|
532
|
+
});
|
|
533
|
+
return result.result.value;
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function scrollIntoView(opts = {}) {
|
|
538
|
+
ensureNotDisposed();
|
|
539
|
+
const { block = 'center', inline = 'nearest' } = opts;
|
|
540
|
+
return wrapCDPOperation('scrollIntoView', async () => {
|
|
541
|
+
await session.send('Runtime.callFunctionOn', {
|
|
542
|
+
objectId,
|
|
543
|
+
functionDeclaration: `function(block, inline) {
|
|
544
|
+
this.scrollIntoView({ block, inline, behavior: 'instant' });
|
|
545
|
+
}`,
|
|
546
|
+
arguments: [{ value: block }, { value: inline }],
|
|
547
|
+
returnByValue: true
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function focus() {
|
|
553
|
+
ensureNotDisposed();
|
|
554
|
+
return wrapCDPOperation('focus', async () => {
|
|
555
|
+
await session.send('Runtime.callFunctionOn', {
|
|
556
|
+
objectId,
|
|
557
|
+
functionDeclaration: `function() {
|
|
558
|
+
this.focus();
|
|
559
|
+
}`,
|
|
560
|
+
returnByValue: true
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function waitForStability(opts = {}) {
|
|
566
|
+
ensureNotDisposed();
|
|
567
|
+
const { frames = 3, timeout = 5000 } = opts;
|
|
568
|
+
const startTime = Date.now();
|
|
569
|
+
let lastBox = null;
|
|
570
|
+
let stableFrames = 0;
|
|
571
|
+
|
|
572
|
+
while (Date.now() - startTime < timeout) {
|
|
573
|
+
const box = await getBoundingBox();
|
|
574
|
+
if (!box) throw new Error('Element not visible');
|
|
575
|
+
|
|
576
|
+
if (lastBox &&
|
|
577
|
+
box.x === lastBox.x && box.y === lastBox.y &&
|
|
578
|
+
box.width === lastBox.width && box.height === lastBox.height) {
|
|
579
|
+
stableFrames++;
|
|
580
|
+
if (stableFrames >= frames) return box;
|
|
581
|
+
} else {
|
|
582
|
+
stableFrames = 0;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
lastBox = box;
|
|
586
|
+
await sleep(16);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
throw new Error(`Element position not stable after ${timeout}ms`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function evaluate(fn, ...args) {
|
|
593
|
+
ensureNotDisposed();
|
|
594
|
+
return wrapCDPOperation('evaluate', async () => {
|
|
595
|
+
const fnString = typeof fn === 'function' ? fn.toString() : fn;
|
|
596
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
597
|
+
objectId,
|
|
598
|
+
functionDeclaration: fnString,
|
|
599
|
+
arguments: args.map(arg => ({ value: arg })),
|
|
600
|
+
returnByValue: true
|
|
601
|
+
});
|
|
602
|
+
return result.result.value;
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function dispose() {
|
|
607
|
+
if (!disposed) {
|
|
608
|
+
try {
|
|
609
|
+
await session.send('Runtime.releaseObject', { objectId });
|
|
610
|
+
} catch {
|
|
611
|
+
// Ignore
|
|
612
|
+
}
|
|
613
|
+
disposed = true;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
get objectId() { return objectId; },
|
|
619
|
+
get selector() { return selector; },
|
|
620
|
+
getBoundingBox,
|
|
621
|
+
getContentQuads,
|
|
622
|
+
getClickPoint,
|
|
623
|
+
isConnectedToDOM,
|
|
624
|
+
ensureConnected,
|
|
625
|
+
isVisible,
|
|
626
|
+
isInViewport,
|
|
627
|
+
waitForInViewport,
|
|
628
|
+
isActionable,
|
|
629
|
+
scrollIntoView,
|
|
630
|
+
focus,
|
|
631
|
+
waitForStability,
|
|
632
|
+
evaluate,
|
|
633
|
+
dispose,
|
|
634
|
+
isDisposed: () => disposed
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ============================================================================
|
|
639
|
+
// Element Locator
|
|
640
|
+
// ============================================================================
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Create an element locator for finding DOM elements
|
|
644
|
+
* @param {Object} session - CDP session
|
|
645
|
+
* @param {Object} [options] - Configuration options
|
|
646
|
+
* @param {number} [options.timeout=30000] - Default timeout in ms
|
|
647
|
+
* @returns {Object} Element locator interface
|
|
648
|
+
*/
|
|
649
|
+
export function createElementLocator(session, options = {}) {
|
|
650
|
+
if (!session) throw new Error('CDP session is required');
|
|
651
|
+
|
|
652
|
+
let defaultTimeout = options.timeout || 30000;
|
|
653
|
+
|
|
654
|
+
function validateTimeout(timeout) {
|
|
655
|
+
if (typeof timeout !== 'number' || !Number.isFinite(timeout)) return defaultTimeout;
|
|
656
|
+
if (timeout < 0) return 0;
|
|
657
|
+
if (timeout > MAX_TIMEOUT) return MAX_TIMEOUT;
|
|
658
|
+
return timeout;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function doReleaseObject(objId) {
|
|
662
|
+
try {
|
|
663
|
+
await session.send('Runtime.releaseObject', { objectId: objId });
|
|
664
|
+
} catch {
|
|
665
|
+
// Ignore
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function querySelector(selector) {
|
|
670
|
+
if (!selector || typeof selector !== 'string') {
|
|
671
|
+
throw new Error('Selector must be a non-empty string');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let result;
|
|
675
|
+
try {
|
|
676
|
+
result = await session.send('Runtime.evaluate', {
|
|
677
|
+
expression: `document.querySelector(${JSON.stringify(selector)})`,
|
|
678
|
+
returnByValue: false
|
|
679
|
+
});
|
|
680
|
+
} catch (error) {
|
|
681
|
+
throw connectionError(error.message, 'Runtime.evaluate (querySelector)');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (result.exceptionDetails) {
|
|
685
|
+
const exceptionMessage = result.exceptionDetails.exception?.description ||
|
|
686
|
+
result.exceptionDetails.exception?.value ||
|
|
687
|
+
result.exceptionDetails.text ||
|
|
688
|
+
'Unknown selector error';
|
|
689
|
+
throw new Error(`Selector error: ${exceptionMessage}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (result.result.subtype === 'null' || result.result.type === 'undefined') {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return createElementHandle(session, result.result.objectId, { selector });
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function querySelectorAll(selector) {
|
|
700
|
+
if (!selector || typeof selector !== 'string') {
|
|
701
|
+
throw new Error('Selector must be a non-empty string');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
let result;
|
|
705
|
+
try {
|
|
706
|
+
result = await session.send('Runtime.evaluate', {
|
|
707
|
+
expression: `Array.from(document.querySelectorAll(${JSON.stringify(selector)}))`,
|
|
708
|
+
returnByValue: false
|
|
709
|
+
});
|
|
710
|
+
} catch (error) {
|
|
711
|
+
throw connectionError(error.message, 'Runtime.evaluate (querySelectorAll)');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (result.exceptionDetails) {
|
|
715
|
+
const exceptionMessage = result.exceptionDetails.exception?.description ||
|
|
716
|
+
result.exceptionDetails.exception?.value ||
|
|
717
|
+
result.exceptionDetails.text ||
|
|
718
|
+
'Unknown selector error';
|
|
719
|
+
throw new Error(`Selector error: ${exceptionMessage}`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (!result.result.objectId) return [];
|
|
723
|
+
|
|
724
|
+
const arrayObjectId = result.result.objectId;
|
|
725
|
+
let props;
|
|
726
|
+
try {
|
|
727
|
+
props = await session.send('Runtime.getProperties', {
|
|
728
|
+
objectId: arrayObjectId,
|
|
729
|
+
ownProperties: true
|
|
730
|
+
});
|
|
731
|
+
} catch (error) {
|
|
732
|
+
await doReleaseObject(arrayObjectId);
|
|
733
|
+
throw connectionError(error.message, 'Runtime.getProperties');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const elements = props.result
|
|
737
|
+
.filter(p => /^\d+$/.test(p.name) && p.value && p.value.objectId)
|
|
738
|
+
.map(p => createElementHandle(session, p.value.objectId, { selector }));
|
|
739
|
+
|
|
740
|
+
await doReleaseObject(arrayObjectId);
|
|
741
|
+
return elements;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function waitForSelector(selector, waitOptions = {}) {
|
|
745
|
+
if (!selector || typeof selector !== 'string') {
|
|
746
|
+
throw new Error('Selector must be a non-empty string');
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const { timeout = defaultTimeout, visible = false } = waitOptions;
|
|
750
|
+
const validatedTimeout = validateTimeout(timeout);
|
|
751
|
+
const startTime = Date.now();
|
|
752
|
+
|
|
753
|
+
while (Date.now() - startTime < validatedTimeout) {
|
|
754
|
+
const element = await querySelector(selector);
|
|
755
|
+
|
|
756
|
+
if (element) {
|
|
757
|
+
if (!visible) return element;
|
|
758
|
+
|
|
759
|
+
try {
|
|
760
|
+
const isVis = await element.isVisible();
|
|
761
|
+
if (isVis) return element;
|
|
762
|
+
} catch {
|
|
763
|
+
// Element may have been removed
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
await element.dispose();
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
await sleep(100);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
throw elementNotFoundError(selector, validatedTimeout);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function waitForText(text, waitOptions = {}) {
|
|
776
|
+
if (text === null || text === undefined) {
|
|
777
|
+
throw new Error('Text must be provided');
|
|
778
|
+
}
|
|
779
|
+
const textStr = String(text);
|
|
780
|
+
|
|
781
|
+
const { timeout = defaultTimeout, exact = false } = waitOptions;
|
|
782
|
+
const validatedTimeout = validateTimeout(timeout);
|
|
783
|
+
const startTime = Date.now();
|
|
784
|
+
|
|
785
|
+
const checkExpr = exact
|
|
786
|
+
? `document.body.innerText.includes(${JSON.stringify(textStr)})`
|
|
787
|
+
: `document.body.innerText.toLowerCase().includes(${JSON.stringify(textStr.toLowerCase())})`;
|
|
788
|
+
|
|
789
|
+
while (Date.now() - startTime < validatedTimeout) {
|
|
790
|
+
let result;
|
|
791
|
+
try {
|
|
792
|
+
result = await session.send('Runtime.evaluate', {
|
|
793
|
+
expression: checkExpr,
|
|
794
|
+
returnByValue: true
|
|
795
|
+
});
|
|
796
|
+
} catch (error) {
|
|
797
|
+
throw connectionError(error.message, 'Runtime.evaluate (waitForText)');
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (result.result.value === true) return true;
|
|
801
|
+
|
|
802
|
+
await sleep(100);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
throw timeoutError(`Timeout (${validatedTimeout}ms) waiting for text: "${textStr}"`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function findElement(selector) {
|
|
809
|
+
const element = await querySelector(selector);
|
|
810
|
+
if (!element) return null;
|
|
811
|
+
return { nodeId: element.objectId, _handle: element };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function getBoundingBox(nodeId) {
|
|
815
|
+
if (!nodeId) return null;
|
|
816
|
+
|
|
817
|
+
let result;
|
|
818
|
+
try {
|
|
819
|
+
result = await session.send('Runtime.callFunctionOn', {
|
|
820
|
+
objectId: nodeId,
|
|
821
|
+
functionDeclaration: `function() {
|
|
822
|
+
const rect = this.getBoundingClientRect();
|
|
823
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
824
|
+
}`,
|
|
825
|
+
returnByValue: true
|
|
826
|
+
});
|
|
827
|
+
} catch {
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (result.exceptionDetails || !result.result.value) return null;
|
|
832
|
+
return result.result.value;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function queryByRole(role, opts = {}) {
|
|
836
|
+
const { name, checked, disabled } = opts;
|
|
837
|
+
|
|
838
|
+
const ROLE_SELECTORS = {
|
|
839
|
+
button: ['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]', '[role="button"]'],
|
|
840
|
+
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"]'],
|
|
841
|
+
checkbox: ['input[type="checkbox"]', '[role="checkbox"]'],
|
|
842
|
+
link: ['a[href]', '[role="link"]'],
|
|
843
|
+
heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', '[role="heading"]'],
|
|
844
|
+
listitem: ['li', '[role="listitem"]'],
|
|
845
|
+
option: ['option', '[role="option"]'],
|
|
846
|
+
combobox: ['select', '[role="combobox"]']
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
const selectors = ROLE_SELECTORS[role] || [`[role="${role}"]`];
|
|
850
|
+
const selectorString = selectors.join(', ');
|
|
851
|
+
|
|
852
|
+
const nameFilter = (name !== undefined && name !== null) ? JSON.stringify(name.toLowerCase()) : null;
|
|
853
|
+
const checkedFilter = checked !== undefined ? checked : null;
|
|
854
|
+
const disabledFilter = disabled !== undefined ? disabled : null;
|
|
855
|
+
|
|
856
|
+
const expression = `
|
|
857
|
+
(function() {
|
|
858
|
+
const selectors = ${JSON.stringify(selectorString)};
|
|
859
|
+
const nameFilter = ${nameFilter};
|
|
860
|
+
const checkedFilter = ${checkedFilter !== null ? checkedFilter : 'null'};
|
|
861
|
+
const disabledFilter = ${disabledFilter !== null ? disabledFilter : 'null'};
|
|
862
|
+
|
|
863
|
+
const elements = Array.from(document.querySelectorAll(selectors));
|
|
864
|
+
|
|
865
|
+
return elements.filter(el => {
|
|
866
|
+
if (nameFilter !== null) {
|
|
867
|
+
const accessibleName = (
|
|
868
|
+
el.getAttribute('aria-label') ||
|
|
869
|
+
el.textContent?.trim() ||
|
|
870
|
+
el.getAttribute('title') ||
|
|
871
|
+
el.getAttribute('placeholder') ||
|
|
872
|
+
el.value ||
|
|
873
|
+
''
|
|
874
|
+
).toLowerCase();
|
|
875
|
+
if (!accessibleName.includes(nameFilter)) return false;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (checkedFilter !== null) {
|
|
879
|
+
const isChecked = el.checked === true || el.getAttribute('aria-checked') === 'true';
|
|
880
|
+
if (isChecked !== checkedFilter) return false;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (disabledFilter !== null) {
|
|
884
|
+
const isDisabled = el.disabled === true || el.getAttribute('aria-disabled') === 'true';
|
|
885
|
+
if (isDisabled !== disabledFilter) return false;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return true;
|
|
889
|
+
});
|
|
890
|
+
})()
|
|
891
|
+
`;
|
|
892
|
+
|
|
893
|
+
let result;
|
|
894
|
+
try {
|
|
895
|
+
result = await session.send('Runtime.evaluate', {
|
|
896
|
+
expression,
|
|
897
|
+
returnByValue: false
|
|
898
|
+
});
|
|
899
|
+
} catch (error) {
|
|
900
|
+
throw connectionError(error.message, 'Runtime.evaluate (queryByRole)');
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (result.exceptionDetails) {
|
|
904
|
+
throw new Error(`Role query error: ${result.exceptionDetails.text}`);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (!result.result.objectId) return [];
|
|
908
|
+
|
|
909
|
+
const arrayObjectId = result.result.objectId;
|
|
910
|
+
let props;
|
|
911
|
+
try {
|
|
912
|
+
props = await session.send('Runtime.getProperties', {
|
|
913
|
+
objectId: arrayObjectId,
|
|
914
|
+
ownProperties: true
|
|
915
|
+
});
|
|
916
|
+
} catch (error) {
|
|
917
|
+
await doReleaseObject(arrayObjectId);
|
|
918
|
+
throw connectionError(error.message, 'Runtime.getProperties');
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const elements = props.result
|
|
922
|
+
.filter(p => /^\d+$/.test(p.name) && p.value && p.value.objectId)
|
|
923
|
+
.map(p => createElementHandle(session, p.value.objectId, { selector: `[role="${role}"]` }));
|
|
924
|
+
|
|
925
|
+
await doReleaseObject(arrayObjectId);
|
|
926
|
+
return elements;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return {
|
|
930
|
+
get session() { return session; },
|
|
931
|
+
querySelector,
|
|
932
|
+
querySelectorAll,
|
|
933
|
+
queryByRole,
|
|
934
|
+
waitForSelector,
|
|
935
|
+
waitForText,
|
|
936
|
+
findElement,
|
|
937
|
+
getBoundingBox,
|
|
938
|
+
getDefaultTimeout: () => defaultTimeout,
|
|
939
|
+
setDefaultTimeout: (timeout) => { defaultTimeout = validateTimeout(timeout); }
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ============================================================================
|
|
944
|
+
// Input Emulator
|
|
945
|
+
// ============================================================================
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Create an input emulator for mouse and keyboard input
|
|
949
|
+
* @param {Object} session - CDP session
|
|
950
|
+
* @returns {Object} Input emulator interface
|
|
951
|
+
*/
|
|
952
|
+
export function createInputEmulator(session) {
|
|
953
|
+
if (!session) throw new Error('CDP session is required');
|
|
954
|
+
|
|
955
|
+
// Transaction-based mouse state (improvement #7)
|
|
956
|
+
// Inspired by Puppeteer's CdpMouse
|
|
957
|
+
const mouseState = {
|
|
958
|
+
x: 0,
|
|
959
|
+
y: 0,
|
|
960
|
+
button: 'none',
|
|
961
|
+
buttons: 0,
|
|
962
|
+
transactionDepth: 0,
|
|
963
|
+
pendingOperations: []
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Begin a mouse transaction for atomic operations
|
|
968
|
+
* Prevents concurrent mouse operations from interfering
|
|
969
|
+
* @returns {Object} Transaction handle with commit/rollback
|
|
970
|
+
*/
|
|
971
|
+
function beginMouseTransaction() {
|
|
972
|
+
mouseState.transactionDepth++;
|
|
973
|
+
const startState = { ...mouseState };
|
|
974
|
+
|
|
975
|
+
return {
|
|
976
|
+
/**
|
|
977
|
+
* Commit the transaction, applying all pending state
|
|
978
|
+
*/
|
|
979
|
+
commit: () => {
|
|
980
|
+
mouseState.transactionDepth--;
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Rollback the transaction, restoring initial state
|
|
985
|
+
*/
|
|
986
|
+
rollback: async () => {
|
|
987
|
+
mouseState.transactionDepth--;
|
|
988
|
+
// Reset mouse to initial state
|
|
989
|
+
if (startState.buttons !== mouseState.buttons) {
|
|
990
|
+
// Release any pressed buttons
|
|
991
|
+
if (mouseState.buttons !== 0) {
|
|
992
|
+
await session.send('Input.dispatchMouseEvent', {
|
|
993
|
+
type: 'mouseReleased',
|
|
994
|
+
x: mouseState.x,
|
|
995
|
+
y: mouseState.y,
|
|
996
|
+
button: mouseState.button,
|
|
997
|
+
buttons: 0
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
mouseState.x = startState.x;
|
|
1002
|
+
mouseState.y = startState.y;
|
|
1003
|
+
mouseState.button = startState.button;
|
|
1004
|
+
mouseState.buttons = startState.buttons;
|
|
1005
|
+
},
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Get current transaction state
|
|
1009
|
+
*/
|
|
1010
|
+
getState: () => ({ ...mouseState })
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Reset mouse state to default
|
|
1016
|
+
* Useful after errors or when starting fresh
|
|
1017
|
+
*/
|
|
1018
|
+
async function resetMouseState() {
|
|
1019
|
+
if (mouseState.buttons !== 0) {
|
|
1020
|
+
await session.send('Input.dispatchMouseEvent', {
|
|
1021
|
+
type: 'mouseReleased',
|
|
1022
|
+
x: mouseState.x,
|
|
1023
|
+
y: mouseState.y,
|
|
1024
|
+
button: mouseState.button,
|
|
1025
|
+
buttons: 0
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
mouseState.x = 0;
|
|
1029
|
+
mouseState.y = 0;
|
|
1030
|
+
mouseState.button = 'none';
|
|
1031
|
+
mouseState.buttons = 0;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Get current mouse state
|
|
1036
|
+
*/
|
|
1037
|
+
function getMouseState() {
|
|
1038
|
+
return { ...mouseState };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function calculateModifiers(modifiers) {
|
|
1042
|
+
let flags = 0;
|
|
1043
|
+
if (modifiers.alt) flags |= 1;
|
|
1044
|
+
if (modifiers.ctrl) flags |= 2;
|
|
1045
|
+
if (modifiers.meta) flags |= 4;
|
|
1046
|
+
if (modifiers.shift) flags |= 8;
|
|
1047
|
+
return flags;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function getButtonMask(button) {
|
|
1051
|
+
const masks = { left: 1, right: 2, middle: 4, back: 8, forward: 16 };
|
|
1052
|
+
return masks[button] || 1;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function getKeyDefinition(char) {
|
|
1056
|
+
if (char >= 'a' && char <= 'z') {
|
|
1057
|
+
return { key: char, code: `Key${char.toUpperCase()}`, keyCode: char.toUpperCase().charCodeAt(0) };
|
|
1058
|
+
}
|
|
1059
|
+
if (char >= 'A' && char <= 'Z') {
|
|
1060
|
+
return { key: char, code: `Key${char}`, keyCode: char.charCodeAt(0) };
|
|
1061
|
+
}
|
|
1062
|
+
if (char >= '0' && char <= '9') {
|
|
1063
|
+
return { key: char, code: `Digit${char}`, keyCode: char.charCodeAt(0) };
|
|
1064
|
+
}
|
|
1065
|
+
return { key: char, code: '', keyCode: char.charCodeAt(0), text: char };
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function validateCoordinates(x, y) {
|
|
1069
|
+
if (typeof x !== 'number' || typeof y !== 'number' ||
|
|
1070
|
+
!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
1071
|
+
throw new Error('Coordinates must be finite numbers');
|
|
1072
|
+
}
|
|
1073
|
+
if (x < 0 || y < 0) {
|
|
1074
|
+
throw new Error('Coordinates must be non-negative');
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function validateButton(button) {
|
|
1079
|
+
const valid = ['left', 'right', 'middle', 'back', 'forward', 'none'];
|
|
1080
|
+
if (!valid.includes(button)) {
|
|
1081
|
+
throw new Error(`Invalid button: ${button}. Must be one of: ${valid.join(', ')}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function validateClickCount(clickCount) {
|
|
1086
|
+
if (typeof clickCount !== 'number' || !Number.isInteger(clickCount) || clickCount < 1) {
|
|
1087
|
+
throw new Error('Click count must be a positive integer');
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function click(x, y, opts = {}) {
|
|
1092
|
+
validateCoordinates(x, y);
|
|
1093
|
+
|
|
1094
|
+
const {
|
|
1095
|
+
button = 'left',
|
|
1096
|
+
clickCount = 1,
|
|
1097
|
+
delay = 0,
|
|
1098
|
+
modifiers = {}
|
|
1099
|
+
} = opts;
|
|
1100
|
+
|
|
1101
|
+
validateButton(button);
|
|
1102
|
+
validateClickCount(clickCount);
|
|
1103
|
+
|
|
1104
|
+
const modifierFlags = calculateModifiers(modifiers);
|
|
1105
|
+
const buttonMask = getButtonMask(button);
|
|
1106
|
+
|
|
1107
|
+
// Update mouse state tracking
|
|
1108
|
+
mouseState.x = x;
|
|
1109
|
+
mouseState.y = y;
|
|
1110
|
+
|
|
1111
|
+
await session.send('Input.dispatchMouseEvent', {
|
|
1112
|
+
type: 'mouseMoved', x, y, modifiers: modifierFlags
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
mouseState.button = button;
|
|
1116
|
+
mouseState.buttons = buttonMask;
|
|
1117
|
+
|
|
1118
|
+
await session.send('Input.dispatchMouseEvent', {
|
|
1119
|
+
type: 'mousePressed', x, y, button, clickCount,
|
|
1120
|
+
modifiers: modifierFlags, buttons: buttonMask
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
if (delay > 0) await sleep(delay);
|
|
1124
|
+
|
|
1125
|
+
mouseState.button = 'none';
|
|
1126
|
+
mouseState.buttons = 0;
|
|
1127
|
+
|
|
1128
|
+
await session.send('Input.dispatchMouseEvent', {
|
|
1129
|
+
type: 'mouseReleased', x, y, button, clickCount,
|
|
1130
|
+
modifiers: modifierFlags, buttons: 0
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async function doubleClick(x, y, opts = {}) {
|
|
1135
|
+
await click(x, y, { ...opts, clickCount: 2 });
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function rightClick(x, y, opts = {}) {
|
|
1139
|
+
await click(x, y, { ...opts, button: 'right' });
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function type(text, opts = {}) {
|
|
1143
|
+
if (typeof text !== 'string') {
|
|
1144
|
+
throw new Error('Text must be a string');
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const { delay = 0 } = opts;
|
|
1148
|
+
|
|
1149
|
+
for (const char of text) {
|
|
1150
|
+
await session.send('Input.dispatchKeyEvent', {
|
|
1151
|
+
type: 'char',
|
|
1152
|
+
text: char,
|
|
1153
|
+
key: char,
|
|
1154
|
+
unmodifiedText: char
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
if (delay > 0) await sleep(delay);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Insert text using Input.insertText (like paste) - much faster than type()
|
|
1163
|
+
* Inspired by Rod & Puppeteer's insertText approach
|
|
1164
|
+
* Triggers synthetic input event for React/Vue bindings
|
|
1165
|
+
* @param {string} text - Text to insert
|
|
1166
|
+
* @param {Object} [opts] - Options
|
|
1167
|
+
* @param {boolean} [opts.dispatchEvents=true] - Dispatch input/change events
|
|
1168
|
+
* @returns {Promise<void>}
|
|
1169
|
+
*/
|
|
1170
|
+
async function insertText(text, opts = {}) {
|
|
1171
|
+
if (typeof text !== 'string') {
|
|
1172
|
+
throw new Error('Text must be a string');
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const { dispatchEvents = true } = opts;
|
|
1176
|
+
|
|
1177
|
+
// Use CDP Input.insertText for fast text insertion
|
|
1178
|
+
await session.send('Input.insertText', { text });
|
|
1179
|
+
|
|
1180
|
+
// Trigger synthetic input event for framework bindings (React, Vue, etc.)
|
|
1181
|
+
if (dispatchEvents) {
|
|
1182
|
+
await session.send('Runtime.evaluate', {
|
|
1183
|
+
expression: `
|
|
1184
|
+
(function() {
|
|
1185
|
+
const el = document.activeElement;
|
|
1186
|
+
if (el) {
|
|
1187
|
+
el.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
|
|
1188
|
+
el.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
|
|
1189
|
+
}
|
|
1190
|
+
})()
|
|
1191
|
+
`
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function fill(x, y, text, opts = {}) {
|
|
1197
|
+
await click(x, y);
|
|
1198
|
+
await sleep(50);
|
|
1199
|
+
|
|
1200
|
+
const isMac = opts.useMeta ?? (typeof process !== 'undefined' && process.platform === 'darwin');
|
|
1201
|
+
const selectAllModifiers = isMac ? { meta: true } : { ctrl: true };
|
|
1202
|
+
await press('a', { modifiers: selectAllModifiers });
|
|
1203
|
+
|
|
1204
|
+
await sleep(50);
|
|
1205
|
+
await type(text, opts);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
async function press(key, opts = {}) {
|
|
1209
|
+
const { modifiers = {}, delay = 0 } = opts;
|
|
1210
|
+
const keyDef = KEY_DEFINITIONS[key] || getKeyDefinition(key);
|
|
1211
|
+
const modifierFlags = calculateModifiers(modifiers);
|
|
1212
|
+
|
|
1213
|
+
await session.send('Input.dispatchKeyEvent', {
|
|
1214
|
+
type: 'rawKeyDown',
|
|
1215
|
+
key: keyDef.key,
|
|
1216
|
+
code: keyDef.code,
|
|
1217
|
+
windowsVirtualKeyCode: keyDef.keyCode,
|
|
1218
|
+
modifiers: modifierFlags
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
if (keyDef.text) {
|
|
1222
|
+
await session.send('Input.dispatchKeyEvent', {
|
|
1223
|
+
type: 'char',
|
|
1224
|
+
text: keyDef.text,
|
|
1225
|
+
key: keyDef.key,
|
|
1226
|
+
modifiers: modifierFlags
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (delay > 0) await sleep(delay);
|
|
1231
|
+
|
|
1232
|
+
await session.send('Input.dispatchKeyEvent', {
|
|
1233
|
+
type: 'keyUp',
|
|
1234
|
+
key: keyDef.key,
|
|
1235
|
+
code: keyDef.code,
|
|
1236
|
+
windowsVirtualKeyCode: keyDef.keyCode,
|
|
1237
|
+
modifiers: modifierFlags
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
async function selectAll() {
|
|
1242
|
+
await session.send('Runtime.evaluate', {
|
|
1243
|
+
expression: `
|
|
1244
|
+
(function() {
|
|
1245
|
+
const el = document.activeElement;
|
|
1246
|
+
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
|
|
1247
|
+
el.select();
|
|
1248
|
+
} else if (window.getSelection) {
|
|
1249
|
+
document.execCommand('selectAll', false, null);
|
|
1250
|
+
}
|
|
1251
|
+
})()
|
|
1252
|
+
`
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async function moveMouse(x, y) {
|
|
1257
|
+
validateCoordinates(x, y);
|
|
1258
|
+
mouseState.x = x;
|
|
1259
|
+
mouseState.y = y;
|
|
1260
|
+
await session.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
async function hover(x, y, opts = {}) {
|
|
1264
|
+
validateCoordinates(x, y);
|
|
1265
|
+
const { duration = 0 } = opts;
|
|
1266
|
+
|
|
1267
|
+
await session.send('Input.dispatchMouseEvent', {
|
|
1268
|
+
type: 'mouseMoved',
|
|
1269
|
+
x,
|
|
1270
|
+
y
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
if (duration > 0) {
|
|
1274
|
+
await sleep(duration);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
async function scroll(deltaX, deltaY, x = 100, y = 100) {
|
|
1279
|
+
await session.send('Input.dispatchMouseEvent', {
|
|
1280
|
+
type: 'mouseWheel', x, y, deltaX, deltaY
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function parseKeyCombo(combo) {
|
|
1285
|
+
const parts = combo.split('+');
|
|
1286
|
+
const modifiers = { ctrl: false, alt: false, meta: false, shift: false };
|
|
1287
|
+
let key = null;
|
|
1288
|
+
|
|
1289
|
+
for (const part of parts) {
|
|
1290
|
+
const lower = part.toLowerCase();
|
|
1291
|
+
if (lower === 'control' || lower === 'ctrl') {
|
|
1292
|
+
modifiers.ctrl = true;
|
|
1293
|
+
} else if (lower === 'alt') {
|
|
1294
|
+
modifiers.alt = true;
|
|
1295
|
+
} else if (lower === 'meta' || lower === 'cmd' || lower === 'command') {
|
|
1296
|
+
modifiers.meta = true;
|
|
1297
|
+
} else if (lower === 'shift') {
|
|
1298
|
+
modifiers.shift = true;
|
|
1299
|
+
} else {
|
|
1300
|
+
key = part;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
return { key, modifiers };
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async function pressCombo(combo, opts = {}) {
|
|
1308
|
+
const { key, modifiers } = parseKeyCombo(combo);
|
|
1309
|
+
if (!key) {
|
|
1310
|
+
throw new Error(`Invalid key combo: ${combo} - no main key specified`);
|
|
1311
|
+
}
|
|
1312
|
+
await press(key, { ...opts, modifiers });
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
return {
|
|
1316
|
+
click,
|
|
1317
|
+
doubleClick,
|
|
1318
|
+
rightClick,
|
|
1319
|
+
type,
|
|
1320
|
+
insertText,
|
|
1321
|
+
fill,
|
|
1322
|
+
press,
|
|
1323
|
+
pressCombo,
|
|
1324
|
+
parseKeyCombo,
|
|
1325
|
+
selectAll,
|
|
1326
|
+
moveMouse,
|
|
1327
|
+
hover,
|
|
1328
|
+
scroll,
|
|
1329
|
+
// Transaction-based mouse state (improvement #7)
|
|
1330
|
+
beginMouseTransaction,
|
|
1331
|
+
resetMouseState,
|
|
1332
|
+
getMouseState
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// ============================================================================
|
|
1337
|
+
// Actionability Checker (from ActionabilityChecker.js)
|
|
1338
|
+
// ============================================================================
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Create an actionability checker for Playwright-style auto-waiting
|
|
1342
|
+
* @param {Object} session - CDP session
|
|
1343
|
+
* @returns {Object} Actionability checker interface
|
|
1344
|
+
*/
|
|
1345
|
+
export function createActionabilityChecker(session) {
|
|
1346
|
+
const retryDelays = [0, 20, 100, 100, 500];
|
|
1347
|
+
const stableFrameCount = 3;
|
|
1348
|
+
|
|
1349
|
+
function getRequiredStates(actionType) {
|
|
1350
|
+
switch (actionType) {
|
|
1351
|
+
case 'click':
|
|
1352
|
+
return ['visible', 'enabled', 'stable'];
|
|
1353
|
+
case 'hover':
|
|
1354
|
+
return ['visible', 'stable'];
|
|
1355
|
+
case 'fill':
|
|
1356
|
+
case 'type':
|
|
1357
|
+
return ['visible', 'enabled', 'editable'];
|
|
1358
|
+
case 'select':
|
|
1359
|
+
return ['visible', 'enabled'];
|
|
1360
|
+
default:
|
|
1361
|
+
return ['visible'];
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function findElementInternal(selector) {
|
|
1366
|
+
try {
|
|
1367
|
+
const result = await session.send('Runtime.evaluate', {
|
|
1368
|
+
expression: `document.querySelector(${JSON.stringify(selector)})`,
|
|
1369
|
+
returnByValue: false
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
if (result.result.subtype === 'null' || !result.result.objectId) {
|
|
1373
|
+
return { success: false, error: `Element not found: ${selector}` };
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return { success: true, objectId: result.result.objectId };
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
return { success: false, error: error.message };
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
async function checkVisible(objectId) {
|
|
1383
|
+
try {
|
|
1384
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
1385
|
+
objectId,
|
|
1386
|
+
functionDeclaration: `function() {
|
|
1387
|
+
const el = this;
|
|
1388
|
+
if (!el.isConnected) {
|
|
1389
|
+
return { matches: false, received: 'detached' };
|
|
1390
|
+
}
|
|
1391
|
+
const style = window.getComputedStyle(el);
|
|
1392
|
+
if (style.visibility === 'hidden') {
|
|
1393
|
+
return { matches: false, received: 'visibility:hidden' };
|
|
1394
|
+
}
|
|
1395
|
+
if (style.display === 'none') {
|
|
1396
|
+
return { matches: false, received: 'display:none' };
|
|
1397
|
+
}
|
|
1398
|
+
const rect = el.getBoundingClientRect();
|
|
1399
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
1400
|
+
return { matches: false, received: 'zero-size' };
|
|
1401
|
+
}
|
|
1402
|
+
if (parseFloat(style.opacity) === 0) {
|
|
1403
|
+
return { matches: false, received: 'opacity:0' };
|
|
1404
|
+
}
|
|
1405
|
+
return { matches: true, received: 'visible' };
|
|
1406
|
+
}`,
|
|
1407
|
+
returnByValue: true
|
|
1408
|
+
});
|
|
1409
|
+
return result.result.value;
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
return { matches: false, received: 'error', error: error.message };
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
async function checkEnabled(objectId) {
|
|
1416
|
+
try {
|
|
1417
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
1418
|
+
objectId,
|
|
1419
|
+
functionDeclaration: `function() {
|
|
1420
|
+
const el = this;
|
|
1421
|
+
if (el.disabled === true) {
|
|
1422
|
+
return { matches: false, received: 'disabled' };
|
|
1423
|
+
}
|
|
1424
|
+
if (el.getAttribute('aria-disabled') === 'true') {
|
|
1425
|
+
return { matches: false, received: 'aria-disabled' };
|
|
1426
|
+
}
|
|
1427
|
+
const fieldset = el.closest('fieldset');
|
|
1428
|
+
if (fieldset && fieldset.disabled) {
|
|
1429
|
+
const legend = fieldset.querySelector('legend');
|
|
1430
|
+
if (!legend || !legend.contains(el)) {
|
|
1431
|
+
return { matches: false, received: 'fieldset-disabled' };
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return { matches: true, received: 'enabled' };
|
|
1435
|
+
}`,
|
|
1436
|
+
returnByValue: true
|
|
1437
|
+
});
|
|
1438
|
+
return result.result.value;
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
return { matches: false, received: 'error', error: error.message };
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async function checkEditable(objectId) {
|
|
1445
|
+
const enabledCheck = await checkEnabled(objectId);
|
|
1446
|
+
if (!enabledCheck.matches) {
|
|
1447
|
+
return enabledCheck;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
try {
|
|
1451
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
1452
|
+
objectId,
|
|
1453
|
+
functionDeclaration: `function() {
|
|
1454
|
+
const el = this;
|
|
1455
|
+
const tagName = el.tagName.toLowerCase();
|
|
1456
|
+
if (el.readOnly === true) {
|
|
1457
|
+
return { matches: false, received: 'readonly' };
|
|
1458
|
+
}
|
|
1459
|
+
if (el.getAttribute('aria-readonly') === 'true') {
|
|
1460
|
+
return { matches: false, received: 'aria-readonly' };
|
|
1461
|
+
}
|
|
1462
|
+
const isFormElement = ['input', 'textarea', 'select'].includes(tagName);
|
|
1463
|
+
const isContentEditable = el.isContentEditable;
|
|
1464
|
+
if (!isFormElement && !isContentEditable) {
|
|
1465
|
+
return { matches: false, received: 'not-editable-element' };
|
|
1466
|
+
}
|
|
1467
|
+
if (tagName === 'input') {
|
|
1468
|
+
const type = el.type.toLowerCase();
|
|
1469
|
+
const textInputTypes = ['text', 'password', 'email', 'number', 'search', 'tel', 'url', 'date', 'datetime-local', 'month', 'time', 'week'];
|
|
1470
|
+
if (!textInputTypes.includes(type)) {
|
|
1471
|
+
return { matches: false, received: 'non-text-input' };
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
return { matches: true, received: 'editable' };
|
|
1475
|
+
}`,
|
|
1476
|
+
returnByValue: true
|
|
1477
|
+
});
|
|
1478
|
+
return result.result.value;
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
return { matches: false, received: 'error', error: error.message };
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
async function checkStable(objectId) {
|
|
1485
|
+
try {
|
|
1486
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
1487
|
+
objectId,
|
|
1488
|
+
functionDeclaration: `async function() {
|
|
1489
|
+
const el = this;
|
|
1490
|
+
const frameCount = ${stableFrameCount};
|
|
1491
|
+
if (!el.isConnected) {
|
|
1492
|
+
return { matches: false, received: 'detached' };
|
|
1493
|
+
}
|
|
1494
|
+
let lastRect = null;
|
|
1495
|
+
let stableCount = 0;
|
|
1496
|
+
const getRect = () => {
|
|
1497
|
+
const r = el.getBoundingClientRect();
|
|
1498
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
1499
|
+
};
|
|
1500
|
+
const checkFrame = () => new Promise(resolve => {
|
|
1501
|
+
requestAnimationFrame(() => {
|
|
1502
|
+
if (!el.isConnected) {
|
|
1503
|
+
resolve({ matches: false, received: 'detached' });
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
const rect = getRect();
|
|
1507
|
+
if (lastRect) {
|
|
1508
|
+
const same = rect.x === lastRect.x &&
|
|
1509
|
+
rect.y === lastRect.y &&
|
|
1510
|
+
rect.width === lastRect.width &&
|
|
1511
|
+
rect.height === lastRect.height;
|
|
1512
|
+
if (same) {
|
|
1513
|
+
stableCount++;
|
|
1514
|
+
if (stableCount >= frameCount) {
|
|
1515
|
+
resolve({ matches: true, received: 'stable' });
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
} else {
|
|
1519
|
+
stableCount = 0;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
lastRect = rect;
|
|
1523
|
+
resolve(null);
|
|
1524
|
+
});
|
|
1525
|
+
});
|
|
1526
|
+
for (let i = 0; i < 10; i++) {
|
|
1527
|
+
const result = await checkFrame();
|
|
1528
|
+
if (result !== null) {
|
|
1529
|
+
return result;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
return { matches: false, received: 'unstable' };
|
|
1533
|
+
}`,
|
|
1534
|
+
returnByValue: true,
|
|
1535
|
+
awaitPromise: true
|
|
1536
|
+
});
|
|
1537
|
+
return result.result.value;
|
|
1538
|
+
} catch (error) {
|
|
1539
|
+
return { matches: false, received: 'error', error: error.message };
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
async function checkState(objectId, state) {
|
|
1544
|
+
switch (state) {
|
|
1545
|
+
case 'visible':
|
|
1546
|
+
return checkVisible(objectId);
|
|
1547
|
+
case 'enabled':
|
|
1548
|
+
return checkEnabled(objectId);
|
|
1549
|
+
case 'editable':
|
|
1550
|
+
return checkEditable(objectId);
|
|
1551
|
+
case 'stable':
|
|
1552
|
+
return checkStable(objectId);
|
|
1553
|
+
default:
|
|
1554
|
+
return { matches: true };
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
async function checkStates(objectId, states) {
|
|
1559
|
+
for (const state of states) {
|
|
1560
|
+
const check = await checkState(objectId, state);
|
|
1561
|
+
if (!check.matches) {
|
|
1562
|
+
return { success: false, missingState: state, received: check.received };
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return { success: true };
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
async function waitForActionable(selector, actionType, opts = {}) {
|
|
1569
|
+
const { timeout = 30000, force = false, autoForce = true } = opts;
|
|
1570
|
+
const startTime = Date.now();
|
|
1571
|
+
|
|
1572
|
+
const requiredStates = getRequiredStates(actionType);
|
|
1573
|
+
|
|
1574
|
+
if (force) {
|
|
1575
|
+
const element = await findElementInternal(selector);
|
|
1576
|
+
if (!element.success) {
|
|
1577
|
+
return element;
|
|
1578
|
+
}
|
|
1579
|
+
return { success: true, objectId: element.objectId, forced: true };
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
let retry = 0;
|
|
1583
|
+
let lastError = null;
|
|
1584
|
+
let lastMissingState = null;
|
|
1585
|
+
let lastObjectId = null;
|
|
1586
|
+
|
|
1587
|
+
while (Date.now() - startTime < timeout) {
|
|
1588
|
+
if (retry > 0) {
|
|
1589
|
+
const delay = retryDelays[Math.min(retry - 1, retryDelays.length - 1)];
|
|
1590
|
+
if (delay > 0) {
|
|
1591
|
+
await sleep(delay);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (lastObjectId) {
|
|
1596
|
+
await releaseObject(session, lastObjectId);
|
|
1597
|
+
lastObjectId = null;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const element = await findElementInternal(selector);
|
|
1601
|
+
if (!element.success) {
|
|
1602
|
+
lastError = element.error;
|
|
1603
|
+
retry++;
|
|
1604
|
+
continue;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
lastObjectId = element.objectId;
|
|
1608
|
+
|
|
1609
|
+
const stateCheck = await checkStates(element.objectId, requiredStates);
|
|
1610
|
+
|
|
1611
|
+
if (stateCheck.success) {
|
|
1612
|
+
return { success: true, objectId: element.objectId };
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
lastMissingState = stateCheck.missingState;
|
|
1616
|
+
lastError = `Element is not ${stateCheck.missingState}`;
|
|
1617
|
+
retry++;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
if (lastObjectId) {
|
|
1621
|
+
await releaseObject(session, lastObjectId);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// Auto-retry with force:true if element was found but not actionable
|
|
1625
|
+
// This helps with overlays, loading states, etc. that may obscure elements
|
|
1626
|
+
if (autoForce && lastMissingState && lastMissingState !== 'not found') {
|
|
1627
|
+
const element = await findElementInternal(selector);
|
|
1628
|
+
if (element.success) {
|
|
1629
|
+
return {
|
|
1630
|
+
success: true,
|
|
1631
|
+
objectId: element.objectId,
|
|
1632
|
+
forced: true,
|
|
1633
|
+
autoForced: true,
|
|
1634
|
+
originalError: lastError
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
return {
|
|
1640
|
+
success: false,
|
|
1641
|
+
error: lastError || 'Timeout waiting for element to be actionable',
|
|
1642
|
+
missingState: lastMissingState
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async function getClickablePoint(objectId) {
|
|
1647
|
+
try {
|
|
1648
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
1649
|
+
objectId,
|
|
1650
|
+
functionDeclaration: `function() {
|
|
1651
|
+
const el = this;
|
|
1652
|
+
const rect = el.getBoundingClientRect();
|
|
1653
|
+
return {
|
|
1654
|
+
x: rect.x + rect.width / 2,
|
|
1655
|
+
y: rect.y + rect.height / 2,
|
|
1656
|
+
rect: {
|
|
1657
|
+
x: rect.x,
|
|
1658
|
+
y: rect.y,
|
|
1659
|
+
width: rect.width,
|
|
1660
|
+
height: rect.height
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
}`,
|
|
1664
|
+
returnByValue: true
|
|
1665
|
+
});
|
|
1666
|
+
return result.result.value;
|
|
1667
|
+
} catch {
|
|
1668
|
+
return null;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
async function checkHitTarget(objectId, point) {
|
|
1673
|
+
try {
|
|
1674
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
1675
|
+
objectId,
|
|
1676
|
+
functionDeclaration: `function(point) {
|
|
1677
|
+
const el = this;
|
|
1678
|
+
const hitEl = document.elementFromPoint(point.x, point.y);
|
|
1679
|
+
if (!hitEl) {
|
|
1680
|
+
return { matches: false, received: 'no-element-at-point' };
|
|
1681
|
+
}
|
|
1682
|
+
if (hitEl === el || el.contains(hitEl)) {
|
|
1683
|
+
return { matches: true, received: 'hit' };
|
|
1684
|
+
}
|
|
1685
|
+
let desc = hitEl.tagName.toLowerCase();
|
|
1686
|
+
if (hitEl.id) desc += '#' + hitEl.id;
|
|
1687
|
+
if (hitEl.className && typeof hitEl.className === 'string') {
|
|
1688
|
+
desc += '.' + hitEl.className.split(' ').filter(c => c).join('.');
|
|
1689
|
+
}
|
|
1690
|
+
return {
|
|
1691
|
+
matches: false,
|
|
1692
|
+
received: 'blocked',
|
|
1693
|
+
blockedBy: desc
|
|
1694
|
+
};
|
|
1695
|
+
}`,
|
|
1696
|
+
arguments: [{ value: point }],
|
|
1697
|
+
returnByValue: true
|
|
1698
|
+
});
|
|
1699
|
+
return result.result.value;
|
|
1700
|
+
} catch (error) {
|
|
1701
|
+
return { matches: false, received: 'error', error: error.message };
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* Check if pointer-events CSS allows clicking (improvement #8)
|
|
1707
|
+
* Elements with pointer-events: none cannot receive click events
|
|
1708
|
+
* @param {string} objectId - Element object ID
|
|
1709
|
+
* @returns {Promise<{clickable: boolean, pointerEvents: string}>}
|
|
1710
|
+
*/
|
|
1711
|
+
async function checkPointerEvents(objectId) {
|
|
1712
|
+
try {
|
|
1713
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
1714
|
+
objectId,
|
|
1715
|
+
functionDeclaration: `function() {
|
|
1716
|
+
const el = this;
|
|
1717
|
+
const style = window.getComputedStyle(el);
|
|
1718
|
+
const pointerEvents = style.pointerEvents;
|
|
1719
|
+
|
|
1720
|
+
// Check if element or any ancestor has pointer-events: none
|
|
1721
|
+
let current = el;
|
|
1722
|
+
while (current) {
|
|
1723
|
+
const currentStyle = window.getComputedStyle(current);
|
|
1724
|
+
if (currentStyle.pointerEvents === 'none') {
|
|
1725
|
+
return {
|
|
1726
|
+
clickable: false,
|
|
1727
|
+
pointerEvents: 'none',
|
|
1728
|
+
blockedBy: current === el ? 'self' : current.tagName.toLowerCase()
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
current = current.parentElement;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
return { clickable: true, pointerEvents: pointerEvents || 'auto' };
|
|
1735
|
+
}`,
|
|
1736
|
+
returnByValue: true
|
|
1737
|
+
});
|
|
1738
|
+
return result.result.value;
|
|
1739
|
+
} catch (error) {
|
|
1740
|
+
return { clickable: true, pointerEvents: 'unknown', error: error.message };
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Detect covered elements using CDP DOM.getNodeForLocation (improvement #1)
|
|
1746
|
+
* Inspired by Rod's Interactable() method
|
|
1747
|
+
* @param {string} objectId - Element object ID
|
|
1748
|
+
* @param {{x: number, y: number}} point - Click coordinates
|
|
1749
|
+
* @returns {Promise<{covered: boolean, coveringElement?: string}>}
|
|
1750
|
+
*/
|
|
1751
|
+
async function checkCovered(objectId, point) {
|
|
1752
|
+
try {
|
|
1753
|
+
// Get the backend node ID for the target element
|
|
1754
|
+
const nodeResult = await session.send('DOM.describeNode', { objectId });
|
|
1755
|
+
const targetBackendNodeId = nodeResult.node.backendNodeId;
|
|
1756
|
+
|
|
1757
|
+
// Use DOM.getNodeForLocation to see what element is actually at the click point
|
|
1758
|
+
const locationResult = await session.send('DOM.getNodeForLocation', {
|
|
1759
|
+
x: Math.floor(point.x),
|
|
1760
|
+
y: Math.floor(point.y),
|
|
1761
|
+
includeUserAgentShadowDOM: false
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
const hitBackendNodeId = locationResult.backendNodeId;
|
|
1765
|
+
|
|
1766
|
+
// If the hit element matches our target, it's not covered
|
|
1767
|
+
if (hitBackendNodeId === targetBackendNodeId) {
|
|
1768
|
+
return { covered: false };
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// Check if the hit element is a child of our target (also valid)
|
|
1772
|
+
const isChild = await session.send('Runtime.callFunctionOn', {
|
|
1773
|
+
objectId,
|
|
1774
|
+
functionDeclaration: `function(hitNodeId) {
|
|
1775
|
+
// We need to find if the hit element is inside this element
|
|
1776
|
+
// This is tricky because we only have backend node IDs
|
|
1777
|
+
// Use elementFromPoint as a fallback check
|
|
1778
|
+
const rect = this.getBoundingClientRect();
|
|
1779
|
+
const centerX = rect.left + rect.width / 2;
|
|
1780
|
+
const centerY = rect.top + rect.height / 2;
|
|
1781
|
+
const hitEl = document.elementFromPoint(centerX, centerY);
|
|
1782
|
+
|
|
1783
|
+
if (!hitEl) return { isChild: false, coverInfo: 'no-element' };
|
|
1784
|
+
|
|
1785
|
+
if (hitEl === this || this.contains(hitEl)) {
|
|
1786
|
+
return { isChild: true };
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// Get info about the covering element
|
|
1790
|
+
let desc = hitEl.tagName.toLowerCase();
|
|
1791
|
+
if (hitEl.id) desc += '#' + hitEl.id;
|
|
1792
|
+
if (hitEl.className && typeof hitEl.className === 'string') {
|
|
1793
|
+
const classes = hitEl.className.split(' ').filter(c => c).slice(0, 3);
|
|
1794
|
+
if (classes.length > 0) desc += '.' + classes.join('.');
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
return { isChild: false, coverInfo: desc };
|
|
1798
|
+
}`,
|
|
1799
|
+
returnByValue: true
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
const childResult = isChild.result.value;
|
|
1803
|
+
|
|
1804
|
+
if (childResult.isChild) {
|
|
1805
|
+
return { covered: false };
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
return {
|
|
1809
|
+
covered: true,
|
|
1810
|
+
coveringElement: childResult.coverInfo || 'unknown'
|
|
1811
|
+
};
|
|
1812
|
+
} catch (error) {
|
|
1813
|
+
// If DOM methods fail, fall back to elementFromPoint check
|
|
1814
|
+
try {
|
|
1815
|
+
const fallbackResult = await session.send('Runtime.callFunctionOn', {
|
|
1816
|
+
objectId,
|
|
1817
|
+
functionDeclaration: `function() {
|
|
1818
|
+
const rect = this.getBoundingClientRect();
|
|
1819
|
+
const centerX = rect.left + rect.width / 2;
|
|
1820
|
+
const centerY = rect.top + rect.height / 2;
|
|
1821
|
+
const hitEl = document.elementFromPoint(centerX, centerY);
|
|
1822
|
+
|
|
1823
|
+
if (!hitEl) return { covered: true, coverInfo: 'no-element-at-center' };
|
|
1824
|
+
if (hitEl === this || this.contains(hitEl)) return { covered: false };
|
|
1825
|
+
|
|
1826
|
+
let desc = hitEl.tagName.toLowerCase();
|
|
1827
|
+
if (hitEl.id) desc += '#' + hitEl.id;
|
|
1828
|
+
return { covered: true, coverInfo: desc };
|
|
1829
|
+
}`,
|
|
1830
|
+
returnByValue: true
|
|
1831
|
+
});
|
|
1832
|
+
return {
|
|
1833
|
+
covered: fallbackResult.result.value.covered,
|
|
1834
|
+
coveringElement: fallbackResult.result.value.coverInfo
|
|
1835
|
+
};
|
|
1836
|
+
} catch {
|
|
1837
|
+
return { covered: false, error: error.message };
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
return {
|
|
1843
|
+
waitForActionable,
|
|
1844
|
+
getClickablePoint,
|
|
1845
|
+
checkHitTarget,
|
|
1846
|
+
checkPointerEvents,
|
|
1847
|
+
checkCovered,
|
|
1848
|
+
checkVisible,
|
|
1849
|
+
checkEnabled,
|
|
1850
|
+
checkEditable,
|
|
1851
|
+
checkStable,
|
|
1852
|
+
getRequiredStates
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// ============================================================================
|
|
1857
|
+
// Element Validator (from ElementValidator.js)
|
|
1858
|
+
// ============================================================================
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* Create an element validator for checking element properties and states
|
|
1862
|
+
* @param {Object} session - CDP session
|
|
1863
|
+
* @returns {Object} Element validator interface
|
|
1864
|
+
*/
|
|
1865
|
+
export function createElementValidator(session) {
|
|
1866
|
+
async function isEditable(objectId) {
|
|
1867
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
1868
|
+
objectId,
|
|
1869
|
+
functionDeclaration: `function() {
|
|
1870
|
+
const el = this;
|
|
1871
|
+
const tagName = el.tagName ? el.tagName.toLowerCase() : '';
|
|
1872
|
+
if (el.isContentEditable) {
|
|
1873
|
+
return { editable: true, reason: null };
|
|
1874
|
+
}
|
|
1875
|
+
if (tagName === 'textarea') {
|
|
1876
|
+
if (el.disabled) {
|
|
1877
|
+
return { editable: false, reason: 'Element is disabled' };
|
|
1878
|
+
}
|
|
1879
|
+
if (el.readOnly) {
|
|
1880
|
+
return { editable: false, reason: 'Element is read-only' };
|
|
1881
|
+
}
|
|
1882
|
+
return { editable: true, reason: null };
|
|
1883
|
+
}
|
|
1884
|
+
if (tagName === 'input') {
|
|
1885
|
+
const inputType = (el.type || 'text').toLowerCase();
|
|
1886
|
+
const nonEditableTypes = ${JSON.stringify(NON_EDITABLE_INPUT_TYPES)};
|
|
1887
|
+
if (nonEditableTypes.includes(inputType)) {
|
|
1888
|
+
return { editable: false, reason: 'Input type "' + inputType + '" is not editable' };
|
|
1889
|
+
}
|
|
1890
|
+
if (el.disabled) {
|
|
1891
|
+
return { editable: false, reason: 'Element is disabled' };
|
|
1892
|
+
}
|
|
1893
|
+
if (el.readOnly) {
|
|
1894
|
+
return { editable: false, reason: 'Element is read-only' };
|
|
1895
|
+
}
|
|
1896
|
+
return { editable: true, reason: null };
|
|
1897
|
+
}
|
|
1898
|
+
return {
|
|
1899
|
+
editable: false,
|
|
1900
|
+
reason: 'Element <' + tagName + '> is not editable (expected input, textarea, or contenteditable)'
|
|
1901
|
+
};
|
|
1902
|
+
}`,
|
|
1903
|
+
returnByValue: true
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
if (result.exceptionDetails) {
|
|
1907
|
+
const errorText = result.exceptionDetails.exception?.description ||
|
|
1908
|
+
result.exceptionDetails.text ||
|
|
1909
|
+
'Unknown error checking editability';
|
|
1910
|
+
return { editable: false, reason: errorText };
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
return result.result.value;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
async function isClickable(objectId) {
|
|
1917
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
1918
|
+
objectId,
|
|
1919
|
+
functionDeclaration: `function() {
|
|
1920
|
+
const el = this;
|
|
1921
|
+
const tagName = el.tagName ? el.tagName.toLowerCase() : '';
|
|
1922
|
+
if (el.disabled) {
|
|
1923
|
+
return { clickable: false, reason: 'Element is disabled', willNavigate: false };
|
|
1924
|
+
}
|
|
1925
|
+
let willNavigate = false;
|
|
1926
|
+
if (tagName === 'a') {
|
|
1927
|
+
const href = el.getAttribute('href');
|
|
1928
|
+
const target = el.getAttribute('target');
|
|
1929
|
+
willNavigate = href && href !== '#' && href !== 'javascript:void(0)' &&
|
|
1930
|
+
target !== '_blank' && !href.startsWith('javascript:');
|
|
1931
|
+
}
|
|
1932
|
+
if ((tagName === 'button' || tagName === 'input') &&
|
|
1933
|
+
(el.type === 'submit' || (!el.type && tagName === 'button'))) {
|
|
1934
|
+
const form = el.closest('form');
|
|
1935
|
+
if (form && form.action) {
|
|
1936
|
+
willNavigate = true;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
if (el.onclick || el.getAttribute('onclick')) {
|
|
1940
|
+
const onclickStr = String(el.getAttribute('onclick') || '');
|
|
1941
|
+
if (onclickStr.includes('location') || onclickStr.includes('href') ||
|
|
1942
|
+
onclickStr.includes('navigate') || onclickStr.includes('submit')) {
|
|
1943
|
+
willNavigate = true;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
return { clickable: true, reason: null, willNavigate: willNavigate };
|
|
1947
|
+
}`,
|
|
1948
|
+
returnByValue: true
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
if (result.exceptionDetails) {
|
|
1952
|
+
const errorText = result.exceptionDetails.exception?.description ||
|
|
1953
|
+
result.exceptionDetails.text ||
|
|
1954
|
+
'Unknown error checking clickability';
|
|
1955
|
+
return { clickable: false, reason: errorText, willNavigate: false };
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
return result.result.value;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
return {
|
|
1962
|
+
isEditable,
|
|
1963
|
+
isClickable
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// ============================================================================
|
|
1968
|
+
// React Input Filler (from ReactInputFiller.js)
|
|
1969
|
+
// ============================================================================
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* Create a React input filler for handling React controlled components
|
|
1973
|
+
* @param {Object} session - CDP session
|
|
1974
|
+
* @returns {Object} React input filler interface
|
|
1975
|
+
*/
|
|
1976
|
+
export function createReactInputFiller(session) {
|
|
1977
|
+
if (!session) {
|
|
1978
|
+
throw new Error('CDP session is required');
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
async function fillByObjectId(objectId, value) {
|
|
1982
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
1983
|
+
objectId,
|
|
1984
|
+
functionDeclaration: `function(newValue) {
|
|
1985
|
+
const el = this;
|
|
1986
|
+
const prototype = el.tagName === 'TEXTAREA'
|
|
1987
|
+
? window.HTMLTextAreaElement.prototype
|
|
1988
|
+
: window.HTMLInputElement.prototype;
|
|
1989
|
+
const nativeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set;
|
|
1990
|
+
nativeValueSetter.call(el, newValue);
|
|
1991
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1992
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1993
|
+
return { success: true, value: el.value };
|
|
1994
|
+
}`,
|
|
1995
|
+
arguments: [{ value: String(value) }],
|
|
1996
|
+
returnByValue: true
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
if (result.exceptionDetails) {
|
|
2000
|
+
const errorText = result.exceptionDetails.exception?.description ||
|
|
2001
|
+
result.exceptionDetails.text ||
|
|
2002
|
+
'Unknown error during React fill';
|
|
2003
|
+
throw new Error(`React fill failed: ${errorText}`);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
return result.result.value;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
async function fillBySelector(selector, value) {
|
|
2010
|
+
const result = await session.send('Runtime.evaluate', {
|
|
2011
|
+
expression: `
|
|
2012
|
+
(function(selector, newValue) {
|
|
2013
|
+
const el = document.querySelector(selector);
|
|
2014
|
+
if (!el) {
|
|
2015
|
+
return { success: false, error: 'Element not found: ' + selector };
|
|
2016
|
+
}
|
|
2017
|
+
const prototype = el.tagName === 'TEXTAREA'
|
|
2018
|
+
? window.HTMLTextAreaElement.prototype
|
|
2019
|
+
: window.HTMLInputElement.prototype;
|
|
2020
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
|
2021
|
+
if (!descriptor || !descriptor.set) {
|
|
2022
|
+
return { success: false, error: 'Cannot get native value setter' };
|
|
2023
|
+
}
|
|
2024
|
+
const nativeValueSetter = descriptor.set;
|
|
2025
|
+
nativeValueSetter.call(el, newValue);
|
|
2026
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2027
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2028
|
+
return { success: true, value: el.value };
|
|
2029
|
+
})(${JSON.stringify(selector)}, ${JSON.stringify(String(value))})
|
|
2030
|
+
`,
|
|
2031
|
+
returnByValue: true
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
if (result.exceptionDetails) {
|
|
2035
|
+
const errorText = result.exceptionDetails.exception?.description ||
|
|
2036
|
+
result.exceptionDetails.text ||
|
|
2037
|
+
'Unknown error during React fill';
|
|
2038
|
+
throw new Error(`React fill failed: ${errorText}`);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
const fillResult = result.result.value;
|
|
2042
|
+
if (!fillResult.success) {
|
|
2043
|
+
throw new Error(fillResult.error);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
return fillResult;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
return {
|
|
2050
|
+
fillByObjectId,
|
|
2051
|
+
fillBySelector
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// ============================================================================
|
|
2056
|
+
// Click Executor (from ClickExecutor.js)
|
|
2057
|
+
// ============================================================================
|
|
2058
|
+
|
|
2059
|
+
/**
|
|
2060
|
+
* Create a click executor for handling click operations
|
|
2061
|
+
* @param {Object} session - CDP session
|
|
2062
|
+
* @param {Object} elementLocator - Element locator instance
|
|
2063
|
+
* @param {Object} inputEmulator - Input emulator instance
|
|
2064
|
+
* @param {Object} [ariaSnapshot] - Optional ARIA snapshot instance
|
|
2065
|
+
* @returns {Object} Click executor interface
|
|
2066
|
+
*/
|
|
2067
|
+
export function createClickExecutor(session, elementLocator, inputEmulator, ariaSnapshot = null) {
|
|
2068
|
+
if (!session) throw new Error('CDP session is required');
|
|
2069
|
+
if (!elementLocator) throw new Error('Element locator is required');
|
|
2070
|
+
if (!inputEmulator) throw new Error('Input emulator is required');
|
|
2071
|
+
|
|
2072
|
+
const actionabilityChecker = createActionabilityChecker(session);
|
|
2073
|
+
const elementValidator = createElementValidator(session);
|
|
2074
|
+
|
|
2075
|
+
function calculateVisibleCenter(box, viewport = null) {
|
|
2076
|
+
let visibleBox = { ...box };
|
|
2077
|
+
|
|
2078
|
+
if (viewport) {
|
|
2079
|
+
visibleBox.x = Math.max(box.x, 0);
|
|
2080
|
+
visibleBox.y = Math.max(box.y, 0);
|
|
2081
|
+
const right = Math.min(box.x + box.width, viewport.width);
|
|
2082
|
+
const bottom = Math.min(box.y + box.height, viewport.height);
|
|
2083
|
+
visibleBox.width = right - visibleBox.x;
|
|
2084
|
+
visibleBox.height = bottom - visibleBox.y;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
return {
|
|
2088
|
+
x: visibleBox.x + visibleBox.width / 2,
|
|
2089
|
+
y: visibleBox.y + visibleBox.height / 2
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
async function getViewportBounds() {
|
|
2094
|
+
const result = await session.send('Runtime.evaluate', {
|
|
2095
|
+
expression: `({
|
|
2096
|
+
width: window.innerWidth || document.documentElement.clientWidth,
|
|
2097
|
+
height: window.innerHeight || document.documentElement.clientHeight
|
|
2098
|
+
})`,
|
|
2099
|
+
returnByValue: true
|
|
2100
|
+
});
|
|
2101
|
+
return result.result.value;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
async function executeJsClick(objectId) {
|
|
2105
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
2106
|
+
objectId,
|
|
2107
|
+
functionDeclaration: `function() {
|
|
2108
|
+
if (this.disabled) {
|
|
2109
|
+
return { success: false, reason: 'element is disabled' };
|
|
2110
|
+
}
|
|
2111
|
+
if (typeof this.focus === 'function') {
|
|
2112
|
+
this.focus();
|
|
2113
|
+
}
|
|
2114
|
+
this.click();
|
|
2115
|
+
return { success: true, targetReceived: true };
|
|
2116
|
+
}`,
|
|
2117
|
+
returnByValue: true
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
const value = result.result.value || {};
|
|
2121
|
+
if (!value.success) {
|
|
2122
|
+
throw new Error(`JS click failed: ${value.reason || 'unknown error'}`);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
return { targetReceived: true };
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
async function executeJsClickOnRef(ref) {
|
|
2129
|
+
const result = await session.send('Runtime.evaluate', {
|
|
2130
|
+
expression: `
|
|
2131
|
+
(function() {
|
|
2132
|
+
const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
|
|
2133
|
+
if (!el) {
|
|
2134
|
+
return { success: false, reason: 'ref not found in __ariaRefs' };
|
|
2135
|
+
}
|
|
2136
|
+
if (!el.isConnected) {
|
|
2137
|
+
return { success: false, reason: 'element is no longer attached to DOM' };
|
|
2138
|
+
}
|
|
2139
|
+
if (el.disabled) {
|
|
2140
|
+
return { success: false, reason: 'element is disabled' };
|
|
2141
|
+
}
|
|
2142
|
+
if (typeof el.focus === 'function') el.focus();
|
|
2143
|
+
el.click();
|
|
2144
|
+
return { success: true };
|
|
2145
|
+
})()
|
|
2146
|
+
`,
|
|
2147
|
+
returnByValue: true
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
const value = result.result.value || {};
|
|
2151
|
+
if (!value.success) {
|
|
2152
|
+
throw new Error(`JS click on ref failed: ${value.reason || 'unknown error'}`);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
async function clickWithVerification(x, y, targetObjectId) {
|
|
2157
|
+
await session.send('Runtime.callFunctionOn', {
|
|
2158
|
+
objectId: targetObjectId,
|
|
2159
|
+
functionDeclaration: `function() {
|
|
2160
|
+
this.__clickReceived = false;
|
|
2161
|
+
this.__clickHandler = () => { this.__clickReceived = true; };
|
|
2162
|
+
this.addEventListener('click', this.__clickHandler, { once: true });
|
|
2163
|
+
}`
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
await inputEmulator.click(x, y);
|
|
2167
|
+
await sleep(50);
|
|
2168
|
+
|
|
2169
|
+
const verifyResult = await session.send('Runtime.callFunctionOn', {
|
|
2170
|
+
objectId: targetObjectId,
|
|
2171
|
+
functionDeclaration: `function() {
|
|
2172
|
+
this.removeEventListener('click', this.__clickHandler);
|
|
2173
|
+
const received = this.__clickReceived;
|
|
2174
|
+
delete this.__clickReceived;
|
|
2175
|
+
delete this.__clickHandler;
|
|
2176
|
+
return received;
|
|
2177
|
+
}`,
|
|
2178
|
+
returnByValue: true
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
return {
|
|
2182
|
+
targetReceived: verifyResult.result.value === true
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
async function addNavigationAndDebugInfo(result, urlBeforeClick, debugData, opts) {
|
|
2187
|
+
const { waitForNavigation = false, navigationTimeout = 100, debug = false } = opts;
|
|
2188
|
+
|
|
2189
|
+
if (waitForNavigation) {
|
|
2190
|
+
const navResult = await detectNavigation(session, urlBeforeClick, navigationTimeout);
|
|
2191
|
+
result.navigated = navResult.navigated;
|
|
2192
|
+
if (navResult.newUrl) {
|
|
2193
|
+
result.newUrl = navResult.newUrl;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
if (debug && debugData) {
|
|
2198
|
+
result.debug = {
|
|
2199
|
+
clickedAt: debugData.point,
|
|
2200
|
+
elementHit: debugData.elementAtPoint
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
return result;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
async function clickAtCoordinates(x, y, opts = {}) {
|
|
2208
|
+
const { debug = false, waitForNavigation = false, navigationTimeout = 100 } = opts;
|
|
2209
|
+
|
|
2210
|
+
const urlBeforeClick = await getCurrentUrl(session);
|
|
2211
|
+
|
|
2212
|
+
let elementAtPoint = null;
|
|
2213
|
+
if (debug) {
|
|
2214
|
+
elementAtPoint = await getElementAtPoint(session, x, y);
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
await inputEmulator.click(x, y);
|
|
2218
|
+
|
|
2219
|
+
const result = {
|
|
2220
|
+
clicked: true,
|
|
2221
|
+
method: 'cdp',
|
|
2222
|
+
coordinates: { x, y }
|
|
2223
|
+
};
|
|
2224
|
+
|
|
2225
|
+
if (waitForNavigation) {
|
|
2226
|
+
const navResult = await detectNavigation(session, urlBeforeClick, navigationTimeout);
|
|
2227
|
+
result.navigated = navResult.navigated;
|
|
2228
|
+
if (navResult.newUrl) {
|
|
2229
|
+
result.newUrl = navResult.newUrl;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
if (debug) {
|
|
2234
|
+
result.debug = {
|
|
2235
|
+
clickedAt: { x, y },
|
|
2236
|
+
elementHit: elementAtPoint
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
return result;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
async function clickByRef(ref, jsClick = false, opts = {}) {
|
|
2244
|
+
const { force = false, debug = false, waitForNavigation, navigationTimeout = 100 } = opts;
|
|
2245
|
+
|
|
2246
|
+
const refInfo = await ariaSnapshot.getElementByRef(ref);
|
|
2247
|
+
if (!refInfo) {
|
|
2248
|
+
throw elementNotFoundError(`ref:${ref}`, 0);
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
if (refInfo.stale) {
|
|
2252
|
+
return {
|
|
2253
|
+
clicked: false,
|
|
2254
|
+
stale: true,
|
|
2255
|
+
warning: `Element ref:${ref} is no longer attached to the DOM. Page content may have changed.`
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
if (!force && refInfo.isVisible === false) {
|
|
2260
|
+
return {
|
|
2261
|
+
clicked: false,
|
|
2262
|
+
warning: `Element ref:${ref} exists but is not visible. It may be hidden or have zero dimensions.`
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
const urlBeforeClick = await getCurrentUrl(session);
|
|
2267
|
+
|
|
2268
|
+
const point = calculateVisibleCenter(refInfo.box);
|
|
2269
|
+
|
|
2270
|
+
let elementAtPoint = null;
|
|
2271
|
+
if (debug) {
|
|
2272
|
+
elementAtPoint = await getElementAtPoint(session, point.x, point.y);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// For ref-based clicks, we try CDP click first then fallback to JS click
|
|
2276
|
+
// This ensures React components and other frameworks work reliably
|
|
2277
|
+
let usedMethod = 'cdp';
|
|
2278
|
+
let usedFallback = false;
|
|
2279
|
+
|
|
2280
|
+
if (jsClick) {
|
|
2281
|
+
// User explicitly requested JS click
|
|
2282
|
+
await executeJsClickOnRef(ref);
|
|
2283
|
+
usedMethod = 'jsClick';
|
|
2284
|
+
} else {
|
|
2285
|
+
// Set up click verification using a global tracker
|
|
2286
|
+
const setupResult = await session.send('Runtime.evaluate', {
|
|
2287
|
+
expression: `
|
|
2288
|
+
(function() {
|
|
2289
|
+
const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
|
|
2290
|
+
if (!el) return { found: false };
|
|
2291
|
+
if (!el.isConnected) return { found: false, stale: true };
|
|
2292
|
+
|
|
2293
|
+
// Set up click verification with a unique key
|
|
2294
|
+
const verifyKey = '__clickVerify_' + ${JSON.stringify(ref)};
|
|
2295
|
+
window[verifyKey] = false;
|
|
2296
|
+
el.__clickVerifyHandler = () => { window[verifyKey] = true; };
|
|
2297
|
+
el.addEventListener('click', el.__clickVerifyHandler, { once: true });
|
|
2298
|
+
|
|
2299
|
+
return { found: true, verifyKey: verifyKey };
|
|
2300
|
+
})()
|
|
2301
|
+
`,
|
|
2302
|
+
returnByValue: true
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
const setupValue = setupResult.result.value;
|
|
2306
|
+
if (!setupValue || !setupValue.found) {
|
|
2307
|
+
if (setupValue && setupValue.stale) {
|
|
2308
|
+
return {
|
|
2309
|
+
clicked: false,
|
|
2310
|
+
stale: true,
|
|
2311
|
+
warning: `Element ref:${ref} is no longer attached to the DOM. Page content may have changed.`
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
throw elementNotFoundError(`ref:${ref}`, 0);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
const verifyKey = setupValue.verifyKey;
|
|
2318
|
+
|
|
2319
|
+
// Perform CDP click at coordinates
|
|
2320
|
+
await inputEmulator.click(point.x, point.y);
|
|
2321
|
+
await sleep(50);
|
|
2322
|
+
|
|
2323
|
+
// Check if the click was received by the target element
|
|
2324
|
+
const checkResult = await session.send('Runtime.evaluate', {
|
|
2325
|
+
expression: `
|
|
2326
|
+
(function() {
|
|
2327
|
+
const received = window[${JSON.stringify(verifyKey)}] === true;
|
|
2328
|
+
delete window[${JSON.stringify(verifyKey)}];
|
|
2329
|
+
|
|
2330
|
+
// Clean up handler if still attached
|
|
2331
|
+
const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
|
|
2332
|
+
if (el && el.__clickVerifyHandler) {
|
|
2333
|
+
el.removeEventListener('click', el.__clickVerifyHandler);
|
|
2334
|
+
delete el.__clickVerifyHandler;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
return received;
|
|
2338
|
+
})()
|
|
2339
|
+
`,
|
|
2340
|
+
returnByValue: true
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
const clickReceived = checkResult.result.value === true;
|
|
2344
|
+
|
|
2345
|
+
if (!clickReceived) {
|
|
2346
|
+
// CDP click didn't reach the target, fallback to JS click
|
|
2347
|
+
await executeJsClickOnRef(ref);
|
|
2348
|
+
usedMethod = 'jsClick-fallback';
|
|
2349
|
+
usedFallback = true;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
// Check for navigation
|
|
2354
|
+
let willNavigate = false;
|
|
2355
|
+
const shouldWaitNav = waitForNavigation || willNavigate;
|
|
2356
|
+
|
|
2357
|
+
const result = {
|
|
2358
|
+
clicked: true,
|
|
2359
|
+
method: usedMethod,
|
|
2360
|
+
ref,
|
|
2361
|
+
willNavigate
|
|
2362
|
+
};
|
|
2363
|
+
|
|
2364
|
+
if (usedFallback) {
|
|
2365
|
+
result.fallbackReason = 'CDP click did not reach target element';
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
if (shouldWaitNav) {
|
|
2369
|
+
const navResult = await detectNavigation(session, urlBeforeClick, navigationTimeout);
|
|
2370
|
+
result.navigated = navResult.navigated;
|
|
2371
|
+
if (navResult.newUrl) {
|
|
2372
|
+
result.newUrl = navResult.newUrl;
|
|
2373
|
+
}
|
|
2374
|
+
} else {
|
|
2375
|
+
result.navigated = false;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
if (debug) {
|
|
2379
|
+
result.debug = {
|
|
2380
|
+
clickedAt: point,
|
|
2381
|
+
elementHit: elementAtPoint
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
return result;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
async function tryJsClickFallback(selector, opts = {}) {
|
|
2389
|
+
const { urlBeforeClick, waitForNavigation = false, navigationTimeout = 100, debug = false, fallbackReason = 'CDP click failed' } = opts;
|
|
2390
|
+
|
|
2391
|
+
const element = await elementLocator.findElement(selector);
|
|
2392
|
+
if (!element) {
|
|
2393
|
+
throw elementNotFoundError(selector, 0);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
try {
|
|
2397
|
+
const result = await executeJsClick(element._handle.objectId);
|
|
2398
|
+
await element._handle.dispose();
|
|
2399
|
+
|
|
2400
|
+
const clickResult = {
|
|
2401
|
+
clicked: true,
|
|
2402
|
+
method: 'jsClick-fallback',
|
|
2403
|
+
fallbackReason,
|
|
2404
|
+
...result
|
|
2405
|
+
};
|
|
2406
|
+
|
|
2407
|
+
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, null, { waitForNavigation, navigationTimeout, debug });
|
|
2408
|
+
} catch (e) {
|
|
2409
|
+
await element._handle.dispose();
|
|
2410
|
+
throw e;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
async function clickBySelector(selector, opts = {}) {
|
|
2415
|
+
const {
|
|
2416
|
+
jsClick = false,
|
|
2417
|
+
verify = false,
|
|
2418
|
+
force = false,
|
|
2419
|
+
debug = false,
|
|
2420
|
+
waitForNavigation = false,
|
|
2421
|
+
navigationTimeout = 100,
|
|
2422
|
+
timeout = 30000
|
|
2423
|
+
} = opts;
|
|
2424
|
+
|
|
2425
|
+
const urlBeforeClick = await getCurrentUrl(session);
|
|
2426
|
+
|
|
2427
|
+
const waitResult = await actionabilityChecker.waitForActionable(selector, 'click', {
|
|
2428
|
+
timeout,
|
|
2429
|
+
force
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
if (!waitResult.success) {
|
|
2433
|
+
if (!jsClick) {
|
|
2434
|
+
try {
|
|
2435
|
+
return await tryJsClickFallback(selector, {
|
|
2436
|
+
urlBeforeClick,
|
|
2437
|
+
waitForNavigation,
|
|
2438
|
+
navigationTimeout,
|
|
2439
|
+
debug,
|
|
2440
|
+
fallbackReason: waitResult.error
|
|
2441
|
+
});
|
|
2442
|
+
} catch {
|
|
2443
|
+
// JS click also failed
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
throw new Error(`Element not actionable: ${waitResult.error}`);
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
const objectId = waitResult.objectId;
|
|
2450
|
+
|
|
2451
|
+
try {
|
|
2452
|
+
if (jsClick) {
|
|
2453
|
+
const result = await executeJsClick(objectId);
|
|
2454
|
+
const clickResult = { clicked: true, method: 'jsClick', ...result };
|
|
2455
|
+
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, null, { waitForNavigation, navigationTimeout, debug });
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
const point = await actionabilityChecker.getClickablePoint(objectId);
|
|
2459
|
+
if (!point) {
|
|
2460
|
+
throw new Error('Could not determine click point for element');
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
const viewportBox = await getViewportBounds();
|
|
2464
|
+
const clippedPoint = calculateVisibleCenter(point.rect, viewportBox);
|
|
2465
|
+
|
|
2466
|
+
let elementAtPoint = null;
|
|
2467
|
+
if (debug) {
|
|
2468
|
+
elementAtPoint = await getElementAtPoint(session, clippedPoint.x, clippedPoint.y);
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
if (verify) {
|
|
2472
|
+
const result = await clickWithVerification(clippedPoint.x, clippedPoint.y, objectId);
|
|
2473
|
+
|
|
2474
|
+
if (!result.targetReceived) {
|
|
2475
|
+
const jsResult = await executeJsClick(objectId);
|
|
2476
|
+
|
|
2477
|
+
const clickResult = {
|
|
2478
|
+
clicked: true,
|
|
2479
|
+
method: 'jsClick-fallback',
|
|
2480
|
+
cdpAttempted: true,
|
|
2481
|
+
targetReceived: jsResult.targetReceived
|
|
2482
|
+
};
|
|
2483
|
+
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, { point: clippedPoint, elementAtPoint }, { waitForNavigation, navigationTimeout, debug });
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
const clickResult = { clicked: true, method: 'cdp', ...result };
|
|
2487
|
+
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, { point: clippedPoint, elementAtPoint }, { waitForNavigation, navigationTimeout, debug });
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
await inputEmulator.click(clippedPoint.x, clippedPoint.y);
|
|
2491
|
+
|
|
2492
|
+
const clickResult = { clicked: true, method: 'cdp' };
|
|
2493
|
+
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, { point: clippedPoint, elementAtPoint }, { waitForNavigation, navigationTimeout, debug });
|
|
2494
|
+
|
|
2495
|
+
} catch (e) {
|
|
2496
|
+
if (!jsClick) {
|
|
2497
|
+
try {
|
|
2498
|
+
return await tryJsClickFallback(selector, {
|
|
2499
|
+
urlBeforeClick,
|
|
2500
|
+
waitForNavigation,
|
|
2501
|
+
navigationTimeout,
|
|
2502
|
+
debug,
|
|
2503
|
+
fallbackReason: e.message
|
|
2504
|
+
});
|
|
2505
|
+
} catch {
|
|
2506
|
+
// JS click also failed
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
throw e;
|
|
2510
|
+
} finally {
|
|
2511
|
+
await releaseObject(session, objectId);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
async function execute(params) {
|
|
2516
|
+
const selector = typeof params === 'string' ? params : params.selector;
|
|
2517
|
+
const ref = typeof params === 'object' ? params.ref : null;
|
|
2518
|
+
const jsClick = typeof params === 'object' && params.jsClick === true;
|
|
2519
|
+
const verify = typeof params === 'object' && params.verify === true;
|
|
2520
|
+
const force = typeof params === 'object' && params.force === true;
|
|
2521
|
+
const debug = typeof params === 'object' && params.debug === true;
|
|
2522
|
+
const waitForNavigation = typeof params === 'object' && params.waitForNavigation === true;
|
|
2523
|
+
const navigationTimeout = typeof params === 'object' ? params.navigationTimeout : undefined;
|
|
2524
|
+
|
|
2525
|
+
if (typeof params === 'object' && typeof params.x === 'number' && typeof params.y === 'number') {
|
|
2526
|
+
return clickAtCoordinates(params.x, params.y, { debug, waitForNavigation, navigationTimeout });
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
if (ref && ariaSnapshot) {
|
|
2530
|
+
return clickByRef(ref, jsClick, { waitForNavigation, navigationTimeout, force, debug });
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
return clickBySelector(selector, { jsClick, verify, force, debug, waitForNavigation, navigationTimeout });
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
return {
|
|
2537
|
+
execute
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
// ============================================================================
|
|
2542
|
+
// Fill Executor (from FillExecutor.js)
|
|
2543
|
+
// ============================================================================
|
|
2544
|
+
|
|
2545
|
+
/**
|
|
2546
|
+
* Create a fill executor for handling fill operations
|
|
2547
|
+
* @param {Object} session - CDP session
|
|
2548
|
+
* @param {Object} elementLocator - Element locator instance
|
|
2549
|
+
* @param {Object} inputEmulator - Input emulator instance
|
|
2550
|
+
* @param {Object} [ariaSnapshot] - Optional ARIA snapshot instance
|
|
2551
|
+
* @returns {Object} Fill executor interface
|
|
2552
|
+
*/
|
|
2553
|
+
export function createFillExecutor(session, elementLocator, inputEmulator, ariaSnapshot = null) {
|
|
2554
|
+
if (!session) throw new Error('CDP session is required');
|
|
2555
|
+
if (!elementLocator) throw new Error('Element locator is required');
|
|
2556
|
+
if (!inputEmulator) throw new Error('Input emulator is required');
|
|
2557
|
+
|
|
2558
|
+
const actionabilityChecker = createActionabilityChecker(session);
|
|
2559
|
+
const elementValidator = createElementValidator(session);
|
|
2560
|
+
const reactInputFiller = createReactInputFiller(session);
|
|
2561
|
+
|
|
2562
|
+
async function fillByRef(ref, value, opts = {}) {
|
|
2563
|
+
const { clear = true, react = false } = opts;
|
|
2564
|
+
|
|
2565
|
+
const refInfo = await ariaSnapshot.getElementByRef(ref);
|
|
2566
|
+
if (!refInfo) {
|
|
2567
|
+
throw elementNotFoundError(`ref:${ref}`, 0);
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
if (refInfo.stale) {
|
|
2571
|
+
throw new Error(`Element ref:${ref} is no longer attached to the DOM. Page content may have changed.`);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
if (refInfo.isVisible === false) {
|
|
2575
|
+
throw new Error(`Element ref:${ref} exists but is not visible. It may be hidden or have zero dimensions.`);
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
const elementResult = await session.send('Runtime.evaluate', {
|
|
2579
|
+
expression: `(function() {
|
|
2580
|
+
const el = window.__ariaRefs && window.__ariaRefs.get('${ref}');
|
|
2581
|
+
return el;
|
|
2582
|
+
})()`,
|
|
2583
|
+
returnByValue: false
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2586
|
+
if (!elementResult.result.objectId) {
|
|
2587
|
+
throw elementNotFoundError(`ref:${ref}`, 0);
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
const objectId = elementResult.result.objectId;
|
|
2591
|
+
|
|
2592
|
+
const editableCheck = await elementValidator.isEditable(objectId);
|
|
2593
|
+
if (!editableCheck.editable) {
|
|
2594
|
+
await releaseObject(session, objectId);
|
|
2595
|
+
throw elementNotEditableError(`ref:${ref}`, editableCheck.reason);
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
try {
|
|
2599
|
+
if (react) {
|
|
2600
|
+
await reactInputFiller.fillByObjectId(objectId, value);
|
|
2601
|
+
return { filled: true, ref, method: 'react' };
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
await session.send('Runtime.callFunctionOn', {
|
|
2605
|
+
objectId,
|
|
2606
|
+
functionDeclaration: `function() {
|
|
2607
|
+
this.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
2608
|
+
}`
|
|
2609
|
+
});
|
|
2610
|
+
|
|
2611
|
+
await sleep(100);
|
|
2612
|
+
|
|
2613
|
+
const x = refInfo.box.x + refInfo.box.width / 2;
|
|
2614
|
+
const y = refInfo.box.y + refInfo.box.height / 2;
|
|
2615
|
+
await inputEmulator.click(x, y);
|
|
2616
|
+
|
|
2617
|
+
await session.send('Runtime.callFunctionOn', {
|
|
2618
|
+
objectId,
|
|
2619
|
+
functionDeclaration: `function() { this.focus(); }`
|
|
2620
|
+
});
|
|
2621
|
+
|
|
2622
|
+
if (clear) {
|
|
2623
|
+
await inputEmulator.selectAll();
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
await inputEmulator.type(String(value));
|
|
2627
|
+
|
|
2628
|
+
return { filled: true, ref, method: 'keyboard' };
|
|
2629
|
+
} finally {
|
|
2630
|
+
await releaseObject(session, objectId);
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
async function fillBySelector(selector, value, opts = {}) {
|
|
2635
|
+
const { clear = true, react = false, force = false, timeout = 30000 } = opts;
|
|
2636
|
+
|
|
2637
|
+
const waitResult = await actionabilityChecker.waitForActionable(selector, 'fill', {
|
|
2638
|
+
timeout,
|
|
2639
|
+
force
|
|
2640
|
+
});
|
|
2641
|
+
|
|
2642
|
+
if (!waitResult.success) {
|
|
2643
|
+
if (waitResult.missingState === 'editable') {
|
|
2644
|
+
throw elementNotEditableError(selector, waitResult.error);
|
|
2645
|
+
}
|
|
2646
|
+
throw new Error(`Element not actionable: ${waitResult.error}`);
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
const objectId = waitResult.objectId;
|
|
2650
|
+
|
|
2651
|
+
try {
|
|
2652
|
+
if (react) {
|
|
2653
|
+
await reactInputFiller.fillByObjectId(objectId, value);
|
|
2654
|
+
return { filled: true, selector, method: 'react' };
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
const point = await actionabilityChecker.getClickablePoint(objectId);
|
|
2658
|
+
if (!point) {
|
|
2659
|
+
throw new Error('Could not determine click point for element');
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
await inputEmulator.click(point.x, point.y);
|
|
2663
|
+
|
|
2664
|
+
await session.send('Runtime.callFunctionOn', {
|
|
2665
|
+
objectId,
|
|
2666
|
+
functionDeclaration: `function() { this.focus(); }`
|
|
2667
|
+
});
|
|
2668
|
+
|
|
2669
|
+
if (clear) {
|
|
2670
|
+
await inputEmulator.selectAll();
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
await inputEmulator.type(String(value));
|
|
2674
|
+
|
|
2675
|
+
return { filled: true, selector, method: 'keyboard' };
|
|
2676
|
+
} catch (e) {
|
|
2677
|
+
await resetInputState(session);
|
|
2678
|
+
throw e;
|
|
2679
|
+
} finally {
|
|
2680
|
+
await releaseObject(session, objectId);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
async function execute(params) {
|
|
2685
|
+
const { selector, ref, value, clear = true, react = false } = params;
|
|
2686
|
+
|
|
2687
|
+
if (value === undefined) {
|
|
2688
|
+
throw new Error('Fill requires value');
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
if (ref && ariaSnapshot) {
|
|
2692
|
+
return fillByRef(ref, value, { clear, react });
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
if (!selector) {
|
|
2696
|
+
throw new Error('Fill requires selector or ref');
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
return fillBySelector(selector, value, { clear, react });
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
async function executeBatch(params) {
|
|
2703
|
+
if (!params || typeof params !== 'object') {
|
|
2704
|
+
throw new Error('fillForm requires an object mapping selectors to values');
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
// Support both formats:
|
|
2708
|
+
// Simple: {"#firstName": "John", "#lastName": "Doe"}
|
|
2709
|
+
// Extended: {"fields": {"#firstName": "John"}, "react": true}
|
|
2710
|
+
let fields;
|
|
2711
|
+
let useReact = false;
|
|
2712
|
+
|
|
2713
|
+
if (params.fields && typeof params.fields === 'object') {
|
|
2714
|
+
// Extended format with fields and react options
|
|
2715
|
+
fields = params.fields;
|
|
2716
|
+
useReact = params.react === true;
|
|
2717
|
+
} else {
|
|
2718
|
+
// Simple format - params is the fields object directly
|
|
2719
|
+
fields = params;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
const entries = Object.entries(fields);
|
|
2723
|
+
if (entries.length === 0) {
|
|
2724
|
+
throw new Error('fillForm requires at least one field');
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
const results = [];
|
|
2728
|
+
const errors = [];
|
|
2729
|
+
|
|
2730
|
+
for (const [selector, value] of entries) {
|
|
2731
|
+
try {
|
|
2732
|
+
const isRef = /^e\d+$/.test(selector);
|
|
2733
|
+
|
|
2734
|
+
if (isRef) {
|
|
2735
|
+
await fillByRef(selector, value, { clear: true, react: useReact });
|
|
2736
|
+
} else {
|
|
2737
|
+
await fillBySelector(selector, value, { clear: true, react: useReact });
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
results.push({ selector, status: 'filled', value: String(value) });
|
|
2741
|
+
} catch (error) {
|
|
2742
|
+
errors.push({ selector, error: error.message });
|
|
2743
|
+
results.push({ selector, status: 'failed', error: error.message });
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
return {
|
|
2748
|
+
total: entries.length,
|
|
2749
|
+
filled: results.filter(r => r.status === 'filled').length,
|
|
2750
|
+
failed: errors.length,
|
|
2751
|
+
results,
|
|
2752
|
+
errors: errors.length > 0 ? errors : undefined
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
return {
|
|
2757
|
+
execute,
|
|
2758
|
+
executeBatch
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// ============================================================================
|
|
2763
|
+
// Keyboard Executor (from KeyboardStepExecutor.js)
|
|
2764
|
+
// ============================================================================
|
|
2765
|
+
|
|
2766
|
+
/**
|
|
2767
|
+
* Create a keyboard executor for handling type and select operations
|
|
2768
|
+
* @param {Object} session - CDP session
|
|
2769
|
+
* @param {Object} elementLocator - Element locator instance
|
|
2770
|
+
* @param {Object} inputEmulator - Input emulator instance
|
|
2771
|
+
* @returns {Object} Keyboard executor interface
|
|
2772
|
+
*/
|
|
2773
|
+
export function createKeyboardExecutor(session, elementLocator, inputEmulator) {
|
|
2774
|
+
const validator = createElementValidator(session);
|
|
2775
|
+
|
|
2776
|
+
async function executeType(params) {
|
|
2777
|
+
const { selector, text, delay = 0 } = params;
|
|
2778
|
+
|
|
2779
|
+
if (!selector || text === undefined) {
|
|
2780
|
+
throw new Error('Type requires selector and text');
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
const element = await elementLocator.findElement(selector);
|
|
2784
|
+
if (!element) {
|
|
2785
|
+
throw elementNotFoundError(selector, 0);
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
const editableCheck = await validator.isEditable(element._handle.objectId);
|
|
2789
|
+
if (!editableCheck.editable) {
|
|
2790
|
+
await element._handle.dispose();
|
|
2791
|
+
throw elementNotEditableError(selector, editableCheck.reason);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
try {
|
|
2795
|
+
await element._handle.scrollIntoView({ block: 'center' });
|
|
2796
|
+
await element._handle.waitForStability({ frames: 2, timeout: 500 });
|
|
2797
|
+
|
|
2798
|
+
await element._handle.focus();
|
|
2799
|
+
|
|
2800
|
+
await inputEmulator.type(String(text), { delay });
|
|
2801
|
+
|
|
2802
|
+
return {
|
|
2803
|
+
selector,
|
|
2804
|
+
typed: String(text),
|
|
2805
|
+
length: String(text).length
|
|
2806
|
+
};
|
|
2807
|
+
} finally {
|
|
2808
|
+
await element._handle.dispose();
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
async function executeSelect(params) {
|
|
2813
|
+
let selector;
|
|
2814
|
+
let start = null;
|
|
2815
|
+
let end = null;
|
|
2816
|
+
|
|
2817
|
+
if (typeof params === 'string') {
|
|
2818
|
+
selector = params;
|
|
2819
|
+
} else if (params && typeof params === 'object') {
|
|
2820
|
+
selector = params.selector;
|
|
2821
|
+
start = params.start !== undefined ? params.start : null;
|
|
2822
|
+
end = params.end !== undefined ? params.end : null;
|
|
2823
|
+
} else {
|
|
2824
|
+
throw new Error('Select requires a selector string or params object');
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
if (!selector) {
|
|
2828
|
+
throw new Error('Select requires selector');
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
const element = await elementLocator.findElement(selector);
|
|
2832
|
+
if (!element) {
|
|
2833
|
+
throw elementNotFoundError(selector, 0);
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
try {
|
|
2837
|
+
await element._handle.scrollIntoView({ block: 'center' });
|
|
2838
|
+
await element._handle.waitForStability({ frames: 2, timeout: 500 });
|
|
2839
|
+
|
|
2840
|
+
await element._handle.focus();
|
|
2841
|
+
|
|
2842
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
2843
|
+
objectId: element._handle.objectId,
|
|
2844
|
+
functionDeclaration: `function(start, end) {
|
|
2845
|
+
const el = this;
|
|
2846
|
+
const tagName = el.tagName.toLowerCase();
|
|
2847
|
+
|
|
2848
|
+
if (tagName === 'input' || tagName === 'textarea') {
|
|
2849
|
+
const len = el.value.length;
|
|
2850
|
+
const selStart = start !== null ? Math.min(start, len) : 0;
|
|
2851
|
+
const selEnd = end !== null ? Math.min(end, len) : len;
|
|
2852
|
+
|
|
2853
|
+
el.focus();
|
|
2854
|
+
el.setSelectionRange(selStart, selEnd);
|
|
2855
|
+
|
|
2856
|
+
return {
|
|
2857
|
+
success: true,
|
|
2858
|
+
start: selStart,
|
|
2859
|
+
end: selEnd,
|
|
2860
|
+
selectedText: el.value.substring(selStart, selEnd),
|
|
2861
|
+
totalLength: len
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
if (el.isContentEditable) {
|
|
2866
|
+
const range = document.createRange();
|
|
2867
|
+
const text = el.textContent || '';
|
|
2868
|
+
const len = text.length;
|
|
2869
|
+
const selStart = start !== null ? Math.min(start, len) : 0;
|
|
2870
|
+
const selEnd = end !== null ? Math.min(end, len) : len;
|
|
2871
|
+
|
|
2872
|
+
let currentPos = 0;
|
|
2873
|
+
let startNode = null, startOffset = 0;
|
|
2874
|
+
let endNode = null, endOffset = 0;
|
|
2875
|
+
|
|
2876
|
+
function findPosition(node, target) {
|
|
2877
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
2878
|
+
const nodeLen = node.textContent.length;
|
|
2879
|
+
if (!startNode && currentPos + nodeLen >= selStart) {
|
|
2880
|
+
startNode = node;
|
|
2881
|
+
startOffset = selStart - currentPos;
|
|
2882
|
+
}
|
|
2883
|
+
if (!endNode && currentPos + nodeLen >= selEnd) {
|
|
2884
|
+
endNode = node;
|
|
2885
|
+
endOffset = selEnd - currentPos;
|
|
2886
|
+
return true;
|
|
2887
|
+
}
|
|
2888
|
+
currentPos += nodeLen;
|
|
2889
|
+
} else {
|
|
2890
|
+
for (const child of node.childNodes) {
|
|
2891
|
+
if (findPosition(child, target)) return true;
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
return false;
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
findPosition(el, null);
|
|
2898
|
+
|
|
2899
|
+
if (startNode && endNode) {
|
|
2900
|
+
range.setStart(startNode, startOffset);
|
|
2901
|
+
range.setEnd(endNode, endOffset);
|
|
2902
|
+
|
|
2903
|
+
const selection = window.getSelection();
|
|
2904
|
+
selection.removeAllRanges();
|
|
2905
|
+
selection.addRange(range);
|
|
2906
|
+
|
|
2907
|
+
return {
|
|
2908
|
+
success: true,
|
|
2909
|
+
start: selStart,
|
|
2910
|
+
end: selEnd,
|
|
2911
|
+
selectedText: text.substring(selStart, selEnd),
|
|
2912
|
+
totalLength: len
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
return {
|
|
2918
|
+
success: false,
|
|
2919
|
+
reason: 'Element does not support text selection'
|
|
2920
|
+
};
|
|
2921
|
+
}`,
|
|
2922
|
+
arguments: [
|
|
2923
|
+
{ value: start },
|
|
2924
|
+
{ value: end }
|
|
2925
|
+
],
|
|
2926
|
+
returnByValue: true
|
|
2927
|
+
});
|
|
2928
|
+
|
|
2929
|
+
const selectionResult = result.result.value;
|
|
2930
|
+
|
|
2931
|
+
if (!selectionResult.success) {
|
|
2932
|
+
throw new Error(selectionResult.reason || 'Selection failed');
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
return {
|
|
2936
|
+
selector,
|
|
2937
|
+
start: selectionResult.start,
|
|
2938
|
+
end: selectionResult.end,
|
|
2939
|
+
selectedText: selectionResult.selectedText,
|
|
2940
|
+
totalLength: selectionResult.totalLength
|
|
2941
|
+
};
|
|
2942
|
+
} finally {
|
|
2943
|
+
await element._handle.dispose();
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
return {
|
|
2948
|
+
executeType,
|
|
2949
|
+
executeSelect
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
// ============================================================================
|
|
2954
|
+
// Wait Executor (from WaitExecutor.js)
|
|
2955
|
+
// ============================================================================
|
|
2956
|
+
|
|
2957
|
+
/**
|
|
2958
|
+
* Create a wait executor for handling wait operations
|
|
2959
|
+
* @param {Object} session - CDP session
|
|
2960
|
+
* @param {Object} elementLocator - Element locator instance
|
|
2961
|
+
* @returns {Object} Wait executor interface
|
|
2962
|
+
*/
|
|
2963
|
+
export function createWaitExecutor(session, elementLocator) {
|
|
2964
|
+
if (!session) throw new Error('CDP session is required');
|
|
2965
|
+
if (!elementLocator) throw new Error('Element locator is required');
|
|
2966
|
+
|
|
2967
|
+
function validateTimeout(timeout) {
|
|
2968
|
+
if (typeof timeout !== 'number' || !Number.isFinite(timeout)) {
|
|
2969
|
+
return DEFAULT_TIMEOUT;
|
|
2970
|
+
}
|
|
2971
|
+
if (timeout < 0) return 0;
|
|
2972
|
+
if (timeout > MAX_TIMEOUT) return MAX_TIMEOUT;
|
|
2973
|
+
return timeout;
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
/**
|
|
2977
|
+
* Wait for selector using browser-side MutationObserver (improvement #3)
|
|
2978
|
+
* Much faster than Node.js polling as it avoids network round-trips
|
|
2979
|
+
*/
|
|
2980
|
+
async function waitForSelector(selector, timeout = DEFAULT_TIMEOUT) {
|
|
2981
|
+
const validatedTimeout = validateTimeout(timeout);
|
|
2982
|
+
|
|
2983
|
+
try {
|
|
2984
|
+
// Use browser-side polling with MutationObserver for better performance
|
|
2985
|
+
const result = await session.send('Runtime.evaluate', {
|
|
2986
|
+
expression: `
|
|
2987
|
+
new Promise((resolve, reject) => {
|
|
2988
|
+
const selector = ${JSON.stringify(selector)};
|
|
2989
|
+
const timeout = ${validatedTimeout};
|
|
2990
|
+
|
|
2991
|
+
// Check if element already exists
|
|
2992
|
+
const existing = document.querySelector(selector);
|
|
2993
|
+
if (existing) {
|
|
2994
|
+
resolve({ found: true, immediate: true });
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
let resolved = false;
|
|
2999
|
+
const timeoutId = setTimeout(() => {
|
|
3000
|
+
if (!resolved) {
|
|
3001
|
+
resolved = true;
|
|
3002
|
+
observer.disconnect();
|
|
3003
|
+
reject(new Error('Timeout waiting for selector: ' + selector));
|
|
3004
|
+
}
|
|
3005
|
+
}, timeout);
|
|
3006
|
+
|
|
3007
|
+
const observer = new MutationObserver((mutations, obs) => {
|
|
3008
|
+
const el = document.querySelector(selector);
|
|
3009
|
+
if (el && !resolved) {
|
|
3010
|
+
resolved = true;
|
|
3011
|
+
obs.disconnect();
|
|
3012
|
+
clearTimeout(timeoutId);
|
|
3013
|
+
resolve({ found: true, mutations: mutations.length });
|
|
3014
|
+
}
|
|
3015
|
+
});
|
|
3016
|
+
|
|
3017
|
+
observer.observe(document.documentElement || document.body, {
|
|
3018
|
+
childList: true,
|
|
3019
|
+
subtree: true,
|
|
3020
|
+
attributes: true,
|
|
3021
|
+
attributeFilter: ['class', 'id', 'style', 'hidden']
|
|
3022
|
+
});
|
|
3023
|
+
|
|
3024
|
+
// Also check with RAF as a fallback
|
|
3025
|
+
const checkWithRAF = () => {
|
|
3026
|
+
if (resolved) return;
|
|
3027
|
+
const el = document.querySelector(selector);
|
|
3028
|
+
if (el) {
|
|
3029
|
+
resolved = true;
|
|
3030
|
+
observer.disconnect();
|
|
3031
|
+
clearTimeout(timeoutId);
|
|
3032
|
+
resolve({ found: true, raf: true });
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
requestAnimationFrame(checkWithRAF);
|
|
3036
|
+
};
|
|
3037
|
+
requestAnimationFrame(checkWithRAF);
|
|
3038
|
+
})
|
|
3039
|
+
`,
|
|
3040
|
+
awaitPromise: true,
|
|
3041
|
+
returnByValue: true
|
|
3042
|
+
});
|
|
3043
|
+
|
|
3044
|
+
if (result.exceptionDetails) {
|
|
3045
|
+
throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
return result.result.value;
|
|
3049
|
+
} catch (error) {
|
|
3050
|
+
// Fall back to original Node.js polling if browser-side fails
|
|
3051
|
+
const element = await elementLocator.waitForSelector(selector, {
|
|
3052
|
+
timeout: validatedTimeout
|
|
3053
|
+
});
|
|
3054
|
+
if (element) await element.dispose();
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
async function checkElementHidden(selector) {
|
|
3059
|
+
try {
|
|
3060
|
+
const result = await session.send('Runtime.evaluate', {
|
|
3061
|
+
expression: `
|
|
3062
|
+
(function() {
|
|
3063
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
3064
|
+
if (!el) return true;
|
|
3065
|
+
const style = window.getComputedStyle(el);
|
|
3066
|
+
if (style.display === 'none') return true;
|
|
3067
|
+
if (style.visibility === 'hidden') return true;
|
|
3068
|
+
if (style.opacity === '0') return true;
|
|
3069
|
+
const rect = el.getBoundingClientRect();
|
|
3070
|
+
if (rect.width === 0 && rect.height === 0) return true;
|
|
3071
|
+
return false;
|
|
3072
|
+
})()
|
|
3073
|
+
`,
|
|
3074
|
+
returnByValue: true
|
|
3075
|
+
});
|
|
3076
|
+
return result.result.value === true;
|
|
3077
|
+
} catch {
|
|
3078
|
+
return true;
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
async function waitForHidden(selector, timeout = DEFAULT_TIMEOUT) {
|
|
3083
|
+
const validatedTimeout = validateTimeout(timeout);
|
|
3084
|
+
const startTime = Date.now();
|
|
3085
|
+
|
|
3086
|
+
while (Date.now() - startTime < validatedTimeout) {
|
|
3087
|
+
const isHidden = await checkElementHidden(selector);
|
|
3088
|
+
if (isHidden) return;
|
|
3089
|
+
await sleep(POLL_INTERVAL);
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
throw timeoutError(
|
|
3093
|
+
`Timeout (${validatedTimeout}ms) waiting for element to disappear: "${selector}"`
|
|
3094
|
+
);
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
async function getElementCount(selector) {
|
|
3098
|
+
try {
|
|
3099
|
+
const result = await session.send('Runtime.evaluate', {
|
|
3100
|
+
expression: `document.querySelectorAll(${JSON.stringify(selector)}).length`,
|
|
3101
|
+
returnByValue: true
|
|
3102
|
+
});
|
|
3103
|
+
return result.result.value || 0;
|
|
3104
|
+
} catch {
|
|
3105
|
+
return 0;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
async function waitForCount(selector, minCount, timeout = DEFAULT_TIMEOUT) {
|
|
3110
|
+
if (typeof minCount !== 'number' || minCount < 0) {
|
|
3111
|
+
throw new Error('minCount must be a non-negative number');
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
const validatedTimeout = validateTimeout(timeout);
|
|
3115
|
+
const startTime = Date.now();
|
|
3116
|
+
|
|
3117
|
+
while (Date.now() - startTime < validatedTimeout) {
|
|
3118
|
+
const count = await getElementCount(selector);
|
|
3119
|
+
if (count >= minCount) return;
|
|
3120
|
+
await sleep(POLL_INTERVAL);
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
const finalCount = await getElementCount(selector);
|
|
3124
|
+
throw timeoutError(
|
|
3125
|
+
`Timeout (${validatedTimeout}ms) waiting for ${minCount} elements matching "${selector}" (found ${finalCount})`
|
|
3126
|
+
);
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
/**
|
|
3130
|
+
* Wait for text using browser-side MutationObserver (improvement #3)
|
|
3131
|
+
*/
|
|
3132
|
+
async function waitForText(text, opts = {}) {
|
|
3133
|
+
const { timeout = DEFAULT_TIMEOUT, caseSensitive = false } = opts;
|
|
3134
|
+
const validatedTimeout = validateTimeout(timeout);
|
|
3135
|
+
|
|
3136
|
+
try {
|
|
3137
|
+
// Use browser-side polling with MutationObserver
|
|
3138
|
+
const result = await session.send('Runtime.evaluate', {
|
|
3139
|
+
expression: `
|
|
3140
|
+
new Promise((resolve, reject) => {
|
|
3141
|
+
const searchText = ${JSON.stringify(text)};
|
|
3142
|
+
const caseSensitive = ${caseSensitive};
|
|
3143
|
+
const timeout = ${validatedTimeout};
|
|
3144
|
+
|
|
3145
|
+
const checkText = () => {
|
|
3146
|
+
const bodyText = document.body ? document.body.innerText : '';
|
|
3147
|
+
if (caseSensitive) {
|
|
3148
|
+
return bodyText.includes(searchText);
|
|
3149
|
+
}
|
|
3150
|
+
return bodyText.toLowerCase().includes(searchText.toLowerCase());
|
|
3151
|
+
};
|
|
3152
|
+
|
|
3153
|
+
// Check if text already exists
|
|
3154
|
+
if (checkText()) {
|
|
3155
|
+
resolve({ found: true, immediate: true });
|
|
3156
|
+
return;
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
let resolved = false;
|
|
3160
|
+
const timeoutId = setTimeout(() => {
|
|
3161
|
+
if (!resolved) {
|
|
3162
|
+
resolved = true;
|
|
3163
|
+
observer.disconnect();
|
|
3164
|
+
reject(new Error('Timeout waiting for text: ' + searchText));
|
|
3165
|
+
}
|
|
3166
|
+
}, timeout);
|
|
3167
|
+
|
|
3168
|
+
const observer = new MutationObserver((mutations, obs) => {
|
|
3169
|
+
if (!resolved && checkText()) {
|
|
3170
|
+
resolved = true;
|
|
3171
|
+
obs.disconnect();
|
|
3172
|
+
clearTimeout(timeoutId);
|
|
3173
|
+
resolve({ found: true, mutations: mutations.length });
|
|
3174
|
+
}
|
|
3175
|
+
});
|
|
3176
|
+
|
|
3177
|
+
observer.observe(document.documentElement || document.body, {
|
|
3178
|
+
childList: true,
|
|
3179
|
+
subtree: true,
|
|
3180
|
+
characterData: true
|
|
3181
|
+
});
|
|
3182
|
+
|
|
3183
|
+
// Also check with RAF as a fallback
|
|
3184
|
+
const checkWithRAF = () => {
|
|
3185
|
+
if (resolved) return;
|
|
3186
|
+
if (checkText()) {
|
|
3187
|
+
resolved = true;
|
|
3188
|
+
observer.disconnect();
|
|
3189
|
+
clearTimeout(timeoutId);
|
|
3190
|
+
resolve({ found: true, raf: true });
|
|
3191
|
+
return;
|
|
3192
|
+
}
|
|
3193
|
+
requestAnimationFrame(checkWithRAF);
|
|
3194
|
+
};
|
|
3195
|
+
requestAnimationFrame(checkWithRAF);
|
|
3196
|
+
})
|
|
3197
|
+
`,
|
|
3198
|
+
awaitPromise: true,
|
|
3199
|
+
returnByValue: true
|
|
3200
|
+
});
|
|
3201
|
+
|
|
3202
|
+
if (result.exceptionDetails) {
|
|
3203
|
+
throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
return result.result.value;
|
|
3207
|
+
} catch (error) {
|
|
3208
|
+
// Fall back to original Node.js polling
|
|
3209
|
+
const startTime = Date.now();
|
|
3210
|
+
const checkExpr = caseSensitive
|
|
3211
|
+
? `document.body.innerText.includes(${JSON.stringify(text)})`
|
|
3212
|
+
: `document.body.innerText.toLowerCase().includes(${JSON.stringify(text.toLowerCase())})`;
|
|
3213
|
+
|
|
3214
|
+
while (Date.now() - startTime < validatedTimeout) {
|
|
3215
|
+
try {
|
|
3216
|
+
const result = await session.send('Runtime.evaluate', {
|
|
3217
|
+
expression: checkExpr,
|
|
3218
|
+
returnByValue: true
|
|
3219
|
+
});
|
|
3220
|
+
if (result.result.value === true) return;
|
|
3221
|
+
} catch {
|
|
3222
|
+
// Continue polling
|
|
3223
|
+
}
|
|
3224
|
+
await sleep(POLL_INTERVAL);
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
throw timeoutError(
|
|
3228
|
+
`Timeout (${validatedTimeout}ms) waiting for text: "${text}"${caseSensitive ? ' (case-sensitive)' : ''}`
|
|
3229
|
+
);
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
async function waitForTextRegex(pattern, timeout = DEFAULT_TIMEOUT) {
|
|
3234
|
+
const validatedTimeout = validateTimeout(timeout);
|
|
3235
|
+
const startTime = Date.now();
|
|
3236
|
+
|
|
3237
|
+
try {
|
|
3238
|
+
new RegExp(pattern);
|
|
3239
|
+
} catch (e) {
|
|
3240
|
+
throw new Error(`Invalid regex pattern: ${pattern} - ${e.message}`);
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
while (Date.now() - startTime < validatedTimeout) {
|
|
3244
|
+
try {
|
|
3245
|
+
const result = await session.send('Runtime.evaluate', {
|
|
3246
|
+
expression: `
|
|
3247
|
+
(function() {
|
|
3248
|
+
try {
|
|
3249
|
+
const regex = new RegExp(${JSON.stringify(pattern)});
|
|
3250
|
+
return regex.test(document.body.innerText);
|
|
3251
|
+
} catch {
|
|
3252
|
+
return false;
|
|
3253
|
+
}
|
|
3254
|
+
})()
|
|
3255
|
+
`,
|
|
3256
|
+
returnByValue: true
|
|
3257
|
+
});
|
|
3258
|
+
if (result.result.value === true) return;
|
|
3259
|
+
} catch {
|
|
3260
|
+
// Continue polling
|
|
3261
|
+
}
|
|
3262
|
+
await sleep(POLL_INTERVAL);
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
throw timeoutError(
|
|
3266
|
+
`Timeout (${validatedTimeout}ms) waiting for text matching pattern: /${pattern}/`
|
|
3267
|
+
);
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
async function waitForUrlContains(substring, timeout = DEFAULT_TIMEOUT) {
|
|
3271
|
+
const validatedTimeout = validateTimeout(timeout);
|
|
3272
|
+
const startTime = Date.now();
|
|
3273
|
+
|
|
3274
|
+
while (Date.now() - startTime < validatedTimeout) {
|
|
3275
|
+
try {
|
|
3276
|
+
const result = await session.send('Runtime.evaluate', {
|
|
3277
|
+
expression: 'window.location.href',
|
|
3278
|
+
returnByValue: true
|
|
3279
|
+
});
|
|
3280
|
+
const currentUrl = result.result.value;
|
|
3281
|
+
if (currentUrl && currentUrl.includes(substring)) return;
|
|
3282
|
+
} catch {
|
|
3283
|
+
// Continue polling
|
|
3284
|
+
}
|
|
3285
|
+
await sleep(POLL_INTERVAL);
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
let finalUrl = 'unknown';
|
|
3289
|
+
try {
|
|
3290
|
+
const result = await session.send('Runtime.evaluate', {
|
|
3291
|
+
expression: 'window.location.href',
|
|
3292
|
+
returnByValue: true
|
|
3293
|
+
});
|
|
3294
|
+
finalUrl = result.result.value || 'unknown';
|
|
3295
|
+
} catch {
|
|
3296
|
+
// Ignore
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
throw timeoutError(
|
|
3300
|
+
`Timeout (${validatedTimeout}ms) waiting for URL to contain "${substring}" (current: ${finalUrl})`
|
|
3301
|
+
);
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
async function waitForTime(ms) {
|
|
3305
|
+
if (typeof ms !== 'number' || ms < 0) {
|
|
3306
|
+
throw new Error('wait time must be a non-negative number');
|
|
3307
|
+
}
|
|
3308
|
+
await sleep(ms);
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
async function execute(params) {
|
|
3312
|
+
if (typeof params === 'string') {
|
|
3313
|
+
return waitForSelector(params);
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
if (params.time !== undefined) {
|
|
3317
|
+
return waitForTime(params.time);
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
if (params.selector !== undefined) {
|
|
3321
|
+
if (params.hidden === true) {
|
|
3322
|
+
return waitForHidden(params.selector, params.timeout);
|
|
3323
|
+
}
|
|
3324
|
+
if (params.minCount !== undefined) {
|
|
3325
|
+
return waitForCount(params.selector, params.minCount, params.timeout);
|
|
3326
|
+
}
|
|
3327
|
+
return waitForSelector(params.selector, params.timeout);
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
if (params.text !== undefined) {
|
|
3331
|
+
return waitForText(params.text, {
|
|
3332
|
+
timeout: params.timeout,
|
|
3333
|
+
caseSensitive: params.caseSensitive
|
|
3334
|
+
});
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
if (params.textRegex !== undefined) {
|
|
3338
|
+
return waitForTextRegex(params.textRegex, params.timeout);
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
if (params.urlContains !== undefined) {
|
|
3342
|
+
return waitForUrlContains(params.urlContains, params.timeout);
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
throw new Error(`Invalid wait params: ${JSON.stringify(params)}`);
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
return {
|
|
3349
|
+
execute,
|
|
3350
|
+
waitForSelector,
|
|
3351
|
+
waitForHidden,
|
|
3352
|
+
waitForCount,
|
|
3353
|
+
waitForText,
|
|
3354
|
+
waitForTextRegex,
|
|
3355
|
+
waitForUrlContains,
|
|
3356
|
+
waitForTime
|
|
3357
|
+
};
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
// ============================================================================
|
|
3361
|
+
// Convenience Functions
|
|
3362
|
+
// ============================================================================
|
|
3363
|
+
|
|
3364
|
+
/**
|
|
3365
|
+
* Find a single element by selector
|
|
3366
|
+
* @param {Object} session - CDP session
|
|
3367
|
+
* @param {string} selector - CSS selector
|
|
3368
|
+
* @returns {Promise<Object|null>}
|
|
3369
|
+
*/
|
|
3370
|
+
export async function querySelector(session, selector) {
|
|
3371
|
+
const locator = createElementLocator(session);
|
|
3372
|
+
return locator.querySelector(selector);
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
/**
|
|
3376
|
+
* Find all elements matching a selector
|
|
3377
|
+
* @param {Object} session - CDP session
|
|
3378
|
+
* @param {string} selector - CSS selector
|
|
3379
|
+
* @returns {Promise<Object[]>}
|
|
3380
|
+
*/
|
|
3381
|
+
export async function querySelectorAll(session, selector) {
|
|
3382
|
+
const locator = createElementLocator(session);
|
|
3383
|
+
return locator.querySelectorAll(selector);
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
/**
|
|
3387
|
+
* Find an element with nodeId for compatibility
|
|
3388
|
+
* @param {Object} session - CDP session
|
|
3389
|
+
* @param {string} selector - CSS selector
|
|
3390
|
+
* @param {Object} [options] - Options
|
|
3391
|
+
* @returns {Promise<{nodeId: string, box: Object, dispose: Function}|null>}
|
|
3392
|
+
*/
|
|
3393
|
+
export async function findElement(session, selector, options = {}) {
|
|
3394
|
+
const locator = createElementLocator(session, options);
|
|
3395
|
+
const element = await locator.querySelector(selector);
|
|
3396
|
+
if (!element) return null;
|
|
3397
|
+
|
|
3398
|
+
const box = await element.getBoundingBox();
|
|
3399
|
+
return {
|
|
3400
|
+
nodeId: element.objectId,
|
|
3401
|
+
box,
|
|
3402
|
+
dispose: () => element.dispose()
|
|
3403
|
+
};
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
/**
|
|
3407
|
+
* Get bounding box for an element by objectId
|
|
3408
|
+
* @param {Object} session - CDP session
|
|
3409
|
+
* @param {string} objectId - Object ID
|
|
3410
|
+
* @returns {Promise<{x: number, y: number, width: number, height: number}|null>}
|
|
3411
|
+
*/
|
|
3412
|
+
export async function getBoundingBox(session, objectId) {
|
|
3413
|
+
const locator = createElementLocator(session);
|
|
3414
|
+
return locator.getBoundingBox(objectId);
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
/**
|
|
3418
|
+
* Check if an element is visible
|
|
3419
|
+
* @param {Object} session - CDP session
|
|
3420
|
+
* @param {string} objectId - Object ID
|
|
3421
|
+
* @returns {Promise<boolean>}
|
|
3422
|
+
*/
|
|
3423
|
+
export async function isVisible(session, objectId) {
|
|
3424
|
+
const handle = createElementHandle(session, objectId);
|
|
3425
|
+
try {
|
|
3426
|
+
return await handle.isVisible();
|
|
3427
|
+
} finally {
|
|
3428
|
+
await handle.dispose();
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
/**
|
|
3433
|
+
* Check if an element is actionable
|
|
3434
|
+
* @param {Object} session - CDP session
|
|
3435
|
+
* @param {string} objectId - Object ID
|
|
3436
|
+
* @returns {Promise<{actionable: boolean, reason: string|null}>}
|
|
3437
|
+
*/
|
|
3438
|
+
export async function isActionable(session, objectId) {
|
|
3439
|
+
const handle = createElementHandle(session, objectId);
|
|
3440
|
+
try {
|
|
3441
|
+
return await handle.isActionable();
|
|
3442
|
+
} finally {
|
|
3443
|
+
await handle.dispose();
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
/**
|
|
3448
|
+
* Scroll element into view
|
|
3449
|
+
* @param {Object} session - CDP session
|
|
3450
|
+
* @param {string} objectId - Object ID
|
|
3451
|
+
* @returns {Promise<void>}
|
|
3452
|
+
*/
|
|
3453
|
+
export async function scrollIntoView(session, objectId) {
|
|
3454
|
+
const handle = createElementHandle(session, objectId);
|
|
3455
|
+
try {
|
|
3456
|
+
await handle.scrollIntoView();
|
|
3457
|
+
} finally {
|
|
3458
|
+
await handle.dispose();
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
/**
|
|
3463
|
+
* Click at coordinates
|
|
3464
|
+
* @param {Object} session - CDP session
|
|
3465
|
+
* @param {number} x - X coordinate
|
|
3466
|
+
* @param {number} y - Y coordinate
|
|
3467
|
+
* @param {Object} [options] - Click options
|
|
3468
|
+
* @returns {Promise<void>}
|
|
3469
|
+
*/
|
|
3470
|
+
export async function click(session, x, y, options = {}) {
|
|
3471
|
+
const input = createInputEmulator(session);
|
|
3472
|
+
return input.click(x, y, options);
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
/**
|
|
3476
|
+
* Type text
|
|
3477
|
+
* @param {Object} session - CDP session
|
|
3478
|
+
* @param {string} text - Text to type
|
|
3479
|
+
* @param {Object} [options] - Type options
|
|
3480
|
+
* @returns {Promise<void>}
|
|
3481
|
+
*/
|
|
3482
|
+
export async function type(session, text, options = {}) {
|
|
3483
|
+
const input = createInputEmulator(session);
|
|
3484
|
+
return input.type(text, options);
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
/**
|
|
3488
|
+
* Fill input at coordinates
|
|
3489
|
+
* @param {Object} session - CDP session
|
|
3490
|
+
* @param {number} x - X coordinate
|
|
3491
|
+
* @param {number} y - Y coordinate
|
|
3492
|
+
* @param {string} text - Text to fill
|
|
3493
|
+
* @param {Object} [options] - Fill options
|
|
3494
|
+
* @returns {Promise<void>}
|
|
3495
|
+
*/
|
|
3496
|
+
export async function fill(session, x, y, text, options = {}) {
|
|
3497
|
+
const input = createInputEmulator(session);
|
|
3498
|
+
return input.fill(x, y, text, options);
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
/**
|
|
3502
|
+
* Press a key
|
|
3503
|
+
* @param {Object} session - CDP session
|
|
3504
|
+
* @param {string} key - Key to press
|
|
3505
|
+
* @param {Object} [options] - Press options
|
|
3506
|
+
* @returns {Promise<void>}
|
|
3507
|
+
*/
|
|
3508
|
+
export async function press(session, key, options = {}) {
|
|
3509
|
+
const input = createInputEmulator(session);
|
|
3510
|
+
return input.press(key, options);
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
/**
|
|
3514
|
+
* Scroll the page
|
|
3515
|
+
* @param {Object} session - CDP session
|
|
3516
|
+
* @param {number} deltaX - Horizontal scroll
|
|
3517
|
+
* @param {number} deltaY - Vertical scroll
|
|
3518
|
+
* @param {number} [x=100] - X origin
|
|
3519
|
+
* @param {number} [y=100] - Y origin
|
|
3520
|
+
* @returns {Promise<void>}
|
|
3521
|
+
*/
|
|
3522
|
+
export async function scroll(session, deltaX, deltaY, x = 100, y = 100) {
|
|
3523
|
+
const input = createInputEmulator(session);
|
|
3524
|
+
return input.scroll(deltaX, deltaY, x, y);
|
|
3525
|
+
}
|