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/SKILL.md +543 -0
- package/install.js +92 -0
- package/package.json +47 -0
- package/src/aria.js +1302 -0
- package/src/capture.js +1359 -0
- package/src/cdp.js +905 -0
- package/src/cli.js +244 -0
- package/src/dom.js +3525 -0
- package/src/index.js +155 -0
- package/src/page.js +1720 -0
- package/src/runner.js +2111 -0
- package/src/tests/BrowserClient.test.js +588 -0
- package/src/tests/CDPConnection.test.js +598 -0
- package/src/tests/ChromeDiscovery.test.js +181 -0
- package/src/tests/ConsoleCapture.test.js +302 -0
- package/src/tests/ElementHandle.test.js +586 -0
- package/src/tests/ElementLocator.test.js +586 -0
- package/src/tests/ErrorAggregator.test.js +327 -0
- package/src/tests/InputEmulator.test.js +641 -0
- package/src/tests/NetworkErrorCapture.test.js +458 -0
- package/src/tests/PageController.test.js +822 -0
- package/src/tests/ScreenshotCapture.test.js +356 -0
- package/src/tests/SessionRegistry.test.js +257 -0
- package/src/tests/TargetManager.test.js +274 -0
- package/src/tests/TestRunner.test.js +1529 -0
- package/src/tests/WaitStrategy.test.js +406 -0
- package/src/tests/integration.test.js +431 -0
- package/src/utils.js +1034 -0
- package/uninstall.js +44 -0
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
|
+
}
|