cdp-skill 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +3 -0
  2. package/SKILL.md +34 -5
  3. package/package.json +2 -1
  4. package/src/capture/console-capture.js +241 -0
  5. package/src/capture/debug-capture.js +144 -0
  6. package/src/capture/error-aggregator.js +151 -0
  7. package/src/capture/eval-serializer.js +320 -0
  8. package/src/capture/index.js +40 -0
  9. package/src/capture/network-capture.js +211 -0
  10. package/src/capture/pdf-capture.js +256 -0
  11. package/src/capture/screenshot-capture.js +325 -0
  12. package/src/cdp/browser.js +569 -0
  13. package/src/cdp/connection.js +369 -0
  14. package/src/cdp/discovery.js +138 -0
  15. package/src/cdp/index.js +29 -0
  16. package/src/cdp/target-and-session.js +439 -0
  17. package/src/cdp-skill.js +25 -11
  18. package/src/constants.js +79 -0
  19. package/src/dom/actionability.js +638 -0
  20. package/src/dom/click-executor.js +923 -0
  21. package/src/dom/element-handle.js +496 -0
  22. package/src/dom/element-locator.js +475 -0
  23. package/src/dom/element-validator.js +120 -0
  24. package/src/dom/fill-executor.js +489 -0
  25. package/src/dom/index.js +248 -0
  26. package/src/dom/input-emulator.js +406 -0
  27. package/src/dom/keyboard-executor.js +202 -0
  28. package/src/dom/quad-helpers.js +89 -0
  29. package/src/dom/react-filler.js +94 -0
  30. package/src/dom/wait-executor.js +423 -0
  31. package/src/index.js +6 -6
  32. package/src/page/cookie-manager.js +202 -0
  33. package/src/page/dom-stability.js +181 -0
  34. package/src/page/index.js +36 -0
  35. package/src/{page.js → page/page-controller.js} +109 -839
  36. package/src/page/wait-utilities.js +302 -0
  37. package/src/page/web-storage-manager.js +108 -0
  38. package/src/runner/context-helpers.js +224 -0
  39. package/src/runner/execute-browser.js +518 -0
  40. package/src/runner/execute-form.js +315 -0
  41. package/src/runner/execute-input.js +308 -0
  42. package/src/runner/execute-interaction.js +672 -0
  43. package/src/runner/execute-navigation.js +180 -0
  44. package/src/runner/execute-query.js +771 -0
  45. package/src/runner/index.js +51 -0
  46. package/src/runner/step-executors.js +421 -0
  47. package/src/runner/step-validator.js +641 -0
  48. package/src/tests/Actionability.test.js +613 -0
  49. package/src/tests/BrowserClient.test.js +1 -1
  50. package/src/tests/ChromeDiscovery.test.js +1 -1
  51. package/src/tests/ClickExecutor.test.js +554 -0
  52. package/src/tests/ConsoleCapture.test.js +1 -1
  53. package/src/tests/ContextHelpers.test.js +453 -0
  54. package/src/tests/CookieManager.test.js +450 -0
  55. package/src/tests/DebugCapture.test.js +307 -0
  56. package/src/tests/ElementHandle.test.js +1 -1
  57. package/src/tests/ElementLocator.test.js +1 -1
  58. package/src/tests/ErrorAggregator.test.js +1 -1
  59. package/src/tests/EvalSerializer.test.js +391 -0
  60. package/src/tests/FillExecutor.test.js +611 -0
  61. package/src/tests/InputEmulator.test.js +1 -1
  62. package/src/tests/KeyboardExecutor.test.js +430 -0
  63. package/src/tests/NetworkErrorCapture.test.js +1 -1
  64. package/src/tests/PageController.test.js +1 -1
  65. package/src/tests/PdfCapture.test.js +333 -0
  66. package/src/tests/ScreenshotCapture.test.js +1 -1
  67. package/src/tests/SessionRegistry.test.js +1 -1
  68. package/src/tests/StepValidator.test.js +527 -0
  69. package/src/tests/TargetManager.test.js +1 -1
  70. package/src/tests/TestRunner.test.js +1 -1
  71. package/src/tests/WaitStrategy.test.js +1 -1
  72. package/src/tests/WaitUtilities.test.js +508 -0
  73. package/src/tests/WebStorageManager.test.js +333 -0
  74. package/src/types.js +309 -0
  75. package/src/capture.js +0 -1400
  76. package/src/cdp.js +0 -1286
  77. package/src/dom.js +0 -4379
  78. package/src/runner.js +0 -3676
@@ -0,0 +1,638 @@
1
+ /**
2
+ * Actionability Checker
3
+ * Playwright-style auto-waiting for element actionability
4
+ *
5
+ * EXPORTS:
6
+ * - createActionabilityChecker(session) → ActionabilityChecker
7
+ * Methods: waitForActionable, getClickablePoint, checkHitTarget, checkPointerEvents,
8
+ * checkCovered, checkVisible, checkEnabled, checkEditable, checkStable,
9
+ * getRequiredStates, scrollUntilVisible
10
+ *
11
+ * DEPENDENCIES:
12
+ * - ../constants.js: TIMEOUTS
13
+ * - ../utils.js: sleep, releaseObject
14
+ */
15
+
16
+ import { TIMEOUTS } from '../constants.js';
17
+ import { sleep, releaseObject } from '../utils.js';
18
+
19
+ // Configurable stability check frame count
20
+ const stableFrameCount = 3;
21
+
22
+ /**
23
+ * Create an actionability checker for Playwright-style auto-waiting
24
+ * @param {Object} session - CDP session
25
+ * @returns {Object} Actionability checker interface
26
+ */
27
+ export function createActionabilityChecker(session) {
28
+ // Simplified: removed stability check, shorter retry delays
29
+ const retryDelays = [0, 50, 100, 200];
30
+
31
+ function getRequiredStates(actionType) {
32
+ // Removed 'stable' requirement - it caused timeouts on elements with CSS transitions
33
+ // Zero-size elements are handled separately with JS click fallback
34
+ switch (actionType) {
35
+ case 'click':
36
+ return ['attached']; // Just check element exists and is connected
37
+ case 'hover':
38
+ return ['attached'];
39
+ case 'fill':
40
+ case 'type':
41
+ return ['attached', 'editable'];
42
+ case 'select':
43
+ return ['attached'];
44
+ default:
45
+ return ['attached'];
46
+ }
47
+ }
48
+
49
+ async function findElementInternal(selector) {
50
+ try {
51
+ const result = await session.send('Runtime.evaluate', {
52
+ expression: `document.querySelector(${JSON.stringify(selector)})`,
53
+ returnByValue: false
54
+ });
55
+
56
+ if (result.result.subtype === 'null' || !result.result.objectId) {
57
+ return { success: false, error: `Element not found: ${selector}` };
58
+ }
59
+
60
+ return { success: true, objectId: result.result.objectId };
61
+ } catch (error) {
62
+ return { success: false, error: error.message };
63
+ }
64
+ }
65
+
66
+ async function checkVisible(objectId) {
67
+ try {
68
+ const result = await session.send('Runtime.callFunctionOn', {
69
+ objectId,
70
+ functionDeclaration: `function() {
71
+ const el = this;
72
+ if (!el.isConnected) {
73
+ return { matches: false, received: 'detached' };
74
+ }
75
+ const style = window.getComputedStyle(el);
76
+ if (style.visibility === 'hidden') {
77
+ return { matches: false, received: 'visibility:hidden' };
78
+ }
79
+ if (style.display === 'none') {
80
+ return { matches: false, received: 'display:none' };
81
+ }
82
+ const rect = el.getBoundingClientRect();
83
+ if (rect.width === 0 || rect.height === 0) {
84
+ return { matches: false, received: 'zero-size' };
85
+ }
86
+ if (parseFloat(style.opacity) === 0) {
87
+ return { matches: false, received: 'opacity:0' };
88
+ }
89
+ return { matches: true, received: 'visible' };
90
+ }`,
91
+ returnByValue: true
92
+ });
93
+ return result.result.value;
94
+ } catch (error) {
95
+ return { matches: false, received: 'error', error: error.message };
96
+ }
97
+ }
98
+
99
+ async function checkEnabled(objectId) {
100
+ try {
101
+ const result = await session.send('Runtime.callFunctionOn', {
102
+ objectId,
103
+ functionDeclaration: `function() {
104
+ const el = this;
105
+ if (el.disabled === true) {
106
+ return { matches: false, received: 'disabled' };
107
+ }
108
+ if (el.getAttribute('aria-disabled') === 'true') {
109
+ return { matches: false, received: 'aria-disabled' };
110
+ }
111
+ const fieldset = el.closest('fieldset');
112
+ if (fieldset && fieldset.disabled) {
113
+ const legend = fieldset.querySelector('legend');
114
+ if (!legend || !legend.contains(el)) {
115
+ return { matches: false, received: 'fieldset-disabled' };
116
+ }
117
+ }
118
+ return { matches: true, received: 'enabled' };
119
+ }`,
120
+ returnByValue: true
121
+ });
122
+ return result.result.value;
123
+ } catch (error) {
124
+ return { matches: false, received: 'error', error: error.message };
125
+ }
126
+ }
127
+
128
+ async function checkEditable(objectId) {
129
+ const enabledCheck = await checkEnabled(objectId);
130
+ if (!enabledCheck.matches) {
131
+ return enabledCheck;
132
+ }
133
+
134
+ try {
135
+ const result = await session.send('Runtime.callFunctionOn', {
136
+ objectId,
137
+ functionDeclaration: `function() {
138
+ const el = this;
139
+ const tagName = el.tagName.toLowerCase();
140
+ if (el.readOnly === true) {
141
+ return { matches: false, received: 'readonly' };
142
+ }
143
+ if (el.getAttribute('aria-readonly') === 'true') {
144
+ return { matches: false, received: 'aria-readonly' };
145
+ }
146
+ const isFormElement = ['input', 'textarea', 'select'].includes(tagName);
147
+ const isContentEditable = el.isContentEditable;
148
+ if (!isFormElement && !isContentEditable) {
149
+ return { matches: false, received: 'not-editable-element' };
150
+ }
151
+ if (tagName === 'input') {
152
+ const type = el.type.toLowerCase();
153
+ const textInputTypes = ['text', 'password', 'email', 'number', 'search', 'tel', 'url', 'date', 'datetime-local', 'month', 'time', 'week'];
154
+ if (!textInputTypes.includes(type)) {
155
+ return { matches: false, received: 'non-text-input' };
156
+ }
157
+ }
158
+ return { matches: true, received: 'editable' };
159
+ }`,
160
+ returnByValue: true
161
+ });
162
+ return result.result.value;
163
+ } catch (error) {
164
+ return { matches: false, received: 'error', error: error.message };
165
+ }
166
+ }
167
+
168
+ async function checkStable(objectId) {
169
+ try {
170
+ const result = await session.send('Runtime.callFunctionOn', {
171
+ objectId,
172
+ functionDeclaration: `async function() {
173
+ const el = this;
174
+ const frameCount = ${stableFrameCount};
175
+ if (!el.isConnected) {
176
+ return { matches: false, received: 'detached' };
177
+ }
178
+ let lastRect = null;
179
+ let stableCount = 0;
180
+ const getRect = () => {
181
+ const r = el.getBoundingClientRect();
182
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
183
+ };
184
+ const checkFrame = () => new Promise(resolve => {
185
+ requestAnimationFrame(() => {
186
+ if (!el.isConnected) {
187
+ resolve({ matches: false, received: 'detached' });
188
+ return;
189
+ }
190
+ const rect = getRect();
191
+ if (lastRect) {
192
+ const same = rect.x === lastRect.x &&
193
+ rect.y === lastRect.y &&
194
+ rect.width === lastRect.width &&
195
+ rect.height === lastRect.height;
196
+ if (same) {
197
+ stableCount++;
198
+ if (stableCount >= frameCount) {
199
+ resolve({ matches: true, received: 'stable' });
200
+ return;
201
+ }
202
+ } else {
203
+ stableCount = 0;
204
+ }
205
+ }
206
+ lastRect = rect;
207
+ resolve(null);
208
+ });
209
+ });
210
+ for (let i = 0; i < 10; i++) {
211
+ const result = await checkFrame();
212
+ if (result !== null) {
213
+ return result;
214
+ }
215
+ }
216
+ return { matches: false, received: 'unstable' };
217
+ }`,
218
+ returnByValue: true,
219
+ awaitPromise: true
220
+ });
221
+ return result.result.value;
222
+ } catch (error) {
223
+ return { matches: false, received: 'error', error: error.message };
224
+ }
225
+ }
226
+
227
+ async function checkAttached(objectId) {
228
+ try {
229
+ const result = await session.send('Runtime.callFunctionOn', {
230
+ objectId,
231
+ functionDeclaration: `function() {
232
+ return { matches: this.isConnected, received: this.isConnected ? 'attached' : 'detached' };
233
+ }`,
234
+ returnByValue: true
235
+ });
236
+ return result.result.value;
237
+ } catch (error) {
238
+ return { matches: false, received: 'error', error: error.message };
239
+ }
240
+ }
241
+
242
+ async function checkState(objectId, state) {
243
+ switch (state) {
244
+ case 'attached':
245
+ return checkAttached(objectId);
246
+ case 'visible':
247
+ return checkVisible(objectId);
248
+ case 'enabled':
249
+ return checkEnabled(objectId);
250
+ case 'editable':
251
+ return checkEditable(objectId);
252
+ case 'stable':
253
+ return checkStable(objectId);
254
+ default:
255
+ return { matches: true };
256
+ }
257
+ }
258
+
259
+ async function checkStates(objectId, states) {
260
+ for (const state of states) {
261
+ const check = await checkState(objectId, state);
262
+ if (!check.matches) {
263
+ return { success: false, missingState: state, received: check.received };
264
+ }
265
+ }
266
+ return { success: true };
267
+ }
268
+
269
+ async function waitForActionable(selector, actionType, opts = {}) {
270
+ // Simplified: shorter default timeout (5s), simpler retry logic
271
+ const { timeout = 5000, force = false } = opts;
272
+ const startTime = Date.now();
273
+
274
+ const requiredStates = getRequiredStates(actionType);
275
+
276
+ // Force mode: just find the element, skip all checks
277
+ if (force) {
278
+ const element = await findElementInternal(selector);
279
+ if (!element.success) {
280
+ return element;
281
+ }
282
+ return { success: true, objectId: element.objectId, forced: true };
283
+ }
284
+
285
+ let retry = 0;
286
+ let lastError = null;
287
+ let lastObjectId = null;
288
+
289
+ while (Date.now() - startTime < timeout) {
290
+ if (retry > 0) {
291
+ const delay = retryDelays[Math.min(retry - 1, retryDelays.length - 1)];
292
+ if (delay > 0) {
293
+ await sleep(delay);
294
+ }
295
+ }
296
+
297
+ if (lastObjectId) {
298
+ await releaseObject(session, lastObjectId);
299
+ lastObjectId = null;
300
+ }
301
+
302
+ const element = await findElementInternal(selector);
303
+ if (!element.success) {
304
+ lastError = element.error;
305
+ retry++;
306
+ continue;
307
+ }
308
+
309
+ lastObjectId = element.objectId;
310
+
311
+ const stateCheck = await checkStates(element.objectId, requiredStates);
312
+
313
+ if (stateCheck.success) {
314
+ return { success: true, objectId: element.objectId };
315
+ }
316
+
317
+ lastError = `Element is not ${stateCheck.missingState}: ${stateCheck.received}`;
318
+ retry++;
319
+ }
320
+
321
+ if (lastObjectId) {
322
+ await releaseObject(session, lastObjectId);
323
+ }
324
+
325
+ return {
326
+ success: false,
327
+ error: lastError || `Element not found: ${selector} (timeout: ${timeout}ms)`
328
+ };
329
+ }
330
+
331
+ async function getClickablePoint(objectId) {
332
+ try {
333
+ const result = await session.send('Runtime.callFunctionOn', {
334
+ objectId,
335
+ functionDeclaration: `function() {
336
+ const el = this;
337
+ const rect = el.getBoundingClientRect();
338
+ return {
339
+ x: rect.x + rect.width / 2,
340
+ y: rect.y + rect.height / 2,
341
+ rect: {
342
+ x: rect.x,
343
+ y: rect.y,
344
+ width: rect.width,
345
+ height: rect.height
346
+ }
347
+ };
348
+ }`,
349
+ returnByValue: true
350
+ });
351
+ return result.result.value;
352
+ } catch {
353
+ return null;
354
+ }
355
+ }
356
+
357
+ async function checkHitTarget(objectId, point) {
358
+ try {
359
+ const result = await session.send('Runtime.callFunctionOn', {
360
+ objectId,
361
+ functionDeclaration: `function(point) {
362
+ const el = this;
363
+ const hitEl = document.elementFromPoint(point.x, point.y);
364
+ if (!hitEl) {
365
+ return { matches: false, received: 'no-element-at-point' };
366
+ }
367
+ if (hitEl === el || el.contains(hitEl)) {
368
+ return { matches: true, received: 'hit' };
369
+ }
370
+ let desc = hitEl.tagName.toLowerCase();
371
+ if (hitEl.id) desc += '#' + hitEl.id;
372
+ if (hitEl.className && typeof hitEl.className === 'string') {
373
+ desc += '.' + hitEl.className.split(' ').filter(c => c).join('.');
374
+ }
375
+ return {
376
+ matches: false,
377
+ received: 'blocked',
378
+ blockedBy: desc
379
+ };
380
+ }`,
381
+ arguments: [{ value: point }],
382
+ returnByValue: true
383
+ });
384
+ return result.result.value;
385
+ } catch (error) {
386
+ return { matches: false, received: 'error', error: error.message };
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Check if pointer-events CSS allows clicking
392
+ * Elements with pointer-events: none cannot receive click events
393
+ * @param {string} objectId - Element object ID
394
+ * @returns {Promise<{clickable: boolean, pointerEvents: string}>}
395
+ */
396
+ async function checkPointerEvents(objectId) {
397
+ try {
398
+ const result = await session.send('Runtime.callFunctionOn', {
399
+ objectId,
400
+ functionDeclaration: `function() {
401
+ const el = this;
402
+ const style = window.getComputedStyle(el);
403
+ const pointerEvents = style.pointerEvents;
404
+
405
+ // Check if element or any ancestor has pointer-events: none
406
+ let current = el;
407
+ while (current) {
408
+ const currentStyle = window.getComputedStyle(current);
409
+ if (currentStyle.pointerEvents === 'none') {
410
+ return {
411
+ clickable: false,
412
+ pointerEvents: 'none',
413
+ blockedBy: current === el ? 'self' : current.tagName.toLowerCase()
414
+ };
415
+ }
416
+ current = current.parentElement;
417
+ }
418
+
419
+ return { clickable: true, pointerEvents: pointerEvents || 'auto' };
420
+ }`,
421
+ returnByValue: true
422
+ });
423
+ return result.result.value;
424
+ } catch (error) {
425
+ return { clickable: true, pointerEvents: 'unknown', error: error.message };
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Detect covered elements using CDP DOM.getNodeForLocation
431
+ * Inspired by Rod's Interactable() method
432
+ * @param {string} objectId - Element object ID
433
+ * @param {{x: number, y: number}} point - Click coordinates
434
+ * @returns {Promise<{covered: boolean, coveringElement?: string}>}
435
+ */
436
+ async function checkCovered(objectId, point) {
437
+ try {
438
+ // Get the backend node ID for the target element
439
+ const nodeResult = await session.send('DOM.describeNode', { objectId });
440
+ const targetBackendNodeId = nodeResult.node.backendNodeId;
441
+
442
+ // Use DOM.getNodeForLocation to see what element is actually at the click point
443
+ const locationResult = await session.send('DOM.getNodeForLocation', {
444
+ x: Math.floor(point.x),
445
+ y: Math.floor(point.y),
446
+ includeUserAgentShadowDOM: false
447
+ });
448
+
449
+ const hitBackendNodeId = locationResult.backendNodeId;
450
+
451
+ // If the hit element matches our target, it's not covered
452
+ if (hitBackendNodeId === targetBackendNodeId) {
453
+ return { covered: false };
454
+ }
455
+
456
+ // Check if the hit element is a child of our target (also valid)
457
+ const isChild = await session.send('Runtime.callFunctionOn', {
458
+ objectId,
459
+ functionDeclaration: `function(hitNodeId) {
460
+ // We need to find if the hit element is inside this element
461
+ // This is tricky because we only have backend node IDs
462
+ // Use elementFromPoint as a fallback check
463
+ const rect = this.getBoundingClientRect();
464
+ const centerX = rect.left + rect.width / 2;
465
+ const centerY = rect.top + rect.height / 2;
466
+ const hitEl = document.elementFromPoint(centerX, centerY);
467
+
468
+ if (!hitEl) return { isChild: false, coverInfo: 'no-element' };
469
+
470
+ if (hitEl === this || this.contains(hitEl)) {
471
+ return { isChild: true };
472
+ }
473
+
474
+ // Get info about the covering element
475
+ let desc = hitEl.tagName.toLowerCase();
476
+ if (hitEl.id) desc += '#' + hitEl.id;
477
+ if (hitEl.className && typeof hitEl.className === 'string') {
478
+ const classes = hitEl.className.split(' ').filter(c => c).slice(0, 3);
479
+ if (classes.length > 0) desc += '.' + classes.join('.');
480
+ }
481
+
482
+ return { isChild: false, coverInfo: desc };
483
+ }`,
484
+ returnByValue: true
485
+ });
486
+
487
+ const childResult = isChild.result.value;
488
+
489
+ if (childResult.isChild) {
490
+ return { covered: false };
491
+ }
492
+
493
+ return {
494
+ covered: true,
495
+ coveringElement: childResult.coverInfo || 'unknown'
496
+ };
497
+ } catch (error) {
498
+ // If DOM methods fail, fall back to elementFromPoint check
499
+ try {
500
+ const fallbackResult = await session.send('Runtime.callFunctionOn', {
501
+ objectId,
502
+ functionDeclaration: `function() {
503
+ const rect = this.getBoundingClientRect();
504
+ const centerX = rect.left + rect.width / 2;
505
+ const centerY = rect.top + rect.height / 2;
506
+ const hitEl = document.elementFromPoint(centerX, centerY);
507
+
508
+ if (!hitEl) return { covered: true, coverInfo: 'no-element-at-center' };
509
+ if (hitEl === this || this.contains(hitEl)) return { covered: false };
510
+
511
+ let desc = hitEl.tagName.toLowerCase();
512
+ if (hitEl.id) desc += '#' + hitEl.id;
513
+ return { covered: true, coverInfo: desc };
514
+ }`,
515
+ returnByValue: true
516
+ });
517
+ return {
518
+ covered: fallbackResult.result.value.covered,
519
+ coveringElement: fallbackResult.result.value.coverInfo
520
+ };
521
+ } catch {
522
+ return { covered: false, error: error.message };
523
+ }
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Scroll incrementally until an element becomes visible
529
+ * Useful for lazy-loaded content or infinite scroll pages
530
+ * @param {string} selector - CSS selector for the element
531
+ * @param {Object} [options] - Scroll options
532
+ * @param {number} [options.maxScrolls=10] - Maximum number of scroll attempts
533
+ * @param {number} [options.scrollAmount=500] - Pixels to scroll each attempt
534
+ * @param {number} [options.timeout=30000] - Total timeout in ms
535
+ * @param {string} [options.direction='down'] - Scroll direction ('down' or 'up')
536
+ * @returns {Promise<{found: boolean, objectId?: string, scrollCount: number}>}
537
+ */
538
+ async function scrollUntilVisible(selector, options = {}) {
539
+ const {
540
+ maxScrolls = 10,
541
+ scrollAmount = 500,
542
+ timeout = 30000,
543
+ direction = 'down'
544
+ } = options;
545
+
546
+ const startTime = Date.now();
547
+ let scrollCount = 0;
548
+
549
+ while (scrollCount < maxScrolls && (Date.now() - startTime) < timeout) {
550
+ // Try to find the element
551
+ const findResult = await findElementInternal(selector);
552
+
553
+ if (findResult.success) {
554
+ // Check if visible
555
+ const visibleResult = await checkVisible(findResult.objectId);
556
+ if (visibleResult.matches) {
557
+ return {
558
+ found: true,
559
+ objectId: findResult.objectId,
560
+ scrollCount,
561
+ visibleAfterScrolls: scrollCount
562
+ };
563
+ }
564
+
565
+ // Element exists but not visible, try scrolling it into view
566
+ try {
567
+ await session.send('Runtime.callFunctionOn', {
568
+ objectId: findResult.objectId,
569
+ functionDeclaration: `function() {
570
+ this.scrollIntoView({ block: 'center', behavior: 'instant' });
571
+ }`
572
+ });
573
+ await sleep(100);
574
+
575
+ // Check visibility again
576
+ const visibleAfterScroll = await checkVisible(findResult.objectId);
577
+ if (visibleAfterScroll.matches) {
578
+ return {
579
+ found: true,
580
+ objectId: findResult.objectId,
581
+ scrollCount,
582
+ scrolledIntoView: true
583
+ };
584
+ }
585
+ } catch {
586
+ // Failed to scroll into view, continue with page scrolling
587
+ }
588
+
589
+ // Release the object as we'll search again
590
+ await releaseObject(session, findResult.objectId);
591
+ }
592
+
593
+ // Scroll the page
594
+ const scrollDir = direction === 'up' ? -scrollAmount : scrollAmount;
595
+ await session.send('Runtime.evaluate', {
596
+ expression: `window.scrollBy(0, ${scrollDir})`
597
+ });
598
+
599
+ scrollCount++;
600
+ await sleep(200); // Wait for content to load/render
601
+ }
602
+
603
+ // Final attempt to find the element
604
+ const finalResult = await findElementInternal(selector);
605
+ if (finalResult.success) {
606
+ const visibleResult = await checkVisible(finalResult.objectId);
607
+ if (visibleResult.matches) {
608
+ return {
609
+ found: true,
610
+ objectId: finalResult.objectId,
611
+ scrollCount,
612
+ foundOnFinalCheck: true
613
+ };
614
+ }
615
+ await releaseObject(session, finalResult.objectId);
616
+ }
617
+
618
+ return {
619
+ found: false,
620
+ scrollCount,
621
+ reason: scrollCount >= maxScrolls ? 'maxScrollsReached' : 'timeout'
622
+ };
623
+ }
624
+
625
+ return {
626
+ waitForActionable,
627
+ getClickablePoint,
628
+ checkHitTarget,
629
+ checkPointerEvents,
630
+ checkCovered,
631
+ checkVisible,
632
+ checkEnabled,
633
+ checkEditable,
634
+ checkStable,
635
+ getRequiredStates,
636
+ scrollUntilVisible
637
+ };
638
+ }