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/page.js ADDED
@@ -0,0 +1,1720 @@
1
+ /**
2
+ * Page Operations and Waiting Utilities
3
+ * Navigation, lifecycle management, wait strategies, and storage management
4
+ *
5
+ * Consolidated: storage.js (cookie and web storage managers)
6
+ */
7
+
8
+ import {
9
+ navigationError,
10
+ navigationAbortedError,
11
+ timeoutError,
12
+ connectionError,
13
+ pageCrashedError,
14
+ contextDestroyedError,
15
+ isContextDestroyed,
16
+ resolveViewport,
17
+ sleep
18
+ } from './utils.js';
19
+
20
+ const MAX_TIMEOUT = 300000; // 5 minutes max timeout
21
+
22
+ // ============================================================================
23
+ // LCS DOM Stability (improvement #9)
24
+ // ============================================================================
25
+
26
+ /**
27
+ * Calculate Longest Common Subsequence length between two arrays
28
+ * Used for comparing DOM structure changes
29
+ * @param {Array} a - First array
30
+ * @param {Array} b - Second array
31
+ * @returns {number} Length of LCS
32
+ */
33
+ function lcsLength(a, b) {
34
+ const m = a.length;
35
+ const n = b.length;
36
+
37
+ // Use space-optimized version for large arrays
38
+ if (m > 1000 || n > 1000) {
39
+ // For very large arrays, use a simpler similarity metric
40
+ const setA = new Set(a);
41
+ const setB = new Set(b);
42
+ let common = 0;
43
+ for (const item of setA) {
44
+ if (setB.has(item)) common++;
45
+ }
46
+ return common;
47
+ }
48
+
49
+ // Standard DP solution
50
+ const dp = Array(n + 1).fill(0);
51
+
52
+ for (let i = 1; i <= m; i++) {
53
+ let prev = 0;
54
+ for (let j = 1; j <= n; j++) {
55
+ const temp = dp[j];
56
+ if (a[i - 1] === b[j - 1]) {
57
+ dp[j] = prev + 1;
58
+ } else {
59
+ dp[j] = Math.max(dp[j], dp[j - 1]);
60
+ }
61
+ prev = temp;
62
+ }
63
+ }
64
+
65
+ return dp[n];
66
+ }
67
+
68
+ /**
69
+ * Calculate similarity ratio between two arrays using LCS
70
+ * @param {Array} a - First array
71
+ * @param {Array} b - Second array
72
+ * @returns {number} Similarity ratio between 0 and 1
73
+ */
74
+ function lcsSimilarity(a, b) {
75
+ if (a.length === 0 && b.length === 0) return 1;
76
+ if (a.length === 0 || b.length === 0) return 0;
77
+
78
+ const lcs = lcsLength(a, b);
79
+ return (2 * lcs) / (a.length + b.length);
80
+ }
81
+
82
+ /**
83
+ * Get DOM structure signature for stability comparison
84
+ * @param {Object} session - CDP session
85
+ * @param {string} [selector='body'] - Root element selector
86
+ * @returns {Promise<string[]>} Array of element signatures
87
+ */
88
+ async function getDOMSignature(session, selector = 'body') {
89
+ const result = await session.send('Runtime.evaluate', {
90
+ expression: `
91
+ (function() {
92
+ const root = document.querySelector(${JSON.stringify(selector)}) || document.body;
93
+ if (!root) return [];
94
+
95
+ const signatures = [];
96
+ const walker = document.createTreeWalker(
97
+ root,
98
+ NodeFilter.SHOW_ELEMENT,
99
+ {
100
+ acceptNode: (node) => {
101
+ // Skip script, style, and hidden elements
102
+ if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'].includes(node.tagName)) {
103
+ return NodeFilter.FILTER_REJECT;
104
+ }
105
+ const style = window.getComputedStyle(node);
106
+ if (style.display === 'none' || style.visibility === 'hidden') {
107
+ return NodeFilter.FILTER_SKIP;
108
+ }
109
+ return NodeFilter.FILTER_ACCEPT;
110
+ }
111
+ }
112
+ );
113
+
114
+ let node;
115
+ let count = 0;
116
+ const maxNodes = 500; // Limit to prevent huge arrays
117
+
118
+ while ((node = walker.nextNode()) && count < maxNodes) {
119
+ // Create signature: tagName + key attributes
120
+ let sig = node.tagName.toLowerCase();
121
+ if (node.id) sig += '#' + node.id;
122
+ if (node.className && typeof node.className === 'string') {
123
+ // Only include first 2 class names to reduce noise
124
+ const classes = node.className.split(' ').filter(c => c).slice(0, 2);
125
+ if (classes.length > 0) sig += '.' + classes.join('.');
126
+ }
127
+ // Include text content hash for leaf nodes
128
+ if (!node.firstElementChild && node.textContent) {
129
+ const text = node.textContent.trim().slice(0, 50);
130
+ if (text) sig += ':' + text.length;
131
+ }
132
+ signatures.push(sig);
133
+ count++;
134
+ }
135
+
136
+ return signatures;
137
+ })()
138
+ `,
139
+ returnByValue: true
140
+ });
141
+
142
+ return result.result.value || [];
143
+ }
144
+
145
+ /**
146
+ * Check if DOM has stabilized by comparing structure over time
147
+ * Uses LCS to distinguish meaningful changes from cosmetic ones
148
+ * @param {Object} session - CDP session
149
+ * @param {Object} [options] - Options
150
+ * @param {string} [options.selector='body'] - Root element to check
151
+ * @param {number} [options.threshold=0.95] - Similarity threshold (0-1)
152
+ * @param {number} [options.checks=3] - Number of consecutive stable checks
153
+ * @param {number} [options.interval=100] - Ms between checks
154
+ * @param {number} [options.timeout=10000] - Total timeout
155
+ * @returns {Promise<{stable: boolean, similarity: number, checks: number}>}
156
+ */
157
+ async function waitForDOMStability(session, options = {}) {
158
+ const {
159
+ selector = 'body',
160
+ threshold = 0.95,
161
+ checks = 3,
162
+ interval = 100,
163
+ timeout = 10000
164
+ } = options;
165
+
166
+ const startTime = Date.now();
167
+ let lastSignature = await getDOMSignature(session, selector);
168
+ let stableCount = 0;
169
+ let lastSimilarity = 1;
170
+
171
+ while (Date.now() - startTime < timeout) {
172
+ await sleep(interval);
173
+
174
+ const currentSignature = await getDOMSignature(session, selector);
175
+ const similarity = lcsSimilarity(lastSignature, currentSignature);
176
+ lastSimilarity = similarity;
177
+
178
+ if (similarity >= threshold) {
179
+ stableCount++;
180
+ if (stableCount >= checks) {
181
+ return { stable: true, similarity, checks: stableCount };
182
+ }
183
+ } else {
184
+ stableCount = 0;
185
+ }
186
+
187
+ lastSignature = currentSignature;
188
+ }
189
+
190
+ return { stable: false, similarity: lastSimilarity, checks: stableCount };
191
+ }
192
+
193
+ // ============================================================================
194
+ // Wait Conditions
195
+ // ============================================================================
196
+
197
+ // Export LCS utilities for DOM stability checking
198
+ export { lcsLength, lcsSimilarity, getDOMSignature, waitForDOMStability };
199
+
200
+ /**
201
+ * Wait conditions for page navigation
202
+ */
203
+ export const WaitCondition = Object.freeze({
204
+ LOAD: 'load',
205
+ DOM_CONTENT_LOADED: 'domcontentloaded',
206
+ NETWORK_IDLE: 'networkidle',
207
+ COMMIT: 'commit'
208
+ });
209
+
210
+ // ============================================================================
211
+ // Page Controller
212
+ // ============================================================================
213
+
214
+ /**
215
+ * Create a page controller for navigation and lifecycle events
216
+ * @param {Object} cdpClient - CDP client with send/on/off methods
217
+ * @returns {Object} Page controller interface
218
+ */
219
+ export function createPageController(cdpClient) {
220
+ let mainFrameId = null;
221
+ let currentFrameId = null;
222
+ let currentExecutionContextId = null;
223
+ const frameExecutionContexts = new Map(); // frameId -> executionContextId
224
+ const lifecycleEvents = new Map();
225
+ const lifecycleWaiters = new Set();
226
+ const pendingRequests = new Set();
227
+ let networkIdleTimer = null;
228
+ const eventListeners = [];
229
+ const networkIdleDelay = 500;
230
+ let navigationInProgress = false;
231
+ let currentNavigationAbort = null;
232
+ let currentNavigationUrl = null;
233
+ let pageCrashed = false;
234
+ const crashWaiters = new Set();
235
+ const abortWaiters = new Set();
236
+
237
+ // Network idle counter (improvement #10)
238
+ // Tracks request counts for precise network quiet detection
239
+ let networkRequestCount = 0;
240
+ let networkIdleWaiters = new Set();
241
+ let lastNetworkActivity = Date.now();
242
+
243
+ function validateTimeout(timeout) {
244
+ if (typeof timeout !== 'number' || !Number.isFinite(timeout)) return 30000;
245
+ if (timeout < 0) return 0;
246
+ if (timeout > MAX_TIMEOUT) return MAX_TIMEOUT;
247
+ return timeout;
248
+ }
249
+
250
+ function resetNetworkIdleTimer() {
251
+ if (networkIdleTimer) {
252
+ clearTimeout(networkIdleTimer);
253
+ networkIdleTimer = null;
254
+ }
255
+ }
256
+
257
+ function addLifecycleWaiter(callback) {
258
+ lifecycleWaiters.add(callback);
259
+ }
260
+
261
+ function removeLifecycleWaiter(callback) {
262
+ lifecycleWaiters.delete(callback);
263
+ }
264
+
265
+ function notifyWaiters(frameId, eventName) {
266
+ for (const waiter of lifecycleWaiters) {
267
+ waiter(frameId, eventName);
268
+ }
269
+ }
270
+
271
+ function addCrashWaiter(rejectFn) {
272
+ crashWaiters.add(rejectFn);
273
+ }
274
+
275
+ function removeCrashWaiter(rejectFn) {
276
+ crashWaiters.delete(rejectFn);
277
+ }
278
+
279
+ function addAbortWaiter(rejectFn) {
280
+ abortWaiters.add(rejectFn);
281
+ }
282
+
283
+ function removeAbortWaiter(rejectFn) {
284
+ abortWaiters.delete(rejectFn);
285
+ }
286
+
287
+ function notifyAbortWaiters(error) {
288
+ for (const rejectFn of abortWaiters) {
289
+ rejectFn(error);
290
+ }
291
+ abortWaiters.clear();
292
+ }
293
+
294
+ function addListener(event, handler) {
295
+ cdpClient.on(event, handler);
296
+ eventListeners.push({ event, handler });
297
+ }
298
+
299
+ function onLifecycleEvent({ frameId, name }) {
300
+ if (!lifecycleEvents.has(frameId)) {
301
+ lifecycleEvents.set(frameId, new Set());
302
+ }
303
+ lifecycleEvents.get(frameId).add(name);
304
+ notifyWaiters(frameId, name);
305
+ }
306
+
307
+ function onFrameNavigated({ frame }) {
308
+ if (!frame.parentId) {
309
+ mainFrameId = frame.id;
310
+ }
311
+ }
312
+
313
+ function onRequestStarted({ requestId, frameId }) {
314
+ if (frameId && frameId !== mainFrameId) return;
315
+ pendingRequests.add(requestId);
316
+ networkRequestCount++;
317
+ lastNetworkActivity = Date.now();
318
+ resetNetworkIdleTimer();
319
+ }
320
+
321
+ function checkNetworkIdle() {
322
+ if (pendingRequests.size === 0) {
323
+ resetNetworkIdleTimer();
324
+ networkIdleTimer = setTimeout(() => {
325
+ notifyWaiters(mainFrameId, 'networkIdle');
326
+ // Notify any direct network idle waiters
327
+ for (const waiter of networkIdleWaiters) {
328
+ waiter({ idle: true, pendingCount: 0, totalRequests: networkRequestCount });
329
+ }
330
+ }, networkIdleDelay);
331
+ }
332
+ }
333
+
334
+ function onRequestFinished({ requestId }) {
335
+ if (!pendingRequests.has(requestId)) return;
336
+ pendingRequests.delete(requestId);
337
+ lastNetworkActivity = Date.now();
338
+ checkNetworkIdle();
339
+ }
340
+
341
+ /**
342
+ * Wait for network to be idle (improvement #10)
343
+ * Uses add/done counter for precise network quiet detection
344
+ * @param {Object} [options] - Wait options
345
+ * @param {number} [options.timeout=30000] - Timeout in ms
346
+ * @param {number} [options.idleTime=500] - Time with no requests to consider idle
347
+ * @returns {Promise<{idle: boolean, pendingCount: number, totalRequests: number}>}
348
+ */
349
+ function waitForNetworkQuiet(options = {}) {
350
+ const { timeout = 30000, idleTime = networkIdleDelay } = options;
351
+
352
+ return new Promise((resolve, reject) => {
353
+ // Already idle?
354
+ if (pendingRequests.size === 0) {
355
+ const timeSinceActivity = Date.now() - lastNetworkActivity;
356
+ if (timeSinceActivity >= idleTime) {
357
+ resolve({ idle: true, pendingCount: 0, totalRequests: networkRequestCount });
358
+ return;
359
+ }
360
+ }
361
+
362
+ let resolved = false;
363
+ let timeoutId = null;
364
+
365
+ const waiter = (result) => {
366
+ if (!resolved) {
367
+ resolved = true;
368
+ if (timeoutId) clearTimeout(timeoutId);
369
+ networkIdleWaiters.delete(waiter);
370
+ resolve(result);
371
+ }
372
+ };
373
+
374
+ networkIdleWaiters.add(waiter);
375
+
376
+ timeoutId = setTimeout(() => {
377
+ if (!resolved) {
378
+ resolved = true;
379
+ networkIdleWaiters.delete(waiter);
380
+ reject(timeoutError(`Network did not become idle within ${timeout}ms (${pendingRequests.size} requests pending)`));
381
+ }
382
+ }, timeout);
383
+
384
+ // Check periodically if we missed the idle event
385
+ const checkInterval = setInterval(() => {
386
+ if (resolved) {
387
+ clearInterval(checkInterval);
388
+ return;
389
+ }
390
+ if (pendingRequests.size === 0) {
391
+ const timeSinceActivity = Date.now() - lastNetworkActivity;
392
+ if (timeSinceActivity >= idleTime) {
393
+ clearInterval(checkInterval);
394
+ waiter({ idle: true, pendingCount: 0, totalRequests: networkRequestCount });
395
+ }
396
+ }
397
+ }, 100);
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Get current network status
403
+ * @returns {{pendingCount: number, totalRequests: number, lastActivity: number}}
404
+ */
405
+ function getNetworkStatus() {
406
+ return {
407
+ pendingCount: pendingRequests.size,
408
+ totalRequests: networkRequestCount,
409
+ lastActivity: lastNetworkActivity,
410
+ isIdle: pendingRequests.size === 0
411
+ };
412
+ }
413
+
414
+ function onTargetCrashed() {
415
+ pageCrashed = true;
416
+ const error = pageCrashedError('Page crashed during operation');
417
+ for (const rejectFn of crashWaiters) {
418
+ rejectFn(error);
419
+ }
420
+ crashWaiters.clear();
421
+ }
422
+
423
+ function waitForLifecycleState(frameId, waitUntil, timeout) {
424
+ return new Promise((resolve, reject) => {
425
+ if (pageCrashed) {
426
+ reject(pageCrashedError('Page crashed before navigation'));
427
+ return;
428
+ }
429
+
430
+ const timeoutId = setTimeout(() => {
431
+ cleanup();
432
+ reject(timeoutError(`Navigation timeout after ${timeout}ms waiting for '${waitUntil}'`));
433
+ }, timeout);
434
+
435
+ const crashReject = (error) => {
436
+ cleanup();
437
+ reject(error);
438
+ };
439
+
440
+ const abortReject = (error) => {
441
+ cleanup();
442
+ reject(error);
443
+ };
444
+
445
+ const checkCondition = () => {
446
+ const events = lifecycleEvents.get(frameId) || new Set();
447
+
448
+ switch (waitUntil) {
449
+ case WaitCondition.COMMIT:
450
+ case 'commit':
451
+ return true;
452
+ case WaitCondition.DOM_CONTENT_LOADED:
453
+ case 'domcontentloaded':
454
+ return events.has('DOMContentLoaded');
455
+ case WaitCondition.LOAD:
456
+ case 'load':
457
+ return events.has('load');
458
+ case WaitCondition.NETWORK_IDLE:
459
+ case 'networkidle':
460
+ return events.has('networkIdle') ||
461
+ (events.has('load') && pendingRequests.size === 0);
462
+ default:
463
+ return events.has('load');
464
+ }
465
+ };
466
+
467
+ const cleanup = () => {
468
+ clearTimeout(timeoutId);
469
+ removeLifecycleWaiter(onUpdate);
470
+ removeCrashWaiter(crashReject);
471
+ removeAbortWaiter(abortReject);
472
+ };
473
+
474
+ const onUpdate = (updatedFrameId) => {
475
+ if (updatedFrameId === frameId && checkCondition()) {
476
+ cleanup();
477
+ resolve();
478
+ }
479
+ };
480
+
481
+ if (checkCondition()) {
482
+ cleanup();
483
+ resolve();
484
+ return;
485
+ }
486
+
487
+ addLifecycleWaiter(onUpdate);
488
+ addCrashWaiter(crashReject);
489
+ addAbortWaiter(abortReject);
490
+ });
491
+ }
492
+
493
+ async function navigateHistory(delta, waitUntil, timeout) {
494
+ let history;
495
+ try {
496
+ history = await cdpClient.send('Page.getNavigationHistory');
497
+ } catch (error) {
498
+ throw connectionError(error.message, 'Page.getNavigationHistory');
499
+ }
500
+
501
+ const { currentIndex, entries } = history;
502
+ const targetIndex = currentIndex + delta;
503
+
504
+ if (targetIndex < 0 || targetIndex >= entries.length) {
505
+ return null;
506
+ }
507
+
508
+ lifecycleEvents.set(mainFrameId, new Set());
509
+ pendingRequests.clear();
510
+ resetNetworkIdleTimer();
511
+
512
+ const waitPromise = waitForLifecycleState(mainFrameId, waitUntil, timeout);
513
+
514
+ try {
515
+ await cdpClient.send('Page.navigateToHistoryEntry', {
516
+ entryId: entries[targetIndex].id
517
+ });
518
+ } catch (error) {
519
+ throw connectionError(error.message, 'Page.navigateToHistoryEntry');
520
+ }
521
+
522
+ await waitPromise;
523
+ return entries[targetIndex];
524
+ }
525
+
526
+ async function initialize() {
527
+ await Promise.all([
528
+ cdpClient.send('Page.enable'),
529
+ cdpClient.send('Page.setLifecycleEventsEnabled', { enabled: true }),
530
+ cdpClient.send('Network.enable'),
531
+ cdpClient.send('Runtime.enable'),
532
+ cdpClient.send('Inspector.enable')
533
+ ]);
534
+
535
+ const { frameTree } = await cdpClient.send('Page.getFrameTree');
536
+ mainFrameId = frameTree.frame.id;
537
+ currentFrameId = mainFrameId;
538
+
539
+ // Enable Runtime to track execution contexts for frames
540
+ await cdpClient.send('Runtime.enable');
541
+
542
+ // Track execution contexts for each frame
543
+ addListener('Runtime.executionContextCreated', ({ context }) => {
544
+ if (context.auxData && context.auxData.frameId) {
545
+ frameExecutionContexts.set(context.auxData.frameId, context.id);
546
+ if (context.auxData.frameId === mainFrameId) {
547
+ currentExecutionContextId = context.id;
548
+ }
549
+ }
550
+ });
551
+
552
+ addListener('Runtime.executionContextDestroyed', ({ executionContextId }) => {
553
+ for (const [frameId, contextId] of frameExecutionContexts) {
554
+ if (contextId === executionContextId) {
555
+ frameExecutionContexts.delete(frameId);
556
+ break;
557
+ }
558
+ }
559
+ });
560
+
561
+ addListener('Page.lifecycleEvent', onLifecycleEvent);
562
+ addListener('Page.frameNavigated', onFrameNavigated);
563
+ addListener('Network.requestWillBeSent', onRequestStarted);
564
+ addListener('Network.loadingFinished', onRequestFinished);
565
+ addListener('Network.loadingFailed', onRequestFinished);
566
+ addListener('Inspector.targetCrashed', onTargetCrashed);
567
+ }
568
+
569
+ async function navigate(url, options = {}) {
570
+ if (!url || typeof url !== 'string') {
571
+ throw navigationError('URL must be a non-empty string', url || '');
572
+ }
573
+
574
+ const {
575
+ waitUntil = WaitCondition.LOAD,
576
+ timeout = 30000,
577
+ referrer
578
+ } = options;
579
+
580
+ const validatedTimeout = validateTimeout(timeout);
581
+
582
+ if (navigationInProgress && currentNavigationAbort) {
583
+ currentNavigationAbort('superseded by another navigation');
584
+ }
585
+
586
+ navigationInProgress = true;
587
+ currentNavigationUrl = url;
588
+
589
+ let abortReason = null;
590
+ const abortController = {
591
+ abort: (reason) => {
592
+ abortReason = reason;
593
+ notifyAbortWaiters(navigationAbortedError(reason, url));
594
+ }
595
+ };
596
+ currentNavigationAbort = abortController.abort;
597
+
598
+ try {
599
+ lifecycleEvents.set(mainFrameId, new Set());
600
+ pendingRequests.clear();
601
+ resetNetworkIdleTimer();
602
+
603
+ const waitPromise = waitForLifecycleState(mainFrameId, waitUntil, validatedTimeout);
604
+
605
+ let response;
606
+ try {
607
+ response = await cdpClient.send('Page.navigate', { url, referrer });
608
+ } catch (error) {
609
+ throw navigationError(error.message, url);
610
+ }
611
+
612
+ if (response.errorText) {
613
+ throw navigationError(response.errorText, url);
614
+ }
615
+
616
+ await waitPromise;
617
+
618
+ if (abortReason) {
619
+ throw navigationAbortedError(abortReason, url);
620
+ }
621
+
622
+ return {
623
+ frameId: response.frameId,
624
+ loaderId: response.loaderId,
625
+ url
626
+ };
627
+ } finally {
628
+ navigationInProgress = false;
629
+ currentNavigationAbort = null;
630
+ currentNavigationUrl = null;
631
+ }
632
+ }
633
+
634
+ async function reload(options = {}) {
635
+ const {
636
+ ignoreCache = false,
637
+ waitUntil = WaitCondition.LOAD,
638
+ timeout = 30000
639
+ } = options;
640
+
641
+ const validatedTimeout = validateTimeout(timeout);
642
+
643
+ lifecycleEvents.set(mainFrameId, new Set());
644
+ pendingRequests.clear();
645
+ resetNetworkIdleTimer();
646
+
647
+ const waitPromise = waitForLifecycleState(mainFrameId, waitUntil, validatedTimeout);
648
+
649
+ try {
650
+ await cdpClient.send('Page.reload', { ignoreCache });
651
+ } catch (error) {
652
+ throw connectionError(error.message, 'Page.reload');
653
+ }
654
+
655
+ await waitPromise;
656
+ }
657
+
658
+ async function goBack(options = {}) {
659
+ const { waitUntil = WaitCondition.LOAD, timeout = 30000 } = options;
660
+ const validatedTimeout = validateTimeout(timeout);
661
+ return navigateHistory(-1, waitUntil, validatedTimeout);
662
+ }
663
+
664
+ async function goForward(options = {}) {
665
+ const { waitUntil = WaitCondition.LOAD, timeout = 30000 } = options;
666
+ const validatedTimeout = validateTimeout(timeout);
667
+ return navigateHistory(1, waitUntil, validatedTimeout);
668
+ }
669
+
670
+ async function stopLoading() {
671
+ if (navigationInProgress && currentNavigationAbort) {
672
+ currentNavigationAbort('loading was stopped');
673
+ }
674
+
675
+ try {
676
+ await cdpClient.send('Page.stopLoading');
677
+ } catch (error) {
678
+ throw connectionError(error.message, 'Page.stopLoading');
679
+ }
680
+ }
681
+
682
+ async function getUrl() {
683
+ try {
684
+ const result = await cdpClient.send('Runtime.evaluate', {
685
+ expression: 'window.location.href',
686
+ returnByValue: true
687
+ });
688
+ return result.result.value;
689
+ } catch (error) {
690
+ throw connectionError(error.message, 'Runtime.evaluate (getUrl)');
691
+ }
692
+ }
693
+
694
+ async function getTitle() {
695
+ try {
696
+ const result = await cdpClient.send('Runtime.evaluate', {
697
+ expression: 'document.title',
698
+ returnByValue: true
699
+ });
700
+ return result.result.value;
701
+ } catch (error) {
702
+ throw connectionError(error.message, 'Runtime.evaluate (getTitle)');
703
+ }
704
+ }
705
+
706
+ function dispose() {
707
+ resetNetworkIdleTimer();
708
+
709
+ for (const { event, handler } of eventListeners) {
710
+ cdpClient.off(event, handler);
711
+ }
712
+
713
+ eventListeners.length = 0;
714
+ lifecycleWaiters.clear();
715
+ lifecycleEvents.clear();
716
+ pendingRequests.clear();
717
+ crashWaiters.clear();
718
+ abortWaiters.clear();
719
+ }
720
+
721
+ /**
722
+ * Set viewport size and device metrics
723
+ * @param {string|Object} options - Device preset name or viewport options object
724
+ * @param {number} [options.width] - Viewport width
725
+ * @param {number} [options.height] - Viewport height
726
+ * @param {number} [options.deviceScaleFactor=1] - Device scale factor (DPR)
727
+ * @param {boolean} [options.mobile=false] - Mobile device emulation
728
+ * @param {boolean} [options.hasTouch=false] - Touch events enabled
729
+ * @param {boolean} [options.isLandscape=false] - Landscape orientation
730
+ * @returns {Object} The resolved viewport configuration
731
+ */
732
+ async function setViewport(options) {
733
+ // Resolve device preset if string provided
734
+ const resolvedOptions = resolveViewport(options);
735
+
736
+ const {
737
+ width,
738
+ height,
739
+ deviceScaleFactor = 1,
740
+ mobile = false,
741
+ hasTouch = false,
742
+ isLandscape = false
743
+ } = resolvedOptions;
744
+
745
+ if (!width || !height || width <= 0 || height <= 0) {
746
+ throw new Error('Viewport requires positive width and height');
747
+ }
748
+
749
+ const screenOrientation = isLandscape
750
+ ? { angle: 90, type: 'landscapePrimary' }
751
+ : { angle: 0, type: 'portraitPrimary' };
752
+
753
+ await cdpClient.send('Emulation.setDeviceMetricsOverride', {
754
+ width: Math.floor(width),
755
+ height: Math.floor(height),
756
+ deviceScaleFactor,
757
+ mobile,
758
+ screenOrientation
759
+ });
760
+
761
+ if (hasTouch) {
762
+ await cdpClient.send('Emulation.setTouchEmulationEnabled', {
763
+ enabled: true,
764
+ maxTouchPoints: 5
765
+ });
766
+ }
767
+
768
+ // Return the resolved viewport config for output
769
+ return { width, height, deviceScaleFactor, mobile, hasTouch, isLandscape };
770
+ }
771
+
772
+ /**
773
+ * Reset viewport to default (clears emulation overrides)
774
+ */
775
+ async function resetViewport() {
776
+ await cdpClient.send('Emulation.clearDeviceMetricsOverride');
777
+ await cdpClient.send('Emulation.setTouchEmulationEnabled', { enabled: false });
778
+ }
779
+
780
+ /**
781
+ * Set geolocation
782
+ * @param {Object} options - Geolocation options
783
+ * @param {number} options.latitude - Latitude
784
+ * @param {number} options.longitude - Longitude
785
+ * @param {number} [options.accuracy=1] - Accuracy in meters
786
+ */
787
+ async function setGeolocation(options) {
788
+ const { latitude, longitude, accuracy = 1 } = options;
789
+
790
+ if (latitude < -90 || latitude > 90) {
791
+ throw new Error('Latitude must be between -90 and 90');
792
+ }
793
+ if (longitude < -180 || longitude > 180) {
794
+ throw new Error('Longitude must be between -180 and 180');
795
+ }
796
+
797
+ await cdpClient.send('Emulation.setGeolocationOverride', {
798
+ latitude,
799
+ longitude,
800
+ accuracy
801
+ });
802
+ }
803
+
804
+ /**
805
+ * Clear geolocation override
806
+ */
807
+ async function clearGeolocation() {
808
+ await cdpClient.send('Emulation.clearGeolocationOverride');
809
+ }
810
+
811
+ /**
812
+ * Get frame tree with all iframes
813
+ * @returns {Promise<Object>} Frame tree
814
+ */
815
+ async function getFrameTree() {
816
+ const { frameTree } = await cdpClient.send('Page.getFrameTree');
817
+
818
+ function flattenFrames(node, depth = 0) {
819
+ const frames = [{
820
+ frameId: node.frame.id,
821
+ url: node.frame.url,
822
+ name: node.frame.name || null,
823
+ parentId: node.frame.parentId || null,
824
+ depth
825
+ }];
826
+
827
+ if (node.childFrames) {
828
+ for (const child of node.childFrames) {
829
+ frames.push(...flattenFrames(child, depth + 1));
830
+ }
831
+ }
832
+
833
+ return frames;
834
+ }
835
+
836
+ return {
837
+ mainFrameId,
838
+ currentFrameId,
839
+ frames: flattenFrames(frameTree)
840
+ };
841
+ }
842
+
843
+ /**
844
+ * Switch to an iframe by selector, index, or name
845
+ * @param {Object|string|number} params - Frame identifier
846
+ * @returns {Promise<Object>} Frame info
847
+ */
848
+ async function switchToFrame(params) {
849
+ const { frameTree } = await cdpClient.send('Page.getFrameTree');
850
+
851
+ function findAllFrames(node) {
852
+ const frames = [node];
853
+ if (node.childFrames) {
854
+ for (const child of node.childFrames) {
855
+ frames.push(...findAllFrames(child));
856
+ }
857
+ }
858
+ return frames;
859
+ }
860
+
861
+ const allFrames = findAllFrames(frameTree);
862
+ let targetFrame = null;
863
+
864
+ if (typeof params === 'number') {
865
+ // Switch by index (0-based, excluding main frame)
866
+ const childFrames = allFrames.filter(f => f.frame.parentId);
867
+ if (params >= 0 && params < childFrames.length) {
868
+ targetFrame = childFrames[params];
869
+ }
870
+ } else if (typeof params === 'string') {
871
+ // Switch by name or selector
872
+ // First try to find by name
873
+ targetFrame = allFrames.find(f => f.frame.name === params);
874
+
875
+ // If not found by name, try as CSS selector
876
+ if (!targetFrame) {
877
+ const result = await cdpClient.send('Runtime.evaluate', {
878
+ expression: `
879
+ (function() {
880
+ const iframe = document.querySelector(${JSON.stringify(params)});
881
+ if (!iframe || iframe.tagName !== 'IFRAME') return null;
882
+ return iframe.contentWindow ? 'found' : null;
883
+ })()
884
+ `,
885
+ returnByValue: true
886
+ });
887
+
888
+ if (result.result.value === 'found') {
889
+ // Get the frame ID by finding the iframe element and matching its src
890
+ const srcResult = await cdpClient.send('Runtime.evaluate', {
891
+ expression: `
892
+ (function() {
893
+ const iframe = document.querySelector(${JSON.stringify(params)});
894
+ if (!iframe) return null;
895
+ return {
896
+ src: iframe.src || iframe.getAttribute('src') || '',
897
+ name: iframe.name || iframe.id || ''
898
+ };
899
+ })()
900
+ `,
901
+ returnByValue: true
902
+ });
903
+
904
+ if (srcResult.result.value) {
905
+ const { src, name } = srcResult.result.value;
906
+ // Try to match by src or name
907
+ targetFrame = allFrames.find(f =>
908
+ (src && f.frame.url === src) ||
909
+ (src && f.frame.url.endsWith(src)) ||
910
+ (name && f.frame.name === name) ||
911
+ f.frame.parentId // Fallback to first child frame if only one
912
+ );
913
+
914
+ // If still not found and there's only one child frame, use it
915
+ if (!targetFrame) {
916
+ const childFrames = allFrames.filter(f => f.frame.parentId);
917
+ if (childFrames.length === 1) {
918
+ targetFrame = childFrames[0];
919
+ }
920
+ }
921
+ }
922
+ }
923
+ }
924
+ } else if (typeof params === 'object') {
925
+ // Object with selector, index, or name
926
+ if (params.selector) {
927
+ return switchToFrame(params.selector);
928
+ } else if (typeof params.index === 'number') {
929
+ return switchToFrame(params.index);
930
+ } else if (params.name) {
931
+ return switchToFrame(params.name);
932
+ } else if (params.frameId) {
933
+ targetFrame = allFrames.find(f => f.frame.id === params.frameId);
934
+ }
935
+ }
936
+
937
+ if (!targetFrame) {
938
+ throw new Error(`Frame not found: ${JSON.stringify(params)}`);
939
+ }
940
+
941
+ currentFrameId = targetFrame.frame.id;
942
+ currentExecutionContextId = frameExecutionContexts.get(currentFrameId) || null;
943
+
944
+ // If we don't have an execution context yet, create an isolated world
945
+ if (!currentExecutionContextId) {
946
+ const { executionContextId } = await cdpClient.send('Page.createIsolatedWorld', {
947
+ frameId: currentFrameId,
948
+ worldName: 'cdp-automation'
949
+ });
950
+ currentExecutionContextId = executionContextId;
951
+ frameExecutionContexts.set(currentFrameId, executionContextId);
952
+ }
953
+
954
+ return {
955
+ frameId: currentFrameId,
956
+ url: targetFrame.frame.url,
957
+ name: targetFrame.frame.name || null
958
+ };
959
+ }
960
+
961
+ /**
962
+ * Switch back to the main frame
963
+ * @returns {Promise<Object>} Main frame info
964
+ */
965
+ async function switchToMainFrame() {
966
+ currentFrameId = mainFrameId;
967
+ currentExecutionContextId = frameExecutionContexts.get(mainFrameId) || null;
968
+
969
+ const { frameTree } = await cdpClient.send('Page.getFrameTree');
970
+
971
+ return {
972
+ frameId: mainFrameId,
973
+ url: frameTree.frame.url,
974
+ name: frameTree.frame.name || null
975
+ };
976
+ }
977
+
978
+ /**
979
+ * Execute code in the current frame context
980
+ * @param {string} expression - JavaScript expression
981
+ * @param {Object} [options] - Options
982
+ * @returns {Promise<any>} Result
983
+ */
984
+ async function evaluateInFrame(expression, options = {}) {
985
+ const params = {
986
+ expression,
987
+ returnByValue: options.returnByValue !== false,
988
+ awaitPromise: options.awaitPromise || false
989
+ };
990
+
991
+ // If we have an execution context for a non-main frame, use it
992
+ if (currentFrameId !== mainFrameId && currentExecutionContextId) {
993
+ params.contextId = currentExecutionContextId;
994
+ }
995
+
996
+ return cdpClient.send('Runtime.evaluate', params);
997
+ }
998
+
999
+ /**
1000
+ * Get current viewport dimensions
1001
+ * @returns {Promise<{width: number, height: number}>}
1002
+ */
1003
+ async function getViewport() {
1004
+ try {
1005
+ const result = await cdpClient.send('Runtime.evaluate', {
1006
+ expression: '({ width: window.innerWidth, height: window.innerHeight })',
1007
+ returnByValue: true
1008
+ });
1009
+ return result.result.value;
1010
+ } catch (error) {
1011
+ throw connectionError(error.message, 'Runtime.evaluate (getViewport)');
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Two-step WaitEvent pattern (improvement #4)
1017
+ * Subscribe to navigation BEFORE triggering action to prevent race conditions
1018
+ * Inspired by Rod's event handling pattern
1019
+ *
1020
+ * Usage:
1021
+ * const navPromise = controller.waitForNavigationEvent();
1022
+ * await click(selector);
1023
+ * await navPromise;
1024
+ *
1025
+ * @param {Object} [options] - Wait options
1026
+ * @param {string} [options.waitUntil='load'] - Lifecycle event to wait for
1027
+ * @param {number} [options.timeout=30000] - Timeout in ms
1028
+ * @returns {Promise<{url: string, navigated: boolean}>}
1029
+ */
1030
+ function waitForNavigationEvent(options = {}) {
1031
+ const { waitUntil = 'load', timeout = 30000 } = options;
1032
+ const validatedTimeout = validateTimeout(timeout);
1033
+
1034
+ return new Promise((resolve, reject) => {
1035
+ let resolved = false;
1036
+ let frameNavigatedHandler = null;
1037
+ let lifecycleHandler = null;
1038
+ let timeoutId = null;
1039
+ let navigationStarted = false;
1040
+ let targetUrl = null;
1041
+ let targetFrameId = null;
1042
+
1043
+ const cleanup = () => {
1044
+ if (timeoutId) clearTimeout(timeoutId);
1045
+ if (frameNavigatedHandler) {
1046
+ cdpClient.off('Page.frameNavigated', frameNavigatedHandler);
1047
+ }
1048
+ if (lifecycleHandler) {
1049
+ removeLifecycleWaiter(lifecycleHandler);
1050
+ }
1051
+ };
1052
+
1053
+ const finish = (result) => {
1054
+ if (!resolved) {
1055
+ resolved = true;
1056
+ cleanup();
1057
+ resolve(result);
1058
+ }
1059
+ };
1060
+
1061
+ const fail = (error) => {
1062
+ if (!resolved) {
1063
+ resolved = true;
1064
+ cleanup();
1065
+ reject(error);
1066
+ }
1067
+ };
1068
+
1069
+ // Set up timeout
1070
+ timeoutId = setTimeout(() => {
1071
+ if (!navigationStarted) {
1072
+ // No navigation happened within timeout - this is OK, not an error
1073
+ finish({ navigated: false });
1074
+ } else {
1075
+ fail(timeoutError(`Navigation timed out after ${validatedTimeout}ms waiting for '${waitUntil}'`));
1076
+ }
1077
+ }, validatedTimeout);
1078
+
1079
+ // Listen for frame navigation start
1080
+ frameNavigatedHandler = ({ frame }) => {
1081
+ if (!frame.parentId) {
1082
+ // Main frame navigation
1083
+ navigationStarted = true;
1084
+ targetUrl = frame.url;
1085
+ targetFrameId = frame.id;
1086
+
1087
+ // For 'commit' wait condition, resolve immediately
1088
+ if (waitUntil === 'commit') {
1089
+ finish({ url: targetUrl, navigated: true });
1090
+ }
1091
+ }
1092
+ };
1093
+ cdpClient.on('Page.frameNavigated', frameNavigatedHandler);
1094
+
1095
+ // Listen for lifecycle events
1096
+ lifecycleHandler = (frameId, eventName) => {
1097
+ if (!navigationStarted) return;
1098
+ if (frameId !== (targetFrameId || mainFrameId)) return;
1099
+
1100
+ const events = lifecycleEvents.get(frameId) || new Set();
1101
+
1102
+ if (waitUntil === 'domcontentloaded' && eventName === 'DOMContentLoaded') {
1103
+ finish({ url: targetUrl, navigated: true });
1104
+ } else if (waitUntil === 'load' && eventName === 'load') {
1105
+ finish({ url: targetUrl, navigated: true });
1106
+ } else if (waitUntil === 'networkidle' &&
1107
+ (eventName === 'networkIdle' || (events.has('load') && pendingRequests.size === 0))) {
1108
+ finish({ url: targetUrl, navigated: true });
1109
+ }
1110
+ };
1111
+ addLifecycleWaiter(lifecycleHandler);
1112
+
1113
+ // Also add crash handler
1114
+ addCrashWaiter(fail);
1115
+ });
1116
+ }
1117
+
1118
+ /**
1119
+ * Convenience method: perform action and wait for navigation
1120
+ * Uses two-step pattern internally
1121
+ * @param {Function} action - Async action to perform (e.g., click)
1122
+ * @param {Object} [options] - Wait options
1123
+ * @returns {Promise<{actionResult: any, navigation: Object}>}
1124
+ */
1125
+ async function withNavigation(action, options = {}) {
1126
+ const navPromise = waitForNavigationEvent(options);
1127
+ const actionResult = await action();
1128
+ const navigation = await navPromise;
1129
+ return { actionResult, navigation };
1130
+ }
1131
+
1132
+ return {
1133
+ initialize,
1134
+ navigate,
1135
+ reload,
1136
+ goBack,
1137
+ goForward,
1138
+ stopLoading,
1139
+ getUrl,
1140
+ getTitle,
1141
+ setViewport,
1142
+ resetViewport,
1143
+ getViewport,
1144
+ setGeolocation,
1145
+ clearGeolocation,
1146
+ getFrameTree,
1147
+ switchToFrame,
1148
+ switchToMainFrame,
1149
+ evaluateInFrame,
1150
+ waitForNavigationEvent,
1151
+ withNavigation,
1152
+ waitForNetworkQuiet,
1153
+ getNetworkStatus,
1154
+ dispose,
1155
+ get mainFrameId() { return mainFrameId; },
1156
+ get currentFrameId() { return currentFrameId; },
1157
+ get currentExecutionContextId() { return currentExecutionContextId; },
1158
+ get session() { return cdpClient; }
1159
+ };
1160
+ }
1161
+
1162
+ // ============================================================================
1163
+ // Wait Utilities
1164
+ // ============================================================================
1165
+
1166
+ /**
1167
+ * Wait for a condition by polling
1168
+ * @param {Function} checkFn - Async function returning boolean
1169
+ * @param {Object} [options] - Wait options
1170
+ * @param {number} [options.timeout=30000] - Timeout in ms
1171
+ * @param {number} [options.pollInterval=100] - Poll interval in ms
1172
+ * @param {string} [options.message] - Custom timeout message
1173
+ * @returns {Promise<void>}
1174
+ */
1175
+ export async function waitForCondition(checkFn, options = {}) {
1176
+ const {
1177
+ timeout = 30000,
1178
+ pollInterval = 100,
1179
+ message = 'Condition not met within timeout'
1180
+ } = options;
1181
+
1182
+ const startTime = Date.now();
1183
+
1184
+ while (Date.now() - startTime < timeout) {
1185
+ const result = await checkFn();
1186
+ if (result) return;
1187
+ await sleep(pollInterval);
1188
+ }
1189
+
1190
+ throw timeoutError(`${message} (${timeout}ms)`);
1191
+ }
1192
+
1193
+ /**
1194
+ * Wait for a JavaScript expression to be truthy
1195
+ * @param {Object} session - CDP session
1196
+ * @param {string} expression - JavaScript expression
1197
+ * @param {Object} [options] - Wait options
1198
+ * @returns {Promise<any>}
1199
+ */
1200
+ export async function waitForFunction(session, expression, options = {}) {
1201
+ const {
1202
+ timeout = 30000,
1203
+ pollInterval = 100
1204
+ } = options;
1205
+
1206
+ const startTime = Date.now();
1207
+
1208
+ while (Date.now() - startTime < timeout) {
1209
+ let result;
1210
+ try {
1211
+ result = await session.send('Runtime.evaluate', {
1212
+ expression,
1213
+ returnByValue: true,
1214
+ awaitPromise: true
1215
+ });
1216
+ } catch (error) {
1217
+ if (isContextDestroyed(null, error)) {
1218
+ throw contextDestroyedError('Page navigated during waitForFunction');
1219
+ }
1220
+ throw error;
1221
+ }
1222
+
1223
+ if (result.exceptionDetails) {
1224
+ if (isContextDestroyed(result.exceptionDetails)) {
1225
+ throw contextDestroyedError('Page navigated during waitForFunction');
1226
+ }
1227
+ await sleep(pollInterval);
1228
+ continue;
1229
+ }
1230
+
1231
+ const value = result.result.value;
1232
+ if (value) return value;
1233
+
1234
+ await sleep(pollInterval);
1235
+ }
1236
+
1237
+ throw timeoutError(`Function did not return truthy within ${timeout}ms: ${expression}`);
1238
+ }
1239
+
1240
+ /**
1241
+ * Wait for network to be idle
1242
+ * @param {Object} session - CDP session
1243
+ * @param {Object} [options] - Wait options
1244
+ * @returns {Promise<void>}
1245
+ */
1246
+ export async function waitForNetworkIdle(session, options = {}) {
1247
+ const {
1248
+ timeout = 30000,
1249
+ idleTime = 500
1250
+ } = options;
1251
+
1252
+ const pendingRequests = new Set();
1253
+ let idleTimer = null;
1254
+ let resolveIdle = null;
1255
+ let cleanupDone = false;
1256
+
1257
+ const onRequestStarted = ({ requestId }) => {
1258
+ pendingRequests.add(requestId);
1259
+ if (idleTimer) {
1260
+ clearTimeout(idleTimer);
1261
+ idleTimer = null;
1262
+ }
1263
+ };
1264
+
1265
+ const onRequestFinished = ({ requestId }) => {
1266
+ pendingRequests.delete(requestId);
1267
+ checkIdle();
1268
+ };
1269
+
1270
+ const checkIdle = () => {
1271
+ if (pendingRequests.size === 0 && !idleTimer) {
1272
+ idleTimer = setTimeout(() => {
1273
+ if (resolveIdle) resolveIdle();
1274
+ }, idleTime);
1275
+ }
1276
+ };
1277
+
1278
+ const cleanup = () => {
1279
+ if (cleanupDone) return;
1280
+ cleanupDone = true;
1281
+ session.off('Network.requestWillBeSent', onRequestStarted);
1282
+ session.off('Network.loadingFinished', onRequestFinished);
1283
+ session.off('Network.loadingFailed', onRequestFinished);
1284
+ if (idleTimer) clearTimeout(idleTimer);
1285
+ };
1286
+
1287
+ session.on('Network.requestWillBeSent', onRequestStarted);
1288
+ session.on('Network.loadingFinished', onRequestFinished);
1289
+ session.on('Network.loadingFailed', onRequestFinished);
1290
+
1291
+ return new Promise((resolve, reject) => {
1292
+ resolveIdle = () => {
1293
+ cleanup();
1294
+ resolve();
1295
+ };
1296
+
1297
+ const timeoutId = setTimeout(() => {
1298
+ cleanup();
1299
+ reject(timeoutError(`Network did not become idle within ${timeout}ms`));
1300
+ }, timeout);
1301
+
1302
+ checkIdle();
1303
+
1304
+ const originalResolve = resolveIdle;
1305
+ resolveIdle = () => {
1306
+ clearTimeout(timeoutId);
1307
+ originalResolve();
1308
+ };
1309
+ });
1310
+ }
1311
+
1312
+ /**
1313
+ * Wait for document.readyState to reach target state
1314
+ * @param {Object} session - CDP session
1315
+ * @param {string} [targetState='complete'] - Target state
1316
+ * @param {Object} [options] - Wait options
1317
+ * @returns {Promise<string>}
1318
+ */
1319
+ export async function waitForDocumentReady(session, targetState = 'complete', options = {}) {
1320
+ const {
1321
+ timeout = 30000,
1322
+ pollInterval = 100
1323
+ } = options;
1324
+
1325
+ const states = ['loading', 'interactive', 'complete'];
1326
+ const targetIndex = states.indexOf(targetState);
1327
+
1328
+ if (targetIndex === -1) {
1329
+ throw new Error(`Invalid target state: ${targetState}`);
1330
+ }
1331
+
1332
+ const startTime = Date.now();
1333
+
1334
+ while (Date.now() - startTime < timeout) {
1335
+ let result;
1336
+ try {
1337
+ result = await session.send('Runtime.evaluate', {
1338
+ expression: 'document.readyState',
1339
+ returnByValue: true
1340
+ });
1341
+ } catch (error) {
1342
+ if (isContextDestroyed(null, error)) {
1343
+ throw contextDestroyedError('Page navigated during waitForDocumentReady');
1344
+ }
1345
+ throw error;
1346
+ }
1347
+
1348
+ if (result.exceptionDetails) {
1349
+ if (isContextDestroyed(result.exceptionDetails)) {
1350
+ throw contextDestroyedError('Page navigated during waitForDocumentReady');
1351
+ }
1352
+ }
1353
+
1354
+ const currentState = result.result?.value;
1355
+ if (currentState) {
1356
+ const currentIndex = states.indexOf(currentState);
1357
+ if (currentIndex >= targetIndex) return currentState;
1358
+ }
1359
+
1360
+ await sleep(pollInterval);
1361
+ }
1362
+
1363
+ throw timeoutError(`Document did not reach '${targetState}' state within ${timeout}ms`);
1364
+ }
1365
+
1366
+ /**
1367
+ * Wait for a selector to appear in the DOM
1368
+ * @param {Object} session - CDP session
1369
+ * @param {string} selector - CSS selector
1370
+ * @param {Object} [options] - Wait options
1371
+ * @returns {Promise<void>}
1372
+ */
1373
+ export async function waitForSelector(session, selector, options = {}) {
1374
+ const {
1375
+ timeout = 30000,
1376
+ pollInterval = 100,
1377
+ visible = false
1378
+ } = options;
1379
+
1380
+ const escapedSelector = selector.replace(/'/g, "\\'");
1381
+
1382
+ let expression;
1383
+ if (visible) {
1384
+ expression = `(() => {
1385
+ const el = document.querySelector('${escapedSelector}');
1386
+ if (!el) return false;
1387
+ const style = window.getComputedStyle(el);
1388
+ return style.display !== 'none' &&
1389
+ style.visibility !== 'hidden' &&
1390
+ style.opacity !== '0';
1391
+ })()`;
1392
+ } else {
1393
+ expression = `!!document.querySelector('${escapedSelector}')`;
1394
+ }
1395
+
1396
+ await waitForFunction(session, expression, { timeout, pollInterval });
1397
+ }
1398
+
1399
+ /**
1400
+ * Wait for text to appear in the page body
1401
+ * @param {Object} session - CDP session
1402
+ * @param {string} text - Text to find
1403
+ * @param {Object} [options] - Wait options
1404
+ * @returns {Promise<boolean>}
1405
+ */
1406
+ export async function waitForText(session, text, options = {}) {
1407
+ const {
1408
+ timeout = 30000,
1409
+ pollInterval = 100,
1410
+ exact = false
1411
+ } = options;
1412
+
1413
+ const textStr = String(text);
1414
+ const checkExpr = exact
1415
+ ? `document.body.innerText.includes(${JSON.stringify(textStr)})`
1416
+ : `document.body.innerText.toLowerCase().includes(${JSON.stringify(textStr.toLowerCase())})`;
1417
+
1418
+ const startTime = Date.now();
1419
+
1420
+ while (Date.now() - startTime < timeout) {
1421
+ let result;
1422
+ try {
1423
+ result = await session.send('Runtime.evaluate', {
1424
+ expression: checkExpr,
1425
+ returnByValue: true
1426
+ });
1427
+ } catch (error) {
1428
+ if (isContextDestroyed(null, error)) {
1429
+ throw contextDestroyedError('Page navigated during waitForText');
1430
+ }
1431
+ throw error;
1432
+ }
1433
+
1434
+ if (result.result.value === true) return true;
1435
+
1436
+ await sleep(pollInterval);
1437
+ }
1438
+
1439
+ throw timeoutError(`Timeout (${timeout}ms) waiting for text: "${textStr}"`);
1440
+ }
1441
+
1442
+ // ============================================================================
1443
+ // Cookie Management (from storage.js)
1444
+ // ============================================================================
1445
+
1446
+ /**
1447
+ * Creates a cookie manager for getting, setting, and clearing cookies
1448
+ * @param {Object} session - CDP session
1449
+ * @returns {Object} Cookie manager interface
1450
+ */
1451
+ export function createCookieManager(session) {
1452
+ /**
1453
+ * Get all cookies, optionally filtered by URLs
1454
+ * @param {string[]} urls - Optional URLs to filter cookies
1455
+ * @returns {Promise<Array>} Array of cookie objects
1456
+ */
1457
+ async function getCookies(urls = []) {
1458
+ const result = await session.send('Storage.getCookies', {});
1459
+ let cookies = result.cookies || [];
1460
+
1461
+ // Filter by URLs if provided
1462
+ if (urls.length > 0) {
1463
+ cookies = cookies.filter(cookie => {
1464
+ return urls.some(url => {
1465
+ try {
1466
+ const parsed = new URL(url);
1467
+ // Domain matching
1468
+ const domainMatch = cookie.domain.startsWith('.')
1469
+ ? parsed.hostname.endsWith(cookie.domain.slice(1))
1470
+ : parsed.hostname === cookie.domain;
1471
+ // Path matching
1472
+ const pathMatch = parsed.pathname.startsWith(cookie.path);
1473
+ // Secure matching
1474
+ const secureMatch = !cookie.secure || parsed.protocol === 'https:';
1475
+ return domainMatch && pathMatch && secureMatch;
1476
+ } catch {
1477
+ return false;
1478
+ }
1479
+ });
1480
+ });
1481
+ }
1482
+
1483
+ return cookies.map(cookie => ({
1484
+ name: cookie.name,
1485
+ value: cookie.value,
1486
+ domain: cookie.domain,
1487
+ path: cookie.path,
1488
+ expires: cookie.expires,
1489
+ httpOnly: cookie.httpOnly,
1490
+ secure: cookie.secure,
1491
+ sameSite: cookie.sameSite || 'Lax'
1492
+ }));
1493
+ }
1494
+
1495
+ /**
1496
+ * Set one or more cookies
1497
+ * @param {Array} cookies - Array of cookie objects to set
1498
+ */
1499
+ async function setCookies(cookies) {
1500
+ const processedCookies = cookies.map(cookie => {
1501
+ const processed = {
1502
+ name: cookie.name,
1503
+ value: cookie.value
1504
+ };
1505
+
1506
+ // If URL provided, derive domain/path/secure from it
1507
+ if (cookie.url) {
1508
+ try {
1509
+ const parsed = new URL(cookie.url);
1510
+ processed.domain = cookie.domain || parsed.hostname;
1511
+ processed.path = cookie.path || '/';
1512
+ processed.secure = cookie.secure !== undefined ? cookie.secure : parsed.protocol === 'https:';
1513
+ } catch {
1514
+ throw new Error(`Invalid cookie URL: ${cookie.url}`);
1515
+ }
1516
+ } else {
1517
+ // Require domain and path if no URL
1518
+ if (!cookie.domain) {
1519
+ throw new Error('Cookie requires either url or domain');
1520
+ }
1521
+ processed.domain = cookie.domain;
1522
+ processed.path = cookie.path || '/';
1523
+ processed.secure = cookie.secure || false;
1524
+ }
1525
+
1526
+ // Optional properties
1527
+ if (cookie.expires !== undefined) {
1528
+ processed.expires = cookie.expires;
1529
+ }
1530
+ if (cookie.httpOnly !== undefined) {
1531
+ processed.httpOnly = cookie.httpOnly;
1532
+ }
1533
+ if (cookie.sameSite) {
1534
+ processed.sameSite = cookie.sameSite;
1535
+ }
1536
+
1537
+ return processed;
1538
+ });
1539
+
1540
+ await session.send('Storage.setCookies', { cookies: processedCookies });
1541
+ }
1542
+
1543
+ /**
1544
+ * Clear all cookies or cookies matching specific domains
1545
+ * @param {string[]} [urls] - Optional URLs to filter cookies by domain
1546
+ * @returns {Promise<{count: number}>} Number of cookies deleted
1547
+ */
1548
+ async function clearCookies(urls = []) {
1549
+ if (urls.length === 0) {
1550
+ // Clear all cookies
1551
+ const allCookies = await getCookies();
1552
+ const count = allCookies.length;
1553
+ await session.send('Storage.clearCookies', {});
1554
+ return { count };
1555
+ }
1556
+
1557
+ // Get cookies matching the domains
1558
+ const cookiesToDelete = await getCookies(urls);
1559
+ let deletedCount = 0;
1560
+
1561
+ for (const cookie of cookiesToDelete) {
1562
+ try {
1563
+ await session.send('Network.deleteCookies', {
1564
+ name: cookie.name,
1565
+ domain: cookie.domain,
1566
+ path: cookie.path
1567
+ });
1568
+ deletedCount++;
1569
+ } catch {
1570
+ // Ignore individual deletion failures
1571
+ }
1572
+ }
1573
+
1574
+ return { count: deletedCount };
1575
+ }
1576
+
1577
+ /**
1578
+ * Delete specific cookies by name
1579
+ * @param {string|string[]} names - Cookie name(s) to delete
1580
+ * @param {Object} [options] - Optional filters
1581
+ * @param {string} [options.domain] - Limit deletion to specific domain
1582
+ * @param {string} [options.path] - Limit deletion to specific path
1583
+ * @returns {Promise<{count: number}>} Number of cookies deleted
1584
+ */
1585
+ async function deleteCookies(names, options = {}) {
1586
+ const nameList = Array.isArray(names) ? names : [names];
1587
+ const { domain, path } = options;
1588
+ let deletedCount = 0;
1589
+
1590
+ // Get all cookies to find matching ones
1591
+ const allCookies = await getCookies();
1592
+
1593
+ for (const cookie of allCookies) {
1594
+ if (!nameList.includes(cookie.name)) continue;
1595
+ if (domain && cookie.domain !== domain && !cookie.domain.endsWith(`.${domain}`)) continue;
1596
+ if (path && cookie.path !== path) continue;
1597
+
1598
+ try {
1599
+ await session.send('Network.deleteCookies', {
1600
+ name: cookie.name,
1601
+ domain: cookie.domain,
1602
+ path: cookie.path
1603
+ });
1604
+ deletedCount++;
1605
+ } catch {
1606
+ // Ignore individual deletion failures
1607
+ }
1608
+ }
1609
+
1610
+ return { count: deletedCount };
1611
+ }
1612
+
1613
+ return {
1614
+ getCookies,
1615
+ setCookies,
1616
+ clearCookies,
1617
+ deleteCookies
1618
+ };
1619
+ }
1620
+
1621
+ // ============================================================================
1622
+ // Web Storage Management (from storage.js)
1623
+ // ============================================================================
1624
+
1625
+ /**
1626
+ * Creates a web storage manager for localStorage and sessionStorage
1627
+ * @param {Object} session - CDP session
1628
+ * @returns {Object} Web storage manager interface
1629
+ */
1630
+ export function createWebStorageManager(session) {
1631
+ const STORAGE_SCRIPT = `
1632
+ (function(storageType) {
1633
+ const storage = storageType === 'session' ? sessionStorage : localStorage;
1634
+ return Object.keys(storage).map(key => ({
1635
+ name: key,
1636
+ value: storage.getItem(key)
1637
+ }));
1638
+ })
1639
+ `;
1640
+
1641
+ const SET_STORAGE_SCRIPT = `
1642
+ (function(storageType, items) {
1643
+ const storage = storageType === 'session' ? sessionStorage : localStorage;
1644
+ for (const [key, value] of Object.entries(items)) {
1645
+ if (value === null) {
1646
+ storage.removeItem(key);
1647
+ } else {
1648
+ storage.setItem(key, value);
1649
+ }
1650
+ }
1651
+ return true;
1652
+ })
1653
+ `;
1654
+
1655
+ const CLEAR_STORAGE_SCRIPT = `
1656
+ (function(storageType) {
1657
+ const storage = storageType === 'session' ? sessionStorage : localStorage;
1658
+ storage.clear();
1659
+ return true;
1660
+ })
1661
+ `;
1662
+
1663
+ /**
1664
+ * Get all items from localStorage or sessionStorage
1665
+ * @param {string} type - 'local' or 'session'
1666
+ * @returns {Promise<Array>} Array of {name, value} objects
1667
+ */
1668
+ async function getStorage(type = 'local') {
1669
+ const storageType = type === 'session' ? 'session' : 'local';
1670
+ const result = await session.send('Runtime.evaluate', {
1671
+ expression: `(${STORAGE_SCRIPT})('${storageType}')`,
1672
+ returnByValue: true
1673
+ });
1674
+
1675
+ if (result.exceptionDetails) {
1676
+ throw new Error(`Failed to get ${storageType}Storage: ${result.exceptionDetails.text}`);
1677
+ }
1678
+
1679
+ return result.result.value || [];
1680
+ }
1681
+
1682
+ /**
1683
+ * Set items in localStorage or sessionStorage
1684
+ * @param {Object} items - Object with key-value pairs (null value removes item)
1685
+ * @param {string} type - 'local' or 'session'
1686
+ */
1687
+ async function setStorage(items, type = 'local') {
1688
+ const storageType = type === 'session' ? 'session' : 'local';
1689
+ const result = await session.send('Runtime.evaluate', {
1690
+ expression: `(${SET_STORAGE_SCRIPT})('${storageType}', ${JSON.stringify(items)})`,
1691
+ returnByValue: true
1692
+ });
1693
+
1694
+ if (result.exceptionDetails) {
1695
+ throw new Error(`Failed to set ${storageType}Storage: ${result.exceptionDetails.text}`);
1696
+ }
1697
+ }
1698
+
1699
+ /**
1700
+ * Clear all items from localStorage or sessionStorage
1701
+ * @param {string} type - 'local' or 'session'
1702
+ */
1703
+ async function clearStorage(type = 'local') {
1704
+ const storageType = type === 'session' ? 'session' : 'local';
1705
+ const result = await session.send('Runtime.evaluate', {
1706
+ expression: `(${CLEAR_STORAGE_SCRIPT})('${storageType}')`,
1707
+ returnByValue: true
1708
+ });
1709
+
1710
+ if (result.exceptionDetails) {
1711
+ throw new Error(`Failed to clear ${storageType}Storage: ${result.exceptionDetails.text}`);
1712
+ }
1713
+ }
1714
+
1715
+ return {
1716
+ getStorage,
1717
+ setStorage,
1718
+ clearStorage
1719
+ };
1720
+ }