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.
@@ -0,0 +1,598 @@
1
+ import { describe, it, mock, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ /**
5
+ * Mock WebSocket class for testing
6
+ */
7
+ class MockWebSocket {
8
+ constructor(url) {
9
+ this.url = url;
10
+ this.listeners = new Map();
11
+ this.sentMessages = [];
12
+ this.readyState = 0; // CONNECTING
13
+ }
14
+
15
+ on(event, callback) {
16
+ if (!this.listeners.has(event)) {
17
+ this.listeners.set(event, []);
18
+ }
19
+ this.listeners.get(event).push(callback);
20
+ }
21
+
22
+ removeAllListeners() {
23
+ this.listeners.clear();
24
+ }
25
+
26
+ send(message) {
27
+ this.sentMessages.push(JSON.parse(message));
28
+ }
29
+
30
+ close() {
31
+ this.readyState = 3; // CLOSED
32
+ this.emit('close');
33
+ }
34
+
35
+ emit(event, data) {
36
+ const callbacks = this.listeners.get(event) || [];
37
+ callbacks.forEach(cb => cb(data));
38
+ }
39
+
40
+ // Simulate successful connection
41
+ simulateOpen() {
42
+ this.readyState = 1; // OPEN
43
+ this.emit('open');
44
+ }
45
+
46
+ // Simulate receiving a message
47
+ simulateMessage(data) {
48
+ this.emit('message', Buffer.from(JSON.stringify(data)));
49
+ }
50
+
51
+ // Simulate error
52
+ simulateError(error) {
53
+ this.emit('error', error);
54
+ }
55
+ }
56
+
57
+ // We need to test CDPConnection behavior without importing it (to avoid ws dependency)
58
+ // So we'll test the logic patterns that CDPConnection uses
59
+
60
+ describe('CDPConnection', () => {
61
+ describe('message ID generation', () => {
62
+ it('should increment message IDs', () => {
63
+ let messageId = 0;
64
+ const ids = [];
65
+ for (let i = 0; i < 5; i++) {
66
+ ids.push(++messageId);
67
+ }
68
+ assert.deepStrictEqual(ids, [1, 2, 3, 4, 5]);
69
+ });
70
+ });
71
+
72
+ describe('event listener management', () => {
73
+ it('should add and remove event listeners', () => {
74
+ const eventListeners = new Map();
75
+ const handler = () => {};
76
+
77
+ // Add listener
78
+ if (!eventListeners.has('Page.loadEventFired')) {
79
+ eventListeners.set('Page.loadEventFired', new Set());
80
+ }
81
+ eventListeners.get('Page.loadEventFired').add(handler);
82
+ assert.strictEqual(eventListeners.get('Page.loadEventFired').size, 1);
83
+
84
+ // Remove listener
85
+ eventListeners.get('Page.loadEventFired').delete(handler);
86
+ assert.strictEqual(eventListeners.get('Page.loadEventFired').size, 0);
87
+ });
88
+
89
+ it('should support multiple listeners for same event', () => {
90
+ const eventListeners = new Map();
91
+ const handler1 = () => {};
92
+ const handler2 = () => {};
93
+
94
+ eventListeners.set('Page.loadEventFired', new Set());
95
+ eventListeners.get('Page.loadEventFired').add(handler1);
96
+ eventListeners.get('Page.loadEventFired').add(handler2);
97
+
98
+ assert.strictEqual(eventListeners.get('Page.loadEventFired').size, 2);
99
+ });
100
+
101
+ it('should remove specific listener only', () => {
102
+ const eventListeners = new Map();
103
+ const handler1 = () => {};
104
+ const handler2 = () => {};
105
+
106
+ eventListeners.set('Page.loadEventFired', new Set());
107
+ eventListeners.get('Page.loadEventFired').add(handler1);
108
+ eventListeners.get('Page.loadEventFired').add(handler2);
109
+
110
+ eventListeners.get('Page.loadEventFired').delete(handler1);
111
+
112
+ assert.strictEqual(eventListeners.get('Page.loadEventFired').size, 1);
113
+ assert.ok(eventListeners.get('Page.loadEventFired').has(handler2));
114
+ });
115
+ });
116
+
117
+ describe('removeAllListeners', () => {
118
+ it('should remove all listeners for specific event', () => {
119
+ const eventListeners = new Map();
120
+ const handler = () => {};
121
+
122
+ eventListeners.set('Page.loadEventFired', new Set([handler]));
123
+ eventListeners.set('Network.requestWillBeSent', new Set([handler]));
124
+
125
+ eventListeners.delete('Page.loadEventFired');
126
+
127
+ assert.strictEqual(eventListeners.has('Page.loadEventFired'), false);
128
+ assert.ok(eventListeners.has('Network.requestWillBeSent'));
129
+ });
130
+
131
+ it('should remove all listeners when clearing', () => {
132
+ const eventListeners = new Map();
133
+ const handler = () => {};
134
+
135
+ eventListeners.set('Page.loadEventFired', new Set([handler]));
136
+ eventListeners.set('Network.requestWillBeSent', new Set([handler]));
137
+
138
+ eventListeners.clear();
139
+
140
+ assert.strictEqual(eventListeners.size, 0);
141
+ });
142
+ });
143
+
144
+ describe('message handling', () => {
145
+ it('should parse JSON messages', () => {
146
+ const data = Buffer.from(JSON.stringify({ id: 1, result: { value: 'test' } }));
147
+ const message = JSON.parse(data.toString());
148
+ assert.strictEqual(message.id, 1);
149
+ assert.deepStrictEqual(message.result, { value: 'test' });
150
+ });
151
+
152
+ it('should identify command responses by id', () => {
153
+ const message = { id: 1, result: { value: 'test' } };
154
+ assert.ok(message.id !== undefined);
155
+ });
156
+
157
+ it('should identify events by method', () => {
158
+ const message = { method: 'Page.loadEventFired', params: { timestamp: 12345 } };
159
+ assert.ok(message.method !== undefined);
160
+ assert.strictEqual(message.id, undefined);
161
+ });
162
+
163
+ it('should handle error responses', () => {
164
+ const message = { id: 1, error: { code: -32000, message: 'Target not found' } };
165
+ assert.ok(message.error);
166
+ assert.strictEqual(message.error.message, 'Target not found');
167
+ });
168
+ });
169
+
170
+ describe('session-scoped events', () => {
171
+ it('should route events with sessionId', () => {
172
+ const eventListeners = new Map();
173
+ const events = [];
174
+
175
+ // Register session-scoped listener
176
+ const sessionKey = 'session123:Page.loadEventFired';
177
+ eventListeners.set(sessionKey, new Set([(params, sessionId) => {
178
+ events.push({ params, sessionId });
179
+ }]));
180
+
181
+ // Simulate event dispatch
182
+ const message = {
183
+ method: 'Page.loadEventFired',
184
+ sessionId: 'session123',
185
+ params: { timestamp: 12345 }
186
+ };
187
+
188
+ const sessionEventKey = `${message.sessionId}:${message.method}`;
189
+ const listeners = eventListeners.get(sessionEventKey);
190
+ if (listeners) {
191
+ for (const callback of listeners) {
192
+ callback(message.params, message.sessionId);
193
+ }
194
+ }
195
+
196
+ assert.strictEqual(events.length, 1);
197
+ assert.strictEqual(events[0].sessionId, 'session123');
198
+ });
199
+ });
200
+
201
+ describe('pending commands', () => {
202
+ it('should track pending commands by id', () => {
203
+ const pendingCommands = new Map();
204
+
205
+ const id = 1;
206
+ const resolve = (result) => result;
207
+ const reject = (error) => { throw error; };
208
+ const timer = setTimeout(() => {}, 30000);
209
+
210
+ pendingCommands.set(id, { resolve, reject, timer });
211
+
212
+ assert.ok(pendingCommands.has(id));
213
+ clearTimeout(timer);
214
+ });
215
+
216
+ it('should clean up on response', () => {
217
+ const pendingCommands = new Map();
218
+ const timer = setTimeout(() => {}, 30000);
219
+ pendingCommands.set(1, { resolve: () => {}, reject: () => {}, timer });
220
+
221
+ // Simulate response handling
222
+ const pending = pendingCommands.get(1);
223
+ clearTimeout(pending.timer);
224
+ pendingCommands.delete(1);
225
+
226
+ assert.strictEqual(pendingCommands.size, 0);
227
+ });
228
+
229
+ it('should reject all pending on disconnect', () => {
230
+ const pendingCommands = new Map();
231
+ const rejected = [];
232
+
233
+ for (let i = 1; i <= 3; i++) {
234
+ const timer = setTimeout(() => {}, 30000);
235
+ pendingCommands.set(i, {
236
+ resolve: () => {},
237
+ reject: (err) => rejected.push(err),
238
+ timer
239
+ });
240
+ }
241
+
242
+ // Simulate connection close
243
+ for (const [id, pending] of pendingCommands) {
244
+ clearTimeout(pending.timer);
245
+ pending.reject(new Error('Connection closed'));
246
+ }
247
+ pendingCommands.clear();
248
+
249
+ assert.strictEqual(rejected.length, 3);
250
+ assert.strictEqual(pendingCommands.size, 0);
251
+ });
252
+ });
253
+
254
+ describe('exponential backoff calculation', () => {
255
+ it('should calculate correct delays for each attempt', () => {
256
+ const retryDelay = 1000;
257
+ const maxRetryDelay = 30000;
258
+
259
+ const calculateBackoff = (attempt) => {
260
+ const delay = retryDelay * Math.pow(2, attempt);
261
+ return Math.min(delay, maxRetryDelay);
262
+ };
263
+
264
+ // attempt 0: 1000 * 2^0 = 1000ms
265
+ assert.strictEqual(calculateBackoff(0), 1000);
266
+ // attempt 1: 1000 * 2^1 = 2000ms
267
+ assert.strictEqual(calculateBackoff(1), 2000);
268
+ // attempt 2: 1000 * 2^2 = 4000ms
269
+ assert.strictEqual(calculateBackoff(2), 4000);
270
+ // attempt 3: 1000 * 2^3 = 8000ms
271
+ assert.strictEqual(calculateBackoff(3), 8000);
272
+ // attempt 4: 1000 * 2^4 = 16000ms
273
+ assert.strictEqual(calculateBackoff(4), 16000);
274
+ // attempt 5: 1000 * 2^5 = 32000ms, but capped at 30000
275
+ assert.strictEqual(calculateBackoff(5), 30000);
276
+ // attempt 6: still capped at 30000
277
+ assert.strictEqual(calculateBackoff(6), 30000);
278
+ });
279
+
280
+ it('should respect custom retry delay', () => {
281
+ const retryDelay = 500;
282
+ const maxRetryDelay = 30000;
283
+
284
+ const calculateBackoff = (attempt) => {
285
+ const delay = retryDelay * Math.pow(2, attempt);
286
+ return Math.min(delay, maxRetryDelay);
287
+ };
288
+
289
+ assert.strictEqual(calculateBackoff(0), 500);
290
+ assert.strictEqual(calculateBackoff(1), 1000);
291
+ assert.strictEqual(calculateBackoff(2), 2000);
292
+ });
293
+
294
+ it('should respect custom max retry delay', () => {
295
+ const retryDelay = 1000;
296
+ const maxRetryDelay = 5000;
297
+
298
+ const calculateBackoff = (attempt) => {
299
+ const delay = retryDelay * Math.pow(2, attempt);
300
+ return Math.min(delay, maxRetryDelay);
301
+ };
302
+
303
+ assert.strictEqual(calculateBackoff(0), 1000);
304
+ assert.strictEqual(calculateBackoff(1), 2000);
305
+ assert.strictEqual(calculateBackoff(2), 4000);
306
+ assert.strictEqual(calculateBackoff(3), 5000); // capped
307
+ assert.strictEqual(calculateBackoff(4), 5000); // still capped
308
+ });
309
+ });
310
+
311
+ describe('reconnection logic', () => {
312
+ it('should track reconnection state', () => {
313
+ let reconnecting = false;
314
+ let retryAttempt = 0;
315
+ const maxRetries = 5;
316
+
317
+ // Simulate starting reconnection
318
+ reconnecting = true;
319
+ retryAttempt = 0;
320
+
321
+ assert.strictEqual(reconnecting, true);
322
+ assert.strictEqual(retryAttempt, 0);
323
+
324
+ // Simulate retry attempts
325
+ while (retryAttempt < maxRetries) {
326
+ retryAttempt++;
327
+ if (retryAttempt === 3) {
328
+ // Simulate successful reconnection
329
+ reconnecting = false;
330
+ retryAttempt = 0;
331
+ break;
332
+ }
333
+ }
334
+
335
+ assert.strictEqual(reconnecting, false);
336
+ assert.strictEqual(retryAttempt, 0);
337
+ });
338
+
339
+ it('should stop reconnection when intentional close is triggered', () => {
340
+ let reconnecting = true;
341
+ let intentionalClose = false;
342
+ const attempts = [];
343
+
344
+ for (let attempt = 0; attempt < 5; attempt++) {
345
+ if (intentionalClose) break;
346
+
347
+ attempts.push(attempt);
348
+
349
+ if (attempt === 2) {
350
+ intentionalClose = true;
351
+ }
352
+ }
353
+
354
+ reconnecting = false;
355
+
356
+ assert.deepStrictEqual(attempts, [0, 1, 2]);
357
+ assert.strictEqual(reconnecting, false);
358
+ assert.strictEqual(intentionalClose, true);
359
+ });
360
+
361
+ it('should emit reconnecting event with attempt and delay info', () => {
362
+ const events = [];
363
+ const retryDelay = 1000;
364
+ const maxRetryDelay = 30000;
365
+
366
+ const emit = (event, data) => events.push({ event, data });
367
+
368
+ const calculateBackoff = (attempt) => {
369
+ const delay = retryDelay * Math.pow(2, attempt);
370
+ return Math.min(delay, maxRetryDelay);
371
+ };
372
+
373
+ // Simulate 3 reconnection attempts
374
+ for (let attempt = 0; attempt < 3; attempt++) {
375
+ const delay = calculateBackoff(attempt);
376
+ emit('reconnecting', { attempt: attempt + 1, delay });
377
+ }
378
+
379
+ assert.strictEqual(events.length, 3);
380
+ assert.deepStrictEqual(events[0], { event: 'reconnecting', data: { attempt: 1, delay: 1000 } });
381
+ assert.deepStrictEqual(events[1], { event: 'reconnecting', data: { attempt: 2, delay: 2000 } });
382
+ assert.deepStrictEqual(events[2], { event: 'reconnecting', data: { attempt: 3, delay: 4000 } });
383
+ });
384
+
385
+ it('should emit reconnected event on successful reconnection', () => {
386
+ const events = [];
387
+ const emit = (event, data) => events.push({ event, data });
388
+
389
+ // Simulate successful reconnection
390
+ emit('reconnected', {});
391
+
392
+ assert.strictEqual(events.length, 1);
393
+ assert.deepStrictEqual(events[0], { event: 'reconnected', data: {} });
394
+ });
395
+
396
+ it('should call onClose callback after max retries exceeded', () => {
397
+ let closeReason = null;
398
+ const onCloseCallback = (reason) => { closeReason = reason; };
399
+
400
+ const maxRetries = 3;
401
+ let retryAttempt = 0;
402
+ let reconnecting = true;
403
+ const intentionalClose = false;
404
+
405
+ // Simulate exhausting all retries
406
+ while (retryAttempt < maxRetries) {
407
+ retryAttempt++;
408
+ }
409
+
410
+ reconnecting = false;
411
+ if (!intentionalClose && onCloseCallback) {
412
+ onCloseCallback('Connection closed unexpectedly after max retries');
413
+ }
414
+
415
+ assert.strictEqual(closeReason, 'Connection closed unexpectedly after max retries');
416
+ });
417
+ });
418
+
419
+ describe('connection options', () => {
420
+ it('should use default options when not provided', () => {
421
+ const defaults = {
422
+ maxRetries: 5,
423
+ retryDelay: 1000,
424
+ maxRetryDelay: 30000,
425
+ autoReconnect: false
426
+ };
427
+
428
+ const options = {};
429
+ const maxRetries = options.maxRetries ?? 5;
430
+ const retryDelay = options.retryDelay ?? 1000;
431
+ const maxRetryDelay = options.maxRetryDelay ?? 30000;
432
+ const autoReconnect = options.autoReconnect ?? false;
433
+
434
+ assert.strictEqual(maxRetries, defaults.maxRetries);
435
+ assert.strictEqual(retryDelay, defaults.retryDelay);
436
+ assert.strictEqual(maxRetryDelay, defaults.maxRetryDelay);
437
+ assert.strictEqual(autoReconnect, defaults.autoReconnect);
438
+ });
439
+
440
+ it('should allow custom options', () => {
441
+ const options = {
442
+ maxRetries: 10,
443
+ retryDelay: 500,
444
+ maxRetryDelay: 15000,
445
+ autoReconnect: true
446
+ };
447
+
448
+ const maxRetries = options.maxRetries ?? 5;
449
+ const retryDelay = options.retryDelay ?? 1000;
450
+ const maxRetryDelay = options.maxRetryDelay ?? 30000;
451
+ const autoReconnect = options.autoReconnect ?? false;
452
+
453
+ assert.strictEqual(maxRetries, 10);
454
+ assert.strictEqual(retryDelay, 500);
455
+ assert.strictEqual(maxRetryDelay, 15000);
456
+ assert.strictEqual(autoReconnect, true);
457
+ });
458
+
459
+ it('should handle partial options', () => {
460
+ const options = {
461
+ maxRetries: 3,
462
+ autoReconnect: true
463
+ };
464
+
465
+ const maxRetries = options.maxRetries ?? 5;
466
+ const retryDelay = options.retryDelay ?? 1000;
467
+ const maxRetryDelay = options.maxRetryDelay ?? 30000;
468
+ const autoReconnect = options.autoReconnect ?? false;
469
+
470
+ assert.strictEqual(maxRetries, 3);
471
+ assert.strictEqual(retryDelay, 1000); // default
472
+ assert.strictEqual(maxRetryDelay, 30000); // default
473
+ assert.strictEqual(autoReconnect, true);
474
+ });
475
+ });
476
+
477
+ describe('autoReconnect behavior', () => {
478
+ it('should not attempt reconnection when autoReconnect is false', () => {
479
+ let reconnectAttempted = false;
480
+ const autoReconnect = false;
481
+ const wasConnected = true;
482
+ const intentionalClose = false;
483
+
484
+ if (wasConnected && !intentionalClose && autoReconnect) {
485
+ reconnectAttempted = true;
486
+ }
487
+
488
+ assert.strictEqual(reconnectAttempted, false);
489
+ });
490
+
491
+ it('should attempt reconnection when autoReconnect is true', () => {
492
+ let reconnectAttempted = false;
493
+ const autoReconnect = true;
494
+ const wasConnected = true;
495
+ const intentionalClose = false;
496
+
497
+ if (wasConnected && !intentionalClose && autoReconnect) {
498
+ reconnectAttempted = true;
499
+ }
500
+
501
+ assert.strictEqual(reconnectAttempted, true);
502
+ });
503
+
504
+ it('should not attempt reconnection on intentional close', () => {
505
+ let reconnectAttempted = false;
506
+ const autoReconnect = true;
507
+ const wasConnected = true;
508
+ const intentionalClose = true;
509
+
510
+ if (wasConnected && !intentionalClose && autoReconnect) {
511
+ reconnectAttempted = true;
512
+ }
513
+
514
+ assert.strictEqual(reconnectAttempted, false);
515
+ });
516
+
517
+ it('should not attempt reconnection if never connected', () => {
518
+ let reconnectAttempted = false;
519
+ const autoReconnect = true;
520
+ const wasConnected = false;
521
+ const intentionalClose = false;
522
+
523
+ if (wasConnected && !intentionalClose && autoReconnect) {
524
+ reconnectAttempted = true;
525
+ }
526
+
527
+ assert.strictEqual(reconnectAttempted, false);
528
+ });
529
+ });
530
+
531
+ describe('MockWebSocket', () => {
532
+ let ws;
533
+
534
+ beforeEach(() => {
535
+ ws = new MockWebSocket('ws://localhost:9222/devtools/browser/abc');
536
+ });
537
+
538
+ it('should store URL', () => {
539
+ assert.strictEqual(ws.url, 'ws://localhost:9222/devtools/browser/abc');
540
+ });
541
+
542
+ it('should register event listeners', () => {
543
+ const handler = () => {};
544
+ ws.on('open', handler);
545
+ assert.strictEqual(ws.listeners.get('open').length, 1);
546
+ });
547
+
548
+ it('should emit events to listeners', () => {
549
+ let called = false;
550
+ ws.on('open', () => { called = true; });
551
+ ws.emit('open');
552
+ assert.strictEqual(called, true);
553
+ });
554
+
555
+ it('should track sent messages', () => {
556
+ ws.send(JSON.stringify({ id: 1, method: 'Page.navigate', params: { url: 'https://example.com' } }));
557
+ assert.strictEqual(ws.sentMessages.length, 1);
558
+ assert.strictEqual(ws.sentMessages[0].method, 'Page.navigate');
559
+ });
560
+
561
+ it('should simulate message receiving', () => {
562
+ const messages = [];
563
+ ws.on('message', (data) => {
564
+ messages.push(JSON.parse(data.toString()));
565
+ });
566
+
567
+ ws.simulateMessage({ id: 1, result: { frameId: 'abc' } });
568
+
569
+ assert.strictEqual(messages.length, 1);
570
+ assert.strictEqual(messages[0].id, 1);
571
+ });
572
+
573
+ it('should simulate errors', () => {
574
+ const errors = [];
575
+ ws.on('error', (err) => errors.push(err));
576
+
577
+ ws.simulateError(new Error('Connection refused'));
578
+
579
+ assert.strictEqual(errors.length, 1);
580
+ assert.strictEqual(errors[0].message, 'Connection refused');
581
+ });
582
+
583
+ it('should emit close on close()', () => {
584
+ let closed = false;
585
+ ws.on('close', () => { closed = true; });
586
+ ws.close();
587
+ assert.strictEqual(closed, true);
588
+ assert.strictEqual(ws.readyState, 3);
589
+ });
590
+
591
+ it('should clear all listeners on removeAllListeners()', () => {
592
+ ws.on('open', () => {});
593
+ ws.on('message', () => {});
594
+ ws.removeAllListeners();
595
+ assert.strictEqual(ws.listeners.size, 0);
596
+ });
597
+ });
598
+ });