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.
Files changed (78) hide show
  1. package/README.md +3 -0
  2. package/SKILL.md +34 -5
  3. package/package.json +2 -1
  4. package/src/capture/console-capture.js +241 -0
  5. package/src/capture/debug-capture.js +144 -0
  6. package/src/capture/error-aggregator.js +151 -0
  7. package/src/capture/eval-serializer.js +320 -0
  8. package/src/capture/index.js +40 -0
  9. package/src/capture/network-capture.js +211 -0
  10. package/src/capture/pdf-capture.js +256 -0
  11. package/src/capture/screenshot-capture.js +325 -0
  12. package/src/cdp/browser.js +569 -0
  13. package/src/cdp/connection.js +369 -0
  14. package/src/cdp/discovery.js +138 -0
  15. package/src/cdp/index.js +29 -0
  16. package/src/cdp/target-and-session.js +439 -0
  17. package/src/cdp-skill.js +25 -11
  18. package/src/constants.js +79 -0
  19. package/src/dom/actionability.js +638 -0
  20. package/src/dom/click-executor.js +923 -0
  21. package/src/dom/element-handle.js +496 -0
  22. package/src/dom/element-locator.js +475 -0
  23. package/src/dom/element-validator.js +120 -0
  24. package/src/dom/fill-executor.js +489 -0
  25. package/src/dom/index.js +248 -0
  26. package/src/dom/input-emulator.js +406 -0
  27. package/src/dom/keyboard-executor.js +202 -0
  28. package/src/dom/quad-helpers.js +89 -0
  29. package/src/dom/react-filler.js +94 -0
  30. package/src/dom/wait-executor.js +423 -0
  31. package/src/index.js +6 -6
  32. package/src/page/cookie-manager.js +202 -0
  33. package/src/page/dom-stability.js +181 -0
  34. package/src/page/index.js +36 -0
  35. package/src/{page.js → page/page-controller.js} +109 -839
  36. package/src/page/wait-utilities.js +302 -0
  37. package/src/page/web-storage-manager.js +108 -0
  38. package/src/runner/context-helpers.js +224 -0
  39. package/src/runner/execute-browser.js +518 -0
  40. package/src/runner/execute-form.js +315 -0
  41. package/src/runner/execute-input.js +308 -0
  42. package/src/runner/execute-interaction.js +672 -0
  43. package/src/runner/execute-navigation.js +180 -0
  44. package/src/runner/execute-query.js +771 -0
  45. package/src/runner/index.js +51 -0
  46. package/src/runner/step-executors.js +421 -0
  47. package/src/runner/step-validator.js +641 -0
  48. package/src/tests/Actionability.test.js +613 -0
  49. package/src/tests/BrowserClient.test.js +1 -1
  50. package/src/tests/ChromeDiscovery.test.js +1 -1
  51. package/src/tests/ClickExecutor.test.js +554 -0
  52. package/src/tests/ConsoleCapture.test.js +1 -1
  53. package/src/tests/ContextHelpers.test.js +453 -0
  54. package/src/tests/CookieManager.test.js +450 -0
  55. package/src/tests/DebugCapture.test.js +307 -0
  56. package/src/tests/ElementHandle.test.js +1 -1
  57. package/src/tests/ElementLocator.test.js +1 -1
  58. package/src/tests/ErrorAggregator.test.js +1 -1
  59. package/src/tests/EvalSerializer.test.js +391 -0
  60. package/src/tests/FillExecutor.test.js +611 -0
  61. package/src/tests/InputEmulator.test.js +1 -1
  62. package/src/tests/KeyboardExecutor.test.js +430 -0
  63. package/src/tests/NetworkErrorCapture.test.js +1 -1
  64. package/src/tests/PageController.test.js +1 -1
  65. package/src/tests/PdfCapture.test.js +333 -0
  66. package/src/tests/ScreenshotCapture.test.js +1 -1
  67. package/src/tests/SessionRegistry.test.js +1 -1
  68. package/src/tests/StepValidator.test.js +527 -0
  69. package/src/tests/TargetManager.test.js +1 -1
  70. package/src/tests/TestRunner.test.js +1 -1
  71. package/src/tests/WaitStrategy.test.js +1 -1
  72. package/src/tests/WaitUtilities.test.js +508 -0
  73. package/src/tests/WebStorageManager.test.js +333 -0
  74. package/src/types.js +309 -0
  75. package/src/capture.js +0 -1400
  76. package/src/cdp.js +0 -1286
  77. package/src/dom.js +0 -4379
  78. 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
- }