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,822 @@
1
+ import { describe, it, mock, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { createPageController, WaitCondition } from '../page.js';
4
+ import { ErrorTypes } from '../utils.js';
5
+
6
+ describe('PageController', () => {
7
+ let mockClient;
8
+ let controller;
9
+ let eventHandlers;
10
+
11
+ beforeEach(() => {
12
+ eventHandlers = {};
13
+ mockClient = {
14
+ send: mock.fn(),
15
+ on: mock.fn((event, handler) => {
16
+ if (!eventHandlers[event]) {
17
+ eventHandlers[event] = [];
18
+ }
19
+ eventHandlers[event].push(handler);
20
+ }),
21
+ off: mock.fn((event, handler) => {
22
+ if (eventHandlers[event]) {
23
+ eventHandlers[event] = eventHandlers[event].filter(h => h !== handler);
24
+ }
25
+ })
26
+ };
27
+ controller = createPageController(mockClient);
28
+ });
29
+
30
+ afterEach(() => {
31
+ if (controller) {
32
+ controller.dispose();
33
+ }
34
+ });
35
+
36
+ const emitEvent = (event, data) => {
37
+ if (eventHandlers[event]) {
38
+ eventHandlers[event].forEach(handler => handler(data));
39
+ }
40
+ };
41
+
42
+ describe('initialize', () => {
43
+ it('should enable required CDP domains', async () => {
44
+ mockClient.send.mock.mockImplementation(async (method) => {
45
+ if (method === 'Page.getFrameTree') {
46
+ return { frameTree: { frame: { id: 'main-frame-id' } } };
47
+ }
48
+ return {};
49
+ });
50
+
51
+ await controller.initialize();
52
+
53
+ const calls = mockClient.send.mock.calls;
54
+ const methods = calls.map(c => c.arguments[0]);
55
+
56
+ assert.ok(methods.includes('Page.enable'));
57
+ assert.ok(methods.includes('Page.setLifecycleEventsEnabled'));
58
+ assert.ok(methods.includes('Network.enable'));
59
+ assert.ok(methods.includes('Runtime.enable'));
60
+ assert.ok(methods.includes('Page.getFrameTree'));
61
+ });
62
+
63
+ it('should set up event listeners', async () => {
64
+ mockClient.send.mock.mockImplementation(async (method) => {
65
+ if (method === 'Page.getFrameTree') {
66
+ return { frameTree: { frame: { id: 'main-frame-id' } } };
67
+ }
68
+ return {};
69
+ });
70
+
71
+ await controller.initialize();
72
+
73
+ const events = mockClient.on.mock.calls.map(c => c.arguments[0]);
74
+ assert.ok(events.includes('Page.lifecycleEvent'));
75
+ assert.ok(events.includes('Page.frameNavigated'));
76
+ assert.ok(events.includes('Network.requestWillBeSent'));
77
+ assert.ok(events.includes('Network.loadingFinished'));
78
+ assert.ok(events.includes('Network.loadingFailed'));
79
+ });
80
+
81
+ it('should store the main frame ID', async () => {
82
+ mockClient.send.mock.mockImplementation(async (method) => {
83
+ if (method === 'Page.getFrameTree') {
84
+ return { frameTree: { frame: { id: 'test-frame-123' } } };
85
+ }
86
+ return {};
87
+ });
88
+
89
+ await controller.initialize();
90
+
91
+ assert.strictEqual(controller.mainFrameId, 'test-frame-123');
92
+ });
93
+ });
94
+
95
+ describe('navigate', () => {
96
+ beforeEach(async () => {
97
+ mockClient.send.mock.mockImplementation(async (method) => {
98
+ if (method === 'Page.getFrameTree') {
99
+ return { frameTree: { frame: { id: 'main-frame' } } };
100
+ }
101
+ if (method === 'Page.navigate') {
102
+ return { frameId: 'main-frame', loaderId: 'loader-1' };
103
+ }
104
+ return {};
105
+ });
106
+ await controller.initialize();
107
+ });
108
+
109
+ it('should navigate to URL and return navigation info', async () => {
110
+ const navigatePromise = controller.navigate('https://example.com', {
111
+ waitUntil: WaitCondition.COMMIT
112
+ });
113
+
114
+ const result = await navigatePromise;
115
+
116
+ assert.strictEqual(result.url, 'https://example.com');
117
+ assert.strictEqual(result.frameId, 'main-frame');
118
+ assert.strictEqual(result.loaderId, 'loader-1');
119
+ });
120
+
121
+ it('should wait for load event by default', async () => {
122
+ const navigatePromise = controller.navigate('https://example.com');
123
+
124
+ // Simulate load event after short delay
125
+ setTimeout(() => {
126
+ emitEvent('Page.lifecycleEvent', { frameId: 'main-frame', name: 'load' });
127
+ }, 10);
128
+
129
+ const result = await navigatePromise;
130
+ assert.strictEqual(result.url, 'https://example.com');
131
+ });
132
+
133
+ it('should wait for DOMContentLoaded when specified', async () => {
134
+ const navigatePromise = controller.navigate('https://example.com', {
135
+ waitUntil: WaitCondition.DOM_CONTENT_LOADED
136
+ });
137
+
138
+ setTimeout(() => {
139
+ emitEvent('Page.lifecycleEvent', { frameId: 'main-frame', name: 'DOMContentLoaded' });
140
+ }, 10);
141
+
142
+ await navigatePromise;
143
+ });
144
+
145
+ it('should throw NavigationError on navigation failure', async () => {
146
+ mockClient.send.mock.mockImplementation(async (method) => {
147
+ if (method === 'Page.navigate') {
148
+ return { errorText: 'net::ERR_NAME_NOT_RESOLVED' };
149
+ }
150
+ if (method === 'Page.getFrameTree') {
151
+ return { frameTree: { frame: { id: 'main-frame' } } };
152
+ }
153
+ return {};
154
+ });
155
+
156
+ await assert.rejects(
157
+ controller.navigate('https://invalid.url', { waitUntil: WaitCondition.COMMIT }),
158
+ (err) => {
159
+ assert.strictEqual(err.name, ErrorTypes.NAVIGATION);
160
+ assert.strictEqual(err.url, 'https://invalid.url');
161
+ return true;
162
+ }
163
+ );
164
+ });
165
+
166
+ it('should throw NavigationError when CDP send fails', async () => {
167
+ mockClient.send.mock.mockImplementation(async (method) => {
168
+ if (method === 'Page.navigate') {
169
+ throw new Error('Connection failed');
170
+ }
171
+ if (method === 'Page.getFrameTree') {
172
+ return { frameTree: { frame: { id: 'main-frame' } } };
173
+ }
174
+ return {};
175
+ });
176
+
177
+ await assert.rejects(
178
+ controller.navigate('https://example.com', { waitUntil: WaitCondition.COMMIT }),
179
+ (err) => err.name === ErrorTypes.NAVIGATION
180
+ );
181
+ });
182
+
183
+ it('should timeout when wait condition not met', async () => {
184
+ await assert.rejects(
185
+ controller.navigate('https://example.com', {
186
+ waitUntil: WaitCondition.LOAD,
187
+ timeout: 50
188
+ }),
189
+ (err) => err.name === ErrorTypes.TIMEOUT
190
+ );
191
+ });
192
+
193
+ it('should pass referrer when provided', async () => {
194
+ const navigatePromise = controller.navigate('https://example.com', {
195
+ waitUntil: WaitCondition.COMMIT,
196
+ referrer: 'https://referrer.com'
197
+ });
198
+
199
+ await navigatePromise;
200
+
201
+ const navigateCall = mockClient.send.mock.calls.find(
202
+ c => c.arguments[0] === 'Page.navigate'
203
+ );
204
+ assert.strictEqual(navigateCall.arguments[1].referrer, 'https://referrer.com');
205
+ });
206
+ });
207
+
208
+ describe('reload', () => {
209
+ beforeEach(async () => {
210
+ mockClient.send.mock.mockImplementation(async (method) => {
211
+ if (method === 'Page.getFrameTree') {
212
+ return { frameTree: { frame: { id: 'main-frame' } } };
213
+ }
214
+ return {};
215
+ });
216
+ await controller.initialize();
217
+ });
218
+
219
+ it('should reload the page', async () => {
220
+ const reloadPromise = controller.reload({ waitUntil: WaitCondition.COMMIT });
221
+
222
+ await reloadPromise;
223
+
224
+ const reloadCall = mockClient.send.mock.calls.find(
225
+ c => c.arguments[0] === 'Page.reload'
226
+ );
227
+ assert.ok(reloadCall);
228
+ });
229
+
230
+ it('should respect ignoreCache option', async () => {
231
+ const reloadPromise = controller.reload({
232
+ ignoreCache: true,
233
+ waitUntil: WaitCondition.COMMIT
234
+ });
235
+
236
+ await reloadPromise;
237
+
238
+ const reloadCall = mockClient.send.mock.calls.find(
239
+ c => c.arguments[0] === 'Page.reload'
240
+ );
241
+ assert.strictEqual(reloadCall.arguments[1].ignoreCache, true);
242
+ });
243
+ });
244
+
245
+ describe('goBack and goForward', () => {
246
+ beforeEach(async () => {
247
+ mockClient.send.mock.mockImplementation(async (method) => {
248
+ if (method === 'Page.getFrameTree') {
249
+ return { frameTree: { frame: { id: 'main-frame' } } };
250
+ }
251
+ if (method === 'Page.getNavigationHistory') {
252
+ return {
253
+ currentIndex: 1,
254
+ entries: [
255
+ { id: 0, url: 'https://first.com' },
256
+ { id: 1, url: 'https://second.com' },
257
+ { id: 2, url: 'https://third.com' }
258
+ ]
259
+ };
260
+ }
261
+ return {};
262
+ });
263
+ await controller.initialize();
264
+ });
265
+
266
+ it('should navigate back in history', async () => {
267
+ const backPromise = controller.goBack({ waitUntil: WaitCondition.COMMIT });
268
+
269
+ const result = await backPromise;
270
+
271
+ assert.strictEqual(result.url, 'https://first.com');
272
+ });
273
+
274
+ it('should navigate forward in history', async () => {
275
+ const forwardPromise = controller.goForward({ waitUntil: WaitCondition.COMMIT });
276
+
277
+ const result = await forwardPromise;
278
+
279
+ assert.strictEqual(result.url, 'https://third.com');
280
+ });
281
+
282
+ it('should return null when no back history', async () => {
283
+ mockClient.send.mock.mockImplementation(async (method) => {
284
+ if (method === 'Page.getFrameTree') {
285
+ return { frameTree: { frame: { id: 'main-frame' } } };
286
+ }
287
+ if (method === 'Page.getNavigationHistory') {
288
+ return {
289
+ currentIndex: 0,
290
+ entries: [{ id: 0, url: 'https://only.com' }]
291
+ };
292
+ }
293
+ return {};
294
+ });
295
+
296
+ const result = await controller.goBack();
297
+ assert.strictEqual(result, null);
298
+ });
299
+
300
+ it('should return null when no forward history', async () => {
301
+ mockClient.send.mock.mockImplementation(async (method) => {
302
+ if (method === 'Page.getFrameTree') {
303
+ return { frameTree: { frame: { id: 'main-frame' } } };
304
+ }
305
+ if (method === 'Page.getNavigationHistory') {
306
+ return {
307
+ currentIndex: 0,
308
+ entries: [{ id: 0, url: 'https://only.com' }]
309
+ };
310
+ }
311
+ return {};
312
+ });
313
+
314
+ const result = await controller.goForward();
315
+ assert.strictEqual(result, null);
316
+ });
317
+ });
318
+
319
+ describe('stopLoading', () => {
320
+ beforeEach(async () => {
321
+ mockClient.send.mock.mockImplementation(async (method) => {
322
+ if (method === 'Page.getFrameTree') {
323
+ return { frameTree: { frame: { id: 'main-frame' } } };
324
+ }
325
+ return {};
326
+ });
327
+ await controller.initialize();
328
+ });
329
+
330
+ it('should call Page.stopLoading', async () => {
331
+ await controller.stopLoading();
332
+
333
+ const stopCall = mockClient.send.mock.calls.find(
334
+ c => c.arguments[0] === 'Page.stopLoading'
335
+ );
336
+ assert.ok(stopCall);
337
+ });
338
+ });
339
+
340
+ describe('getUrl and getTitle', () => {
341
+ beforeEach(async () => {
342
+ mockClient.send.mock.mockImplementation(async (method, params) => {
343
+ if (method === 'Page.getFrameTree') {
344
+ return { frameTree: { frame: { id: 'main-frame' } } };
345
+ }
346
+ if (method === 'Runtime.evaluate') {
347
+ if (params.expression === 'window.location.href') {
348
+ return { result: { value: 'https://current.url' } };
349
+ }
350
+ if (params.expression === 'document.title') {
351
+ return { result: { value: 'Page Title' } };
352
+ }
353
+ }
354
+ return {};
355
+ });
356
+ await controller.initialize();
357
+ });
358
+
359
+ it('should return current URL', async () => {
360
+ const url = await controller.getUrl();
361
+ assert.strictEqual(url, 'https://current.url');
362
+ });
363
+
364
+ it('should return current title', async () => {
365
+ const title = await controller.getTitle();
366
+ assert.strictEqual(title, 'Page Title');
367
+ });
368
+ });
369
+
370
+ describe('lifecycle events', () => {
371
+ beforeEach(async () => {
372
+ mockClient.send.mock.mockImplementation(async (method) => {
373
+ if (method === 'Page.getFrameTree') {
374
+ return { frameTree: { frame: { id: 'main-frame' } } };
375
+ }
376
+ if (method === 'Page.navigate') {
377
+ return { frameId: 'main-frame', loaderId: 'loader-1' };
378
+ }
379
+ return {};
380
+ });
381
+ await controller.initialize();
382
+ });
383
+
384
+ it('should track lifecycle events per frame', async () => {
385
+ const navigatePromise = controller.navigate('https://example.com', {
386
+ waitUntil: WaitCondition.LOAD,
387
+ timeout: 1000
388
+ });
389
+
390
+ emitEvent('Page.lifecycleEvent', { frameId: 'main-frame', name: 'DOMContentLoaded' });
391
+ emitEvent('Page.lifecycleEvent', { frameId: 'main-frame', name: 'load' });
392
+
393
+ await navigatePromise;
394
+ });
395
+
396
+ it('should update main frame ID on frameNavigated', async () => {
397
+ emitEvent('Page.frameNavigated', { frame: { id: 'new-main-frame' } });
398
+ assert.strictEqual(controller.mainFrameId, 'new-main-frame');
399
+ });
400
+
401
+ it('should not update main frame ID for child frames', async () => {
402
+ emitEvent('Page.frameNavigated', { frame: { id: 'child-frame', parentId: 'main-frame' } });
403
+ assert.strictEqual(controller.mainFrameId, 'main-frame');
404
+ });
405
+ });
406
+
407
+ describe('network idle', () => {
408
+ beforeEach(async () => {
409
+ mockClient.send.mock.mockImplementation(async (method) => {
410
+ if (method === 'Page.getFrameTree') {
411
+ return { frameTree: { frame: { id: 'main-frame' } } };
412
+ }
413
+ if (method === 'Page.navigate') {
414
+ return { frameId: 'main-frame', loaderId: 'loader-1' };
415
+ }
416
+ return {};
417
+ });
418
+ await controller.initialize();
419
+ });
420
+
421
+ it('should wait for network idle', async () => {
422
+ const navigatePromise = controller.navigate('https://example.com', {
423
+ waitUntil: WaitCondition.NETWORK_IDLE,
424
+ timeout: 2000
425
+ });
426
+
427
+ // Simulate page load
428
+ emitEvent('Page.lifecycleEvent', { frameId: 'main-frame', name: 'load' });
429
+
430
+ // Network is already idle (no pending requests), should resolve
431
+ await navigatePromise;
432
+ });
433
+
434
+ it('should track pending requests', async () => {
435
+ const navigatePromise = controller.navigate('https://example.com', {
436
+ waitUntil: WaitCondition.NETWORK_IDLE,
437
+ timeout: 2000
438
+ });
439
+
440
+ // Simulate request flow
441
+ emitEvent('Network.requestWillBeSent', { requestId: 'req-1' });
442
+ emitEvent('Network.loadingFinished', { requestId: 'req-1' });
443
+ emitEvent('Page.lifecycleEvent', { frameId: 'main-frame', name: 'load' });
444
+
445
+ await navigatePromise;
446
+ });
447
+ });
448
+
449
+ describe('dispose', () => {
450
+ beforeEach(async () => {
451
+ mockClient.send.mock.mockImplementation(async (method) => {
452
+ if (method === 'Page.getFrameTree') {
453
+ return { frameTree: { frame: { id: 'main-frame' } } };
454
+ }
455
+ return {};
456
+ });
457
+ await controller.initialize();
458
+ });
459
+
460
+ it('should remove all event listeners', () => {
461
+ const initialOffCalls = mockClient.off.mock.calls.length;
462
+
463
+ controller.dispose();
464
+
465
+ assert.ok(mockClient.off.mock.calls.length > initialOffCalls);
466
+ });
467
+
468
+ it('should clear lifecycle waiters', async () => {
469
+ // Start a navigation that will wait
470
+ const navigatePromise = controller.navigate('https://example.com', {
471
+ waitUntil: WaitCondition.LOAD,
472
+ timeout: 5000
473
+ }).catch(() => {});
474
+
475
+ // Dispose immediately
476
+ controller.dispose();
477
+
478
+ // The promise should eventually resolve/reject but the waiter should be cleared
479
+ // We just verify dispose doesn't throw
480
+ });
481
+ });
482
+
483
+ describe('edge cases', () => {
484
+ beforeEach(async () => {
485
+ mockClient.send.mock.mockImplementation(async (method) => {
486
+ if (method === 'Page.getFrameTree') {
487
+ return { frameTree: { frame: { id: 'main-frame' } } };
488
+ }
489
+ if (method === 'Page.navigate') {
490
+ return { frameId: 'main-frame', loaderId: 'loader-1' };
491
+ }
492
+ return {};
493
+ });
494
+ await controller.initialize();
495
+ });
496
+
497
+ describe('navigate edge cases', () => {
498
+ it('should throw NavigationError on empty URL', async () => {
499
+ await assert.rejects(
500
+ controller.navigate('', { waitUntil: WaitCondition.COMMIT }),
501
+ (err) => {
502
+ assert.strictEqual(err.name, ErrorTypes.NAVIGATION);
503
+ assert.ok(err.message.includes('URL must be a non-empty string'));
504
+ return true;
505
+ }
506
+ );
507
+ });
508
+
509
+ it('should throw NavigationError on null URL', async () => {
510
+ await assert.rejects(
511
+ controller.navigate(null, { waitUntil: WaitCondition.COMMIT }),
512
+ (err) => err.name === ErrorTypes.NAVIGATION
513
+ );
514
+ });
515
+
516
+ it('should clamp very long timeout to max', async () => {
517
+ const navigatePromise = controller.navigate('https://example.com', {
518
+ waitUntil: WaitCondition.COMMIT,
519
+ timeout: 999999999
520
+ });
521
+
522
+ const result = await navigatePromise;
523
+ assert.strictEqual(result.url, 'https://example.com');
524
+ });
525
+
526
+ it('should handle negative timeout', async () => {
527
+ // With timeout 0, it should succeed immediately for COMMIT
528
+ const result = await controller.navigate('https://example.com', {
529
+ waitUntil: WaitCondition.COMMIT,
530
+ timeout: -100
531
+ });
532
+ assert.strictEqual(result.url, 'https://example.com');
533
+ });
534
+
535
+ it('should handle non-finite timeout', async () => {
536
+ const result = await controller.navigate('https://example.com', {
537
+ waitUntil: WaitCondition.COMMIT,
538
+ timeout: NaN
539
+ });
540
+ assert.strictEqual(result.url, 'https://example.com');
541
+ });
542
+ });
543
+
544
+ describe('reload edge cases', () => {
545
+ it('should throw CDPConnectionError when connection drops', async () => {
546
+ mockClient.send.mock.mockImplementation(async (method) => {
547
+ if (method === 'Page.getFrameTree') {
548
+ return { frameTree: { frame: { id: 'main-frame' } } };
549
+ }
550
+ if (method === 'Page.reload') {
551
+ throw new Error('WebSocket closed');
552
+ }
553
+ return {};
554
+ });
555
+
556
+ await assert.rejects(
557
+ controller.reload({ waitUntil: WaitCondition.COMMIT }),
558
+ (err) => {
559
+ assert.strictEqual(err.name, ErrorTypes.CONNECTION);
560
+ assert.ok(err.message.includes('WebSocket closed'));
561
+ return true;
562
+ }
563
+ );
564
+ });
565
+ });
566
+
567
+ describe('stopLoading edge cases', () => {
568
+ it('should throw CDPConnectionError when connection drops', async () => {
569
+ mockClient.send.mock.mockImplementation(async (method) => {
570
+ if (method === 'Page.getFrameTree') {
571
+ return { frameTree: { frame: { id: 'main-frame' } } };
572
+ }
573
+ if (method === 'Page.stopLoading') {
574
+ throw new Error('Connection reset');
575
+ }
576
+ return {};
577
+ });
578
+
579
+ await assert.rejects(
580
+ controller.stopLoading(),
581
+ (err) => {
582
+ assert.strictEqual(err.name, ErrorTypes.CONNECTION);
583
+ return true;
584
+ }
585
+ );
586
+ });
587
+ });
588
+
589
+ describe('getUrl edge cases', () => {
590
+ it('should throw CDPConnectionError when connection drops', async () => {
591
+ mockClient.send.mock.mockImplementation(async (method) => {
592
+ if (method === 'Page.getFrameTree') {
593
+ return { frameTree: { frame: { id: 'main-frame' } } };
594
+ }
595
+ if (method === 'Runtime.evaluate') {
596
+ throw new Error('Connection lost');
597
+ }
598
+ return {};
599
+ });
600
+
601
+ await assert.rejects(
602
+ controller.getUrl(),
603
+ (err) => {
604
+ assert.strictEqual(err.name, ErrorTypes.CONNECTION);
605
+ return true;
606
+ }
607
+ );
608
+ });
609
+ });
610
+
611
+ describe('getTitle edge cases', () => {
612
+ it('should throw CDPConnectionError when connection drops', async () => {
613
+ mockClient.send.mock.mockImplementation(async (method) => {
614
+ if (method === 'Page.getFrameTree') {
615
+ return { frameTree: { frame: { id: 'main-frame' } } };
616
+ }
617
+ if (method === 'Runtime.evaluate') {
618
+ throw new Error('Socket closed');
619
+ }
620
+ return {};
621
+ });
622
+
623
+ await assert.rejects(
624
+ controller.getTitle(),
625
+ (err) => {
626
+ assert.strictEqual(err.name, ErrorTypes.CONNECTION);
627
+ return true;
628
+ }
629
+ );
630
+ });
631
+ });
632
+
633
+ describe('goBack/goForward edge cases', () => {
634
+ it('should throw CDPConnectionError when getNavigationHistory fails', async () => {
635
+ mockClient.send.mock.mockImplementation(async (method) => {
636
+ if (method === 'Page.getFrameTree') {
637
+ return { frameTree: { frame: { id: 'main-frame' } } };
638
+ }
639
+ if (method === 'Page.getNavigationHistory') {
640
+ throw new Error('Connection dropped');
641
+ }
642
+ return {};
643
+ });
644
+
645
+ await assert.rejects(
646
+ controller.goBack(),
647
+ (err) => {
648
+ assert.strictEqual(err.name, ErrorTypes.CONNECTION);
649
+ return true;
650
+ }
651
+ );
652
+ });
653
+
654
+ it('should throw CDPConnectionError when navigateToHistoryEntry fails', async () => {
655
+ mockClient.send.mock.mockImplementation(async (method) => {
656
+ if (method === 'Page.getFrameTree') {
657
+ return { frameTree: { frame: { id: 'main-frame' } } };
658
+ }
659
+ if (method === 'Page.getNavigationHistory') {
660
+ return {
661
+ currentIndex: 1,
662
+ entries: [
663
+ { id: 0, url: 'https://first.com' },
664
+ { id: 1, url: 'https://second.com' }
665
+ ]
666
+ };
667
+ }
668
+ if (method === 'Page.navigateToHistoryEntry') {
669
+ throw new Error('Navigation failed');
670
+ }
671
+ return {};
672
+ });
673
+
674
+ await assert.rejects(
675
+ controller.goBack({ waitUntil: WaitCondition.COMMIT }),
676
+ (err) => {
677
+ assert.strictEqual(err.name, ErrorTypes.CONNECTION);
678
+ return true;
679
+ }
680
+ );
681
+ });
682
+ });
683
+ });
684
+
685
+ describe('navigation abort handling', () => {
686
+ beforeEach(async () => {
687
+ mockClient.send.mock.mockImplementation(async (method) => {
688
+ if (method === 'Page.getFrameTree') {
689
+ return { frameTree: { frame: { id: 'main-frame' } } };
690
+ }
691
+ if (method === 'Page.navigate') {
692
+ return { frameId: 'main-frame', loaderId: 'loader-1' };
693
+ }
694
+ return {};
695
+ });
696
+ await controller.initialize();
697
+ });
698
+
699
+ it('should abort previous navigation when new navigation starts', async () => {
700
+ // Start first navigation that will wait for load
701
+ const firstNav = controller.navigate('https://first.com', {
702
+ waitUntil: WaitCondition.LOAD,
703
+ timeout: 5000
704
+ });
705
+
706
+ // Start second navigation immediately - should abort first
707
+ const secondNav = controller.navigate('https://second.com', {
708
+ waitUntil: WaitCondition.COMMIT
709
+ });
710
+
711
+ // First navigation should be aborted
712
+ await assert.rejects(
713
+ firstNav,
714
+ (err) => {
715
+ assert.strictEqual(err.name, ErrorTypes.NAVIGATION_ABORTED);
716
+ assert.ok(err.message.includes('superseded'));
717
+ assert.strictEqual(err.url, 'https://first.com');
718
+ return true;
719
+ }
720
+ );
721
+
722
+ // Second navigation should succeed
723
+ const result = await secondNav;
724
+ assert.strictEqual(result.url, 'https://second.com');
725
+ });
726
+
727
+ it('should abort navigation when stopLoading is called', async () => {
728
+ // Start navigation that will wait for load
729
+ const navPromise = controller.navigate('https://example.com', {
730
+ waitUntil: WaitCondition.LOAD,
731
+ timeout: 5000
732
+ });
733
+
734
+ // Call stopLoading after a short delay
735
+ setTimeout(async () => {
736
+ await controller.stopLoading();
737
+ }, 10);
738
+
739
+ // Navigation should be aborted
740
+ await assert.rejects(
741
+ navPromise,
742
+ (err) => {
743
+ assert.strictEqual(err.name, ErrorTypes.NAVIGATION_ABORTED);
744
+ assert.ok(err.message.includes('stopped'));
745
+ assert.strictEqual(err.url, 'https://example.com');
746
+ return true;
747
+ }
748
+ );
749
+ });
750
+
751
+ it('should not affect navigation that has already completed', async () => {
752
+ // Start navigation with COMMIT (immediate completion)
753
+ const result = await controller.navigate('https://example.com', {
754
+ waitUntil: WaitCondition.COMMIT
755
+ });
756
+
757
+ assert.strictEqual(result.url, 'https://example.com');
758
+
759
+ // stopLoading after navigation completes should not throw
760
+ await controller.stopLoading();
761
+ });
762
+
763
+ it('should properly clean up abort state after navigation completes', async () => {
764
+ // First navigation
765
+ await controller.navigate('https://first.com', {
766
+ waitUntil: WaitCondition.COMMIT
767
+ });
768
+
769
+ // Second navigation should not be affected by first
770
+ const result = await controller.navigate('https://second.com', {
771
+ waitUntil: WaitCondition.COMMIT
772
+ });
773
+
774
+ assert.strictEqual(result.url, 'https://second.com');
775
+ });
776
+
777
+ it('should handle multiple rapid navigation cancellations', async () => {
778
+ // Start three navigations in quick succession
779
+ const nav1 = controller.navigate('https://first.com', {
780
+ waitUntil: WaitCondition.LOAD,
781
+ timeout: 5000
782
+ });
783
+
784
+ const nav2 = controller.navigate('https://second.com', {
785
+ waitUntil: WaitCondition.LOAD,
786
+ timeout: 5000
787
+ });
788
+
789
+ const nav3 = controller.navigate('https://third.com', {
790
+ waitUntil: WaitCondition.COMMIT
791
+ });
792
+
793
+ // First two should be aborted
794
+ await assert.rejects(nav1, (err) => err.name === ErrorTypes.NAVIGATION_ABORTED);
795
+ await assert.rejects(nav2, (err) => err.name === ErrorTypes.NAVIGATION_ABORTED);
796
+
797
+ // Third should succeed
798
+ const result = await nav3;
799
+ assert.strictEqual(result.url, 'https://third.com');
800
+ });
801
+
802
+ it('should include correct URL in abort error', async () => {
803
+ const nav = controller.navigate('https://specific-url.com/path?query=1', {
804
+ waitUntil: WaitCondition.LOAD,
805
+ timeout: 5000
806
+ });
807
+
808
+ // Abort with new navigation
809
+ controller.navigate('https://new.com', { waitUntil: WaitCondition.COMMIT });
810
+
811
+ await assert.rejects(
812
+ nav,
813
+ (err) => {
814
+ assert.strictEqual(err.name, ErrorTypes.NAVIGATION_ABORTED);
815
+ assert.strictEqual(err.url, 'https://specific-url.com/path?query=1');
816
+ assert.ok(err.originalMessage.includes('superseded'));
817
+ return true;
818
+ }
819
+ );
820
+ });
821
+ });
822
+ });