cdp-skill 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/SKILL.md +34 -5
- package/package.json +2 -1
- package/src/capture/console-capture.js +241 -0
- package/src/capture/debug-capture.js +144 -0
- package/src/capture/error-aggregator.js +151 -0
- package/src/capture/eval-serializer.js +320 -0
- package/src/capture/index.js +40 -0
- package/src/capture/network-capture.js +211 -0
- package/src/capture/pdf-capture.js +256 -0
- package/src/capture/screenshot-capture.js +325 -0
- package/src/cdp/browser.js +569 -0
- package/src/cdp/connection.js +369 -0
- package/src/cdp/discovery.js +138 -0
- package/src/cdp/index.js +29 -0
- package/src/cdp/target-and-session.js +439 -0
- package/src/cdp-skill.js +25 -11
- package/src/constants.js +79 -0
- package/src/dom/actionability.js +638 -0
- package/src/dom/click-executor.js +923 -0
- package/src/dom/element-handle.js +496 -0
- package/src/dom/element-locator.js +475 -0
- package/src/dom/element-validator.js +120 -0
- package/src/dom/fill-executor.js +489 -0
- package/src/dom/index.js +248 -0
- package/src/dom/input-emulator.js +406 -0
- package/src/dom/keyboard-executor.js +202 -0
- package/src/dom/quad-helpers.js +89 -0
- package/src/dom/react-filler.js +94 -0
- package/src/dom/wait-executor.js +423 -0
- package/src/index.js +6 -6
- package/src/page/cookie-manager.js +202 -0
- package/src/page/dom-stability.js +181 -0
- package/src/page/index.js +36 -0
- package/src/{page.js → page/page-controller.js} +109 -839
- package/src/page/wait-utilities.js +302 -0
- package/src/page/web-storage-manager.js +108 -0
- package/src/runner/context-helpers.js +224 -0
- package/src/runner/execute-browser.js +518 -0
- package/src/runner/execute-form.js +315 -0
- package/src/runner/execute-input.js +308 -0
- package/src/runner/execute-interaction.js +672 -0
- package/src/runner/execute-navigation.js +180 -0
- package/src/runner/execute-query.js +771 -0
- package/src/runner/index.js +51 -0
- package/src/runner/step-executors.js +421 -0
- package/src/runner/step-validator.js +641 -0
- package/src/tests/Actionability.test.js +613 -0
- package/src/tests/BrowserClient.test.js +1 -1
- package/src/tests/ChromeDiscovery.test.js +1 -1
- package/src/tests/ClickExecutor.test.js +554 -0
- package/src/tests/ConsoleCapture.test.js +1 -1
- package/src/tests/ContextHelpers.test.js +453 -0
- package/src/tests/CookieManager.test.js +450 -0
- package/src/tests/DebugCapture.test.js +307 -0
- package/src/tests/ElementHandle.test.js +1 -1
- package/src/tests/ElementLocator.test.js +1 -1
- package/src/tests/ErrorAggregator.test.js +1 -1
- package/src/tests/EvalSerializer.test.js +391 -0
- package/src/tests/FillExecutor.test.js +611 -0
- package/src/tests/InputEmulator.test.js +1 -1
- package/src/tests/KeyboardExecutor.test.js +430 -0
- package/src/tests/NetworkErrorCapture.test.js +1 -1
- package/src/tests/PageController.test.js +1 -1
- package/src/tests/PdfCapture.test.js +333 -0
- package/src/tests/ScreenshotCapture.test.js +1 -1
- package/src/tests/SessionRegistry.test.js +1 -1
- package/src/tests/StepValidator.test.js +527 -0
- package/src/tests/TargetManager.test.js +1 -1
- package/src/tests/TestRunner.test.js +1 -1
- package/src/tests/WaitStrategy.test.js +1 -1
- package/src/tests/WaitUtilities.test.js +508 -0
- package/src/tests/WebStorageManager.test.js +333 -0
- package/src/types.js +309 -0
- package/src/capture.js +0 -1400
- package/src/cdp.js +0 -1286
- package/src/dom.js +0 -4379
- package/src/runner.js +0 -3676
package/src/dom.js
DELETED
|
@@ -1,4379 +0,0 @@
|
|
|
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 = 10000; // 10 seconds - auto-force kicks in if element exists but not actionable
|
|
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
|
-
/**
|
|
930
|
-
* Find an element by its visible text content
|
|
931
|
-
* Priority order: buttons → links → [role="button"] → any clickable element
|
|
932
|
-
* @param {string} text - Text to search for
|
|
933
|
-
* @param {Object} [opts] - Options
|
|
934
|
-
* @param {boolean} [opts.exact=false] - Require exact text match
|
|
935
|
-
* @param {string} [opts.tag] - Limit search to specific tag (e.g., 'button', 'a')
|
|
936
|
-
* @returns {Promise<Object|null>} Element handle or null
|
|
937
|
-
*/
|
|
938
|
-
async function findElementByText(text, opts = {}) {
|
|
939
|
-
if (!text || typeof text !== 'string') {
|
|
940
|
-
throw new Error('Text must be a non-empty string');
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
const { exact = false, tag = null } = opts;
|
|
944
|
-
const textLower = text.toLowerCase();
|
|
945
|
-
const textJson = JSON.stringify(text);
|
|
946
|
-
const textLowerJson = JSON.stringify(textLower);
|
|
947
|
-
|
|
948
|
-
// Build the selector priorities based on tag filter
|
|
949
|
-
let selectorGroups;
|
|
950
|
-
if (tag) {
|
|
951
|
-
selectorGroups = [[tag]];
|
|
952
|
-
} else {
|
|
953
|
-
// Priority: buttons → links → role buttons → other clickable
|
|
954
|
-
selectorGroups = [
|
|
955
|
-
['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]'],
|
|
956
|
-
['a[href]'],
|
|
957
|
-
['[role="button"]'],
|
|
958
|
-
['[onclick]', '[tabindex]', 'label', 'summary']
|
|
959
|
-
];
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
const expression = `
|
|
963
|
-
(function() {
|
|
964
|
-
const text = ${textJson};
|
|
965
|
-
const textLower = ${textLowerJson};
|
|
966
|
-
const exact = ${exact};
|
|
967
|
-
const selectorGroups = ${JSON.stringify(selectorGroups)};
|
|
968
|
-
|
|
969
|
-
function getElementText(el) {
|
|
970
|
-
// Check aria-label first
|
|
971
|
-
const ariaLabel = el.getAttribute('aria-label');
|
|
972
|
-
if (ariaLabel) return ariaLabel;
|
|
973
|
-
|
|
974
|
-
// For inputs, check value and placeholder
|
|
975
|
-
if (el.tagName === 'INPUT') {
|
|
976
|
-
return el.value || el.placeholder || '';
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// Get visible text content
|
|
980
|
-
return el.textContent || '';
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
function matchesText(elText) {
|
|
984
|
-
if (exact) {
|
|
985
|
-
return elText.trim() === text;
|
|
986
|
-
}
|
|
987
|
-
return elText.toLowerCase().includes(textLower);
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
function isVisible(el) {
|
|
991
|
-
if (!el.isConnected) return false;
|
|
992
|
-
const style = window.getComputedStyle(el);
|
|
993
|
-
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
994
|
-
return false;
|
|
995
|
-
}
|
|
996
|
-
const rect = el.getBoundingClientRect();
|
|
997
|
-
return rect.width > 0 && rect.height > 0;
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
// Search in priority order
|
|
1001
|
-
for (const selectors of selectorGroups) {
|
|
1002
|
-
const selectorString = selectors.join(', ');
|
|
1003
|
-
const elements = document.querySelectorAll(selectorString);
|
|
1004
|
-
|
|
1005
|
-
for (const el of elements) {
|
|
1006
|
-
if (!isVisible(el)) continue;
|
|
1007
|
-
const elText = getElementText(el);
|
|
1008
|
-
if (matchesText(elText)) {
|
|
1009
|
-
return el;
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
return null;
|
|
1015
|
-
})()
|
|
1016
|
-
`;
|
|
1017
|
-
|
|
1018
|
-
let result;
|
|
1019
|
-
try {
|
|
1020
|
-
result = await session.send('Runtime.evaluate', {
|
|
1021
|
-
expression,
|
|
1022
|
-
returnByValue: false
|
|
1023
|
-
});
|
|
1024
|
-
} catch (error) {
|
|
1025
|
-
throw connectionError(error.message, 'Runtime.evaluate (findElementByText)');
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
if (result.exceptionDetails) {
|
|
1029
|
-
throw new Error(`Text search error: ${result.exceptionDetails.text}`);
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
if (result.result.subtype === 'null' || result.result.type === 'undefined') {
|
|
1033
|
-
return null;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
return createElementHandle(session, result.result.objectId, { selector: `text:${text}` });
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
/**
|
|
1040
|
-
* Wait for an element with specific text to appear
|
|
1041
|
-
* @param {string} text - Text to search for
|
|
1042
|
-
* @param {Object} [opts] - Options
|
|
1043
|
-
* @param {number} [opts.timeout=30000] - Timeout in ms
|
|
1044
|
-
* @param {boolean} [opts.exact=false] - Require exact match
|
|
1045
|
-
* @param {boolean} [opts.visible=true] - Require element to be visible
|
|
1046
|
-
* @returns {Promise<Object>} Element handle
|
|
1047
|
-
*/
|
|
1048
|
-
async function waitForElementByText(text, opts = {}) {
|
|
1049
|
-
const { timeout = defaultTimeout, exact = false, visible = true } = opts;
|
|
1050
|
-
const validatedTimeout = validateTimeout(timeout);
|
|
1051
|
-
const startTime = Date.now();
|
|
1052
|
-
|
|
1053
|
-
while (Date.now() - startTime < validatedTimeout) {
|
|
1054
|
-
const element = await findElementByText(text, { exact });
|
|
1055
|
-
|
|
1056
|
-
if (element) {
|
|
1057
|
-
if (!visible) return element;
|
|
1058
|
-
|
|
1059
|
-
try {
|
|
1060
|
-
const isVis = await element.isVisible();
|
|
1061
|
-
if (isVis) return element;
|
|
1062
|
-
} catch {
|
|
1063
|
-
// Element may have been removed
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
await element.dispose();
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
await sleep(100);
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
throw elementNotFoundError(`text:"${text}"`, validatedTimeout);
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
return {
|
|
1076
|
-
get session() { return session; },
|
|
1077
|
-
querySelector,
|
|
1078
|
-
querySelectorAll,
|
|
1079
|
-
queryByRole,
|
|
1080
|
-
waitForSelector,
|
|
1081
|
-
waitForText,
|
|
1082
|
-
findElement,
|
|
1083
|
-
findElementByText,
|
|
1084
|
-
waitForElementByText,
|
|
1085
|
-
getBoundingBox,
|
|
1086
|
-
getDefaultTimeout: () => defaultTimeout,
|
|
1087
|
-
setDefaultTimeout: (timeout) => { defaultTimeout = validateTimeout(timeout); }
|
|
1088
|
-
};
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
// ============================================================================
|
|
1092
|
-
// Input Emulator
|
|
1093
|
-
// ============================================================================
|
|
1094
|
-
|
|
1095
|
-
/**
|
|
1096
|
-
* Create an input emulator for mouse and keyboard input
|
|
1097
|
-
* @param {Object} session - CDP session
|
|
1098
|
-
* @returns {Object} Input emulator interface
|
|
1099
|
-
*/
|
|
1100
|
-
export function createInputEmulator(session) {
|
|
1101
|
-
if (!session) throw new Error('CDP session is required');
|
|
1102
|
-
|
|
1103
|
-
// Transaction-based mouse state (improvement #7)
|
|
1104
|
-
// Inspired by Puppeteer's CdpMouse
|
|
1105
|
-
const mouseState = {
|
|
1106
|
-
x: 0,
|
|
1107
|
-
y: 0,
|
|
1108
|
-
button: 'none',
|
|
1109
|
-
buttons: 0,
|
|
1110
|
-
transactionDepth: 0,
|
|
1111
|
-
pendingOperations: []
|
|
1112
|
-
};
|
|
1113
|
-
|
|
1114
|
-
/**
|
|
1115
|
-
* Begin a mouse transaction for atomic operations
|
|
1116
|
-
* Prevents concurrent mouse operations from interfering
|
|
1117
|
-
* @returns {Object} Transaction handle with commit/rollback
|
|
1118
|
-
*/
|
|
1119
|
-
function beginMouseTransaction() {
|
|
1120
|
-
mouseState.transactionDepth++;
|
|
1121
|
-
const startState = { ...mouseState };
|
|
1122
|
-
|
|
1123
|
-
return {
|
|
1124
|
-
/**
|
|
1125
|
-
* Commit the transaction, applying all pending state
|
|
1126
|
-
*/
|
|
1127
|
-
commit: () => {
|
|
1128
|
-
mouseState.transactionDepth--;
|
|
1129
|
-
},
|
|
1130
|
-
|
|
1131
|
-
/**
|
|
1132
|
-
* Rollback the transaction, restoring initial state
|
|
1133
|
-
*/
|
|
1134
|
-
rollback: async () => {
|
|
1135
|
-
mouseState.transactionDepth--;
|
|
1136
|
-
// Reset mouse to initial state
|
|
1137
|
-
if (startState.buttons !== mouseState.buttons) {
|
|
1138
|
-
// Release any pressed buttons
|
|
1139
|
-
if (mouseState.buttons !== 0) {
|
|
1140
|
-
await session.send('Input.dispatchMouseEvent', {
|
|
1141
|
-
type: 'mouseReleased',
|
|
1142
|
-
x: mouseState.x,
|
|
1143
|
-
y: mouseState.y,
|
|
1144
|
-
button: mouseState.button,
|
|
1145
|
-
buttons: 0
|
|
1146
|
-
});
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
mouseState.x = startState.x;
|
|
1150
|
-
mouseState.y = startState.y;
|
|
1151
|
-
mouseState.button = startState.button;
|
|
1152
|
-
mouseState.buttons = startState.buttons;
|
|
1153
|
-
},
|
|
1154
|
-
|
|
1155
|
-
/**
|
|
1156
|
-
* Get current transaction state
|
|
1157
|
-
*/
|
|
1158
|
-
getState: () => ({ ...mouseState })
|
|
1159
|
-
};
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
/**
|
|
1163
|
-
* Reset mouse state to default
|
|
1164
|
-
* Useful after errors or when starting fresh
|
|
1165
|
-
*/
|
|
1166
|
-
async function resetMouseState() {
|
|
1167
|
-
if (mouseState.buttons !== 0) {
|
|
1168
|
-
await session.send('Input.dispatchMouseEvent', {
|
|
1169
|
-
type: 'mouseReleased',
|
|
1170
|
-
x: mouseState.x,
|
|
1171
|
-
y: mouseState.y,
|
|
1172
|
-
button: mouseState.button,
|
|
1173
|
-
buttons: 0
|
|
1174
|
-
});
|
|
1175
|
-
}
|
|
1176
|
-
mouseState.x = 0;
|
|
1177
|
-
mouseState.y = 0;
|
|
1178
|
-
mouseState.button = 'none';
|
|
1179
|
-
mouseState.buttons = 0;
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
/**
|
|
1183
|
-
* Get current mouse state
|
|
1184
|
-
*/
|
|
1185
|
-
function getMouseState() {
|
|
1186
|
-
return { ...mouseState };
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
function calculateModifiers(modifiers) {
|
|
1190
|
-
let flags = 0;
|
|
1191
|
-
if (modifiers.alt) flags |= 1;
|
|
1192
|
-
if (modifiers.ctrl) flags |= 2;
|
|
1193
|
-
if (modifiers.meta) flags |= 4;
|
|
1194
|
-
if (modifiers.shift) flags |= 8;
|
|
1195
|
-
return flags;
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
function getButtonMask(button) {
|
|
1199
|
-
const masks = { left: 1, right: 2, middle: 4, back: 8, forward: 16 };
|
|
1200
|
-
return masks[button] || 1;
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
function getKeyDefinition(char) {
|
|
1204
|
-
if (char >= 'a' && char <= 'z') {
|
|
1205
|
-
return { key: char, code: `Key${char.toUpperCase()}`, keyCode: char.toUpperCase().charCodeAt(0) };
|
|
1206
|
-
}
|
|
1207
|
-
if (char >= 'A' && char <= 'Z') {
|
|
1208
|
-
return { key: char, code: `Key${char}`, keyCode: char.charCodeAt(0) };
|
|
1209
|
-
}
|
|
1210
|
-
if (char >= '0' && char <= '9') {
|
|
1211
|
-
return { key: char, code: `Digit${char}`, keyCode: char.charCodeAt(0) };
|
|
1212
|
-
}
|
|
1213
|
-
return { key: char, code: '', keyCode: char.charCodeAt(0), text: char };
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
function validateCoordinates(x, y) {
|
|
1217
|
-
if (typeof x !== 'number' || typeof y !== 'number' ||
|
|
1218
|
-
!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
1219
|
-
throw new Error('Coordinates must be finite numbers');
|
|
1220
|
-
}
|
|
1221
|
-
if (x < 0 || y < 0) {
|
|
1222
|
-
throw new Error('Coordinates must be non-negative');
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
function validateButton(button) {
|
|
1227
|
-
const valid = ['left', 'right', 'middle', 'back', 'forward', 'none'];
|
|
1228
|
-
if (!valid.includes(button)) {
|
|
1229
|
-
throw new Error(`Invalid button: ${button}. Must be one of: ${valid.join(', ')}`);
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
function validateClickCount(clickCount) {
|
|
1234
|
-
if (typeof clickCount !== 'number' || !Number.isInteger(clickCount) || clickCount < 1) {
|
|
1235
|
-
throw new Error('Click count must be a positive integer');
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
async function click(x, y, opts = {}) {
|
|
1240
|
-
validateCoordinates(x, y);
|
|
1241
|
-
|
|
1242
|
-
const {
|
|
1243
|
-
button = 'left',
|
|
1244
|
-
clickCount = 1,
|
|
1245
|
-
delay = 0,
|
|
1246
|
-
modifiers = {}
|
|
1247
|
-
} = opts;
|
|
1248
|
-
|
|
1249
|
-
validateButton(button);
|
|
1250
|
-
validateClickCount(clickCount);
|
|
1251
|
-
|
|
1252
|
-
const modifierFlags = calculateModifiers(modifiers);
|
|
1253
|
-
const buttonMask = getButtonMask(button);
|
|
1254
|
-
|
|
1255
|
-
// Update mouse state tracking
|
|
1256
|
-
mouseState.x = x;
|
|
1257
|
-
mouseState.y = y;
|
|
1258
|
-
|
|
1259
|
-
await session.send('Input.dispatchMouseEvent', {
|
|
1260
|
-
type: 'mouseMoved', x, y, modifiers: modifierFlags
|
|
1261
|
-
});
|
|
1262
|
-
|
|
1263
|
-
mouseState.button = button;
|
|
1264
|
-
mouseState.buttons = buttonMask;
|
|
1265
|
-
|
|
1266
|
-
await session.send('Input.dispatchMouseEvent', {
|
|
1267
|
-
type: 'mousePressed', x, y, button, clickCount,
|
|
1268
|
-
modifiers: modifierFlags, buttons: buttonMask
|
|
1269
|
-
});
|
|
1270
|
-
|
|
1271
|
-
if (delay > 0) await sleep(delay);
|
|
1272
|
-
|
|
1273
|
-
mouseState.button = 'none';
|
|
1274
|
-
mouseState.buttons = 0;
|
|
1275
|
-
|
|
1276
|
-
await session.send('Input.dispatchMouseEvent', {
|
|
1277
|
-
type: 'mouseReleased', x, y, button, clickCount,
|
|
1278
|
-
modifiers: modifierFlags, buttons: 0
|
|
1279
|
-
});
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
async function doubleClick(x, y, opts = {}) {
|
|
1283
|
-
await click(x, y, { ...opts, clickCount: 2 });
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
async function rightClick(x, y, opts = {}) {
|
|
1287
|
-
await click(x, y, { ...opts, button: 'right' });
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
async function type(text, opts = {}) {
|
|
1291
|
-
if (typeof text !== 'string') {
|
|
1292
|
-
throw new Error('Text must be a string');
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
const { delay = 0 } = opts;
|
|
1296
|
-
|
|
1297
|
-
for (const char of text) {
|
|
1298
|
-
await session.send('Input.dispatchKeyEvent', {
|
|
1299
|
-
type: 'char',
|
|
1300
|
-
text: char,
|
|
1301
|
-
key: char,
|
|
1302
|
-
unmodifiedText: char
|
|
1303
|
-
});
|
|
1304
|
-
|
|
1305
|
-
if (delay > 0) await sleep(delay);
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
/**
|
|
1310
|
-
* Insert text using Input.insertText (like paste) - much faster than type()
|
|
1311
|
-
* Inspired by Rod & Puppeteer's insertText approach
|
|
1312
|
-
* Triggers synthetic input event for React/Vue bindings
|
|
1313
|
-
* @param {string} text - Text to insert
|
|
1314
|
-
* @param {Object} [opts] - Options
|
|
1315
|
-
* @param {boolean} [opts.dispatchEvents=true] - Dispatch input/change events
|
|
1316
|
-
* @returns {Promise<void>}
|
|
1317
|
-
*/
|
|
1318
|
-
async function insertText(text, opts = {}) {
|
|
1319
|
-
if (typeof text !== 'string') {
|
|
1320
|
-
throw new Error('Text must be a string');
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
const { dispatchEvents = true } = opts;
|
|
1324
|
-
|
|
1325
|
-
// Use CDP Input.insertText for fast text insertion
|
|
1326
|
-
await session.send('Input.insertText', { text });
|
|
1327
|
-
|
|
1328
|
-
// Trigger synthetic input event for framework bindings (React, Vue, etc.)
|
|
1329
|
-
if (dispatchEvents) {
|
|
1330
|
-
await session.send('Runtime.evaluate', {
|
|
1331
|
-
expression: `
|
|
1332
|
-
(function() {
|
|
1333
|
-
const el = document.activeElement;
|
|
1334
|
-
if (el) {
|
|
1335
|
-
el.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
|
|
1336
|
-
el.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
|
|
1337
|
-
}
|
|
1338
|
-
})()
|
|
1339
|
-
`
|
|
1340
|
-
});
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
async function fill(x, y, text, opts = {}) {
|
|
1345
|
-
await click(x, y);
|
|
1346
|
-
await sleep(50);
|
|
1347
|
-
|
|
1348
|
-
const isMac = opts.useMeta ?? (typeof process !== 'undefined' && process.platform === 'darwin');
|
|
1349
|
-
const selectAllModifiers = isMac ? { meta: true } : { ctrl: true };
|
|
1350
|
-
await press('a', { modifiers: selectAllModifiers });
|
|
1351
|
-
|
|
1352
|
-
await sleep(50);
|
|
1353
|
-
await type(text, opts);
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
async function press(key, opts = {}) {
|
|
1357
|
-
const { modifiers = {}, delay = 0 } = opts;
|
|
1358
|
-
const keyDef = KEY_DEFINITIONS[key] || getKeyDefinition(key);
|
|
1359
|
-
const modifierFlags = calculateModifiers(modifiers);
|
|
1360
|
-
|
|
1361
|
-
await session.send('Input.dispatchKeyEvent', {
|
|
1362
|
-
type: 'rawKeyDown',
|
|
1363
|
-
key: keyDef.key,
|
|
1364
|
-
code: keyDef.code,
|
|
1365
|
-
windowsVirtualKeyCode: keyDef.keyCode,
|
|
1366
|
-
modifiers: modifierFlags
|
|
1367
|
-
});
|
|
1368
|
-
|
|
1369
|
-
if (keyDef.text) {
|
|
1370
|
-
await session.send('Input.dispatchKeyEvent', {
|
|
1371
|
-
type: 'char',
|
|
1372
|
-
text: keyDef.text,
|
|
1373
|
-
key: keyDef.key,
|
|
1374
|
-
modifiers: modifierFlags
|
|
1375
|
-
});
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
if (delay > 0) await sleep(delay);
|
|
1379
|
-
|
|
1380
|
-
await session.send('Input.dispatchKeyEvent', {
|
|
1381
|
-
type: 'keyUp',
|
|
1382
|
-
key: keyDef.key,
|
|
1383
|
-
code: keyDef.code,
|
|
1384
|
-
windowsVirtualKeyCode: keyDef.keyCode,
|
|
1385
|
-
modifiers: modifierFlags
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
async function selectAll() {
|
|
1390
|
-
await session.send('Runtime.evaluate', {
|
|
1391
|
-
expression: `
|
|
1392
|
-
(function() {
|
|
1393
|
-
const el = document.activeElement;
|
|
1394
|
-
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
|
|
1395
|
-
el.select();
|
|
1396
|
-
} else if (window.getSelection) {
|
|
1397
|
-
document.execCommand('selectAll', false, null);
|
|
1398
|
-
}
|
|
1399
|
-
})()
|
|
1400
|
-
`
|
|
1401
|
-
});
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
async function moveMouse(x, y) {
|
|
1405
|
-
validateCoordinates(x, y);
|
|
1406
|
-
mouseState.x = x;
|
|
1407
|
-
mouseState.y = y;
|
|
1408
|
-
await session.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
async function hover(x, y, opts = {}) {
|
|
1412
|
-
validateCoordinates(x, y);
|
|
1413
|
-
const { duration = 0 } = opts;
|
|
1414
|
-
|
|
1415
|
-
await session.send('Input.dispatchMouseEvent', {
|
|
1416
|
-
type: 'mouseMoved',
|
|
1417
|
-
x,
|
|
1418
|
-
y
|
|
1419
|
-
});
|
|
1420
|
-
|
|
1421
|
-
if (duration > 0) {
|
|
1422
|
-
await sleep(duration);
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
async function scroll(deltaX, deltaY, x = 100, y = 100) {
|
|
1427
|
-
await session.send('Input.dispatchMouseEvent', {
|
|
1428
|
-
type: 'mouseWheel', x, y, deltaX, deltaY
|
|
1429
|
-
});
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
function parseKeyCombo(combo) {
|
|
1433
|
-
const parts = combo.split('+');
|
|
1434
|
-
const modifiers = { ctrl: false, alt: false, meta: false, shift: false };
|
|
1435
|
-
let key = null;
|
|
1436
|
-
|
|
1437
|
-
for (const part of parts) {
|
|
1438
|
-
const lower = part.toLowerCase();
|
|
1439
|
-
if (lower === 'control' || lower === 'ctrl') {
|
|
1440
|
-
modifiers.ctrl = true;
|
|
1441
|
-
} else if (lower === 'alt') {
|
|
1442
|
-
modifiers.alt = true;
|
|
1443
|
-
} else if (lower === 'meta' || lower === 'cmd' || lower === 'command') {
|
|
1444
|
-
modifiers.meta = true;
|
|
1445
|
-
} else if (lower === 'shift') {
|
|
1446
|
-
modifiers.shift = true;
|
|
1447
|
-
} else {
|
|
1448
|
-
key = part;
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
return { key, modifiers };
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
async function pressCombo(combo, opts = {}) {
|
|
1456
|
-
const { key, modifiers } = parseKeyCombo(combo);
|
|
1457
|
-
if (!key) {
|
|
1458
|
-
throw new Error(`Invalid key combo: ${combo} - no main key specified`);
|
|
1459
|
-
}
|
|
1460
|
-
await press(key, { ...opts, modifiers });
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
return {
|
|
1464
|
-
click,
|
|
1465
|
-
doubleClick,
|
|
1466
|
-
rightClick,
|
|
1467
|
-
type,
|
|
1468
|
-
insertText,
|
|
1469
|
-
fill,
|
|
1470
|
-
press,
|
|
1471
|
-
pressCombo,
|
|
1472
|
-
parseKeyCombo,
|
|
1473
|
-
selectAll,
|
|
1474
|
-
moveMouse,
|
|
1475
|
-
hover,
|
|
1476
|
-
scroll,
|
|
1477
|
-
// Transaction-based mouse state (improvement #7)
|
|
1478
|
-
beginMouseTransaction,
|
|
1479
|
-
resetMouseState,
|
|
1480
|
-
getMouseState
|
|
1481
|
-
};
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
// ============================================================================
|
|
1485
|
-
// Actionability Checker (from ActionabilityChecker.js)
|
|
1486
|
-
// ============================================================================
|
|
1487
|
-
|
|
1488
|
-
/**
|
|
1489
|
-
* Create an actionability checker for Playwright-style auto-waiting
|
|
1490
|
-
* @param {Object} session - CDP session
|
|
1491
|
-
* @returns {Object} Actionability checker interface
|
|
1492
|
-
*/
|
|
1493
|
-
export function createActionabilityChecker(session) {
|
|
1494
|
-
// Simplified: removed stability check, shorter retry delays
|
|
1495
|
-
const retryDelays = [0, 50, 100, 200];
|
|
1496
|
-
|
|
1497
|
-
function getRequiredStates(actionType) {
|
|
1498
|
-
// Removed 'stable' requirement - it caused timeouts on elements with CSS transitions
|
|
1499
|
-
// Zero-size elements are handled separately with JS click fallback
|
|
1500
|
-
switch (actionType) {
|
|
1501
|
-
case 'click':
|
|
1502
|
-
return ['attached']; // Just check element exists and is connected
|
|
1503
|
-
case 'hover':
|
|
1504
|
-
return ['attached'];
|
|
1505
|
-
case 'fill':
|
|
1506
|
-
case 'type':
|
|
1507
|
-
return ['attached', 'editable'];
|
|
1508
|
-
case 'select':
|
|
1509
|
-
return ['attached'];
|
|
1510
|
-
default:
|
|
1511
|
-
return ['attached'];
|
|
1512
|
-
}
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
async function findElementInternal(selector) {
|
|
1516
|
-
try {
|
|
1517
|
-
const result = await session.send('Runtime.evaluate', {
|
|
1518
|
-
expression: `document.querySelector(${JSON.stringify(selector)})`,
|
|
1519
|
-
returnByValue: false
|
|
1520
|
-
});
|
|
1521
|
-
|
|
1522
|
-
if (result.result.subtype === 'null' || !result.result.objectId) {
|
|
1523
|
-
return { success: false, error: `Element not found: ${selector}` };
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
return { success: true, objectId: result.result.objectId };
|
|
1527
|
-
} catch (error) {
|
|
1528
|
-
return { success: false, error: error.message };
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
async function checkVisible(objectId) {
|
|
1533
|
-
try {
|
|
1534
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
1535
|
-
objectId,
|
|
1536
|
-
functionDeclaration: `function() {
|
|
1537
|
-
const el = this;
|
|
1538
|
-
if (!el.isConnected) {
|
|
1539
|
-
return { matches: false, received: 'detached' };
|
|
1540
|
-
}
|
|
1541
|
-
const style = window.getComputedStyle(el);
|
|
1542
|
-
if (style.visibility === 'hidden') {
|
|
1543
|
-
return { matches: false, received: 'visibility:hidden' };
|
|
1544
|
-
}
|
|
1545
|
-
if (style.display === 'none') {
|
|
1546
|
-
return { matches: false, received: 'display:none' };
|
|
1547
|
-
}
|
|
1548
|
-
const rect = el.getBoundingClientRect();
|
|
1549
|
-
if (rect.width === 0 || rect.height === 0) {
|
|
1550
|
-
return { matches: false, received: 'zero-size' };
|
|
1551
|
-
}
|
|
1552
|
-
if (parseFloat(style.opacity) === 0) {
|
|
1553
|
-
return { matches: false, received: 'opacity:0' };
|
|
1554
|
-
}
|
|
1555
|
-
return { matches: true, received: 'visible' };
|
|
1556
|
-
}`,
|
|
1557
|
-
returnByValue: true
|
|
1558
|
-
});
|
|
1559
|
-
return result.result.value;
|
|
1560
|
-
} catch (error) {
|
|
1561
|
-
return { matches: false, received: 'error', error: error.message };
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
async function checkEnabled(objectId) {
|
|
1566
|
-
try {
|
|
1567
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
1568
|
-
objectId,
|
|
1569
|
-
functionDeclaration: `function() {
|
|
1570
|
-
const el = this;
|
|
1571
|
-
if (el.disabled === true) {
|
|
1572
|
-
return { matches: false, received: 'disabled' };
|
|
1573
|
-
}
|
|
1574
|
-
if (el.getAttribute('aria-disabled') === 'true') {
|
|
1575
|
-
return { matches: false, received: 'aria-disabled' };
|
|
1576
|
-
}
|
|
1577
|
-
const fieldset = el.closest('fieldset');
|
|
1578
|
-
if (fieldset && fieldset.disabled) {
|
|
1579
|
-
const legend = fieldset.querySelector('legend');
|
|
1580
|
-
if (!legend || !legend.contains(el)) {
|
|
1581
|
-
return { matches: false, received: 'fieldset-disabled' };
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
return { matches: true, received: 'enabled' };
|
|
1585
|
-
}`,
|
|
1586
|
-
returnByValue: true
|
|
1587
|
-
});
|
|
1588
|
-
return result.result.value;
|
|
1589
|
-
} catch (error) {
|
|
1590
|
-
return { matches: false, received: 'error', error: error.message };
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
async function checkEditable(objectId) {
|
|
1595
|
-
const enabledCheck = await checkEnabled(objectId);
|
|
1596
|
-
if (!enabledCheck.matches) {
|
|
1597
|
-
return enabledCheck;
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
try {
|
|
1601
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
1602
|
-
objectId,
|
|
1603
|
-
functionDeclaration: `function() {
|
|
1604
|
-
const el = this;
|
|
1605
|
-
const tagName = el.tagName.toLowerCase();
|
|
1606
|
-
if (el.readOnly === true) {
|
|
1607
|
-
return { matches: false, received: 'readonly' };
|
|
1608
|
-
}
|
|
1609
|
-
if (el.getAttribute('aria-readonly') === 'true') {
|
|
1610
|
-
return { matches: false, received: 'aria-readonly' };
|
|
1611
|
-
}
|
|
1612
|
-
const isFormElement = ['input', 'textarea', 'select'].includes(tagName);
|
|
1613
|
-
const isContentEditable = el.isContentEditable;
|
|
1614
|
-
if (!isFormElement && !isContentEditable) {
|
|
1615
|
-
return { matches: false, received: 'not-editable-element' };
|
|
1616
|
-
}
|
|
1617
|
-
if (tagName === 'input') {
|
|
1618
|
-
const type = el.type.toLowerCase();
|
|
1619
|
-
const textInputTypes = ['text', 'password', 'email', 'number', 'search', 'tel', 'url', 'date', 'datetime-local', 'month', 'time', 'week'];
|
|
1620
|
-
if (!textInputTypes.includes(type)) {
|
|
1621
|
-
return { matches: false, received: 'non-text-input' };
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
return { matches: true, received: 'editable' };
|
|
1625
|
-
}`,
|
|
1626
|
-
returnByValue: true
|
|
1627
|
-
});
|
|
1628
|
-
return result.result.value;
|
|
1629
|
-
} catch (error) {
|
|
1630
|
-
return { matches: false, received: 'error', error: error.message };
|
|
1631
|
-
}
|
|
1632
|
-
}
|
|
1633
|
-
|
|
1634
|
-
async function checkStable(objectId) {
|
|
1635
|
-
try {
|
|
1636
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
1637
|
-
objectId,
|
|
1638
|
-
functionDeclaration: `async function() {
|
|
1639
|
-
const el = this;
|
|
1640
|
-
const frameCount = ${stableFrameCount};
|
|
1641
|
-
if (!el.isConnected) {
|
|
1642
|
-
return { matches: false, received: 'detached' };
|
|
1643
|
-
}
|
|
1644
|
-
let lastRect = null;
|
|
1645
|
-
let stableCount = 0;
|
|
1646
|
-
const getRect = () => {
|
|
1647
|
-
const r = el.getBoundingClientRect();
|
|
1648
|
-
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
1649
|
-
};
|
|
1650
|
-
const checkFrame = () => new Promise(resolve => {
|
|
1651
|
-
requestAnimationFrame(() => {
|
|
1652
|
-
if (!el.isConnected) {
|
|
1653
|
-
resolve({ matches: false, received: 'detached' });
|
|
1654
|
-
return;
|
|
1655
|
-
}
|
|
1656
|
-
const rect = getRect();
|
|
1657
|
-
if (lastRect) {
|
|
1658
|
-
const same = rect.x === lastRect.x &&
|
|
1659
|
-
rect.y === lastRect.y &&
|
|
1660
|
-
rect.width === lastRect.width &&
|
|
1661
|
-
rect.height === lastRect.height;
|
|
1662
|
-
if (same) {
|
|
1663
|
-
stableCount++;
|
|
1664
|
-
if (stableCount >= frameCount) {
|
|
1665
|
-
resolve({ matches: true, received: 'stable' });
|
|
1666
|
-
return;
|
|
1667
|
-
}
|
|
1668
|
-
} else {
|
|
1669
|
-
stableCount = 0;
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
lastRect = rect;
|
|
1673
|
-
resolve(null);
|
|
1674
|
-
});
|
|
1675
|
-
});
|
|
1676
|
-
for (let i = 0; i < 10; i++) {
|
|
1677
|
-
const result = await checkFrame();
|
|
1678
|
-
if (result !== null) {
|
|
1679
|
-
return result;
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
return { matches: false, received: 'unstable' };
|
|
1683
|
-
}`,
|
|
1684
|
-
returnByValue: true,
|
|
1685
|
-
awaitPromise: true
|
|
1686
|
-
});
|
|
1687
|
-
return result.result.value;
|
|
1688
|
-
} catch (error) {
|
|
1689
|
-
return { matches: false, received: 'error', error: error.message };
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
async function checkAttached(objectId) {
|
|
1694
|
-
try {
|
|
1695
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
1696
|
-
objectId,
|
|
1697
|
-
functionDeclaration: `function() {
|
|
1698
|
-
return { matches: this.isConnected, received: this.isConnected ? 'attached' : 'detached' };
|
|
1699
|
-
}`,
|
|
1700
|
-
returnByValue: true
|
|
1701
|
-
});
|
|
1702
|
-
return result.result.value;
|
|
1703
|
-
} catch (error) {
|
|
1704
|
-
return { matches: false, received: 'error', error: error.message };
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
async function checkState(objectId, state) {
|
|
1709
|
-
switch (state) {
|
|
1710
|
-
case 'attached':
|
|
1711
|
-
return checkAttached(objectId);
|
|
1712
|
-
case 'visible':
|
|
1713
|
-
return checkVisible(objectId);
|
|
1714
|
-
case 'enabled':
|
|
1715
|
-
return checkEnabled(objectId);
|
|
1716
|
-
case 'editable':
|
|
1717
|
-
return checkEditable(objectId);
|
|
1718
|
-
case 'stable':
|
|
1719
|
-
return checkStable(objectId);
|
|
1720
|
-
default:
|
|
1721
|
-
return { matches: true };
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
async function checkStates(objectId, states) {
|
|
1726
|
-
for (const state of states) {
|
|
1727
|
-
const check = await checkState(objectId, state);
|
|
1728
|
-
if (!check.matches) {
|
|
1729
|
-
return { success: false, missingState: state, received: check.received };
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
return { success: true };
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
async function waitForActionable(selector, actionType, opts = {}) {
|
|
1736
|
-
// Simplified: shorter default timeout (5s), simpler retry logic
|
|
1737
|
-
const { timeout = 5000, force = false } = opts;
|
|
1738
|
-
const startTime = Date.now();
|
|
1739
|
-
|
|
1740
|
-
const requiredStates = getRequiredStates(actionType);
|
|
1741
|
-
|
|
1742
|
-
// Force mode: just find the element, skip all checks
|
|
1743
|
-
if (force) {
|
|
1744
|
-
const element = await findElementInternal(selector);
|
|
1745
|
-
if (!element.success) {
|
|
1746
|
-
return element;
|
|
1747
|
-
}
|
|
1748
|
-
return { success: true, objectId: element.objectId, forced: true };
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
let retry = 0;
|
|
1752
|
-
let lastError = null;
|
|
1753
|
-
let lastObjectId = null;
|
|
1754
|
-
|
|
1755
|
-
while (Date.now() - startTime < timeout) {
|
|
1756
|
-
if (retry > 0) {
|
|
1757
|
-
const delay = retryDelays[Math.min(retry - 1, retryDelays.length - 1)];
|
|
1758
|
-
if (delay > 0) {
|
|
1759
|
-
await sleep(delay);
|
|
1760
|
-
}
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
if (lastObjectId) {
|
|
1764
|
-
await releaseObject(session, lastObjectId);
|
|
1765
|
-
lastObjectId = null;
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
const element = await findElementInternal(selector);
|
|
1769
|
-
if (!element.success) {
|
|
1770
|
-
lastError = element.error;
|
|
1771
|
-
retry++;
|
|
1772
|
-
continue;
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
lastObjectId = element.objectId;
|
|
1776
|
-
|
|
1777
|
-
const stateCheck = await checkStates(element.objectId, requiredStates);
|
|
1778
|
-
|
|
1779
|
-
if (stateCheck.success) {
|
|
1780
|
-
return { success: true, objectId: element.objectId };
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
lastError = `Element is not ${stateCheck.missingState}: ${stateCheck.received}`;
|
|
1784
|
-
retry++;
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
if (lastObjectId) {
|
|
1788
|
-
await releaseObject(session, lastObjectId);
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
return {
|
|
1792
|
-
success: false,
|
|
1793
|
-
error: lastError || `Element not found: ${selector} (timeout: ${timeout}ms)`
|
|
1794
|
-
};
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
async function getClickablePoint(objectId) {
|
|
1798
|
-
try {
|
|
1799
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
1800
|
-
objectId,
|
|
1801
|
-
functionDeclaration: `function() {
|
|
1802
|
-
const el = this;
|
|
1803
|
-
const rect = el.getBoundingClientRect();
|
|
1804
|
-
return {
|
|
1805
|
-
x: rect.x + rect.width / 2,
|
|
1806
|
-
y: rect.y + rect.height / 2,
|
|
1807
|
-
rect: {
|
|
1808
|
-
x: rect.x,
|
|
1809
|
-
y: rect.y,
|
|
1810
|
-
width: rect.width,
|
|
1811
|
-
height: rect.height
|
|
1812
|
-
}
|
|
1813
|
-
};
|
|
1814
|
-
}`,
|
|
1815
|
-
returnByValue: true
|
|
1816
|
-
});
|
|
1817
|
-
return result.result.value;
|
|
1818
|
-
} catch {
|
|
1819
|
-
return null;
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
async function checkHitTarget(objectId, point) {
|
|
1824
|
-
try {
|
|
1825
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
1826
|
-
objectId,
|
|
1827
|
-
functionDeclaration: `function(point) {
|
|
1828
|
-
const el = this;
|
|
1829
|
-
const hitEl = document.elementFromPoint(point.x, point.y);
|
|
1830
|
-
if (!hitEl) {
|
|
1831
|
-
return { matches: false, received: 'no-element-at-point' };
|
|
1832
|
-
}
|
|
1833
|
-
if (hitEl === el || el.contains(hitEl)) {
|
|
1834
|
-
return { matches: true, received: 'hit' };
|
|
1835
|
-
}
|
|
1836
|
-
let desc = hitEl.tagName.toLowerCase();
|
|
1837
|
-
if (hitEl.id) desc += '#' + hitEl.id;
|
|
1838
|
-
if (hitEl.className && typeof hitEl.className === 'string') {
|
|
1839
|
-
desc += '.' + hitEl.className.split(' ').filter(c => c).join('.');
|
|
1840
|
-
}
|
|
1841
|
-
return {
|
|
1842
|
-
matches: false,
|
|
1843
|
-
received: 'blocked',
|
|
1844
|
-
blockedBy: desc
|
|
1845
|
-
};
|
|
1846
|
-
}`,
|
|
1847
|
-
arguments: [{ value: point }],
|
|
1848
|
-
returnByValue: true
|
|
1849
|
-
});
|
|
1850
|
-
return result.result.value;
|
|
1851
|
-
} catch (error) {
|
|
1852
|
-
return { matches: false, received: 'error', error: error.message };
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
|
|
1856
|
-
/**
|
|
1857
|
-
* Check if pointer-events CSS allows clicking (improvement #8)
|
|
1858
|
-
* Elements with pointer-events: none cannot receive click events
|
|
1859
|
-
* @param {string} objectId - Element object ID
|
|
1860
|
-
* @returns {Promise<{clickable: boolean, pointerEvents: string}>}
|
|
1861
|
-
*/
|
|
1862
|
-
async function checkPointerEvents(objectId) {
|
|
1863
|
-
try {
|
|
1864
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
1865
|
-
objectId,
|
|
1866
|
-
functionDeclaration: `function() {
|
|
1867
|
-
const el = this;
|
|
1868
|
-
const style = window.getComputedStyle(el);
|
|
1869
|
-
const pointerEvents = style.pointerEvents;
|
|
1870
|
-
|
|
1871
|
-
// Check if element or any ancestor has pointer-events: none
|
|
1872
|
-
let current = el;
|
|
1873
|
-
while (current) {
|
|
1874
|
-
const currentStyle = window.getComputedStyle(current);
|
|
1875
|
-
if (currentStyle.pointerEvents === 'none') {
|
|
1876
|
-
return {
|
|
1877
|
-
clickable: false,
|
|
1878
|
-
pointerEvents: 'none',
|
|
1879
|
-
blockedBy: current === el ? 'self' : current.tagName.toLowerCase()
|
|
1880
|
-
};
|
|
1881
|
-
}
|
|
1882
|
-
current = current.parentElement;
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
return { clickable: true, pointerEvents: pointerEvents || 'auto' };
|
|
1886
|
-
}`,
|
|
1887
|
-
returnByValue: true
|
|
1888
|
-
});
|
|
1889
|
-
return result.result.value;
|
|
1890
|
-
} catch (error) {
|
|
1891
|
-
return { clickable: true, pointerEvents: 'unknown', error: error.message };
|
|
1892
|
-
}
|
|
1893
|
-
}
|
|
1894
|
-
|
|
1895
|
-
/**
|
|
1896
|
-
* Detect covered elements using CDP DOM.getNodeForLocation (improvement #1)
|
|
1897
|
-
* Inspired by Rod's Interactable() method
|
|
1898
|
-
* @param {string} objectId - Element object ID
|
|
1899
|
-
* @param {{x: number, y: number}} point - Click coordinates
|
|
1900
|
-
* @returns {Promise<{covered: boolean, coveringElement?: string}>}
|
|
1901
|
-
*/
|
|
1902
|
-
async function checkCovered(objectId, point) {
|
|
1903
|
-
try {
|
|
1904
|
-
// Get the backend node ID for the target element
|
|
1905
|
-
const nodeResult = await session.send('DOM.describeNode', { objectId });
|
|
1906
|
-
const targetBackendNodeId = nodeResult.node.backendNodeId;
|
|
1907
|
-
|
|
1908
|
-
// Use DOM.getNodeForLocation to see what element is actually at the click point
|
|
1909
|
-
const locationResult = await session.send('DOM.getNodeForLocation', {
|
|
1910
|
-
x: Math.floor(point.x),
|
|
1911
|
-
y: Math.floor(point.y),
|
|
1912
|
-
includeUserAgentShadowDOM: false
|
|
1913
|
-
});
|
|
1914
|
-
|
|
1915
|
-
const hitBackendNodeId = locationResult.backendNodeId;
|
|
1916
|
-
|
|
1917
|
-
// If the hit element matches our target, it's not covered
|
|
1918
|
-
if (hitBackendNodeId === targetBackendNodeId) {
|
|
1919
|
-
return { covered: false };
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
// Check if the hit element is a child of our target (also valid)
|
|
1923
|
-
const isChild = await session.send('Runtime.callFunctionOn', {
|
|
1924
|
-
objectId,
|
|
1925
|
-
functionDeclaration: `function(hitNodeId) {
|
|
1926
|
-
// We need to find if the hit element is inside this element
|
|
1927
|
-
// This is tricky because we only have backend node IDs
|
|
1928
|
-
// Use elementFromPoint as a fallback check
|
|
1929
|
-
const rect = this.getBoundingClientRect();
|
|
1930
|
-
const centerX = rect.left + rect.width / 2;
|
|
1931
|
-
const centerY = rect.top + rect.height / 2;
|
|
1932
|
-
const hitEl = document.elementFromPoint(centerX, centerY);
|
|
1933
|
-
|
|
1934
|
-
if (!hitEl) return { isChild: false, coverInfo: 'no-element' };
|
|
1935
|
-
|
|
1936
|
-
if (hitEl === this || this.contains(hitEl)) {
|
|
1937
|
-
return { isChild: true };
|
|
1938
|
-
}
|
|
1939
|
-
|
|
1940
|
-
// Get info about the covering element
|
|
1941
|
-
let desc = hitEl.tagName.toLowerCase();
|
|
1942
|
-
if (hitEl.id) desc += '#' + hitEl.id;
|
|
1943
|
-
if (hitEl.className && typeof hitEl.className === 'string') {
|
|
1944
|
-
const classes = hitEl.className.split(' ').filter(c => c).slice(0, 3);
|
|
1945
|
-
if (classes.length > 0) desc += '.' + classes.join('.');
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
return { isChild: false, coverInfo: desc };
|
|
1949
|
-
}`,
|
|
1950
|
-
returnByValue: true
|
|
1951
|
-
});
|
|
1952
|
-
|
|
1953
|
-
const childResult = isChild.result.value;
|
|
1954
|
-
|
|
1955
|
-
if (childResult.isChild) {
|
|
1956
|
-
return { covered: false };
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
return {
|
|
1960
|
-
covered: true,
|
|
1961
|
-
coveringElement: childResult.coverInfo || 'unknown'
|
|
1962
|
-
};
|
|
1963
|
-
} catch (error) {
|
|
1964
|
-
// If DOM methods fail, fall back to elementFromPoint check
|
|
1965
|
-
try {
|
|
1966
|
-
const fallbackResult = await session.send('Runtime.callFunctionOn', {
|
|
1967
|
-
objectId,
|
|
1968
|
-
functionDeclaration: `function() {
|
|
1969
|
-
const rect = this.getBoundingClientRect();
|
|
1970
|
-
const centerX = rect.left + rect.width / 2;
|
|
1971
|
-
const centerY = rect.top + rect.height / 2;
|
|
1972
|
-
const hitEl = document.elementFromPoint(centerX, centerY);
|
|
1973
|
-
|
|
1974
|
-
if (!hitEl) return { covered: true, coverInfo: 'no-element-at-center' };
|
|
1975
|
-
if (hitEl === this || this.contains(hitEl)) return { covered: false };
|
|
1976
|
-
|
|
1977
|
-
let desc = hitEl.tagName.toLowerCase();
|
|
1978
|
-
if (hitEl.id) desc += '#' + hitEl.id;
|
|
1979
|
-
return { covered: true, coverInfo: desc };
|
|
1980
|
-
}`,
|
|
1981
|
-
returnByValue: true
|
|
1982
|
-
});
|
|
1983
|
-
return {
|
|
1984
|
-
covered: fallbackResult.result.value.covered,
|
|
1985
|
-
coveringElement: fallbackResult.result.value.coverInfo
|
|
1986
|
-
};
|
|
1987
|
-
} catch {
|
|
1988
|
-
return { covered: false, error: error.message };
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
/**
|
|
1994
|
-
* Scroll incrementally until an element becomes visible (Feature 10)
|
|
1995
|
-
* Useful for lazy-loaded content or infinite scroll pages
|
|
1996
|
-
* @param {string} selector - CSS selector for the element
|
|
1997
|
-
* @param {Object} [options] - Scroll options
|
|
1998
|
-
* @param {number} [options.maxScrolls=10] - Maximum number of scroll attempts
|
|
1999
|
-
* @param {number} [options.scrollAmount=500] - Pixels to scroll each attempt
|
|
2000
|
-
* @param {number} [options.timeout=30000] - Total timeout in ms
|
|
2001
|
-
* @param {string} [options.direction='down'] - Scroll direction ('down' or 'up')
|
|
2002
|
-
* @returns {Promise<{found: boolean, objectId?: string, scrollCount: number}>}
|
|
2003
|
-
*/
|
|
2004
|
-
async function scrollUntilVisible(selector, options = {}) {
|
|
2005
|
-
const {
|
|
2006
|
-
maxScrolls = 10,
|
|
2007
|
-
scrollAmount = 500,
|
|
2008
|
-
timeout = 30000,
|
|
2009
|
-
direction = 'down'
|
|
2010
|
-
} = options;
|
|
2011
|
-
|
|
2012
|
-
const startTime = Date.now();
|
|
2013
|
-
let scrollCount = 0;
|
|
2014
|
-
|
|
2015
|
-
while (scrollCount < maxScrolls && (Date.now() - startTime) < timeout) {
|
|
2016
|
-
// Try to find the element
|
|
2017
|
-
const findResult = await findElementInternal(selector);
|
|
2018
|
-
|
|
2019
|
-
if (findResult.success) {
|
|
2020
|
-
// Check if visible
|
|
2021
|
-
const visibleResult = await checkVisible(findResult.objectId);
|
|
2022
|
-
if (visibleResult.matches) {
|
|
2023
|
-
return {
|
|
2024
|
-
found: true,
|
|
2025
|
-
objectId: findResult.objectId,
|
|
2026
|
-
scrollCount,
|
|
2027
|
-
visibleAfterScrolls: scrollCount
|
|
2028
|
-
};
|
|
2029
|
-
}
|
|
2030
|
-
|
|
2031
|
-
// Element exists but not visible, try scrolling it into view
|
|
2032
|
-
try {
|
|
2033
|
-
await session.send('Runtime.callFunctionOn', {
|
|
2034
|
-
objectId: findResult.objectId,
|
|
2035
|
-
functionDeclaration: `function() {
|
|
2036
|
-
this.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
2037
|
-
}`
|
|
2038
|
-
});
|
|
2039
|
-
await sleep(100);
|
|
2040
|
-
|
|
2041
|
-
// Check visibility again
|
|
2042
|
-
const visibleAfterScroll = await checkVisible(findResult.objectId);
|
|
2043
|
-
if (visibleAfterScroll.matches) {
|
|
2044
|
-
return {
|
|
2045
|
-
found: true,
|
|
2046
|
-
objectId: findResult.objectId,
|
|
2047
|
-
scrollCount,
|
|
2048
|
-
scrolledIntoView: true
|
|
2049
|
-
};
|
|
2050
|
-
}
|
|
2051
|
-
} catch {
|
|
2052
|
-
// Failed to scroll into view, continue with page scrolling
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
// Release the object as we'll search again
|
|
2056
|
-
await releaseObject(session, findResult.objectId);
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
// Scroll the page
|
|
2060
|
-
const scrollDir = direction === 'up' ? -scrollAmount : scrollAmount;
|
|
2061
|
-
await session.send('Runtime.evaluate', {
|
|
2062
|
-
expression: `window.scrollBy(0, ${scrollDir})`
|
|
2063
|
-
});
|
|
2064
|
-
|
|
2065
|
-
scrollCount++;
|
|
2066
|
-
await sleep(200); // Wait for content to load/render
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
// Final attempt to find the element
|
|
2070
|
-
const finalResult = await findElementInternal(selector);
|
|
2071
|
-
if (finalResult.success) {
|
|
2072
|
-
const visibleResult = await checkVisible(finalResult.objectId);
|
|
2073
|
-
if (visibleResult.matches) {
|
|
2074
|
-
return {
|
|
2075
|
-
found: true,
|
|
2076
|
-
objectId: finalResult.objectId,
|
|
2077
|
-
scrollCount,
|
|
2078
|
-
foundOnFinalCheck: true
|
|
2079
|
-
};
|
|
2080
|
-
}
|
|
2081
|
-
await releaseObject(session, finalResult.objectId);
|
|
2082
|
-
}
|
|
2083
|
-
|
|
2084
|
-
return {
|
|
2085
|
-
found: false,
|
|
2086
|
-
scrollCount,
|
|
2087
|
-
reason: scrollCount >= maxScrolls ? 'maxScrollsReached' : 'timeout'
|
|
2088
|
-
};
|
|
2089
|
-
}
|
|
2090
|
-
|
|
2091
|
-
return {
|
|
2092
|
-
waitForActionable,
|
|
2093
|
-
getClickablePoint,
|
|
2094
|
-
checkHitTarget,
|
|
2095
|
-
checkPointerEvents,
|
|
2096
|
-
checkCovered,
|
|
2097
|
-
checkVisible,
|
|
2098
|
-
checkEnabled,
|
|
2099
|
-
checkEditable,
|
|
2100
|
-
checkStable,
|
|
2101
|
-
getRequiredStates,
|
|
2102
|
-
scrollUntilVisible
|
|
2103
|
-
};
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
// ============================================================================
|
|
2107
|
-
// Element Validator (from ElementValidator.js)
|
|
2108
|
-
// ============================================================================
|
|
2109
|
-
|
|
2110
|
-
/**
|
|
2111
|
-
* Create an element validator for checking element properties and states
|
|
2112
|
-
* @param {Object} session - CDP session
|
|
2113
|
-
* @returns {Object} Element validator interface
|
|
2114
|
-
*/
|
|
2115
|
-
export function createElementValidator(session) {
|
|
2116
|
-
async function isEditable(objectId) {
|
|
2117
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
2118
|
-
objectId,
|
|
2119
|
-
functionDeclaration: `function() {
|
|
2120
|
-
const el = this;
|
|
2121
|
-
const tagName = el.tagName ? el.tagName.toLowerCase() : '';
|
|
2122
|
-
if (el.isContentEditable) {
|
|
2123
|
-
return { editable: true, reason: null };
|
|
2124
|
-
}
|
|
2125
|
-
if (tagName === 'textarea') {
|
|
2126
|
-
if (el.disabled) {
|
|
2127
|
-
return { editable: false, reason: 'Element is disabled' };
|
|
2128
|
-
}
|
|
2129
|
-
if (el.readOnly) {
|
|
2130
|
-
return { editable: false, reason: 'Element is read-only' };
|
|
2131
|
-
}
|
|
2132
|
-
return { editable: true, reason: null };
|
|
2133
|
-
}
|
|
2134
|
-
if (tagName === 'input') {
|
|
2135
|
-
const inputType = (el.type || 'text').toLowerCase();
|
|
2136
|
-
const nonEditableTypes = ${JSON.stringify(NON_EDITABLE_INPUT_TYPES)};
|
|
2137
|
-
if (nonEditableTypes.includes(inputType)) {
|
|
2138
|
-
return { editable: false, reason: 'Input type "' + inputType + '" is not editable' };
|
|
2139
|
-
}
|
|
2140
|
-
if (el.disabled) {
|
|
2141
|
-
return { editable: false, reason: 'Element is disabled' };
|
|
2142
|
-
}
|
|
2143
|
-
if (el.readOnly) {
|
|
2144
|
-
return { editable: false, reason: 'Element is read-only' };
|
|
2145
|
-
}
|
|
2146
|
-
return { editable: true, reason: null };
|
|
2147
|
-
}
|
|
2148
|
-
return {
|
|
2149
|
-
editable: false,
|
|
2150
|
-
reason: 'Element <' + tagName + '> is not editable (expected input, textarea, or contenteditable)'
|
|
2151
|
-
};
|
|
2152
|
-
}`,
|
|
2153
|
-
returnByValue: true
|
|
2154
|
-
});
|
|
2155
|
-
|
|
2156
|
-
if (result.exceptionDetails) {
|
|
2157
|
-
const errorText = result.exceptionDetails.exception?.description ||
|
|
2158
|
-
result.exceptionDetails.text ||
|
|
2159
|
-
'Unknown error checking editability';
|
|
2160
|
-
return { editable: false, reason: errorText };
|
|
2161
|
-
}
|
|
2162
|
-
|
|
2163
|
-
return result.result.value;
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
async function isClickable(objectId) {
|
|
2167
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
2168
|
-
objectId,
|
|
2169
|
-
functionDeclaration: `function() {
|
|
2170
|
-
const el = this;
|
|
2171
|
-
const tagName = el.tagName ? el.tagName.toLowerCase() : '';
|
|
2172
|
-
if (el.disabled) {
|
|
2173
|
-
return { clickable: false, reason: 'Element is disabled', willNavigate: false };
|
|
2174
|
-
}
|
|
2175
|
-
let willNavigate = false;
|
|
2176
|
-
if (tagName === 'a') {
|
|
2177
|
-
const href = el.getAttribute('href');
|
|
2178
|
-
const target = el.getAttribute('target');
|
|
2179
|
-
willNavigate = href && href !== '#' && href !== 'javascript:void(0)' &&
|
|
2180
|
-
target !== '_blank' && !href.startsWith('javascript:');
|
|
2181
|
-
}
|
|
2182
|
-
if ((tagName === 'button' || tagName === 'input') &&
|
|
2183
|
-
(el.type === 'submit' || (!el.type && tagName === 'button'))) {
|
|
2184
|
-
const form = el.closest('form');
|
|
2185
|
-
if (form && form.action) {
|
|
2186
|
-
willNavigate = true;
|
|
2187
|
-
}
|
|
2188
|
-
}
|
|
2189
|
-
if (el.onclick || el.getAttribute('onclick')) {
|
|
2190
|
-
const onclickStr = String(el.getAttribute('onclick') || '');
|
|
2191
|
-
if (onclickStr.includes('location') || onclickStr.includes('href') ||
|
|
2192
|
-
onclickStr.includes('navigate') || onclickStr.includes('submit')) {
|
|
2193
|
-
willNavigate = true;
|
|
2194
|
-
}
|
|
2195
|
-
}
|
|
2196
|
-
return { clickable: true, reason: null, willNavigate: willNavigate };
|
|
2197
|
-
}`,
|
|
2198
|
-
returnByValue: true
|
|
2199
|
-
});
|
|
2200
|
-
|
|
2201
|
-
if (result.exceptionDetails) {
|
|
2202
|
-
const errorText = result.exceptionDetails.exception?.description ||
|
|
2203
|
-
result.exceptionDetails.text ||
|
|
2204
|
-
'Unknown error checking clickability';
|
|
2205
|
-
return { clickable: false, reason: errorText, willNavigate: false };
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2208
|
-
return result.result.value;
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
return {
|
|
2212
|
-
isEditable,
|
|
2213
|
-
isClickable
|
|
2214
|
-
};
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
// ============================================================================
|
|
2218
|
-
// React Input Filler (from ReactInputFiller.js)
|
|
2219
|
-
// ============================================================================
|
|
2220
|
-
|
|
2221
|
-
/**
|
|
2222
|
-
* Create a React input filler for handling React controlled components
|
|
2223
|
-
* @param {Object} session - CDP session
|
|
2224
|
-
* @returns {Object} React input filler interface
|
|
2225
|
-
*/
|
|
2226
|
-
export function createReactInputFiller(session) {
|
|
2227
|
-
if (!session) {
|
|
2228
|
-
throw new Error('CDP session is required');
|
|
2229
|
-
}
|
|
2230
|
-
|
|
2231
|
-
async function fillByObjectId(objectId, value) {
|
|
2232
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
2233
|
-
objectId,
|
|
2234
|
-
functionDeclaration: `function(newValue) {
|
|
2235
|
-
const el = this;
|
|
2236
|
-
const prototype = el.tagName === 'TEXTAREA'
|
|
2237
|
-
? window.HTMLTextAreaElement.prototype
|
|
2238
|
-
: window.HTMLInputElement.prototype;
|
|
2239
|
-
const nativeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set;
|
|
2240
|
-
nativeValueSetter.call(el, newValue);
|
|
2241
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2242
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2243
|
-
return { success: true, value: el.value };
|
|
2244
|
-
}`,
|
|
2245
|
-
arguments: [{ value: String(value) }],
|
|
2246
|
-
returnByValue: true
|
|
2247
|
-
});
|
|
2248
|
-
|
|
2249
|
-
if (result.exceptionDetails) {
|
|
2250
|
-
const errorText = result.exceptionDetails.exception?.description ||
|
|
2251
|
-
result.exceptionDetails.text ||
|
|
2252
|
-
'Unknown error during React fill';
|
|
2253
|
-
throw new Error(`React fill failed: ${errorText}`);
|
|
2254
|
-
}
|
|
2255
|
-
|
|
2256
|
-
return result.result.value;
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
async function fillBySelector(selector, value) {
|
|
2260
|
-
const result = await session.send('Runtime.evaluate', {
|
|
2261
|
-
expression: `
|
|
2262
|
-
(function(selector, newValue) {
|
|
2263
|
-
const el = document.querySelector(selector);
|
|
2264
|
-
if (!el) {
|
|
2265
|
-
return { success: false, error: 'Element not found: ' + selector };
|
|
2266
|
-
}
|
|
2267
|
-
const prototype = el.tagName === 'TEXTAREA'
|
|
2268
|
-
? window.HTMLTextAreaElement.prototype
|
|
2269
|
-
: window.HTMLInputElement.prototype;
|
|
2270
|
-
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
|
2271
|
-
if (!descriptor || !descriptor.set) {
|
|
2272
|
-
return { success: false, error: 'Cannot get native value setter' };
|
|
2273
|
-
}
|
|
2274
|
-
const nativeValueSetter = descriptor.set;
|
|
2275
|
-
nativeValueSetter.call(el, newValue);
|
|
2276
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2277
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2278
|
-
return { success: true, value: el.value };
|
|
2279
|
-
})(${JSON.stringify(selector)}, ${JSON.stringify(String(value))})
|
|
2280
|
-
`,
|
|
2281
|
-
returnByValue: true
|
|
2282
|
-
});
|
|
2283
|
-
|
|
2284
|
-
if (result.exceptionDetails) {
|
|
2285
|
-
const errorText = result.exceptionDetails.exception?.description ||
|
|
2286
|
-
result.exceptionDetails.text ||
|
|
2287
|
-
'Unknown error during React fill';
|
|
2288
|
-
throw new Error(`React fill failed: ${errorText}`);
|
|
2289
|
-
}
|
|
2290
|
-
|
|
2291
|
-
const fillResult = result.result.value;
|
|
2292
|
-
if (!fillResult.success) {
|
|
2293
|
-
throw new Error(fillResult.error);
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
return fillResult;
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
return {
|
|
2300
|
-
fillByObjectId,
|
|
2301
|
-
fillBySelector
|
|
2302
|
-
};
|
|
2303
|
-
}
|
|
2304
|
-
|
|
2305
|
-
// ============================================================================
|
|
2306
|
-
// Click Executor (from ClickExecutor.js)
|
|
2307
|
-
// ============================================================================
|
|
2308
|
-
|
|
2309
|
-
/**
|
|
2310
|
-
* Create a click executor for handling click operations
|
|
2311
|
-
* @param {Object} session - CDP session
|
|
2312
|
-
* @param {Object} elementLocator - Element locator instance
|
|
2313
|
-
* @param {Object} inputEmulator - Input emulator instance
|
|
2314
|
-
* @param {Object} [ariaSnapshot] - Optional ARIA snapshot instance
|
|
2315
|
-
* @returns {Object} Click executor interface
|
|
2316
|
-
*/
|
|
2317
|
-
export function createClickExecutor(session, elementLocator, inputEmulator, ariaSnapshot = null) {
|
|
2318
|
-
if (!session) throw new Error('CDP session is required');
|
|
2319
|
-
if (!elementLocator) throw new Error('Element locator is required');
|
|
2320
|
-
if (!inputEmulator) throw new Error('Input emulator is required');
|
|
2321
|
-
|
|
2322
|
-
const actionabilityChecker = createActionabilityChecker(session);
|
|
2323
|
-
const elementValidator = createElementValidator(session);
|
|
2324
|
-
|
|
2325
|
-
function calculateVisibleCenter(box, viewport = null) {
|
|
2326
|
-
let visibleBox = { ...box };
|
|
2327
|
-
|
|
2328
|
-
if (viewport) {
|
|
2329
|
-
visibleBox.x = Math.max(box.x, 0);
|
|
2330
|
-
visibleBox.y = Math.max(box.y, 0);
|
|
2331
|
-
const right = Math.min(box.x + box.width, viewport.width);
|
|
2332
|
-
const bottom = Math.min(box.y + box.height, viewport.height);
|
|
2333
|
-
visibleBox.width = right - visibleBox.x;
|
|
2334
|
-
visibleBox.height = bottom - visibleBox.y;
|
|
2335
|
-
}
|
|
2336
|
-
|
|
2337
|
-
return {
|
|
2338
|
-
x: visibleBox.x + visibleBox.width / 2,
|
|
2339
|
-
y: visibleBox.y + visibleBox.height / 2
|
|
2340
|
-
};
|
|
2341
|
-
}
|
|
2342
|
-
|
|
2343
|
-
async function getViewportBounds() {
|
|
2344
|
-
const result = await session.send('Runtime.evaluate', {
|
|
2345
|
-
expression: `({
|
|
2346
|
-
width: window.innerWidth || document.documentElement.clientWidth,
|
|
2347
|
-
height: window.innerHeight || document.documentElement.clientHeight
|
|
2348
|
-
})`,
|
|
2349
|
-
returnByValue: true
|
|
2350
|
-
});
|
|
2351
|
-
return result.result.value;
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
/**
|
|
2355
|
-
* Detect content changes after an action using MutationObserver (Feature 6)
|
|
2356
|
-
* @param {Object} [options] - Detection options
|
|
2357
|
-
* @param {number} [options.timeout=5000] - Max wait time in ms
|
|
2358
|
-
* @param {number} [options.stableTime=500] - Time with no changes to consider stable
|
|
2359
|
-
* @param {boolean} [options.checkNavigation=true] - Also check for URL changes
|
|
2360
|
-
* @returns {Promise<Object>} Content change result
|
|
2361
|
-
*/
|
|
2362
|
-
async function detectContentChange(options = {}) {
|
|
2363
|
-
const {
|
|
2364
|
-
timeout = 5000,
|
|
2365
|
-
stableTime = 500,
|
|
2366
|
-
checkNavigation = true
|
|
2367
|
-
} = options;
|
|
2368
|
-
|
|
2369
|
-
const urlBefore = checkNavigation ? await getCurrentUrl(session) : null;
|
|
2370
|
-
|
|
2371
|
-
const result = await session.send('Runtime.evaluate', {
|
|
2372
|
-
expression: `
|
|
2373
|
-
(function() {
|
|
2374
|
-
return new Promise((resolve) => {
|
|
2375
|
-
const timeout = ${timeout};
|
|
2376
|
-
const stableTime = ${stableTime};
|
|
2377
|
-
const startTime = Date.now();
|
|
2378
|
-
|
|
2379
|
-
let changeCount = 0;
|
|
2380
|
-
let lastChangeTime = startTime;
|
|
2381
|
-
let stableCheckTimer = null;
|
|
2382
|
-
|
|
2383
|
-
const observer = new MutationObserver((mutations) => {
|
|
2384
|
-
changeCount += mutations.length;
|
|
2385
|
-
lastChangeTime = Date.now();
|
|
2386
|
-
|
|
2387
|
-
// Reset stable timer on each change
|
|
2388
|
-
if (stableCheckTimer) {
|
|
2389
|
-
clearTimeout(stableCheckTimer);
|
|
2390
|
-
}
|
|
2391
|
-
|
|
2392
|
-
stableCheckTimer = setTimeout(() => {
|
|
2393
|
-
cleanup('contentChange');
|
|
2394
|
-
}, stableTime);
|
|
2395
|
-
});
|
|
2396
|
-
|
|
2397
|
-
observer.observe(document.body, {
|
|
2398
|
-
childList: true,
|
|
2399
|
-
subtree: true,
|
|
2400
|
-
attributes: true,
|
|
2401
|
-
characterData: true
|
|
2402
|
-
});
|
|
2403
|
-
|
|
2404
|
-
const timeoutId = setTimeout(() => {
|
|
2405
|
-
cleanup(changeCount > 0 ? 'contentChange' : 'none');
|
|
2406
|
-
}, timeout);
|
|
2407
|
-
|
|
2408
|
-
function cleanup(type) {
|
|
2409
|
-
observer.disconnect();
|
|
2410
|
-
clearTimeout(timeoutId);
|
|
2411
|
-
if (stableCheckTimer) clearTimeout(stableCheckTimer);
|
|
2412
|
-
|
|
2413
|
-
resolve({
|
|
2414
|
-
type,
|
|
2415
|
-
changeCount,
|
|
2416
|
-
duration: Date.now() - startTime
|
|
2417
|
-
});
|
|
2418
|
-
}
|
|
2419
|
-
|
|
2420
|
-
// Initial check: if no changes for stableTime, resolve as 'none'
|
|
2421
|
-
stableCheckTimer = setTimeout(() => {
|
|
2422
|
-
if (changeCount === 0) {
|
|
2423
|
-
cleanup('none');
|
|
2424
|
-
}
|
|
2425
|
-
}, stableTime);
|
|
2426
|
-
});
|
|
2427
|
-
})()
|
|
2428
|
-
`,
|
|
2429
|
-
returnByValue: true,
|
|
2430
|
-
awaitPromise: true
|
|
2431
|
-
});
|
|
2432
|
-
|
|
2433
|
-
const changeResult = result.result.value || { type: 'none', changeCount: 0 };
|
|
2434
|
-
|
|
2435
|
-
// Check for navigation
|
|
2436
|
-
if (checkNavigation) {
|
|
2437
|
-
const urlAfter = await getCurrentUrl(session);
|
|
2438
|
-
if (urlAfter !== urlBefore) {
|
|
2439
|
-
return {
|
|
2440
|
-
type: 'navigation',
|
|
2441
|
-
newUrl: urlAfter,
|
|
2442
|
-
previousUrl: urlBefore,
|
|
2443
|
-
changeCount: changeResult.changeCount,
|
|
2444
|
-
duration: changeResult.duration
|
|
2445
|
-
};
|
|
2446
|
-
}
|
|
2447
|
-
}
|
|
2448
|
-
|
|
2449
|
-
return changeResult;
|
|
2450
|
-
}
|
|
2451
|
-
|
|
2452
|
-
/**
|
|
2453
|
-
* Get information about what element is intercepting a click at given coordinates (Feature 4)
|
|
2454
|
-
* @param {number} x - X coordinate
|
|
2455
|
-
* @param {number} y - Y coordinate
|
|
2456
|
-
* @param {string} [targetObjectId] - Optional object ID of expected target
|
|
2457
|
-
* @returns {Promise<Object|null>} Interceptor info or null if no interception
|
|
2458
|
-
*/
|
|
2459
|
-
async function getInterceptorInfo(x, y, targetObjectId = null) {
|
|
2460
|
-
const expression = `
|
|
2461
|
-
(function() {
|
|
2462
|
-
const x = ${x};
|
|
2463
|
-
const y = ${y};
|
|
2464
|
-
const el = document.elementFromPoint(x, y);
|
|
2465
|
-
if (!el) return null;
|
|
2466
|
-
|
|
2467
|
-
function getSelector(element) {
|
|
2468
|
-
if (element.id) return '#' + element.id;
|
|
2469
|
-
let selector = element.tagName.toLowerCase();
|
|
2470
|
-
if (element.className && typeof element.className === 'string') {
|
|
2471
|
-
const classes = element.className.trim().split(/\\s+/).slice(0, 2);
|
|
2472
|
-
if (classes.length > 0 && classes[0]) {
|
|
2473
|
-
selector += '.' + classes.join('.');
|
|
2474
|
-
}
|
|
2475
|
-
}
|
|
2476
|
-
return selector;
|
|
2477
|
-
}
|
|
2478
|
-
|
|
2479
|
-
function getText(element) {
|
|
2480
|
-
const text = element.textContent || '';
|
|
2481
|
-
return text.trim().substring(0, 100);
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
|
-
function isOverlay(element) {
|
|
2485
|
-
const style = window.getComputedStyle(element);
|
|
2486
|
-
const position = style.position;
|
|
2487
|
-
const zIndex = parseInt(style.zIndex) || 0;
|
|
2488
|
-
return (position === 'fixed' || position === 'absolute') && zIndex > 0;
|
|
2489
|
-
}
|
|
2490
|
-
|
|
2491
|
-
function getCommonOverlayType(element) {
|
|
2492
|
-
const text = getText(element).toLowerCase();
|
|
2493
|
-
const classes = (element.className || '').toLowerCase();
|
|
2494
|
-
const id = (element.id || '').toLowerCase();
|
|
2495
|
-
|
|
2496
|
-
if (text.includes('cookie') || classes.includes('cookie') || id.includes('cookie')) {
|
|
2497
|
-
return 'cookie-banner';
|
|
2498
|
-
}
|
|
2499
|
-
if (text.includes('accept') || classes.includes('consent') || id.includes('consent')) {
|
|
2500
|
-
return 'consent-dialog';
|
|
2501
|
-
}
|
|
2502
|
-
if (classes.includes('modal') || id.includes('modal') || element.getAttribute('role') === 'dialog') {
|
|
2503
|
-
return 'modal';
|
|
2504
|
-
}
|
|
2505
|
-
if (classes.includes('overlay') || id.includes('overlay')) {
|
|
2506
|
-
return 'overlay';
|
|
2507
|
-
}
|
|
2508
|
-
if (classes.includes('popup') || id.includes('popup')) {
|
|
2509
|
-
return 'popup';
|
|
2510
|
-
}
|
|
2511
|
-
if (classes.includes('toast') || id.includes('toast') || classes.includes('notification')) {
|
|
2512
|
-
return 'notification';
|
|
2513
|
-
}
|
|
2514
|
-
return null;
|
|
2515
|
-
}
|
|
2516
|
-
|
|
2517
|
-
const rect = el.getBoundingClientRect();
|
|
2518
|
-
const overlayType = getCommonOverlayType(el);
|
|
2519
|
-
|
|
2520
|
-
return {
|
|
2521
|
-
selector: getSelector(el),
|
|
2522
|
-
text: getText(el),
|
|
2523
|
-
tagName: el.tagName.toLowerCase(),
|
|
2524
|
-
isOverlay: isOverlay(el),
|
|
2525
|
-
overlayType,
|
|
2526
|
-
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
|
2527
|
-
};
|
|
2528
|
-
})()
|
|
2529
|
-
`;
|
|
2530
|
-
|
|
2531
|
-
const result = await session.send('Runtime.evaluate', {
|
|
2532
|
-
expression,
|
|
2533
|
-
returnByValue: true
|
|
2534
|
-
});
|
|
2535
|
-
|
|
2536
|
-
if (result.exceptionDetails || !result.result.value) {
|
|
2537
|
-
return null;
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
const interceptor = result.result.value;
|
|
2541
|
-
|
|
2542
|
-
// If we have a target objectId, check if the interceptor is the same element
|
|
2543
|
-
if (targetObjectId) {
|
|
2544
|
-
const checkResult = await session.send('Runtime.callFunctionOn', {
|
|
2545
|
-
objectId: targetObjectId,
|
|
2546
|
-
functionDeclaration: `function(x, y) {
|
|
2547
|
-
const topEl = document.elementFromPoint(x, y);
|
|
2548
|
-
return topEl === this || this.contains(topEl);
|
|
2549
|
-
}`,
|
|
2550
|
-
arguments: [{ value: x }, { value: y }],
|
|
2551
|
-
returnByValue: true
|
|
2552
|
-
});
|
|
2553
|
-
|
|
2554
|
-
if (checkResult.result.value === true) {
|
|
2555
|
-
// The target element is at the click point, no interception
|
|
2556
|
-
return null;
|
|
2557
|
-
}
|
|
2558
|
-
}
|
|
2559
|
-
|
|
2560
|
-
return interceptor;
|
|
2561
|
-
}
|
|
2562
|
-
|
|
2563
|
-
async function executeJsClick(objectId) {
|
|
2564
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
2565
|
-
objectId,
|
|
2566
|
-
functionDeclaration: `function() {
|
|
2567
|
-
if (this.disabled) {
|
|
2568
|
-
return { success: false, reason: 'element is disabled' };
|
|
2569
|
-
}
|
|
2570
|
-
if (typeof this.focus === 'function') {
|
|
2571
|
-
this.focus();
|
|
2572
|
-
}
|
|
2573
|
-
this.click();
|
|
2574
|
-
return { success: true, targetReceived: true };
|
|
2575
|
-
}`,
|
|
2576
|
-
returnByValue: true
|
|
2577
|
-
});
|
|
2578
|
-
|
|
2579
|
-
const value = result.result.value || {};
|
|
2580
|
-
if (!value.success) {
|
|
2581
|
-
throw new Error(`JS click failed: ${value.reason || 'unknown error'}`);
|
|
2582
|
-
}
|
|
2583
|
-
|
|
2584
|
-
return { targetReceived: true };
|
|
2585
|
-
}
|
|
2586
|
-
|
|
2587
|
-
async function executeJsClickOnRef(ref) {
|
|
2588
|
-
const result = await session.send('Runtime.evaluate', {
|
|
2589
|
-
expression: `
|
|
2590
|
-
(function() {
|
|
2591
|
-
const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
|
|
2592
|
-
if (!el) {
|
|
2593
|
-
return { success: false, reason: 'ref not found in __ariaRefs' };
|
|
2594
|
-
}
|
|
2595
|
-
if (!el.isConnected) {
|
|
2596
|
-
return { success: false, reason: 'element is no longer attached to DOM' };
|
|
2597
|
-
}
|
|
2598
|
-
if (el.disabled) {
|
|
2599
|
-
return { success: false, reason: 'element is disabled' };
|
|
2600
|
-
}
|
|
2601
|
-
if (typeof el.focus === 'function') el.focus();
|
|
2602
|
-
el.click();
|
|
2603
|
-
return { success: true };
|
|
2604
|
-
})()
|
|
2605
|
-
`,
|
|
2606
|
-
returnByValue: true
|
|
2607
|
-
});
|
|
2608
|
-
|
|
2609
|
-
const value = result.result.value || {};
|
|
2610
|
-
if (!value.success) {
|
|
2611
|
-
throw new Error(`JS click on ref failed: ${value.reason || 'unknown error'}`);
|
|
2612
|
-
}
|
|
2613
|
-
}
|
|
2614
|
-
|
|
2615
|
-
async function clickWithVerification(x, y, targetObjectId) {
|
|
2616
|
-
await session.send('Runtime.callFunctionOn', {
|
|
2617
|
-
objectId: targetObjectId,
|
|
2618
|
-
functionDeclaration: `function() {
|
|
2619
|
-
this.__clickReceived = false;
|
|
2620
|
-
this.__clickHandler = () => { this.__clickReceived = true; };
|
|
2621
|
-
this.addEventListener('click', this.__clickHandler, { once: true });
|
|
2622
|
-
}`
|
|
2623
|
-
});
|
|
2624
|
-
|
|
2625
|
-
await inputEmulator.click(x, y);
|
|
2626
|
-
await sleep(50);
|
|
2627
|
-
|
|
2628
|
-
const verifyResult = await session.send('Runtime.callFunctionOn', {
|
|
2629
|
-
objectId: targetObjectId,
|
|
2630
|
-
functionDeclaration: `function() {
|
|
2631
|
-
this.removeEventListener('click', this.__clickHandler);
|
|
2632
|
-
const received = this.__clickReceived;
|
|
2633
|
-
delete this.__clickReceived;
|
|
2634
|
-
delete this.__clickHandler;
|
|
2635
|
-
return received;
|
|
2636
|
-
}`,
|
|
2637
|
-
returnByValue: true
|
|
2638
|
-
});
|
|
2639
|
-
|
|
2640
|
-
const targetReceived = verifyResult.result.value === true;
|
|
2641
|
-
const result = { targetReceived };
|
|
2642
|
-
|
|
2643
|
-
// Feature 4: If click didn't reach target, get interceptor info
|
|
2644
|
-
if (!targetReceived) {
|
|
2645
|
-
const interceptor = await getInterceptorInfo(x, y, targetObjectId);
|
|
2646
|
-
if (interceptor) {
|
|
2647
|
-
result.interceptedBy = interceptor;
|
|
2648
|
-
}
|
|
2649
|
-
}
|
|
2650
|
-
|
|
2651
|
-
return result;
|
|
2652
|
-
}
|
|
2653
|
-
|
|
2654
|
-
async function addNavigationAndDebugInfo(result, urlBeforeClick, debugData, opts) {
|
|
2655
|
-
const { waitForNavigation = false, navigationTimeout = 100, debug = false, waitAfter = false, waitAfterOptions = {} } = opts;
|
|
2656
|
-
|
|
2657
|
-
if (waitForNavigation) {
|
|
2658
|
-
const navResult = await detectNavigation(session, urlBeforeClick, navigationTimeout);
|
|
2659
|
-
result.navigated = navResult.navigated;
|
|
2660
|
-
if (navResult.newUrl) {
|
|
2661
|
-
result.newUrl = navResult.newUrl;
|
|
2662
|
-
}
|
|
2663
|
-
}
|
|
2664
|
-
|
|
2665
|
-
// Feature 6: Auto-wait after click
|
|
2666
|
-
if (waitAfter) {
|
|
2667
|
-
const changeResult = await detectContentChange({
|
|
2668
|
-
timeout: waitAfterOptions.timeout || 5000,
|
|
2669
|
-
stableTime: waitAfterOptions.stableTime || 500,
|
|
2670
|
-
checkNavigation: true
|
|
2671
|
-
});
|
|
2672
|
-
result.waitResult = changeResult;
|
|
2673
|
-
}
|
|
2674
|
-
|
|
2675
|
-
if (debug && debugData) {
|
|
2676
|
-
result.debug = {
|
|
2677
|
-
clickedAt: debugData.point,
|
|
2678
|
-
elementHit: debugData.elementAtPoint
|
|
2679
|
-
};
|
|
2680
|
-
}
|
|
2681
|
-
|
|
2682
|
-
return result;
|
|
2683
|
-
}
|
|
2684
|
-
|
|
2685
|
-
async function clickAtCoordinates(x, y, opts = {}) {
|
|
2686
|
-
const { debug = false, waitForNavigation = false, navigationTimeout = 100 } = opts;
|
|
2687
|
-
|
|
2688
|
-
const urlBeforeClick = await getCurrentUrl(session);
|
|
2689
|
-
|
|
2690
|
-
let elementAtPoint = null;
|
|
2691
|
-
if (debug) {
|
|
2692
|
-
elementAtPoint = await getElementAtPoint(session, x, y);
|
|
2693
|
-
}
|
|
2694
|
-
|
|
2695
|
-
await inputEmulator.click(x, y);
|
|
2696
|
-
|
|
2697
|
-
const result = {
|
|
2698
|
-
clicked: true,
|
|
2699
|
-
method: 'cdp',
|
|
2700
|
-
coordinates: { x, y }
|
|
2701
|
-
};
|
|
2702
|
-
|
|
2703
|
-
if (waitForNavigation) {
|
|
2704
|
-
const navResult = await detectNavigation(session, urlBeforeClick, navigationTimeout);
|
|
2705
|
-
result.navigated = navResult.navigated;
|
|
2706
|
-
if (navResult.newUrl) {
|
|
2707
|
-
result.newUrl = navResult.newUrl;
|
|
2708
|
-
}
|
|
2709
|
-
}
|
|
2710
|
-
|
|
2711
|
-
if (debug) {
|
|
2712
|
-
result.debug = {
|
|
2713
|
-
clickedAt: { x, y },
|
|
2714
|
-
elementHit: elementAtPoint
|
|
2715
|
-
};
|
|
2716
|
-
}
|
|
2717
|
-
|
|
2718
|
-
return result;
|
|
2719
|
-
}
|
|
2720
|
-
|
|
2721
|
-
async function clickByRef(ref, jsClick = false, opts = {}) {
|
|
2722
|
-
const { force = false, debug = false, waitForNavigation, navigationTimeout = 100 } = opts;
|
|
2723
|
-
|
|
2724
|
-
if (!ariaSnapshot) {
|
|
2725
|
-
throw new Error('ariaSnapshot is required for ref-based clicks');
|
|
2726
|
-
}
|
|
2727
|
-
|
|
2728
|
-
const refInfo = await ariaSnapshot.getElementByRef(ref);
|
|
2729
|
-
if (!refInfo) {
|
|
2730
|
-
throw elementNotFoundError(`ref:${ref}`, 0);
|
|
2731
|
-
}
|
|
2732
|
-
|
|
2733
|
-
if (refInfo.stale) {
|
|
2734
|
-
return {
|
|
2735
|
-
clicked: false,
|
|
2736
|
-
stale: true,
|
|
2737
|
-
warning: `Element ref:${ref} is no longer attached to the DOM. Page content may have changed. Run 'snapshot' again to get fresh refs.`
|
|
2738
|
-
};
|
|
2739
|
-
}
|
|
2740
|
-
|
|
2741
|
-
if (!force && refInfo.isVisible === false) {
|
|
2742
|
-
return {
|
|
2743
|
-
clicked: false,
|
|
2744
|
-
warning: `Element ref:${ref} exists but is not visible. It may be hidden or have zero dimensions.`
|
|
2745
|
-
};
|
|
2746
|
-
}
|
|
2747
|
-
|
|
2748
|
-
const urlBeforeClick = await getCurrentUrl(session);
|
|
2749
|
-
|
|
2750
|
-
const point = calculateVisibleCenter(refInfo.box);
|
|
2751
|
-
|
|
2752
|
-
let elementAtPoint = null;
|
|
2753
|
-
if (debug) {
|
|
2754
|
-
elementAtPoint = await getElementAtPoint(session, point.x, point.y);
|
|
2755
|
-
}
|
|
2756
|
-
|
|
2757
|
-
// Simple approach: do the click and trust it worked
|
|
2758
|
-
// We have exact coordinates from snapshot, so CDP click should hit the target
|
|
2759
|
-
let usedMethod = 'cdp';
|
|
2760
|
-
|
|
2761
|
-
if (jsClick) {
|
|
2762
|
-
// User explicitly requested JS click
|
|
2763
|
-
await executeJsClickOnRef(ref);
|
|
2764
|
-
usedMethod = 'jsClick';
|
|
2765
|
-
} else {
|
|
2766
|
-
// Perform CDP click at coordinates
|
|
2767
|
-
await inputEmulator.click(point.x, point.y);
|
|
2768
|
-
}
|
|
2769
|
-
|
|
2770
|
-
// Brief wait for any navigation to start
|
|
2771
|
-
await sleep(50);
|
|
2772
|
-
|
|
2773
|
-
// Check for navigation
|
|
2774
|
-
let navigated = false;
|
|
2775
|
-
try {
|
|
2776
|
-
const urlAfterClick = await getCurrentUrl(session);
|
|
2777
|
-
navigated = urlAfterClick !== urlBeforeClick;
|
|
2778
|
-
} catch {
|
|
2779
|
-
// If we can't get URL, page likely navigated
|
|
2780
|
-
navigated = true;
|
|
2781
|
-
}
|
|
2782
|
-
|
|
2783
|
-
const result = {
|
|
2784
|
-
clicked: true,
|
|
2785
|
-
method: usedMethod,
|
|
2786
|
-
ref,
|
|
2787
|
-
navigated
|
|
2788
|
-
};
|
|
2789
|
-
|
|
2790
|
-
if (navigated) {
|
|
2791
|
-
try {
|
|
2792
|
-
result.newUrl = await getCurrentUrl(session);
|
|
2793
|
-
} catch {
|
|
2794
|
-
// Page still navigating
|
|
2795
|
-
}
|
|
2796
|
-
}
|
|
2797
|
-
|
|
2798
|
-
if (debug) {
|
|
2799
|
-
result.debug = {
|
|
2800
|
-
clickedAt: point,
|
|
2801
|
-
elementHit: elementAtPoint
|
|
2802
|
-
};
|
|
2803
|
-
}
|
|
2804
|
-
|
|
2805
|
-
return result;
|
|
2806
|
-
}
|
|
2807
|
-
|
|
2808
|
-
async function tryJsClickFallback(selector, opts = {}) {
|
|
2809
|
-
const { urlBeforeClick, waitForNavigation = false, navigationTimeout = 100, debug = false, waitAfter = false, waitAfterOptions = {}, fallbackReason = 'CDP click failed' } = opts;
|
|
2810
|
-
|
|
2811
|
-
const element = await elementLocator.findElement(selector);
|
|
2812
|
-
if (!element) {
|
|
2813
|
-
throw elementNotFoundError(selector, 0);
|
|
2814
|
-
}
|
|
2815
|
-
|
|
2816
|
-
try {
|
|
2817
|
-
const result = await executeJsClick(element._handle.objectId);
|
|
2818
|
-
await element._handle.dispose();
|
|
2819
|
-
|
|
2820
|
-
const clickResult = {
|
|
2821
|
-
clicked: true,
|
|
2822
|
-
method: 'jsClick-fallback',
|
|
2823
|
-
fallbackReason,
|
|
2824
|
-
...result
|
|
2825
|
-
};
|
|
2826
|
-
|
|
2827
|
-
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, null, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
|
|
2828
|
-
} catch (e) {
|
|
2829
|
-
await element._handle.dispose();
|
|
2830
|
-
throw e;
|
|
2831
|
-
}
|
|
2832
|
-
}
|
|
2833
|
-
|
|
2834
|
-
async function clickBySelector(selector, opts = {}) {
|
|
2835
|
-
const {
|
|
2836
|
-
jsClick = false,
|
|
2837
|
-
verify = false,
|
|
2838
|
-
force = false,
|
|
2839
|
-
debug = false,
|
|
2840
|
-
waitForNavigation = false,
|
|
2841
|
-
navigationTimeout = 100,
|
|
2842
|
-
timeout = 5000, // Reduced from 30s to 5s for faster failure
|
|
2843
|
-
waitAfter = false,
|
|
2844
|
-
waitAfterOptions = {}
|
|
2845
|
-
} = opts;
|
|
2846
|
-
|
|
2847
|
-
const urlBeforeClick = await getCurrentUrl(session);
|
|
2848
|
-
|
|
2849
|
-
const waitResult = await actionabilityChecker.waitForActionable(selector, 'click', {
|
|
2850
|
-
timeout,
|
|
2851
|
-
force
|
|
2852
|
-
});
|
|
2853
|
-
|
|
2854
|
-
if (!waitResult.success) {
|
|
2855
|
-
throw new Error(waitResult.error || `Element not found: ${selector}`);
|
|
2856
|
-
}
|
|
2857
|
-
|
|
2858
|
-
const objectId = waitResult.objectId;
|
|
2859
|
-
|
|
2860
|
-
try {
|
|
2861
|
-
// User explicitly requested JS click
|
|
2862
|
-
if (jsClick) {
|
|
2863
|
-
const result = await executeJsClick(objectId);
|
|
2864
|
-
const clickResult = { clicked: true, method: 'jsClick', ...result };
|
|
2865
|
-
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, null, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
|
|
2866
|
-
}
|
|
2867
|
-
|
|
2868
|
-
const point = await actionabilityChecker.getClickablePoint(objectId);
|
|
2869
|
-
if (!point) {
|
|
2870
|
-
throw new Error('Could not determine click point for element');
|
|
2871
|
-
}
|
|
2872
|
-
|
|
2873
|
-
// Auto-fallback to JS click for zero-size elements (hidden inputs, etc.)
|
|
2874
|
-
if (point.rect.width === 0 || point.rect.height === 0) {
|
|
2875
|
-
const result = await executeJsClick(objectId);
|
|
2876
|
-
const clickResult = { clicked: true, method: 'jsClick', reason: 'zero-size-element', ...result };
|
|
2877
|
-
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, null, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
|
|
2878
|
-
}
|
|
2879
|
-
|
|
2880
|
-
const viewportBox = await getViewportBounds();
|
|
2881
|
-
const clippedPoint = calculateVisibleCenter(point.rect, viewportBox);
|
|
2882
|
-
|
|
2883
|
-
let elementAtPoint = null;
|
|
2884
|
-
if (debug) {
|
|
2885
|
-
elementAtPoint = await getElementAtPoint(session, clippedPoint.x, clippedPoint.y);
|
|
2886
|
-
}
|
|
2887
|
-
|
|
2888
|
-
// CDP click at coordinates
|
|
2889
|
-
await inputEmulator.click(clippedPoint.x, clippedPoint.y);
|
|
2890
|
-
|
|
2891
|
-
const clickResult = { clicked: true, method: 'cdp' };
|
|
2892
|
-
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, { point: clippedPoint, elementAtPoint }, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
|
|
2893
|
-
|
|
2894
|
-
} catch (e) {
|
|
2895
|
-
if (!jsClick) {
|
|
2896
|
-
try {
|
|
2897
|
-
return await tryJsClickFallback(selector, {
|
|
2898
|
-
urlBeforeClick,
|
|
2899
|
-
waitForNavigation,
|
|
2900
|
-
navigationTimeout,
|
|
2901
|
-
debug,
|
|
2902
|
-
waitAfter,
|
|
2903
|
-
waitAfterOptions,
|
|
2904
|
-
fallbackReason: e.message
|
|
2905
|
-
});
|
|
2906
|
-
} catch {
|
|
2907
|
-
// JS click also failed
|
|
2908
|
-
}
|
|
2909
|
-
}
|
|
2910
|
-
throw e;
|
|
2911
|
-
} finally {
|
|
2912
|
-
await releaseObject(session, objectId);
|
|
2913
|
-
}
|
|
2914
|
-
}
|
|
2915
|
-
|
|
2916
|
-
/**
|
|
2917
|
-
* Click an element by its visible text content
|
|
2918
|
-
* @param {string} text - Text to find and click
|
|
2919
|
-
* @param {Object} opts - Click options
|
|
2920
|
-
* @returns {Promise<Object>} Click result
|
|
2921
|
-
*/
|
|
2922
|
-
async function clickByText(text, opts = {}) {
|
|
2923
|
-
const {
|
|
2924
|
-
exact = false,
|
|
2925
|
-
tag = null,
|
|
2926
|
-
jsClick = false,
|
|
2927
|
-
verify = false,
|
|
2928
|
-
force = false,
|
|
2929
|
-
debug = false,
|
|
2930
|
-
waitForNavigation = false,
|
|
2931
|
-
navigationTimeout = 100,
|
|
2932
|
-
timeout = 30000,
|
|
2933
|
-
waitAfter = false,
|
|
2934
|
-
waitAfterOptions = {}
|
|
2935
|
-
} = opts;
|
|
2936
|
-
|
|
2937
|
-
const urlBeforeClick = await getCurrentUrl(session);
|
|
2938
|
-
|
|
2939
|
-
// Find element by text using the locator
|
|
2940
|
-
const element = await elementLocator.findElementByText(text, { exact, tag });
|
|
2941
|
-
if (!element) {
|
|
2942
|
-
throw elementNotFoundError(`text:"${text}"`, timeout);
|
|
2943
|
-
}
|
|
2944
|
-
|
|
2945
|
-
const objectId = element.objectId;
|
|
2946
|
-
|
|
2947
|
-
try {
|
|
2948
|
-
// Check actionability unless force is true
|
|
2949
|
-
if (!force) {
|
|
2950
|
-
const actionable = await element.isActionable();
|
|
2951
|
-
if (!actionable.actionable) {
|
|
2952
|
-
// Try JS click as fallback
|
|
2953
|
-
if (!jsClick) {
|
|
2954
|
-
try {
|
|
2955
|
-
const result = await executeJsClick(objectId);
|
|
2956
|
-
const clickResult = {
|
|
2957
|
-
clicked: true,
|
|
2958
|
-
method: 'jsClick-fallback',
|
|
2959
|
-
text,
|
|
2960
|
-
fallbackReason: actionable.reason,
|
|
2961
|
-
...result
|
|
2962
|
-
};
|
|
2963
|
-
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, null, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
|
|
2964
|
-
} catch {
|
|
2965
|
-
// JS click also failed
|
|
2966
|
-
}
|
|
2967
|
-
}
|
|
2968
|
-
throw new Error(`Element with text "${text}" not actionable: ${actionable.reason}`);
|
|
2969
|
-
}
|
|
2970
|
-
}
|
|
2971
|
-
|
|
2972
|
-
if (jsClick) {
|
|
2973
|
-
const result = await executeJsClick(objectId);
|
|
2974
|
-
const clickResult = { clicked: true, method: 'jsClick', text, ...result };
|
|
2975
|
-
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, null, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
|
|
2976
|
-
}
|
|
2977
|
-
|
|
2978
|
-
const point = await element.getClickPoint();
|
|
2979
|
-
if (!point) {
|
|
2980
|
-
throw new Error(`Could not determine click point for element with text "${text}"`);
|
|
2981
|
-
}
|
|
2982
|
-
|
|
2983
|
-
let elementAtPoint = null;
|
|
2984
|
-
if (debug) {
|
|
2985
|
-
elementAtPoint = await getElementAtPoint(session, point.x, point.y);
|
|
2986
|
-
}
|
|
2987
|
-
|
|
2988
|
-
if (verify) {
|
|
2989
|
-
const result = await clickWithVerification(point.x, point.y, objectId);
|
|
2990
|
-
|
|
2991
|
-
if (!result.targetReceived) {
|
|
2992
|
-
const jsResult = await executeJsClick(objectId);
|
|
2993
|
-
const clickResult = {
|
|
2994
|
-
clicked: true,
|
|
2995
|
-
method: 'jsClick-fallback',
|
|
2996
|
-
text,
|
|
2997
|
-
cdpAttempted: true,
|
|
2998
|
-
targetReceived: jsResult.targetReceived,
|
|
2999
|
-
interceptedBy: result.interceptedBy
|
|
3000
|
-
};
|
|
3001
|
-
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, { point, elementAtPoint }, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
const clickResult = { clicked: true, method: 'cdp', text, ...result };
|
|
3005
|
-
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, { point, elementAtPoint }, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
|
|
3006
|
-
}
|
|
3007
|
-
|
|
3008
|
-
await inputEmulator.click(point.x, point.y);
|
|
3009
|
-
|
|
3010
|
-
const clickResult = { clicked: true, method: 'cdp', text };
|
|
3011
|
-
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, { point, elementAtPoint }, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
|
|
3012
|
-
|
|
3013
|
-
} catch (e) {
|
|
3014
|
-
if (!jsClick) {
|
|
3015
|
-
try {
|
|
3016
|
-
const result = await executeJsClick(objectId);
|
|
3017
|
-
const clickResult = {
|
|
3018
|
-
clicked: true,
|
|
3019
|
-
method: 'jsClick-fallback',
|
|
3020
|
-
text,
|
|
3021
|
-
fallbackReason: e.message,
|
|
3022
|
-
...result
|
|
3023
|
-
};
|
|
3024
|
-
return addNavigationAndDebugInfo(clickResult, urlBeforeClick, null, { waitForNavigation, navigationTimeout, debug, waitAfter, waitAfterOptions });
|
|
3025
|
-
} catch {
|
|
3026
|
-
// JS click also failed
|
|
3027
|
-
}
|
|
3028
|
-
}
|
|
3029
|
-
throw e;
|
|
3030
|
-
} finally {
|
|
3031
|
-
await element.dispose();
|
|
3032
|
-
}
|
|
3033
|
-
}
|
|
3034
|
-
|
|
3035
|
-
async function execute(params) {
|
|
3036
|
-
const selector = typeof params === 'string' ? params : params.selector;
|
|
3037
|
-
let ref = typeof params === 'object' ? params.ref : null;
|
|
3038
|
-
const text = typeof params === 'object' ? params.text : null;
|
|
3039
|
-
const selectors = typeof params === 'object' ? params.selectors : null;
|
|
3040
|
-
const jsClick = typeof params === 'object' && params.jsClick === true;
|
|
3041
|
-
const verify = typeof params === 'object' && params.verify === true;
|
|
3042
|
-
const force = typeof params === 'object' && params.force === true;
|
|
3043
|
-
const debug = typeof params === 'object' && params.debug === true;
|
|
3044
|
-
const waitForNavigation = typeof params === 'object' && params.waitForNavigation === true;
|
|
3045
|
-
const navigationTimeout = typeof params === 'object' ? params.navigationTimeout : undefined;
|
|
3046
|
-
const exact = typeof params === 'object' && params.exact === true;
|
|
3047
|
-
const tag = typeof params === 'object' ? params.tag : null;
|
|
3048
|
-
// Feature 6: Auto-wait after click
|
|
3049
|
-
const waitAfter = typeof params === 'object' && params.waitAfter === true;
|
|
3050
|
-
const waitAfterOptions = typeof params === 'object' ? params.waitAfterOptions : {};
|
|
3051
|
-
// Feature 10: Scroll until visible
|
|
3052
|
-
const scrollUntilVisible = typeof params === 'object' && params.scrollUntilVisible === true;
|
|
3053
|
-
const scrollOptions = typeof params === 'object' ? params.scrollOptions : {};
|
|
3054
|
-
|
|
3055
|
-
// Detect if string selector looks like a ref (e.g., "e1", "e12", "e123")
|
|
3056
|
-
// This allows {"click": "e1"} to work the same as {"click": {"ref": "e1"}}
|
|
3057
|
-
if (!ref && selector && /^e\d+$/.test(selector)) {
|
|
3058
|
-
ref = selector;
|
|
3059
|
-
}
|
|
3060
|
-
|
|
3061
|
-
// Handle coordinate-based click
|
|
3062
|
-
if (typeof params === 'object' && typeof params.x === 'number' && typeof params.y === 'number') {
|
|
3063
|
-
return clickAtCoordinates(params.x, params.y, { debug, waitForNavigation, navigationTimeout, waitAfter, waitAfterOptions });
|
|
3064
|
-
}
|
|
3065
|
-
|
|
3066
|
-
// Handle click by ref
|
|
3067
|
-
if (ref && ariaSnapshot) {
|
|
3068
|
-
return clickByRef(ref, jsClick, { waitForNavigation, navigationTimeout, force, debug, waitAfter, waitAfterOptions });
|
|
3069
|
-
}
|
|
3070
|
-
|
|
3071
|
-
// Handle click by visible text (Feature 5)
|
|
3072
|
-
if (text) {
|
|
3073
|
-
return clickByText(text, { exact, tag, jsClick, verify, force, debug, waitForNavigation, navigationTimeout, waitAfter, waitAfterOptions });
|
|
3074
|
-
}
|
|
3075
|
-
|
|
3076
|
-
// Handle multi-selector fallback (Feature 1)
|
|
3077
|
-
if (selectors && Array.isArray(selectors)) {
|
|
3078
|
-
return clickWithMultiSelector(selectors, { jsClick, verify, force, debug, waitForNavigation, navigationTimeout, waitAfter, waitAfterOptions });
|
|
3079
|
-
}
|
|
3080
|
-
|
|
3081
|
-
// Feature 10: If scrollUntilVisible is set, first scroll to find the element
|
|
3082
|
-
if (scrollUntilVisible && selector) {
|
|
3083
|
-
const scrollResult = await actionabilityChecker.scrollUntilVisible(selector, scrollOptions);
|
|
3084
|
-
if (!scrollResult.found) {
|
|
3085
|
-
throw elementNotFoundError(selector, scrollOptions.timeout || 30000);
|
|
3086
|
-
}
|
|
3087
|
-
// Release the objectId from scroll search since clickBySelector will find it again
|
|
3088
|
-
if (scrollResult.objectId) {
|
|
3089
|
-
try {
|
|
3090
|
-
await releaseObject(session, scrollResult.objectId);
|
|
3091
|
-
} catch { /* ignore cleanup errors */ }
|
|
3092
|
-
}
|
|
3093
|
-
// Element found, now proceed with normal click
|
|
3094
|
-
// The scrollUntilVisible already scrolled it into view, so the actionability check should pass
|
|
3095
|
-
}
|
|
3096
|
-
|
|
3097
|
-
return clickBySelector(selector, { jsClick, verify, force, debug, waitForNavigation, navigationTimeout, waitAfter, waitAfterOptions });
|
|
3098
|
-
}
|
|
3099
|
-
|
|
3100
|
-
/**
|
|
3101
|
-
* Click using multiple selectors with fallback (Feature 1)
|
|
3102
|
-
* Tries selectors in order until one succeeds
|
|
3103
|
-
* @param {Array} selectors - Array of selectors to try
|
|
3104
|
-
* @param {Object} opts - Click options
|
|
3105
|
-
* @returns {Promise<Object>} Click result
|
|
3106
|
-
*/
|
|
3107
|
-
async function clickWithMultiSelector(selectors, opts = {}) {
|
|
3108
|
-
const errors = [];
|
|
3109
|
-
|
|
3110
|
-
for (const selectorSpec of selectors) {
|
|
3111
|
-
try {
|
|
3112
|
-
// Handle role-based selector objects
|
|
3113
|
-
if (typeof selectorSpec === 'object' && selectorSpec.role) {
|
|
3114
|
-
const { role, name } = selectorSpec;
|
|
3115
|
-
const elements = await elementLocator.queryByRole(role, { name });
|
|
3116
|
-
if (elements.length > 0) {
|
|
3117
|
-
const element = elements[0];
|
|
3118
|
-
const result = await clickBySelector(element.selector || `[role="${role}"]`, opts);
|
|
3119
|
-
result.usedSelector = selectorSpec;
|
|
3120
|
-
result.selectorIndex = selectors.indexOf(selectorSpec);
|
|
3121
|
-
return result;
|
|
3122
|
-
}
|
|
3123
|
-
errors.push({ selector: selectorSpec, error: `No elements found with role="${role}"${name ? ` and name="${name}"` : ''}` });
|
|
3124
|
-
continue;
|
|
3125
|
-
}
|
|
3126
|
-
|
|
3127
|
-
// Handle regular CSS selector
|
|
3128
|
-
const result = await clickBySelector(selectorSpec, opts);
|
|
3129
|
-
result.usedSelector = selectorSpec;
|
|
3130
|
-
result.selectorIndex = selectors.indexOf(selectorSpec);
|
|
3131
|
-
return result;
|
|
3132
|
-
} catch (e) {
|
|
3133
|
-
errors.push({ selector: selectorSpec, error: e.message });
|
|
3134
|
-
}
|
|
3135
|
-
}
|
|
3136
|
-
|
|
3137
|
-
// All selectors failed
|
|
3138
|
-
const errorMessages = errors.map((e, i) => ` ${i + 1}. ${typeof e.selector === 'object' ? JSON.stringify(e.selector) : e.selector}: ${e.error}`).join('\n');
|
|
3139
|
-
throw new Error(`All ${selectors.length} selectors failed:\n${errorMessages}`);
|
|
3140
|
-
}
|
|
3141
|
-
|
|
3142
|
-
return {
|
|
3143
|
-
execute,
|
|
3144
|
-
clickByText,
|
|
3145
|
-
clickWithMultiSelector
|
|
3146
|
-
};
|
|
3147
|
-
}
|
|
3148
|
-
|
|
3149
|
-
// ============================================================================
|
|
3150
|
-
// Fill Executor (from FillExecutor.js)
|
|
3151
|
-
// ============================================================================
|
|
3152
|
-
|
|
3153
|
-
/**
|
|
3154
|
-
* Create a fill executor for handling fill operations
|
|
3155
|
-
* @param {Object} session - CDP session
|
|
3156
|
-
* @param {Object} elementLocator - Element locator instance
|
|
3157
|
-
* @param {Object} inputEmulator - Input emulator instance
|
|
3158
|
-
* @param {Object} [ariaSnapshot] - Optional ARIA snapshot instance
|
|
3159
|
-
* @returns {Object} Fill executor interface
|
|
3160
|
-
*/
|
|
3161
|
-
export function createFillExecutor(session, elementLocator, inputEmulator, ariaSnapshot = null) {
|
|
3162
|
-
if (!session) throw new Error('CDP session is required');
|
|
3163
|
-
if (!elementLocator) throw new Error('Element locator is required');
|
|
3164
|
-
if (!inputEmulator) throw new Error('Input emulator is required');
|
|
3165
|
-
|
|
3166
|
-
const actionabilityChecker = createActionabilityChecker(session);
|
|
3167
|
-
const elementValidator = createElementValidator(session);
|
|
3168
|
-
const reactInputFiller = createReactInputFiller(session);
|
|
3169
|
-
|
|
3170
|
-
async function fillByRef(ref, value, opts = {}) {
|
|
3171
|
-
const { clear = true, react = false } = opts;
|
|
3172
|
-
|
|
3173
|
-
if (!ariaSnapshot) {
|
|
3174
|
-
throw new Error('ariaSnapshot is required for ref-based fills');
|
|
3175
|
-
}
|
|
3176
|
-
|
|
3177
|
-
const refInfo = await ariaSnapshot.getElementByRef(ref);
|
|
3178
|
-
if (!refInfo) {
|
|
3179
|
-
throw elementNotFoundError(`ref:${ref}`, 0);
|
|
3180
|
-
}
|
|
3181
|
-
|
|
3182
|
-
if (refInfo.stale) {
|
|
3183
|
-
throw new Error(`Element ref:${ref} is no longer attached to the DOM. Page content may have changed. Run 'snapshot' again to get fresh refs.`);
|
|
3184
|
-
}
|
|
3185
|
-
|
|
3186
|
-
if (refInfo.isVisible === false) {
|
|
3187
|
-
throw new Error(`Element ref:${ref} exists but is not visible. It may be hidden or have zero dimensions.`);
|
|
3188
|
-
}
|
|
3189
|
-
|
|
3190
|
-
const elementResult = await session.send('Runtime.evaluate', {
|
|
3191
|
-
expression: `(function() {
|
|
3192
|
-
const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
|
|
3193
|
-
return el;
|
|
3194
|
-
})()`,
|
|
3195
|
-
returnByValue: false
|
|
3196
|
-
});
|
|
3197
|
-
|
|
3198
|
-
if (!elementResult.result.objectId) {
|
|
3199
|
-
throw elementNotFoundError(`ref:${ref}`, 0);
|
|
3200
|
-
}
|
|
3201
|
-
|
|
3202
|
-
const objectId = elementResult.result.objectId;
|
|
3203
|
-
|
|
3204
|
-
const editableCheck = await elementValidator.isEditable(objectId);
|
|
3205
|
-
if (!editableCheck.editable) {
|
|
3206
|
-
await releaseObject(session, objectId);
|
|
3207
|
-
throw elementNotEditableError(`ref:${ref}`, editableCheck.reason);
|
|
3208
|
-
}
|
|
3209
|
-
|
|
3210
|
-
try {
|
|
3211
|
-
if (react) {
|
|
3212
|
-
await reactInputFiller.fillByObjectId(objectId, value);
|
|
3213
|
-
return { filled: true, ref, method: 'react' };
|
|
3214
|
-
}
|
|
3215
|
-
|
|
3216
|
-
await session.send('Runtime.callFunctionOn', {
|
|
3217
|
-
objectId,
|
|
3218
|
-
functionDeclaration: `function() {
|
|
3219
|
-
this.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
3220
|
-
}`
|
|
3221
|
-
});
|
|
3222
|
-
|
|
3223
|
-
await sleep(100);
|
|
3224
|
-
|
|
3225
|
-
const x = refInfo.box.x + refInfo.box.width / 2;
|
|
3226
|
-
const y = refInfo.box.y + refInfo.box.height / 2;
|
|
3227
|
-
await inputEmulator.click(x, y);
|
|
3228
|
-
|
|
3229
|
-
await session.send('Runtime.callFunctionOn', {
|
|
3230
|
-
objectId,
|
|
3231
|
-
functionDeclaration: `function() { this.focus(); }`
|
|
3232
|
-
});
|
|
3233
|
-
|
|
3234
|
-
if (clear) {
|
|
3235
|
-
await inputEmulator.selectAll();
|
|
3236
|
-
}
|
|
3237
|
-
|
|
3238
|
-
await inputEmulator.type(String(value));
|
|
3239
|
-
|
|
3240
|
-
return { filled: true, ref, method: 'keyboard' };
|
|
3241
|
-
} finally {
|
|
3242
|
-
await releaseObject(session, objectId);
|
|
3243
|
-
}
|
|
3244
|
-
}
|
|
3245
|
-
|
|
3246
|
-
async function fillBySelector(selector, value, opts = {}) {
|
|
3247
|
-
const { clear = true, react = false, force = false, timeout = 5000 } = opts; // Reduced from 30s
|
|
3248
|
-
|
|
3249
|
-
const waitResult = await actionabilityChecker.waitForActionable(selector, 'fill', {
|
|
3250
|
-
timeout,
|
|
3251
|
-
force
|
|
3252
|
-
});
|
|
3253
|
-
|
|
3254
|
-
if (!waitResult.success) {
|
|
3255
|
-
if (waitResult.missingState === 'editable') {
|
|
3256
|
-
throw elementNotEditableError(selector, waitResult.error);
|
|
3257
|
-
}
|
|
3258
|
-
throw new Error(`Element not actionable: ${waitResult.error}`);
|
|
3259
|
-
}
|
|
3260
|
-
|
|
3261
|
-
const objectId = waitResult.objectId;
|
|
3262
|
-
|
|
3263
|
-
try {
|
|
3264
|
-
if (react) {
|
|
3265
|
-
await reactInputFiller.fillByObjectId(objectId, value);
|
|
3266
|
-
return { filled: true, selector, method: 'react' };
|
|
3267
|
-
}
|
|
3268
|
-
|
|
3269
|
-
const point = await actionabilityChecker.getClickablePoint(objectId);
|
|
3270
|
-
if (!point) {
|
|
3271
|
-
throw new Error('Could not determine click point for element');
|
|
3272
|
-
}
|
|
3273
|
-
|
|
3274
|
-
await inputEmulator.click(point.x, point.y);
|
|
3275
|
-
|
|
3276
|
-
await session.send('Runtime.callFunctionOn', {
|
|
3277
|
-
objectId,
|
|
3278
|
-
functionDeclaration: `function() { this.focus(); }`
|
|
3279
|
-
});
|
|
3280
|
-
|
|
3281
|
-
if (clear) {
|
|
3282
|
-
await inputEmulator.selectAll();
|
|
3283
|
-
}
|
|
3284
|
-
|
|
3285
|
-
await inputEmulator.type(String(value));
|
|
3286
|
-
|
|
3287
|
-
return { filled: true, selector, method: 'keyboard' };
|
|
3288
|
-
} catch (e) {
|
|
3289
|
-
await resetInputState(session);
|
|
3290
|
-
throw e;
|
|
3291
|
-
} finally {
|
|
3292
|
-
await releaseObject(session, objectId);
|
|
3293
|
-
}
|
|
3294
|
-
}
|
|
3295
|
-
|
|
3296
|
-
/**
|
|
3297
|
-
* Find an input element by its associated label text (Feature 9)
|
|
3298
|
-
* Search order: label[for] → nested input in label → aria-label → placeholder
|
|
3299
|
-
* @param {string} labelText - Label text to search for
|
|
3300
|
-
* @param {Object} [opts] - Options
|
|
3301
|
-
* @param {boolean} [opts.exact=false] - Require exact match
|
|
3302
|
-
* @returns {Promise<{objectId: string, method: string}|null>} Element info or null
|
|
3303
|
-
*/
|
|
3304
|
-
async function findInputByLabel(labelText, opts = {}) {
|
|
3305
|
-
const { exact = false } = opts;
|
|
3306
|
-
const labelTextJson = JSON.stringify(labelText);
|
|
3307
|
-
const labelTextLowerJson = JSON.stringify(labelText.toLowerCase());
|
|
3308
|
-
|
|
3309
|
-
const expression = `
|
|
3310
|
-
(function() {
|
|
3311
|
-
const labelText = ${labelTextJson};
|
|
3312
|
-
const labelTextLower = ${labelTextLowerJson};
|
|
3313
|
-
const exact = ${exact};
|
|
3314
|
-
|
|
3315
|
-
function matchesText(text) {
|
|
3316
|
-
if (!text) return false;
|
|
3317
|
-
if (exact) {
|
|
3318
|
-
return text.trim() === labelText;
|
|
3319
|
-
}
|
|
3320
|
-
return text.toLowerCase().includes(labelTextLower);
|
|
3321
|
-
}
|
|
3322
|
-
|
|
3323
|
-
function isEditable(el) {
|
|
3324
|
-
if (!el || !el.isConnected) return false;
|
|
3325
|
-
const tag = el.tagName.toLowerCase();
|
|
3326
|
-
if (tag === 'textarea') return true;
|
|
3327
|
-
if (tag === 'select') return true;
|
|
3328
|
-
if (el.isContentEditable) return true;
|
|
3329
|
-
if (tag === 'input') {
|
|
3330
|
-
const type = (el.type || 'text').toLowerCase();
|
|
3331
|
-
const editableTypes = ['text', 'password', 'email', 'number', 'search', 'tel', 'url', 'date', 'datetime-local', 'month', 'time', 'week'];
|
|
3332
|
-
return editableTypes.includes(type);
|
|
3333
|
-
}
|
|
3334
|
-
return false;
|
|
3335
|
-
}
|
|
3336
|
-
|
|
3337
|
-
function isVisible(el) {
|
|
3338
|
-
if (!el.isConnected) return false;
|
|
3339
|
-
const style = window.getComputedStyle(el);
|
|
3340
|
-
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
3341
|
-
const rect = el.getBoundingClientRect();
|
|
3342
|
-
return rect.width > 0 && rect.height > 0;
|
|
3343
|
-
}
|
|
3344
|
-
|
|
3345
|
-
// 1. Search label[for] pointing to an input
|
|
3346
|
-
const labels = document.querySelectorAll('label[for]');
|
|
3347
|
-
for (const label of labels) {
|
|
3348
|
-
if (matchesText(label.textContent)) {
|
|
3349
|
-
const input = document.getElementById(label.getAttribute('for'));
|
|
3350
|
-
if (input && isEditable(input) && isVisible(input)) {
|
|
3351
|
-
return { element: input, method: 'label-for' };
|
|
3352
|
-
}
|
|
3353
|
-
}
|
|
3354
|
-
}
|
|
3355
|
-
|
|
3356
|
-
// 2. Search for nested input inside label
|
|
3357
|
-
const allLabels = document.querySelectorAll('label');
|
|
3358
|
-
for (const label of allLabels) {
|
|
3359
|
-
if (matchesText(label.textContent)) {
|
|
3360
|
-
const input = label.querySelector('input, textarea, select');
|
|
3361
|
-
if (input && isEditable(input) && isVisible(input)) {
|
|
3362
|
-
return { element: input, method: 'label-nested' };
|
|
3363
|
-
}
|
|
3364
|
-
}
|
|
3365
|
-
}
|
|
3366
|
-
|
|
3367
|
-
// 3. Search by aria-label attribute
|
|
3368
|
-
const ariaElements = document.querySelectorAll('[aria-label]');
|
|
3369
|
-
for (const el of ariaElements) {
|
|
3370
|
-
if (matchesText(el.getAttribute('aria-label'))) {
|
|
3371
|
-
if (isEditable(el) && isVisible(el)) {
|
|
3372
|
-
return { element: el, method: 'aria-label' };
|
|
3373
|
-
}
|
|
3374
|
-
}
|
|
3375
|
-
}
|
|
3376
|
-
|
|
3377
|
-
// 4. Search by aria-labelledby
|
|
3378
|
-
const ariaLabelledByElements = document.querySelectorAll('[aria-labelledby]');
|
|
3379
|
-
for (const el of ariaLabelledByElements) {
|
|
3380
|
-
const labelId = el.getAttribute('aria-labelledby');
|
|
3381
|
-
const labelEl = document.getElementById(labelId);
|
|
3382
|
-
if (labelEl && matchesText(labelEl.textContent)) {
|
|
3383
|
-
if (isEditable(el) && isVisible(el)) {
|
|
3384
|
-
return { element: el, method: 'aria-labelledby' };
|
|
3385
|
-
}
|
|
3386
|
-
}
|
|
3387
|
-
}
|
|
3388
|
-
|
|
3389
|
-
// 5. Search by placeholder attribute
|
|
3390
|
-
const placeholderElements = document.querySelectorAll('[placeholder]');
|
|
3391
|
-
for (const el of placeholderElements) {
|
|
3392
|
-
if (matchesText(el.getAttribute('placeholder'))) {
|
|
3393
|
-
if (isEditable(el) && isVisible(el)) {
|
|
3394
|
-
return { element: el, method: 'placeholder' };
|
|
3395
|
-
}
|
|
3396
|
-
}
|
|
3397
|
-
}
|
|
3398
|
-
|
|
3399
|
-
return null;
|
|
3400
|
-
})()
|
|
3401
|
-
`;
|
|
3402
|
-
|
|
3403
|
-
let result;
|
|
3404
|
-
try {
|
|
3405
|
-
result = await session.send('Runtime.evaluate', {
|
|
3406
|
-
expression,
|
|
3407
|
-
returnByValue: false
|
|
3408
|
-
});
|
|
3409
|
-
} catch (error) {
|
|
3410
|
-
throw connectionError(error.message, 'Runtime.evaluate (findInputByLabel)');
|
|
3411
|
-
}
|
|
3412
|
-
|
|
3413
|
-
if (result.exceptionDetails) {
|
|
3414
|
-
throw new Error(`Label search error: ${result.exceptionDetails.text}`);
|
|
3415
|
-
}
|
|
3416
|
-
|
|
3417
|
-
if (result.result.subtype === 'null' || result.result.type === 'undefined') {
|
|
3418
|
-
return null;
|
|
3419
|
-
}
|
|
3420
|
-
|
|
3421
|
-
// The result is an object with element and method
|
|
3422
|
-
// We need to get the element's objectId
|
|
3423
|
-
const objId = result.result.objectId;
|
|
3424
|
-
const propsResult = await session.send('Runtime.getProperties', {
|
|
3425
|
-
objectId: objId,
|
|
3426
|
-
ownProperties: true
|
|
3427
|
-
});
|
|
3428
|
-
|
|
3429
|
-
let elementObjectId = null;
|
|
3430
|
-
let method = null;
|
|
3431
|
-
|
|
3432
|
-
for (const prop of propsResult.result) {
|
|
3433
|
-
if (prop.name === 'element' && prop.value && prop.value.objectId) {
|
|
3434
|
-
elementObjectId = prop.value.objectId;
|
|
3435
|
-
}
|
|
3436
|
-
if (prop.name === 'method' && prop.value) {
|
|
3437
|
-
method = prop.value.value;
|
|
3438
|
-
}
|
|
3439
|
-
}
|
|
3440
|
-
|
|
3441
|
-
// Release the wrapper object
|
|
3442
|
-
await releaseObject(session, objId);
|
|
3443
|
-
|
|
3444
|
-
if (!elementObjectId) {
|
|
3445
|
-
return null;
|
|
3446
|
-
}
|
|
3447
|
-
|
|
3448
|
-
return { objectId: elementObjectId, method };
|
|
3449
|
-
}
|
|
3450
|
-
|
|
3451
|
-
/**
|
|
3452
|
-
* Fill an input field by its label text (Feature 9)
|
|
3453
|
-
* @param {string} label - Label text to find
|
|
3454
|
-
* @param {*} value - Value to fill
|
|
3455
|
-
* @param {Object} [opts] - Options
|
|
3456
|
-
* @returns {Promise<Object>} Fill result
|
|
3457
|
-
*/
|
|
3458
|
-
async function fillByLabel(label, value, opts = {}) {
|
|
3459
|
-
const { clear = true, react = false, exact = false } = opts;
|
|
3460
|
-
|
|
3461
|
-
const inputInfo = await findInputByLabel(label, { exact });
|
|
3462
|
-
if (!inputInfo) {
|
|
3463
|
-
throw elementNotFoundError(`label:"${label}"`, 0);
|
|
3464
|
-
}
|
|
3465
|
-
|
|
3466
|
-
const { objectId, method: foundMethod } = inputInfo;
|
|
3467
|
-
|
|
3468
|
-
const editableCheck = await elementValidator.isEditable(objectId);
|
|
3469
|
-
if (!editableCheck.editable) {
|
|
3470
|
-
await releaseObject(session, objectId);
|
|
3471
|
-
throw elementNotEditableError(`label:"${label}"`, editableCheck.reason);
|
|
3472
|
-
}
|
|
3473
|
-
|
|
3474
|
-
try {
|
|
3475
|
-
if (react) {
|
|
3476
|
-
await reactInputFiller.fillByObjectId(objectId, value);
|
|
3477
|
-
return { filled: true, label, method: 'react', foundBy: foundMethod };
|
|
3478
|
-
}
|
|
3479
|
-
|
|
3480
|
-
// Scroll into view
|
|
3481
|
-
await session.send('Runtime.callFunctionOn', {
|
|
3482
|
-
objectId,
|
|
3483
|
-
functionDeclaration: `function() {
|
|
3484
|
-
this.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
3485
|
-
}`
|
|
3486
|
-
});
|
|
3487
|
-
|
|
3488
|
-
await sleep(100);
|
|
3489
|
-
|
|
3490
|
-
// Get element bounds for clicking
|
|
3491
|
-
const boxResult = await session.send('Runtime.callFunctionOn', {
|
|
3492
|
-
objectId,
|
|
3493
|
-
functionDeclaration: `function() {
|
|
3494
|
-
const rect = this.getBoundingClientRect();
|
|
3495
|
-
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
3496
|
-
}`,
|
|
3497
|
-
returnByValue: true
|
|
3498
|
-
});
|
|
3499
|
-
|
|
3500
|
-
const box = boxResult.result.value;
|
|
3501
|
-
const x = box.x + box.width / 2;
|
|
3502
|
-
const y = box.y + box.height / 2;
|
|
3503
|
-
await inputEmulator.click(x, y);
|
|
3504
|
-
|
|
3505
|
-
// Focus the element
|
|
3506
|
-
await session.send('Runtime.callFunctionOn', {
|
|
3507
|
-
objectId,
|
|
3508
|
-
functionDeclaration: `function() { this.focus(); }`
|
|
3509
|
-
});
|
|
3510
|
-
|
|
3511
|
-
if (clear) {
|
|
3512
|
-
await inputEmulator.selectAll();
|
|
3513
|
-
}
|
|
3514
|
-
|
|
3515
|
-
await inputEmulator.type(String(value));
|
|
3516
|
-
|
|
3517
|
-
return { filled: true, label, method: 'keyboard', foundBy: foundMethod };
|
|
3518
|
-
} catch (e) {
|
|
3519
|
-
await resetInputState(session);
|
|
3520
|
-
throw e;
|
|
3521
|
-
} finally {
|
|
3522
|
-
await releaseObject(session, objectId);
|
|
3523
|
-
}
|
|
3524
|
-
}
|
|
3525
|
-
|
|
3526
|
-
async function execute(params) {
|
|
3527
|
-
let { selector, ref, label, value, clear = true, react = false, exact = false } = params;
|
|
3528
|
-
|
|
3529
|
-
if (value === undefined) {
|
|
3530
|
-
throw new Error('Fill requires value');
|
|
3531
|
-
}
|
|
3532
|
-
|
|
3533
|
-
// Detect if selector looks like a ref (e.g., "e1", "e12", "e123")
|
|
3534
|
-
// This allows {"fill": {"selector": "e1", "value": "..."}} to work like {"fill": {"ref": "e1", "value": "..."}}
|
|
3535
|
-
if (!ref && selector && /^e\d+$/.test(selector)) {
|
|
3536
|
-
ref = selector;
|
|
3537
|
-
}
|
|
3538
|
-
|
|
3539
|
-
// Handle fill by ref
|
|
3540
|
-
if (ref && ariaSnapshot) {
|
|
3541
|
-
return fillByRef(ref, value, { clear, react });
|
|
3542
|
-
}
|
|
3543
|
-
|
|
3544
|
-
// Handle fill by label (Feature 9)
|
|
3545
|
-
if (label) {
|
|
3546
|
-
return fillByLabel(label, value, { clear, react, exact });
|
|
3547
|
-
}
|
|
3548
|
-
|
|
3549
|
-
if (!selector) {
|
|
3550
|
-
throw new Error('Fill requires selector, ref, or label');
|
|
3551
|
-
}
|
|
3552
|
-
|
|
3553
|
-
return fillBySelector(selector, value, { clear, react });
|
|
3554
|
-
}
|
|
3555
|
-
|
|
3556
|
-
async function executeBatch(params) {
|
|
3557
|
-
if (!params || typeof params !== 'object') {
|
|
3558
|
-
throw new Error('fillForm requires an object mapping selectors to values');
|
|
3559
|
-
}
|
|
3560
|
-
|
|
3561
|
-
// Support both formats:
|
|
3562
|
-
// Simple: {"#firstName": "John", "#lastName": "Doe"}
|
|
3563
|
-
// Extended: {"fields": {"#firstName": "John"}, "react": true}
|
|
3564
|
-
let fields;
|
|
3565
|
-
let useReact = false;
|
|
3566
|
-
|
|
3567
|
-
if (params.fields && typeof params.fields === 'object') {
|
|
3568
|
-
// Extended format with fields and react options
|
|
3569
|
-
fields = params.fields;
|
|
3570
|
-
useReact = params.react === true;
|
|
3571
|
-
} else {
|
|
3572
|
-
// Simple format - params is the fields object directly
|
|
3573
|
-
fields = params;
|
|
3574
|
-
}
|
|
3575
|
-
|
|
3576
|
-
const entries = Object.entries(fields);
|
|
3577
|
-
if (entries.length === 0) {
|
|
3578
|
-
throw new Error('fillForm requires at least one field');
|
|
3579
|
-
}
|
|
3580
|
-
|
|
3581
|
-
const results = [];
|
|
3582
|
-
const errors = [];
|
|
3583
|
-
|
|
3584
|
-
for (const [selector, value] of entries) {
|
|
3585
|
-
try {
|
|
3586
|
-
const isRef = /^e\d+$/.test(selector);
|
|
3587
|
-
|
|
3588
|
-
if (isRef) {
|
|
3589
|
-
await fillByRef(selector, value, { clear: true, react: useReact });
|
|
3590
|
-
} else {
|
|
3591
|
-
await fillBySelector(selector, value, { clear: true, react: useReact });
|
|
3592
|
-
}
|
|
3593
|
-
|
|
3594
|
-
results.push({ selector, status: 'filled', value: String(value) });
|
|
3595
|
-
} catch (error) {
|
|
3596
|
-
errors.push({ selector, error: error.message });
|
|
3597
|
-
results.push({ selector, status: 'failed', error: error.message });
|
|
3598
|
-
}
|
|
3599
|
-
}
|
|
3600
|
-
|
|
3601
|
-
return {
|
|
3602
|
-
total: entries.length,
|
|
3603
|
-
filled: results.filter(r => r.status === 'filled').length,
|
|
3604
|
-
failed: errors.length,
|
|
3605
|
-
results,
|
|
3606
|
-
errors: errors.length > 0 ? errors : undefined
|
|
3607
|
-
};
|
|
3608
|
-
}
|
|
3609
|
-
|
|
3610
|
-
return {
|
|
3611
|
-
execute,
|
|
3612
|
-
executeBatch
|
|
3613
|
-
};
|
|
3614
|
-
}
|
|
3615
|
-
|
|
3616
|
-
// ============================================================================
|
|
3617
|
-
// Keyboard Executor (from KeyboardStepExecutor.js)
|
|
3618
|
-
// ============================================================================
|
|
3619
|
-
|
|
3620
|
-
/**
|
|
3621
|
-
* Create a keyboard executor for handling type and select operations
|
|
3622
|
-
* @param {Object} session - CDP session
|
|
3623
|
-
* @param {Object} elementLocator - Element locator instance
|
|
3624
|
-
* @param {Object} inputEmulator - Input emulator instance
|
|
3625
|
-
* @returns {Object} Keyboard executor interface
|
|
3626
|
-
*/
|
|
3627
|
-
export function createKeyboardExecutor(session, elementLocator, inputEmulator) {
|
|
3628
|
-
const validator = createElementValidator(session);
|
|
3629
|
-
|
|
3630
|
-
async function executeType(params) {
|
|
3631
|
-
const { selector, text, delay = 0 } = params;
|
|
3632
|
-
|
|
3633
|
-
if (!selector || text === undefined) {
|
|
3634
|
-
throw new Error('Type requires selector and text');
|
|
3635
|
-
}
|
|
3636
|
-
|
|
3637
|
-
const element = await elementLocator.findElement(selector);
|
|
3638
|
-
if (!element) {
|
|
3639
|
-
throw elementNotFoundError(selector, 0);
|
|
3640
|
-
}
|
|
3641
|
-
|
|
3642
|
-
const editableCheck = await validator.isEditable(element._handle.objectId);
|
|
3643
|
-
if (!editableCheck.editable) {
|
|
3644
|
-
await element._handle.dispose();
|
|
3645
|
-
throw elementNotEditableError(selector, editableCheck.reason);
|
|
3646
|
-
}
|
|
3647
|
-
|
|
3648
|
-
try {
|
|
3649
|
-
await element._handle.scrollIntoView({ block: 'center' });
|
|
3650
|
-
await element._handle.waitForStability({ frames: 2, timeout: 500 });
|
|
3651
|
-
|
|
3652
|
-
await element._handle.focus();
|
|
3653
|
-
|
|
3654
|
-
await inputEmulator.type(String(text), { delay });
|
|
3655
|
-
|
|
3656
|
-
return {
|
|
3657
|
-
selector,
|
|
3658
|
-
typed: String(text),
|
|
3659
|
-
length: String(text).length
|
|
3660
|
-
};
|
|
3661
|
-
} finally {
|
|
3662
|
-
await element._handle.dispose();
|
|
3663
|
-
}
|
|
3664
|
-
}
|
|
3665
|
-
|
|
3666
|
-
async function executeSelect(params) {
|
|
3667
|
-
let selector;
|
|
3668
|
-
let start = null;
|
|
3669
|
-
let end = null;
|
|
3670
|
-
|
|
3671
|
-
if (typeof params === 'string') {
|
|
3672
|
-
selector = params;
|
|
3673
|
-
} else if (params && typeof params === 'object') {
|
|
3674
|
-
selector = params.selector;
|
|
3675
|
-
start = params.start !== undefined ? params.start : null;
|
|
3676
|
-
end = params.end !== undefined ? params.end : null;
|
|
3677
|
-
} else {
|
|
3678
|
-
throw new Error('Select requires a selector string or params object');
|
|
3679
|
-
}
|
|
3680
|
-
|
|
3681
|
-
if (!selector) {
|
|
3682
|
-
throw new Error('Select requires selector');
|
|
3683
|
-
}
|
|
3684
|
-
|
|
3685
|
-
const element = await elementLocator.findElement(selector);
|
|
3686
|
-
if (!element) {
|
|
3687
|
-
throw elementNotFoundError(selector, 0);
|
|
3688
|
-
}
|
|
3689
|
-
|
|
3690
|
-
try {
|
|
3691
|
-
await element._handle.scrollIntoView({ block: 'center' });
|
|
3692
|
-
await element._handle.waitForStability({ frames: 2, timeout: 500 });
|
|
3693
|
-
|
|
3694
|
-
await element._handle.focus();
|
|
3695
|
-
|
|
3696
|
-
const result = await session.send('Runtime.callFunctionOn', {
|
|
3697
|
-
objectId: element._handle.objectId,
|
|
3698
|
-
functionDeclaration: `function(start, end) {
|
|
3699
|
-
const el = this;
|
|
3700
|
-
const tagName = el.tagName.toLowerCase();
|
|
3701
|
-
|
|
3702
|
-
if (tagName === 'input' || tagName === 'textarea') {
|
|
3703
|
-
const len = el.value.length;
|
|
3704
|
-
const selStart = start !== null ? Math.min(start, len) : 0;
|
|
3705
|
-
const selEnd = end !== null ? Math.min(end, len) : len;
|
|
3706
|
-
|
|
3707
|
-
el.focus();
|
|
3708
|
-
el.setSelectionRange(selStart, selEnd);
|
|
3709
|
-
|
|
3710
|
-
return {
|
|
3711
|
-
success: true,
|
|
3712
|
-
start: selStart,
|
|
3713
|
-
end: selEnd,
|
|
3714
|
-
selectedText: el.value.substring(selStart, selEnd),
|
|
3715
|
-
totalLength: len
|
|
3716
|
-
};
|
|
3717
|
-
}
|
|
3718
|
-
|
|
3719
|
-
if (el.isContentEditable) {
|
|
3720
|
-
const range = document.createRange();
|
|
3721
|
-
const text = el.textContent || '';
|
|
3722
|
-
const len = text.length;
|
|
3723
|
-
const selStart = start !== null ? Math.min(start, len) : 0;
|
|
3724
|
-
const selEnd = end !== null ? Math.min(end, len) : len;
|
|
3725
|
-
|
|
3726
|
-
let currentPos = 0;
|
|
3727
|
-
let startNode = null, startOffset = 0;
|
|
3728
|
-
let endNode = null, endOffset = 0;
|
|
3729
|
-
|
|
3730
|
-
function findPosition(node, target) {
|
|
3731
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
3732
|
-
const nodeLen = node.textContent.length;
|
|
3733
|
-
if (!startNode && currentPos + nodeLen >= selStart) {
|
|
3734
|
-
startNode = node;
|
|
3735
|
-
startOffset = selStart - currentPos;
|
|
3736
|
-
}
|
|
3737
|
-
if (!endNode && currentPos + nodeLen >= selEnd) {
|
|
3738
|
-
endNode = node;
|
|
3739
|
-
endOffset = selEnd - currentPos;
|
|
3740
|
-
return true;
|
|
3741
|
-
}
|
|
3742
|
-
currentPos += nodeLen;
|
|
3743
|
-
} else {
|
|
3744
|
-
for (const child of node.childNodes) {
|
|
3745
|
-
if (findPosition(child, target)) return true;
|
|
3746
|
-
}
|
|
3747
|
-
}
|
|
3748
|
-
return false;
|
|
3749
|
-
}
|
|
3750
|
-
|
|
3751
|
-
findPosition(el, null);
|
|
3752
|
-
|
|
3753
|
-
if (startNode && endNode) {
|
|
3754
|
-
range.setStart(startNode, startOffset);
|
|
3755
|
-
range.setEnd(endNode, endOffset);
|
|
3756
|
-
|
|
3757
|
-
const selection = window.getSelection();
|
|
3758
|
-
selection.removeAllRanges();
|
|
3759
|
-
selection.addRange(range);
|
|
3760
|
-
|
|
3761
|
-
return {
|
|
3762
|
-
success: true,
|
|
3763
|
-
start: selStart,
|
|
3764
|
-
end: selEnd,
|
|
3765
|
-
selectedText: text.substring(selStart, selEnd),
|
|
3766
|
-
totalLength: len
|
|
3767
|
-
};
|
|
3768
|
-
}
|
|
3769
|
-
}
|
|
3770
|
-
|
|
3771
|
-
return {
|
|
3772
|
-
success: false,
|
|
3773
|
-
reason: 'Element does not support text selection'
|
|
3774
|
-
};
|
|
3775
|
-
}`,
|
|
3776
|
-
arguments: [
|
|
3777
|
-
{ value: start },
|
|
3778
|
-
{ value: end }
|
|
3779
|
-
],
|
|
3780
|
-
returnByValue: true
|
|
3781
|
-
});
|
|
3782
|
-
|
|
3783
|
-
const selectionResult = result.result.value;
|
|
3784
|
-
|
|
3785
|
-
if (!selectionResult.success) {
|
|
3786
|
-
throw new Error(selectionResult.reason || 'Selection failed');
|
|
3787
|
-
}
|
|
3788
|
-
|
|
3789
|
-
return {
|
|
3790
|
-
selector,
|
|
3791
|
-
start: selectionResult.start,
|
|
3792
|
-
end: selectionResult.end,
|
|
3793
|
-
selectedText: selectionResult.selectedText,
|
|
3794
|
-
totalLength: selectionResult.totalLength
|
|
3795
|
-
};
|
|
3796
|
-
} finally {
|
|
3797
|
-
await element._handle.dispose();
|
|
3798
|
-
}
|
|
3799
|
-
}
|
|
3800
|
-
|
|
3801
|
-
return {
|
|
3802
|
-
executeType,
|
|
3803
|
-
executeSelect
|
|
3804
|
-
};
|
|
3805
|
-
}
|
|
3806
|
-
|
|
3807
|
-
// ============================================================================
|
|
3808
|
-
// Wait Executor (from WaitExecutor.js)
|
|
3809
|
-
// ============================================================================
|
|
3810
|
-
|
|
3811
|
-
/**
|
|
3812
|
-
* Create a wait executor for handling wait operations
|
|
3813
|
-
* @param {Object} session - CDP session
|
|
3814
|
-
* @param {Object} elementLocator - Element locator instance
|
|
3815
|
-
* @returns {Object} Wait executor interface
|
|
3816
|
-
*/
|
|
3817
|
-
export function createWaitExecutor(session, elementLocator) {
|
|
3818
|
-
if (!session) throw new Error('CDP session is required');
|
|
3819
|
-
if (!elementLocator) throw new Error('Element locator is required');
|
|
3820
|
-
|
|
3821
|
-
function validateTimeout(timeout) {
|
|
3822
|
-
if (typeof timeout !== 'number' || !Number.isFinite(timeout)) {
|
|
3823
|
-
return DEFAULT_TIMEOUT;
|
|
3824
|
-
}
|
|
3825
|
-
if (timeout < 0) return 0;
|
|
3826
|
-
if (timeout > MAX_TIMEOUT) return MAX_TIMEOUT;
|
|
3827
|
-
return timeout;
|
|
3828
|
-
}
|
|
3829
|
-
|
|
3830
|
-
/**
|
|
3831
|
-
* Wait for selector using browser-side MutationObserver (improvement #3)
|
|
3832
|
-
* Much faster than Node.js polling as it avoids network round-trips
|
|
3833
|
-
*/
|
|
3834
|
-
async function waitForSelector(selector, timeout = DEFAULT_TIMEOUT) {
|
|
3835
|
-
const validatedTimeout = validateTimeout(timeout);
|
|
3836
|
-
|
|
3837
|
-
try {
|
|
3838
|
-
// Use browser-side polling with MutationObserver for better performance
|
|
3839
|
-
const result = await session.send('Runtime.evaluate', {
|
|
3840
|
-
expression: `
|
|
3841
|
-
new Promise((resolve, reject) => {
|
|
3842
|
-
const selector = ${JSON.stringify(selector)};
|
|
3843
|
-
const timeout = ${validatedTimeout};
|
|
3844
|
-
|
|
3845
|
-
// Check if element already exists
|
|
3846
|
-
const existing = document.querySelector(selector);
|
|
3847
|
-
if (existing) {
|
|
3848
|
-
resolve({ found: true, immediate: true });
|
|
3849
|
-
return;
|
|
3850
|
-
}
|
|
3851
|
-
|
|
3852
|
-
let resolved = false;
|
|
3853
|
-
const timeoutId = setTimeout(() => {
|
|
3854
|
-
if (!resolved) {
|
|
3855
|
-
resolved = true;
|
|
3856
|
-
observer.disconnect();
|
|
3857
|
-
reject(new Error('Timeout waiting for selector: ' + selector));
|
|
3858
|
-
}
|
|
3859
|
-
}, timeout);
|
|
3860
|
-
|
|
3861
|
-
const observer = new MutationObserver((mutations, obs) => {
|
|
3862
|
-
const el = document.querySelector(selector);
|
|
3863
|
-
if (el && !resolved) {
|
|
3864
|
-
resolved = true;
|
|
3865
|
-
obs.disconnect();
|
|
3866
|
-
clearTimeout(timeoutId);
|
|
3867
|
-
resolve({ found: true, mutations: mutations.length });
|
|
3868
|
-
}
|
|
3869
|
-
});
|
|
3870
|
-
|
|
3871
|
-
observer.observe(document.documentElement || document.body, {
|
|
3872
|
-
childList: true,
|
|
3873
|
-
subtree: true,
|
|
3874
|
-
attributes: true,
|
|
3875
|
-
attributeFilter: ['class', 'id', 'style', 'hidden']
|
|
3876
|
-
});
|
|
3877
|
-
|
|
3878
|
-
// Also check with RAF as a fallback
|
|
3879
|
-
const checkWithRAF = () => {
|
|
3880
|
-
if (resolved) return;
|
|
3881
|
-
const el = document.querySelector(selector);
|
|
3882
|
-
if (el) {
|
|
3883
|
-
resolved = true;
|
|
3884
|
-
observer.disconnect();
|
|
3885
|
-
clearTimeout(timeoutId);
|
|
3886
|
-
resolve({ found: true, raf: true });
|
|
3887
|
-
return;
|
|
3888
|
-
}
|
|
3889
|
-
requestAnimationFrame(checkWithRAF);
|
|
3890
|
-
};
|
|
3891
|
-
requestAnimationFrame(checkWithRAF);
|
|
3892
|
-
})
|
|
3893
|
-
`,
|
|
3894
|
-
awaitPromise: true,
|
|
3895
|
-
returnByValue: true
|
|
3896
|
-
});
|
|
3897
|
-
|
|
3898
|
-
if (result.exceptionDetails) {
|
|
3899
|
-
throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
|
3900
|
-
}
|
|
3901
|
-
|
|
3902
|
-
return result.result.value;
|
|
3903
|
-
} catch (error) {
|
|
3904
|
-
// Fall back to original Node.js polling if browser-side fails
|
|
3905
|
-
const element = await elementLocator.waitForSelector(selector, {
|
|
3906
|
-
timeout: validatedTimeout
|
|
3907
|
-
});
|
|
3908
|
-
if (element) await element.dispose();
|
|
3909
|
-
}
|
|
3910
|
-
}
|
|
3911
|
-
|
|
3912
|
-
async function checkElementHidden(selector) {
|
|
3913
|
-
try {
|
|
3914
|
-
const result = await session.send('Runtime.evaluate', {
|
|
3915
|
-
expression: `
|
|
3916
|
-
(function() {
|
|
3917
|
-
const el = document.querySelector(${JSON.stringify(selector)});
|
|
3918
|
-
if (!el) return true;
|
|
3919
|
-
const style = window.getComputedStyle(el);
|
|
3920
|
-
if (style.display === 'none') return true;
|
|
3921
|
-
if (style.visibility === 'hidden') return true;
|
|
3922
|
-
if (style.opacity === '0') return true;
|
|
3923
|
-
const rect = el.getBoundingClientRect();
|
|
3924
|
-
if (rect.width === 0 && rect.height === 0) return true;
|
|
3925
|
-
return false;
|
|
3926
|
-
})()
|
|
3927
|
-
`,
|
|
3928
|
-
returnByValue: true
|
|
3929
|
-
});
|
|
3930
|
-
return result.result.value === true;
|
|
3931
|
-
} catch {
|
|
3932
|
-
return true;
|
|
3933
|
-
}
|
|
3934
|
-
}
|
|
3935
|
-
|
|
3936
|
-
async function waitForHidden(selector, timeout = DEFAULT_TIMEOUT) {
|
|
3937
|
-
const validatedTimeout = validateTimeout(timeout);
|
|
3938
|
-
const startTime = Date.now();
|
|
3939
|
-
|
|
3940
|
-
while (Date.now() - startTime < validatedTimeout) {
|
|
3941
|
-
const isHidden = await checkElementHidden(selector);
|
|
3942
|
-
if (isHidden) return;
|
|
3943
|
-
await sleep(POLL_INTERVAL);
|
|
3944
|
-
}
|
|
3945
|
-
|
|
3946
|
-
throw timeoutError(
|
|
3947
|
-
`Timeout (${validatedTimeout}ms) waiting for element to disappear: "${selector}"`
|
|
3948
|
-
);
|
|
3949
|
-
}
|
|
3950
|
-
|
|
3951
|
-
async function getElementCount(selector) {
|
|
3952
|
-
try {
|
|
3953
|
-
const result = await session.send('Runtime.evaluate', {
|
|
3954
|
-
expression: `document.querySelectorAll(${JSON.stringify(selector)}).length`,
|
|
3955
|
-
returnByValue: true
|
|
3956
|
-
});
|
|
3957
|
-
return result.result.value || 0;
|
|
3958
|
-
} catch {
|
|
3959
|
-
return 0;
|
|
3960
|
-
}
|
|
3961
|
-
}
|
|
3962
|
-
|
|
3963
|
-
async function waitForCount(selector, minCount, timeout = DEFAULT_TIMEOUT) {
|
|
3964
|
-
if (typeof minCount !== 'number' || minCount < 0) {
|
|
3965
|
-
throw new Error('minCount must be a non-negative number');
|
|
3966
|
-
}
|
|
3967
|
-
|
|
3968
|
-
const validatedTimeout = validateTimeout(timeout);
|
|
3969
|
-
const startTime = Date.now();
|
|
3970
|
-
|
|
3971
|
-
while (Date.now() - startTime < validatedTimeout) {
|
|
3972
|
-
const count = await getElementCount(selector);
|
|
3973
|
-
if (count >= minCount) return;
|
|
3974
|
-
await sleep(POLL_INTERVAL);
|
|
3975
|
-
}
|
|
3976
|
-
|
|
3977
|
-
const finalCount = await getElementCount(selector);
|
|
3978
|
-
throw timeoutError(
|
|
3979
|
-
`Timeout (${validatedTimeout}ms) waiting for ${minCount} elements matching "${selector}" (found ${finalCount})`
|
|
3980
|
-
);
|
|
3981
|
-
}
|
|
3982
|
-
|
|
3983
|
-
/**
|
|
3984
|
-
* Wait for text using browser-side MutationObserver (improvement #3)
|
|
3985
|
-
*/
|
|
3986
|
-
async function waitForText(text, opts = {}) {
|
|
3987
|
-
const { timeout = DEFAULT_TIMEOUT, caseSensitive = false } = opts;
|
|
3988
|
-
const validatedTimeout = validateTimeout(timeout);
|
|
3989
|
-
|
|
3990
|
-
try {
|
|
3991
|
-
// Use browser-side polling with MutationObserver
|
|
3992
|
-
const result = await session.send('Runtime.evaluate', {
|
|
3993
|
-
expression: `
|
|
3994
|
-
new Promise((resolve, reject) => {
|
|
3995
|
-
const searchText = ${JSON.stringify(text)};
|
|
3996
|
-
const caseSensitive = ${caseSensitive};
|
|
3997
|
-
const timeout = ${validatedTimeout};
|
|
3998
|
-
|
|
3999
|
-
const checkText = () => {
|
|
4000
|
-
const bodyText = document.body ? document.body.innerText : '';
|
|
4001
|
-
if (caseSensitive) {
|
|
4002
|
-
return bodyText.includes(searchText);
|
|
4003
|
-
}
|
|
4004
|
-
return bodyText.toLowerCase().includes(searchText.toLowerCase());
|
|
4005
|
-
};
|
|
4006
|
-
|
|
4007
|
-
// Check if text already exists
|
|
4008
|
-
if (checkText()) {
|
|
4009
|
-
resolve({ found: true, immediate: true });
|
|
4010
|
-
return;
|
|
4011
|
-
}
|
|
4012
|
-
|
|
4013
|
-
let resolved = false;
|
|
4014
|
-
const timeoutId = setTimeout(() => {
|
|
4015
|
-
if (!resolved) {
|
|
4016
|
-
resolved = true;
|
|
4017
|
-
observer.disconnect();
|
|
4018
|
-
reject(new Error('Timeout waiting for text: ' + searchText));
|
|
4019
|
-
}
|
|
4020
|
-
}, timeout);
|
|
4021
|
-
|
|
4022
|
-
const observer = new MutationObserver((mutations, obs) => {
|
|
4023
|
-
if (!resolved && checkText()) {
|
|
4024
|
-
resolved = true;
|
|
4025
|
-
obs.disconnect();
|
|
4026
|
-
clearTimeout(timeoutId);
|
|
4027
|
-
resolve({ found: true, mutations: mutations.length });
|
|
4028
|
-
}
|
|
4029
|
-
});
|
|
4030
|
-
|
|
4031
|
-
observer.observe(document.documentElement || document.body, {
|
|
4032
|
-
childList: true,
|
|
4033
|
-
subtree: true,
|
|
4034
|
-
characterData: true
|
|
4035
|
-
});
|
|
4036
|
-
|
|
4037
|
-
// Also check with RAF as a fallback
|
|
4038
|
-
const checkWithRAF = () => {
|
|
4039
|
-
if (resolved) return;
|
|
4040
|
-
if (checkText()) {
|
|
4041
|
-
resolved = true;
|
|
4042
|
-
observer.disconnect();
|
|
4043
|
-
clearTimeout(timeoutId);
|
|
4044
|
-
resolve({ found: true, raf: true });
|
|
4045
|
-
return;
|
|
4046
|
-
}
|
|
4047
|
-
requestAnimationFrame(checkWithRAF);
|
|
4048
|
-
};
|
|
4049
|
-
requestAnimationFrame(checkWithRAF);
|
|
4050
|
-
})
|
|
4051
|
-
`,
|
|
4052
|
-
awaitPromise: true,
|
|
4053
|
-
returnByValue: true
|
|
4054
|
-
});
|
|
4055
|
-
|
|
4056
|
-
if (result.exceptionDetails) {
|
|
4057
|
-
throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
|
4058
|
-
}
|
|
4059
|
-
|
|
4060
|
-
return result.result.value;
|
|
4061
|
-
} catch (error) {
|
|
4062
|
-
// Fall back to original Node.js polling
|
|
4063
|
-
const startTime = Date.now();
|
|
4064
|
-
const checkExpr = caseSensitive
|
|
4065
|
-
? `document.body.innerText.includes(${JSON.stringify(text)})`
|
|
4066
|
-
: `document.body.innerText.toLowerCase().includes(${JSON.stringify(text.toLowerCase())})`;
|
|
4067
|
-
|
|
4068
|
-
while (Date.now() - startTime < validatedTimeout) {
|
|
4069
|
-
try {
|
|
4070
|
-
const result = await session.send('Runtime.evaluate', {
|
|
4071
|
-
expression: checkExpr,
|
|
4072
|
-
returnByValue: true
|
|
4073
|
-
});
|
|
4074
|
-
if (result.result.value === true) return;
|
|
4075
|
-
} catch {
|
|
4076
|
-
// Continue polling
|
|
4077
|
-
}
|
|
4078
|
-
await sleep(POLL_INTERVAL);
|
|
4079
|
-
}
|
|
4080
|
-
|
|
4081
|
-
throw timeoutError(
|
|
4082
|
-
`Timeout (${validatedTimeout}ms) waiting for text: "${text}"${caseSensitive ? ' (case-sensitive)' : ''}`
|
|
4083
|
-
);
|
|
4084
|
-
}
|
|
4085
|
-
}
|
|
4086
|
-
|
|
4087
|
-
async function waitForTextRegex(pattern, timeout = DEFAULT_TIMEOUT) {
|
|
4088
|
-
const validatedTimeout = validateTimeout(timeout);
|
|
4089
|
-
const startTime = Date.now();
|
|
4090
|
-
|
|
4091
|
-
try {
|
|
4092
|
-
new RegExp(pattern);
|
|
4093
|
-
} catch (e) {
|
|
4094
|
-
throw new Error(`Invalid regex pattern: ${pattern} - ${e.message}`);
|
|
4095
|
-
}
|
|
4096
|
-
|
|
4097
|
-
while (Date.now() - startTime < validatedTimeout) {
|
|
4098
|
-
try {
|
|
4099
|
-
const result = await session.send('Runtime.evaluate', {
|
|
4100
|
-
expression: `
|
|
4101
|
-
(function() {
|
|
4102
|
-
try {
|
|
4103
|
-
const regex = new RegExp(${JSON.stringify(pattern)});
|
|
4104
|
-
return regex.test(document.body.innerText);
|
|
4105
|
-
} catch {
|
|
4106
|
-
return false;
|
|
4107
|
-
}
|
|
4108
|
-
})()
|
|
4109
|
-
`,
|
|
4110
|
-
returnByValue: true
|
|
4111
|
-
});
|
|
4112
|
-
if (result.result.value === true) return;
|
|
4113
|
-
} catch {
|
|
4114
|
-
// Continue polling
|
|
4115
|
-
}
|
|
4116
|
-
await sleep(POLL_INTERVAL);
|
|
4117
|
-
}
|
|
4118
|
-
|
|
4119
|
-
throw timeoutError(
|
|
4120
|
-
`Timeout (${validatedTimeout}ms) waiting for text matching pattern: /${pattern}/`
|
|
4121
|
-
);
|
|
4122
|
-
}
|
|
4123
|
-
|
|
4124
|
-
async function waitForUrlContains(substring, timeout = DEFAULT_TIMEOUT) {
|
|
4125
|
-
const validatedTimeout = validateTimeout(timeout);
|
|
4126
|
-
const startTime = Date.now();
|
|
4127
|
-
|
|
4128
|
-
while (Date.now() - startTime < validatedTimeout) {
|
|
4129
|
-
try {
|
|
4130
|
-
const result = await session.send('Runtime.evaluate', {
|
|
4131
|
-
expression: 'window.location.href',
|
|
4132
|
-
returnByValue: true
|
|
4133
|
-
});
|
|
4134
|
-
const currentUrl = result.result.value;
|
|
4135
|
-
if (currentUrl && currentUrl.includes(substring)) return;
|
|
4136
|
-
} catch {
|
|
4137
|
-
// Continue polling
|
|
4138
|
-
}
|
|
4139
|
-
await sleep(POLL_INTERVAL);
|
|
4140
|
-
}
|
|
4141
|
-
|
|
4142
|
-
let finalUrl = 'unknown';
|
|
4143
|
-
try {
|
|
4144
|
-
const result = await session.send('Runtime.evaluate', {
|
|
4145
|
-
expression: 'window.location.href',
|
|
4146
|
-
returnByValue: true
|
|
4147
|
-
});
|
|
4148
|
-
finalUrl = result.result.value || 'unknown';
|
|
4149
|
-
} catch {
|
|
4150
|
-
// Ignore
|
|
4151
|
-
}
|
|
4152
|
-
|
|
4153
|
-
throw timeoutError(
|
|
4154
|
-
`Timeout (${validatedTimeout}ms) waiting for URL to contain "${substring}" (current: ${finalUrl})`
|
|
4155
|
-
);
|
|
4156
|
-
}
|
|
4157
|
-
|
|
4158
|
-
async function waitForTime(ms) {
|
|
4159
|
-
if (typeof ms !== 'number' || ms < 0) {
|
|
4160
|
-
throw new Error('wait time must be a non-negative number');
|
|
4161
|
-
}
|
|
4162
|
-
await sleep(ms);
|
|
4163
|
-
}
|
|
4164
|
-
|
|
4165
|
-
async function execute(params) {
|
|
4166
|
-
if (typeof params === 'string') {
|
|
4167
|
-
return waitForSelector(params);
|
|
4168
|
-
}
|
|
4169
|
-
|
|
4170
|
-
if (params.time !== undefined) {
|
|
4171
|
-
return waitForTime(params.time);
|
|
4172
|
-
}
|
|
4173
|
-
|
|
4174
|
-
if (params.selector !== undefined) {
|
|
4175
|
-
if (params.hidden === true) {
|
|
4176
|
-
return waitForHidden(params.selector, params.timeout);
|
|
4177
|
-
}
|
|
4178
|
-
if (params.minCount !== undefined) {
|
|
4179
|
-
return waitForCount(params.selector, params.minCount, params.timeout);
|
|
4180
|
-
}
|
|
4181
|
-
return waitForSelector(params.selector, params.timeout);
|
|
4182
|
-
}
|
|
4183
|
-
|
|
4184
|
-
if (params.text !== undefined) {
|
|
4185
|
-
return waitForText(params.text, {
|
|
4186
|
-
timeout: params.timeout,
|
|
4187
|
-
caseSensitive: params.caseSensitive
|
|
4188
|
-
});
|
|
4189
|
-
}
|
|
4190
|
-
|
|
4191
|
-
if (params.textRegex !== undefined) {
|
|
4192
|
-
return waitForTextRegex(params.textRegex, params.timeout);
|
|
4193
|
-
}
|
|
4194
|
-
|
|
4195
|
-
if (params.urlContains !== undefined) {
|
|
4196
|
-
return waitForUrlContains(params.urlContains, params.timeout);
|
|
4197
|
-
}
|
|
4198
|
-
|
|
4199
|
-
throw new Error(`Invalid wait params: ${JSON.stringify(params)}`);
|
|
4200
|
-
}
|
|
4201
|
-
|
|
4202
|
-
return {
|
|
4203
|
-
execute,
|
|
4204
|
-
waitForSelector,
|
|
4205
|
-
waitForHidden,
|
|
4206
|
-
waitForCount,
|
|
4207
|
-
waitForText,
|
|
4208
|
-
waitForTextRegex,
|
|
4209
|
-
waitForUrlContains,
|
|
4210
|
-
waitForTime
|
|
4211
|
-
};
|
|
4212
|
-
}
|
|
4213
|
-
|
|
4214
|
-
// ============================================================================
|
|
4215
|
-
// Convenience Functions
|
|
4216
|
-
// ============================================================================
|
|
4217
|
-
|
|
4218
|
-
/**
|
|
4219
|
-
* Find a single element by selector
|
|
4220
|
-
* @param {Object} session - CDP session
|
|
4221
|
-
* @param {string} selector - CSS selector
|
|
4222
|
-
* @returns {Promise<Object|null>}
|
|
4223
|
-
*/
|
|
4224
|
-
export async function querySelector(session, selector) {
|
|
4225
|
-
const locator = createElementLocator(session);
|
|
4226
|
-
return locator.querySelector(selector);
|
|
4227
|
-
}
|
|
4228
|
-
|
|
4229
|
-
/**
|
|
4230
|
-
* Find all elements matching a selector
|
|
4231
|
-
* @param {Object} session - CDP session
|
|
4232
|
-
* @param {string} selector - CSS selector
|
|
4233
|
-
* @returns {Promise<Object[]>}
|
|
4234
|
-
*/
|
|
4235
|
-
export async function querySelectorAll(session, selector) {
|
|
4236
|
-
const locator = createElementLocator(session);
|
|
4237
|
-
return locator.querySelectorAll(selector);
|
|
4238
|
-
}
|
|
4239
|
-
|
|
4240
|
-
/**
|
|
4241
|
-
* Find an element with nodeId for compatibility
|
|
4242
|
-
* @param {Object} session - CDP session
|
|
4243
|
-
* @param {string} selector - CSS selector
|
|
4244
|
-
* @param {Object} [options] - Options
|
|
4245
|
-
* @returns {Promise<{nodeId: string, box: Object, dispose: Function}|null>}
|
|
4246
|
-
*/
|
|
4247
|
-
export async function findElement(session, selector, options = {}) {
|
|
4248
|
-
const locator = createElementLocator(session, options);
|
|
4249
|
-
const element = await locator.querySelector(selector);
|
|
4250
|
-
if (!element) return null;
|
|
4251
|
-
|
|
4252
|
-
const box = await element.getBoundingBox();
|
|
4253
|
-
return {
|
|
4254
|
-
nodeId: element.objectId,
|
|
4255
|
-
box,
|
|
4256
|
-
dispose: () => element.dispose()
|
|
4257
|
-
};
|
|
4258
|
-
}
|
|
4259
|
-
|
|
4260
|
-
/**
|
|
4261
|
-
* Get bounding box for an element by objectId
|
|
4262
|
-
* @param {Object} session - CDP session
|
|
4263
|
-
* @param {string} objectId - Object ID
|
|
4264
|
-
* @returns {Promise<{x: number, y: number, width: number, height: number}|null>}
|
|
4265
|
-
*/
|
|
4266
|
-
export async function getBoundingBox(session, objectId) {
|
|
4267
|
-
const locator = createElementLocator(session);
|
|
4268
|
-
return locator.getBoundingBox(objectId);
|
|
4269
|
-
}
|
|
4270
|
-
|
|
4271
|
-
/**
|
|
4272
|
-
* Check if an element is visible
|
|
4273
|
-
* @param {Object} session - CDP session
|
|
4274
|
-
* @param {string} objectId - Object ID
|
|
4275
|
-
* @returns {Promise<boolean>}
|
|
4276
|
-
*/
|
|
4277
|
-
export async function isVisible(session, objectId) {
|
|
4278
|
-
const handle = createElementHandle(session, objectId);
|
|
4279
|
-
try {
|
|
4280
|
-
return await handle.isVisible();
|
|
4281
|
-
} finally {
|
|
4282
|
-
await handle.dispose();
|
|
4283
|
-
}
|
|
4284
|
-
}
|
|
4285
|
-
|
|
4286
|
-
/**
|
|
4287
|
-
* Check if an element is actionable
|
|
4288
|
-
* @param {Object} session - CDP session
|
|
4289
|
-
* @param {string} objectId - Object ID
|
|
4290
|
-
* @returns {Promise<{actionable: boolean, reason: string|null}>}
|
|
4291
|
-
*/
|
|
4292
|
-
export async function isActionable(session, objectId) {
|
|
4293
|
-
const handle = createElementHandle(session, objectId);
|
|
4294
|
-
try {
|
|
4295
|
-
return await handle.isActionable();
|
|
4296
|
-
} finally {
|
|
4297
|
-
await handle.dispose();
|
|
4298
|
-
}
|
|
4299
|
-
}
|
|
4300
|
-
|
|
4301
|
-
/**
|
|
4302
|
-
* Scroll element into view
|
|
4303
|
-
* @param {Object} session - CDP session
|
|
4304
|
-
* @param {string} objectId - Object ID
|
|
4305
|
-
* @returns {Promise<void>}
|
|
4306
|
-
*/
|
|
4307
|
-
export async function scrollIntoView(session, objectId) {
|
|
4308
|
-
const handle = createElementHandle(session, objectId);
|
|
4309
|
-
try {
|
|
4310
|
-
await handle.scrollIntoView();
|
|
4311
|
-
} finally {
|
|
4312
|
-
await handle.dispose();
|
|
4313
|
-
}
|
|
4314
|
-
}
|
|
4315
|
-
|
|
4316
|
-
/**
|
|
4317
|
-
* Click at coordinates
|
|
4318
|
-
* @param {Object} session - CDP session
|
|
4319
|
-
* @param {number} x - X coordinate
|
|
4320
|
-
* @param {number} y - Y coordinate
|
|
4321
|
-
* @param {Object} [options] - Click options
|
|
4322
|
-
* @returns {Promise<void>}
|
|
4323
|
-
*/
|
|
4324
|
-
export async function click(session, x, y, options = {}) {
|
|
4325
|
-
const input = createInputEmulator(session);
|
|
4326
|
-
return input.click(x, y, options);
|
|
4327
|
-
}
|
|
4328
|
-
|
|
4329
|
-
/**
|
|
4330
|
-
* Type text
|
|
4331
|
-
* @param {Object} session - CDP session
|
|
4332
|
-
* @param {string} text - Text to type
|
|
4333
|
-
* @param {Object} [options] - Type options
|
|
4334
|
-
* @returns {Promise<void>}
|
|
4335
|
-
*/
|
|
4336
|
-
export async function type(session, text, options = {}) {
|
|
4337
|
-
const input = createInputEmulator(session);
|
|
4338
|
-
return input.type(text, options);
|
|
4339
|
-
}
|
|
4340
|
-
|
|
4341
|
-
/**
|
|
4342
|
-
* Fill input at coordinates
|
|
4343
|
-
* @param {Object} session - CDP session
|
|
4344
|
-
* @param {number} x - X coordinate
|
|
4345
|
-
* @param {number} y - Y coordinate
|
|
4346
|
-
* @param {string} text - Text to fill
|
|
4347
|
-
* @param {Object} [options] - Fill options
|
|
4348
|
-
* @returns {Promise<void>}
|
|
4349
|
-
*/
|
|
4350
|
-
export async function fill(session, x, y, text, options = {}) {
|
|
4351
|
-
const input = createInputEmulator(session);
|
|
4352
|
-
return input.fill(x, y, text, options);
|
|
4353
|
-
}
|
|
4354
|
-
|
|
4355
|
-
/**
|
|
4356
|
-
* Press a key
|
|
4357
|
-
* @param {Object} session - CDP session
|
|
4358
|
-
* @param {string} key - Key to press
|
|
4359
|
-
* @param {Object} [options] - Press options
|
|
4360
|
-
* @returns {Promise<void>}
|
|
4361
|
-
*/
|
|
4362
|
-
export async function press(session, key, options = {}) {
|
|
4363
|
-
const input = createInputEmulator(session);
|
|
4364
|
-
return input.press(key, options);
|
|
4365
|
-
}
|
|
4366
|
-
|
|
4367
|
-
/**
|
|
4368
|
-
* Scroll the page
|
|
4369
|
-
* @param {Object} session - CDP session
|
|
4370
|
-
* @param {number} deltaX - Horizontal scroll
|
|
4371
|
-
* @param {number} deltaY - Vertical scroll
|
|
4372
|
-
* @param {number} [x=100] - X origin
|
|
4373
|
-
* @param {number} [y=100] - Y origin
|
|
4374
|
-
* @returns {Promise<void>}
|
|
4375
|
-
*/
|
|
4376
|
-
export async function scroll(session, deltaX, deltaY, x = 100, y = 100) {
|
|
4377
|
-
const input = createInputEmulator(session);
|
|
4378
|
-
return input.scroll(deltaX, deltaY, x, y);
|
|
4379
|
-
}
|