cdp-skill 1.0.0

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