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/cdp.js
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Protocol and Browser Management
|
|
3
|
+
* Core CDP connection, discovery, target management, and browser client
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { timeoutError } from './utils.js';
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Connection
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a CDP WebSocket connection
|
|
14
|
+
* @param {string} wsUrl - WebSocket URL for CDP endpoint
|
|
15
|
+
* @param {Object} [options] - Connection options
|
|
16
|
+
* @param {number} [options.maxRetries=5] - Max reconnection attempts
|
|
17
|
+
* @param {number} [options.retryDelay=1000] - Base delay between retries
|
|
18
|
+
* @param {number} [options.maxRetryDelay=30000] - Maximum retry delay cap
|
|
19
|
+
* @param {boolean} [options.autoReconnect=false] - Enable auto reconnection
|
|
20
|
+
* @returns {Object} Connection interface
|
|
21
|
+
*/
|
|
22
|
+
export function createConnection(wsUrl, options = {}) {
|
|
23
|
+
const maxRetries = options.maxRetries ?? 5;
|
|
24
|
+
const retryDelay = options.retryDelay ?? 1000;
|
|
25
|
+
const maxRetryDelay = options.maxRetryDelay ?? 30000;
|
|
26
|
+
const autoReconnect = options.autoReconnect ?? false;
|
|
27
|
+
|
|
28
|
+
let ws = null;
|
|
29
|
+
let messageId = 0;
|
|
30
|
+
const pendingCommands = new Map();
|
|
31
|
+
const eventListeners = new Map();
|
|
32
|
+
let connected = false;
|
|
33
|
+
let connecting = false;
|
|
34
|
+
let onCloseCallback = null;
|
|
35
|
+
let reconnecting = false;
|
|
36
|
+
let retryAttempt = 0;
|
|
37
|
+
let intentionalClose = false;
|
|
38
|
+
|
|
39
|
+
function emit(event, data = {}) {
|
|
40
|
+
const listeners = eventListeners.get(event);
|
|
41
|
+
if (listeners) {
|
|
42
|
+
for (const callback of listeners) {
|
|
43
|
+
try {
|
|
44
|
+
callback(data);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(`Event handler error for ${event}:`, err);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function calculateBackoff(attempt) {
|
|
53
|
+
const delay = retryDelay * Math.pow(2, attempt);
|
|
54
|
+
return Math.min(delay, maxRetryDelay);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sleep(ms) {
|
|
58
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rejectPendingCommands(reason) {
|
|
62
|
+
for (const [id, pending] of pendingCommands) {
|
|
63
|
+
clearTimeout(pending.timer);
|
|
64
|
+
pending.reject(new Error(reason));
|
|
65
|
+
}
|
|
66
|
+
pendingCommands.clear();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handleMessage(data) {
|
|
70
|
+
let message;
|
|
71
|
+
try {
|
|
72
|
+
message = JSON.parse(data.toString());
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (message.id !== undefined) {
|
|
78
|
+
const pending = pendingCommands.get(message.id);
|
|
79
|
+
if (pending) {
|
|
80
|
+
clearTimeout(pending.timer);
|
|
81
|
+
pendingCommands.delete(message.id);
|
|
82
|
+
if (message.error) {
|
|
83
|
+
pending.reject(new Error(`CDP error: ${message.error.message}`));
|
|
84
|
+
} else {
|
|
85
|
+
pending.resolve(message.result);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (message.method) {
|
|
92
|
+
if (message.sessionId) {
|
|
93
|
+
const sessionEventKey = `${message.sessionId}:${message.method}`;
|
|
94
|
+
const sessionListeners = eventListeners.get(sessionEventKey);
|
|
95
|
+
if (sessionListeners) {
|
|
96
|
+
for (const callback of sessionListeners) {
|
|
97
|
+
try {
|
|
98
|
+
callback(message.params, message.sessionId);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.error(`Event handler error for ${sessionEventKey}:`, err);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const globalListeners = eventListeners.get(message.method);
|
|
107
|
+
if (globalListeners) {
|
|
108
|
+
for (const callback of globalListeners) {
|
|
109
|
+
try {
|
|
110
|
+
callback(message.params, message.sessionId);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error(`Event handler error for ${message.method}:`, err);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function setupWebSocketListeners() {
|
|
120
|
+
ws.addEventListener('close', () => {
|
|
121
|
+
const wasConnected = connected;
|
|
122
|
+
connected = false;
|
|
123
|
+
connecting = false;
|
|
124
|
+
rejectPendingCommands('Connection closed');
|
|
125
|
+
|
|
126
|
+
if (wasConnected && !intentionalClose && autoReconnect) {
|
|
127
|
+
attemptReconnect();
|
|
128
|
+
} else if (wasConnected && onCloseCallback && !intentionalClose) {
|
|
129
|
+
onCloseCallback('Connection closed unexpectedly');
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
ws.addEventListener('message', (event) => handleMessage(event.data));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function attemptReconnect() {
|
|
137
|
+
if (reconnecting || intentionalClose) return;
|
|
138
|
+
|
|
139
|
+
reconnecting = true;
|
|
140
|
+
retryAttempt = 0;
|
|
141
|
+
|
|
142
|
+
while (retryAttempt < maxRetries && !intentionalClose) {
|
|
143
|
+
const delay = calculateBackoff(retryAttempt);
|
|
144
|
+
emit('reconnecting', { attempt: retryAttempt + 1, delay });
|
|
145
|
+
|
|
146
|
+
await sleep(delay);
|
|
147
|
+
if (intentionalClose) break;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await doReconnect();
|
|
151
|
+
reconnecting = false;
|
|
152
|
+
retryAttempt = 0;
|
|
153
|
+
emit('reconnected', {});
|
|
154
|
+
return;
|
|
155
|
+
} catch {
|
|
156
|
+
retryAttempt++;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
reconnecting = false;
|
|
161
|
+
if (!intentionalClose && onCloseCallback) {
|
|
162
|
+
onCloseCallback('Connection closed unexpectedly after max retries');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function doReconnect() {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
ws = new WebSocket(wsUrl);
|
|
169
|
+
ws.addEventListener('open', () => {
|
|
170
|
+
connected = true;
|
|
171
|
+
setupWebSocketListeners();
|
|
172
|
+
resolve();
|
|
173
|
+
});
|
|
174
|
+
ws.addEventListener('error', (event) => {
|
|
175
|
+
reject(new Error(`CDP reconnection error: ${event.message || 'Connection failed'}`));
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function connect() {
|
|
181
|
+
if (connected) return;
|
|
182
|
+
if (connecting) throw new Error('Connection already in progress');
|
|
183
|
+
|
|
184
|
+
connecting = true;
|
|
185
|
+
intentionalClose = false;
|
|
186
|
+
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
ws = new WebSocket(wsUrl);
|
|
189
|
+
|
|
190
|
+
ws.addEventListener('open', () => {
|
|
191
|
+
connected = true;
|
|
192
|
+
connecting = false;
|
|
193
|
+
resolve();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
ws.addEventListener('error', (event) => {
|
|
197
|
+
connecting = false;
|
|
198
|
+
reject(new Error(`CDP connection error: ${event.message || 'Connection failed'}`));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
ws.addEventListener('close', () => {
|
|
202
|
+
const wasConnected = connected;
|
|
203
|
+
connected = false;
|
|
204
|
+
connecting = false;
|
|
205
|
+
rejectPendingCommands('Connection closed');
|
|
206
|
+
|
|
207
|
+
if (wasConnected && !intentionalClose && autoReconnect) {
|
|
208
|
+
attemptReconnect();
|
|
209
|
+
} else if (wasConnected && onCloseCallback && !intentionalClose) {
|
|
210
|
+
onCloseCallback('Connection closed unexpectedly');
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
ws.addEventListener('message', (event) => handleMessage(event.data));
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function send(method, params = {}, timeout = 30000) {
|
|
219
|
+
if (!connected) throw new Error('Not connected to CDP');
|
|
220
|
+
|
|
221
|
+
const id = ++messageId;
|
|
222
|
+
const message = JSON.stringify({ id, method, params });
|
|
223
|
+
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
const timer = setTimeout(() => {
|
|
226
|
+
pendingCommands.delete(id);
|
|
227
|
+
reject(new Error(`CDP command timeout: ${method}`));
|
|
228
|
+
}, timeout);
|
|
229
|
+
|
|
230
|
+
pendingCommands.set(id, { resolve, reject, timer });
|
|
231
|
+
ws.send(message);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function sendToSession(sessionId, method, params = {}, timeout = 30000) {
|
|
236
|
+
if (!connected) throw new Error('Not connected to CDP');
|
|
237
|
+
|
|
238
|
+
const id = ++messageId;
|
|
239
|
+
const message = JSON.stringify({ id, sessionId, method, params });
|
|
240
|
+
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const timer = setTimeout(() => {
|
|
243
|
+
pendingCommands.delete(id);
|
|
244
|
+
reject(new Error(`CDP command timeout: ${method} (session: ${sessionId})`));
|
|
245
|
+
}, timeout);
|
|
246
|
+
|
|
247
|
+
pendingCommands.set(id, { resolve, reject, timer });
|
|
248
|
+
ws.send(message);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function on(event, callback) {
|
|
253
|
+
if (!eventListeners.has(event)) {
|
|
254
|
+
eventListeners.set(event, new Set());
|
|
255
|
+
}
|
|
256
|
+
eventListeners.get(event).add(callback);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function off(event, callback) {
|
|
260
|
+
const listeners = eventListeners.get(event);
|
|
261
|
+
if (listeners) {
|
|
262
|
+
listeners.delete(callback);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function waitForEvent(event, predicate = () => true, timeout = 30000) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const timer = setTimeout(() => {
|
|
269
|
+
off(event, handler);
|
|
270
|
+
reject(new Error(`Timeout waiting for event: ${event}`));
|
|
271
|
+
}, timeout);
|
|
272
|
+
|
|
273
|
+
const handler = (params) => {
|
|
274
|
+
if (predicate(params)) {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
off(event, handler);
|
|
277
|
+
resolve(params);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
on(event, handler);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function close() {
|
|
286
|
+
intentionalClose = true;
|
|
287
|
+
reconnecting = false;
|
|
288
|
+
if (ws) {
|
|
289
|
+
rejectPendingCommands('Connection closed');
|
|
290
|
+
ws.close();
|
|
291
|
+
ws = null;
|
|
292
|
+
connected = false;
|
|
293
|
+
eventListeners.clear();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function removeAllListeners(event) {
|
|
298
|
+
if (event) {
|
|
299
|
+
eventListeners.delete(event);
|
|
300
|
+
} else {
|
|
301
|
+
eventListeners.clear();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function onClose(callback) {
|
|
306
|
+
onCloseCallback = callback;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
connect,
|
|
311
|
+
send,
|
|
312
|
+
sendToSession,
|
|
313
|
+
on,
|
|
314
|
+
off,
|
|
315
|
+
waitForEvent,
|
|
316
|
+
close,
|
|
317
|
+
removeAllListeners,
|
|
318
|
+
onClose,
|
|
319
|
+
isConnected: () => connected,
|
|
320
|
+
getWsUrl: () => wsUrl
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ============================================================================
|
|
325
|
+
// Discovery
|
|
326
|
+
// ============================================================================
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Discover Chrome CDP endpoints via HTTP
|
|
330
|
+
* @param {string} [host='localhost'] - Chrome debugging host
|
|
331
|
+
* @param {number} [port=9222] - Chrome debugging port
|
|
332
|
+
* @param {number} [timeout=5000] - Request timeout in ms
|
|
333
|
+
* @returns {Object} Discovery interface
|
|
334
|
+
*/
|
|
335
|
+
export function createDiscovery(host = 'localhost', port = 9222, timeout = 5000) {
|
|
336
|
+
const baseUrl = `http://${host}:${port}`;
|
|
337
|
+
|
|
338
|
+
function createTimeoutController() {
|
|
339
|
+
const controller = new AbortController();
|
|
340
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
341
|
+
return {
|
|
342
|
+
signal: controller.signal,
|
|
343
|
+
clear: () => clearTimeout(timeoutId)
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function getVersion() {
|
|
348
|
+
const timeoutCtrl = createTimeoutController();
|
|
349
|
+
try {
|
|
350
|
+
const response = await fetch(`${baseUrl}/json/version`, { signal: timeoutCtrl.signal });
|
|
351
|
+
if (!response.ok) {
|
|
352
|
+
throw new Error(`Chrome not reachable at ${baseUrl}: ${response.status}`);
|
|
353
|
+
}
|
|
354
|
+
const data = await response.json();
|
|
355
|
+
return {
|
|
356
|
+
browser: data.Browser,
|
|
357
|
+
protocolVersion: data['Protocol-Version'],
|
|
358
|
+
webSocketDebuggerUrl: data.webSocketDebuggerUrl
|
|
359
|
+
};
|
|
360
|
+
} catch (err) {
|
|
361
|
+
if (err.name === 'AbortError') {
|
|
362
|
+
throw new Error(`Chrome discovery timeout at ${baseUrl}`);
|
|
363
|
+
}
|
|
364
|
+
throw err;
|
|
365
|
+
} finally {
|
|
366
|
+
timeoutCtrl.clear();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function getTargets() {
|
|
371
|
+
const timeoutCtrl = createTimeoutController();
|
|
372
|
+
try {
|
|
373
|
+
const response = await fetch(`${baseUrl}/json/list`, { signal: timeoutCtrl.signal });
|
|
374
|
+
if (!response.ok) {
|
|
375
|
+
throw new Error(`Failed to get targets: ${response.status}`);
|
|
376
|
+
}
|
|
377
|
+
return response.json();
|
|
378
|
+
} catch (err) {
|
|
379
|
+
if (err.name === 'AbortError') {
|
|
380
|
+
throw new Error('Chrome discovery timeout getting targets');
|
|
381
|
+
}
|
|
382
|
+
throw err;
|
|
383
|
+
} finally {
|
|
384
|
+
timeoutCtrl.clear();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function getPages() {
|
|
389
|
+
const targets = await getTargets();
|
|
390
|
+
return targets.filter(t => t.type === 'page');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function findPageByUrl(urlPattern) {
|
|
394
|
+
const pages = await getPages();
|
|
395
|
+
const regex = urlPattern instanceof RegExp ? urlPattern : new RegExp(urlPattern);
|
|
396
|
+
return pages.find(p => regex.test(p.url)) || null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function isAvailable() {
|
|
400
|
+
try {
|
|
401
|
+
await getVersion();
|
|
402
|
+
return true;
|
|
403
|
+
} catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
getVersion,
|
|
410
|
+
getTargets,
|
|
411
|
+
getPages,
|
|
412
|
+
findPageByUrl,
|
|
413
|
+
isAvailable
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Convenience function to discover Chrome
|
|
419
|
+
* @param {string} [host='localhost'] - Chrome debugging host
|
|
420
|
+
* @param {number} [port=9222] - Chrome debugging port
|
|
421
|
+
* @param {number} [timeout=5000] - Request timeout in ms
|
|
422
|
+
* @returns {Promise<{wsUrl: string, version: Object, targets: Array}>}
|
|
423
|
+
*/
|
|
424
|
+
export async function discoverChrome(host = 'localhost', port = 9222, timeout = 5000) {
|
|
425
|
+
const discovery = createDiscovery(host, port, timeout);
|
|
426
|
+
const version = await discovery.getVersion();
|
|
427
|
+
const targets = await discovery.getTargets();
|
|
428
|
+
return {
|
|
429
|
+
wsUrl: version.webSocketDebuggerUrl,
|
|
430
|
+
version,
|
|
431
|
+
targets
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ============================================================================
|
|
436
|
+
// Target Manager
|
|
437
|
+
// ============================================================================
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Create a target manager for browser targets
|
|
441
|
+
* @param {Object} connection - CDP connection
|
|
442
|
+
* @returns {Object} Target manager interface
|
|
443
|
+
*/
|
|
444
|
+
export function createTargetManager(connection) {
|
|
445
|
+
const targets = new Map();
|
|
446
|
+
let discoveryEnabled = false;
|
|
447
|
+
let boundHandlers = null;
|
|
448
|
+
|
|
449
|
+
function onTargetCreated(params) {
|
|
450
|
+
targets.set(params.targetInfo.targetId, params.targetInfo);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function onTargetDestroyed(params) {
|
|
454
|
+
targets.delete(params.targetId);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function onTargetInfoChanged(params) {
|
|
458
|
+
targets.set(params.targetInfo.targetId, params.targetInfo);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function enableDiscovery() {
|
|
462
|
+
if (discoveryEnabled) return;
|
|
463
|
+
|
|
464
|
+
boundHandlers = { onTargetCreated, onTargetDestroyed, onTargetInfoChanged };
|
|
465
|
+
connection.on('Target.targetCreated', boundHandlers.onTargetCreated);
|
|
466
|
+
connection.on('Target.targetDestroyed', boundHandlers.onTargetDestroyed);
|
|
467
|
+
connection.on('Target.targetInfoChanged', boundHandlers.onTargetInfoChanged);
|
|
468
|
+
|
|
469
|
+
await connection.send('Target.setDiscoverTargets', { discover: true });
|
|
470
|
+
discoveryEnabled = true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function disableDiscovery() {
|
|
474
|
+
if (!discoveryEnabled) return;
|
|
475
|
+
|
|
476
|
+
await connection.send('Target.setDiscoverTargets', { discover: false });
|
|
477
|
+
|
|
478
|
+
if (boundHandlers) {
|
|
479
|
+
connection.off('Target.targetCreated', boundHandlers.onTargetCreated);
|
|
480
|
+
connection.off('Target.targetDestroyed', boundHandlers.onTargetDestroyed);
|
|
481
|
+
connection.off('Target.targetInfoChanged', boundHandlers.onTargetInfoChanged);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
discoveryEnabled = false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function getTargets(filter = null) {
|
|
488
|
+
const result = await connection.send('Target.getTargets', {
|
|
489
|
+
filter: filter ? [filter] : undefined
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
for (const info of result.targetInfos) {
|
|
493
|
+
targets.set(info.targetId, info);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return result.targetInfos;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function getPages() {
|
|
500
|
+
const allTargets = await getTargets();
|
|
501
|
+
return allTargets.filter(t => t.type === 'page');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function createTarget(url = 'about:blank', options = {}) {
|
|
505
|
+
const result = await connection.send('Target.createTarget', {
|
|
506
|
+
url,
|
|
507
|
+
width: options.width,
|
|
508
|
+
height: options.height,
|
|
509
|
+
background: options.background ?? false,
|
|
510
|
+
newWindow: options.newWindow ?? false
|
|
511
|
+
});
|
|
512
|
+
return result.targetId;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function closeTarget(targetId) {
|
|
516
|
+
const result = await connection.send('Target.closeTarget', { targetId });
|
|
517
|
+
targets.delete(targetId);
|
|
518
|
+
return result.success ?? true;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function activateTarget(targetId) {
|
|
522
|
+
await connection.send('Target.activateTarget', { targetId });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function getTargetInfo(targetId) {
|
|
526
|
+
const result = await connection.send('Target.getTargetInfo', { targetId });
|
|
527
|
+
targets.set(targetId, result.targetInfo);
|
|
528
|
+
return result.targetInfo;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function getCachedTarget(targetId) {
|
|
532
|
+
return targets.get(targetId);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function getCachedTargets() {
|
|
536
|
+
return new Map(targets);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function cleanup() {
|
|
540
|
+
await disableDiscovery();
|
|
541
|
+
targets.clear();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
enableDiscovery,
|
|
546
|
+
disableDiscovery,
|
|
547
|
+
getTargets,
|
|
548
|
+
getPages,
|
|
549
|
+
createTarget,
|
|
550
|
+
closeTarget,
|
|
551
|
+
activateTarget,
|
|
552
|
+
getTargetInfo,
|
|
553
|
+
getCachedTarget,
|
|
554
|
+
getCachedTargets,
|
|
555
|
+
cleanup
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ============================================================================
|
|
560
|
+
// Session Registry
|
|
561
|
+
// ============================================================================
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Create a session registry for managing CDP sessions
|
|
565
|
+
* @param {Object} connection - CDP connection
|
|
566
|
+
* @returns {Object} Session registry interface
|
|
567
|
+
*/
|
|
568
|
+
export function createSessionRegistry(connection) {
|
|
569
|
+
const sessions = new Map();
|
|
570
|
+
const targetToSession = new Map();
|
|
571
|
+
const pendingAttach = new Map();
|
|
572
|
+
let boundHandlers = null;
|
|
573
|
+
|
|
574
|
+
function onAttached(params) {
|
|
575
|
+
const { sessionId, targetInfo } = params;
|
|
576
|
+
sessions.set(sessionId, { targetId: targetInfo.targetId, attached: true });
|
|
577
|
+
targetToSession.set(targetInfo.targetId, sessionId);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function onDetached(params) {
|
|
581
|
+
const { sessionId } = params;
|
|
582
|
+
const session = sessions.get(sessionId);
|
|
583
|
+
if (session) {
|
|
584
|
+
targetToSession.delete(session.targetId);
|
|
585
|
+
sessions.delete(sessionId);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function onTargetDestroyed(params) {
|
|
590
|
+
const { targetId } = params;
|
|
591
|
+
const sessionId = targetToSession.get(targetId);
|
|
592
|
+
if (sessionId) {
|
|
593
|
+
sessions.delete(sessionId);
|
|
594
|
+
targetToSession.delete(targetId);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Setup handlers on creation
|
|
599
|
+
boundHandlers = { onAttached, onDetached, onTargetDestroyed };
|
|
600
|
+
connection.on('Target.attachedToTarget', boundHandlers.onAttached);
|
|
601
|
+
connection.on('Target.detachedFromTarget', boundHandlers.onDetached);
|
|
602
|
+
connection.on('Target.targetDestroyed', boundHandlers.onTargetDestroyed);
|
|
603
|
+
|
|
604
|
+
async function doAttach(targetId) {
|
|
605
|
+
const result = await connection.send('Target.attachToTarget', {
|
|
606
|
+
targetId,
|
|
607
|
+
flatten: true
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const sessionId = result.sessionId;
|
|
611
|
+
sessions.set(sessionId, { targetId, attached: true });
|
|
612
|
+
targetToSession.set(targetId, sessionId);
|
|
613
|
+
|
|
614
|
+
return sessionId;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function attach(targetId) {
|
|
618
|
+
const existing = targetToSession.get(targetId);
|
|
619
|
+
if (existing) return existing;
|
|
620
|
+
|
|
621
|
+
const pending = pendingAttach.get(targetId);
|
|
622
|
+
if (pending) return pending;
|
|
623
|
+
|
|
624
|
+
const attachPromise = doAttach(targetId);
|
|
625
|
+
pendingAttach.set(targetId, attachPromise);
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
return await attachPromise;
|
|
629
|
+
} finally {
|
|
630
|
+
pendingAttach.delete(targetId);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function detach(sessionId) {
|
|
635
|
+
const session = sessions.get(sessionId);
|
|
636
|
+
if (!session) return;
|
|
637
|
+
|
|
638
|
+
await connection.send('Target.detachFromTarget', { sessionId });
|
|
639
|
+
sessions.delete(sessionId);
|
|
640
|
+
targetToSession.delete(session.targetId);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function detachByTarget(targetId) {
|
|
644
|
+
const sessionId = targetToSession.get(targetId);
|
|
645
|
+
if (sessionId) {
|
|
646
|
+
await detach(sessionId);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function getSessionForTarget(targetId) {
|
|
651
|
+
return targetToSession.get(targetId);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function getTargetForSession(sessionId) {
|
|
655
|
+
return sessions.get(sessionId)?.targetId;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function isAttached(targetId) {
|
|
659
|
+
return targetToSession.has(targetId);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function getAllSessions() {
|
|
663
|
+
return Array.from(sessions.entries()).map(([sessionId, data]) => ({
|
|
664
|
+
sessionId,
|
|
665
|
+
targetId: data.targetId
|
|
666
|
+
}));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function detachAll() {
|
|
670
|
+
const sessionIds = Array.from(sessions.keys());
|
|
671
|
+
await Promise.all(sessionIds.map(s => detach(s)));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function cleanup() {
|
|
675
|
+
await detachAll();
|
|
676
|
+
if (boundHandlers) {
|
|
677
|
+
connection.off('Target.attachedToTarget', boundHandlers.onAttached);
|
|
678
|
+
connection.off('Target.detachedFromTarget', boundHandlers.onDetached);
|
|
679
|
+
connection.off('Target.targetDestroyed', boundHandlers.onTargetDestroyed);
|
|
680
|
+
}
|
|
681
|
+
sessions.clear();
|
|
682
|
+
targetToSession.clear();
|
|
683
|
+
pendingAttach.clear();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
attach,
|
|
688
|
+
detach,
|
|
689
|
+
detachByTarget,
|
|
690
|
+
getSessionForTarget,
|
|
691
|
+
getTargetForSession,
|
|
692
|
+
isAttached,
|
|
693
|
+
getAllSessions,
|
|
694
|
+
detachAll,
|
|
695
|
+
cleanup
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ============================================================================
|
|
700
|
+
// Page Session
|
|
701
|
+
// ============================================================================
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Create a page session for CDP communication with a specific page
|
|
705
|
+
* @param {Object} connection - CDP connection
|
|
706
|
+
* @param {string} sessionId - Session ID
|
|
707
|
+
* @param {string} targetId - Target ID
|
|
708
|
+
* @returns {Object} Page session interface
|
|
709
|
+
*/
|
|
710
|
+
export function createPageSession(connection, sessionId, targetId) {
|
|
711
|
+
let valid = true;
|
|
712
|
+
let detachHandler = null;
|
|
713
|
+
|
|
714
|
+
function onDetached(params) {
|
|
715
|
+
if (params.sessionId === sessionId) {
|
|
716
|
+
valid = false;
|
|
717
|
+
if (detachHandler) {
|
|
718
|
+
connection.off('Target.detachedFromTarget', detachHandler);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
detachHandler = onDetached;
|
|
724
|
+
connection.on('Target.detachedFromTarget', detachHandler);
|
|
725
|
+
|
|
726
|
+
async function send(method, params = {}) {
|
|
727
|
+
if (!valid) {
|
|
728
|
+
throw new Error(`Session ${sessionId} is no longer valid (target was closed or detached)`);
|
|
729
|
+
}
|
|
730
|
+
return connection.sendToSession(sessionId, method, params);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function on(event, callback) {
|
|
734
|
+
connection.on(`${sessionId}:${event}`, callback);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function off(event, callback) {
|
|
738
|
+
connection.off(`${sessionId}:${event}`, callback);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function dispose() {
|
|
742
|
+
valid = false;
|
|
743
|
+
if (detachHandler) {
|
|
744
|
+
connection.off('Target.detachedFromTarget', detachHandler);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return {
|
|
749
|
+
send,
|
|
750
|
+
on,
|
|
751
|
+
off,
|
|
752
|
+
dispose,
|
|
753
|
+
isValid: () => valid,
|
|
754
|
+
get sessionId() { return sessionId; },
|
|
755
|
+
get targetId() { return targetId; }
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ============================================================================
|
|
760
|
+
// Browser Client
|
|
761
|
+
// ============================================================================
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Create a high-level browser client
|
|
765
|
+
* @param {Object} [options] - Configuration options
|
|
766
|
+
* @param {string} [options.host='localhost'] - Chrome host
|
|
767
|
+
* @param {number} [options.port=9222] - Chrome debugging port
|
|
768
|
+
* @param {number} [options.connectTimeout=30000] - Connection timeout in ms
|
|
769
|
+
* @returns {Object} Browser client interface
|
|
770
|
+
*/
|
|
771
|
+
export function createBrowser(options = {}) {
|
|
772
|
+
const host = options.host ?? 'localhost';
|
|
773
|
+
const port = options.port ?? 9222;
|
|
774
|
+
const connectTimeout = options.connectTimeout ?? 30000;
|
|
775
|
+
|
|
776
|
+
let discovery = createDiscovery(host, port, connectTimeout);
|
|
777
|
+
let connection = null;
|
|
778
|
+
let targetManager = null;
|
|
779
|
+
let sessionRegistry = null;
|
|
780
|
+
let connected = false;
|
|
781
|
+
const targetLocks = new Map();
|
|
782
|
+
|
|
783
|
+
async function acquireLock(targetId) {
|
|
784
|
+
// Wait for any existing lock to be released
|
|
785
|
+
while (targetLocks.has(targetId)) {
|
|
786
|
+
await targetLocks.get(targetId);
|
|
787
|
+
}
|
|
788
|
+
// Create a new lock - this Promise will resolve when releaseLock is called
|
|
789
|
+
let releaseFn;
|
|
790
|
+
const lockPromise = new Promise(resolve => {
|
|
791
|
+
releaseFn = resolve;
|
|
792
|
+
});
|
|
793
|
+
targetLocks.set(targetId, lockPromise);
|
|
794
|
+
// Return a lock handle that can be used to release
|
|
795
|
+
return { promise: lockPromise, release: releaseFn };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function releaseLock(targetId, lock) {
|
|
799
|
+
if (targetLocks.get(targetId) === lock.promise) {
|
|
800
|
+
targetLocks.delete(targetId);
|
|
801
|
+
}
|
|
802
|
+
lock.release();
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function ensureConnected() {
|
|
806
|
+
if (!connected) {
|
|
807
|
+
throw new Error('BrowserClient not connected. Call connect() first.');
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function doConnect() {
|
|
812
|
+
const version = await discovery.getVersion();
|
|
813
|
+
connection = createConnection(version.webSocketDebuggerUrl);
|
|
814
|
+
await connection.connect();
|
|
815
|
+
|
|
816
|
+
targetManager = createTargetManager(connection);
|
|
817
|
+
sessionRegistry = createSessionRegistry(connection);
|
|
818
|
+
|
|
819
|
+
await targetManager.enableDiscovery();
|
|
820
|
+
connected = true;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function connect() {
|
|
824
|
+
if (connected) return;
|
|
825
|
+
|
|
826
|
+
const connectPromise = doConnect();
|
|
827
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
828
|
+
setTimeout(() => {
|
|
829
|
+
reject(timeoutError(`Connection to Chrome timed out after ${connectTimeout}ms`));
|
|
830
|
+
}, connectTimeout);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
await Promise.race([connectPromise, timeoutPromise]);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function disconnect() {
|
|
837
|
+
if (!connected) return;
|
|
838
|
+
|
|
839
|
+
await sessionRegistry.cleanup();
|
|
840
|
+
await targetManager.cleanup();
|
|
841
|
+
await connection.close();
|
|
842
|
+
connected = false;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function getPages() {
|
|
846
|
+
ensureConnected();
|
|
847
|
+
return targetManager.getPages();
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function newPage(url = 'about:blank') {
|
|
851
|
+
ensureConnected();
|
|
852
|
+
|
|
853
|
+
const targetId = await targetManager.createTarget(url);
|
|
854
|
+
const sessionId = await sessionRegistry.attach(targetId);
|
|
855
|
+
|
|
856
|
+
return createPageSession(connection, sessionId, targetId);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function attachToPage(targetId) {
|
|
860
|
+
ensureConnected();
|
|
861
|
+
const lock = await acquireLock(targetId);
|
|
862
|
+
try {
|
|
863
|
+
const sessionId = await sessionRegistry.attach(targetId);
|
|
864
|
+
return createPageSession(connection, sessionId, targetId);
|
|
865
|
+
} finally {
|
|
866
|
+
releaseLock(targetId, lock);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function findPage(urlPattern) {
|
|
871
|
+
ensureConnected();
|
|
872
|
+
|
|
873
|
+
const pages = await getPages();
|
|
874
|
+
const regex = urlPattern instanceof RegExp ? urlPattern : new RegExp(urlPattern);
|
|
875
|
+
const target = pages.find(p => regex.test(p.url));
|
|
876
|
+
|
|
877
|
+
if (!target) return null;
|
|
878
|
+
return attachToPage(target.targetId);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function closePage(targetId) {
|
|
882
|
+
ensureConnected();
|
|
883
|
+
const lock = await acquireLock(targetId);
|
|
884
|
+
try {
|
|
885
|
+
await sessionRegistry.detachByTarget(targetId);
|
|
886
|
+
await targetManager.closeTarget(targetId);
|
|
887
|
+
} finally {
|
|
888
|
+
releaseLock(targetId, lock);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
connect,
|
|
894
|
+
disconnect,
|
|
895
|
+
getPages,
|
|
896
|
+
newPage,
|
|
897
|
+
attachToPage,
|
|
898
|
+
findPage,
|
|
899
|
+
closePage,
|
|
900
|
+
isConnected: () => connected,
|
|
901
|
+
get connection() { return connection; },
|
|
902
|
+
get targets() { return targetManager; },
|
|
903
|
+
get sessions() { return sessionRegistry; }
|
|
904
|
+
};
|
|
905
|
+
}
|