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
|
@@ -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
|
+
});
|