whopper 0.2.0 → 0.3.1

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.
@@ -1,9 +1,11 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { openPage } from "./index.js";
3
3
  // Mock playwright
4
4
  vi.mock("playwright", () => {
5
5
  const mockPage = {
6
6
  on: vi.fn(),
7
+ route: vi.fn(),
8
+ mainFrame: vi.fn(),
7
9
  goto: vi.fn(),
8
10
  context: vi.fn(),
9
11
  evaluate: vi.fn(),
@@ -47,11 +49,14 @@ describe("openPage", () => {
47
49
  let mockPage;
48
50
  let mockBrowserContext;
49
51
  let mockBrowser;
52
+ let mockMainFrame;
50
53
  beforeEach(() => {
51
54
  vi.clearAllMocks();
52
55
  // Get references to mocked objects
53
56
  mockPage = {
54
57
  on: vi.fn(),
58
+ route: vi.fn(),
59
+ mainFrame: vi.fn(),
55
60
  goto: vi.fn(() => Promise.resolve()),
56
61
  context: vi.fn(),
57
62
  evaluate: vi.fn(() => Promise.resolve({})),
@@ -65,7 +70,9 @@ describe("openPage", () => {
65
70
  newContext: vi.fn(() => Promise.resolve(mockBrowserContext)),
66
71
  close: vi.fn(),
67
72
  };
73
+ mockMainFrame = { id: "main-frame" };
68
74
  mockPage.context.mockReturnValue(mockBrowserContext);
75
+ mockPage.mainFrame.mockReturnValue(mockMainFrame);
69
76
  vi.mocked(chromium.launch).mockResolvedValue(mockBrowser);
70
77
  });
71
78
  afterEach(() => {
@@ -83,12 +90,28 @@ describe("openPage", () => {
83
90
  });
84
91
  });
85
92
  it("should pass custom userAgent to browser context", async () => {
86
- await openPage("https://example.com", 10000, [], "MyCustomAgent/1.0");
93
+ await openPage("https://example.com", 10000, [], { userAgent: "MyCustomAgent/1.0" });
87
94
  expect(mockBrowser.newContext).toHaveBeenCalledWith({
88
95
  ignoreHTTPSErrors: true,
89
96
  userAgent: "MyCustomAgent/1.0",
90
97
  });
91
98
  });
99
+ it("should pass locale to browser context", async () => {
100
+ await openPage("https://example.com", 10000, [], { locale: "ja-JP" });
101
+ expect(mockBrowser.newContext).toHaveBeenCalledWith({
102
+ ignoreHTTPSErrors: true,
103
+ locale: "ja-JP",
104
+ });
105
+ });
106
+ it("should pass extraHTTPHeaders to browser context", async () => {
107
+ await openPage("https://example.com", 10000, [], {
108
+ extraHTTPHeaders: { "Accept-Language": "ja", "X-Custom": "value" },
109
+ });
110
+ expect(mockBrowser.newContext).toHaveBeenCalledWith({
111
+ ignoreHTTPSErrors: true,
112
+ extraHTTPHeaders: { "Accept-Language": "ja", "X-Custom": "value" },
113
+ });
114
+ });
92
115
  it("should navigate to the specified URL", async () => {
93
116
  await openPage("https://example.com", 10000, []);
94
117
  expect(mockPage.goto).toHaveBeenCalledWith("https://example.com", {
@@ -114,6 +137,863 @@ describe("openPage", () => {
114
137
  expect(result).toHaveProperty("timeoutOccurred", false);
115
138
  });
116
139
  });
140
+ describe("redirect policy", () => {
141
+ it("should register route handler for request interception", async () => {
142
+ await openPage("https://example.com", 10000, []);
143
+ expect(mockPage.route).toHaveBeenCalledWith("**/*", expect.any(Function));
144
+ });
145
+ it("should block cross-domain navigation when blocking is enabled", async () => {
146
+ let routeHandler = async () => { };
147
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
148
+ routeHandler = handler;
149
+ });
150
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
151
+ await openPage("https://example.com", 10000, [], {
152
+ blockCrossDomainRedirect: true,
153
+ });
154
+ const continueMock = vi.fn(() => Promise.resolve());
155
+ const abortMock = vi.fn(() => Promise.resolve());
156
+ const fetchMock = vi.fn();
157
+ const fulfillMock = vi.fn(() => Promise.resolve());
158
+ await routeHandler({
159
+ request: () => ({
160
+ isNavigationRequest: () => true,
161
+ frame: () => mockMainFrame,
162
+ url: () => "https://www.example.com",
163
+ }),
164
+ continue: continueMock,
165
+ abort: abortMock,
166
+ fetch: fetchMock,
167
+ fulfill: fulfillMock,
168
+ });
169
+ expect(abortMock).toHaveBeenCalledWith("blockedbyclient");
170
+ expect(continueMock).not.toHaveBeenCalled();
171
+ expect(fetchMock).not.toHaveBeenCalled();
172
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("Blocked cross-domain redirect"));
173
+ });
174
+ it("should allow any redirect when blocking is disabled", async () => {
175
+ let routeHandler = async () => { };
176
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
177
+ routeHandler = handler;
178
+ });
179
+ await openPage("https://example.com", 10000, [], { blockCrossDomainRedirect: false });
180
+ const mockResponse = { status: () => 200, headers: () => ({}) };
181
+ const continueMock = vi.fn(() => Promise.resolve());
182
+ const abortMock = vi.fn(() => Promise.resolve());
183
+ const fetchMock = vi.fn(() => Promise.resolve(mockResponse));
184
+ const fulfillMock = vi.fn(() => Promise.resolve());
185
+ await routeHandler({
186
+ request: () => ({
187
+ isNavigationRequest: () => true,
188
+ frame: () => mockMainFrame,
189
+ url: () => "https://example.net",
190
+ }),
191
+ continue: continueMock,
192
+ abort: abortMock,
193
+ fetch: fetchMock,
194
+ fulfill: fulfillMock,
195
+ });
196
+ expect(fetchMock).toHaveBeenCalledWith({ maxRedirects: 0 });
197
+ expect(fulfillMock).toHaveBeenCalledWith({ response: mockResponse });
198
+ expect(abortMock).not.toHaveBeenCalled();
199
+ });
200
+ it("should continue for non-navigation requests", async () => {
201
+ let routeHandler = async () => { };
202
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
203
+ routeHandler = handler;
204
+ });
205
+ await openPage("https://example.com", 10000, [], {
206
+ blockCrossDomainRedirect: true,
207
+ });
208
+ const continueMock = vi.fn(() => Promise.resolve());
209
+ const abortMock = vi.fn(() => Promise.resolve());
210
+ await routeHandler({
211
+ request: () => ({
212
+ isNavigationRequest: () => false,
213
+ frame: () => mockMainFrame,
214
+ url: () => "https://example.net/script.js",
215
+ }),
216
+ continue: continueMock,
217
+ abort: abortMock,
218
+ });
219
+ expect(continueMock).toHaveBeenCalledTimes(1);
220
+ expect(abortMock).not.toHaveBeenCalled();
221
+ });
222
+ it("should continue for non-main-frame navigation requests", async () => {
223
+ let routeHandler = async () => { };
224
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
225
+ routeHandler = handler;
226
+ });
227
+ await openPage("https://example.com", 10000, [], {
228
+ blockCrossDomainRedirect: true,
229
+ });
230
+ const continueMock = vi.fn(() => Promise.resolve());
231
+ const abortMock = vi.fn(() => Promise.resolve());
232
+ await routeHandler({
233
+ request: () => ({
234
+ isNavigationRequest: () => true,
235
+ frame: () => ({ id: "iframe-1" }),
236
+ url: () => "https://example.net/embedded",
237
+ }),
238
+ continue: continueMock,
239
+ abort: abortMock,
240
+ });
241
+ expect(continueMock).toHaveBeenCalledTimes(1);
242
+ expect(abortMock).not.toHaveBeenCalled();
243
+ });
244
+ it("should continue when navigation target host cannot be parsed", async () => {
245
+ let routeHandler = async () => { };
246
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
247
+ routeHandler = handler;
248
+ });
249
+ await openPage("https://example.com", 10000, [], {
250
+ blockCrossDomainRedirect: true,
251
+ });
252
+ const mockResponse = { status: () => 200, headers: () => ({}) };
253
+ const continueMock = vi.fn(() => Promise.resolve());
254
+ const abortMock = vi.fn(() => Promise.resolve());
255
+ const fetchMock = vi.fn(() => Promise.resolve(mockResponse));
256
+ const fulfillMock = vi.fn(() => Promise.resolve());
257
+ // first top-level navigation
258
+ await routeHandler({
259
+ request: () => ({
260
+ isNavigationRequest: () => true,
261
+ frame: () => mockMainFrame,
262
+ url: () => "https://example.com",
263
+ }),
264
+ continue: continueMock,
265
+ abort: abortMock,
266
+ fetch: fetchMock,
267
+ fulfill: fulfillMock,
268
+ });
269
+ // second top-level navigation with invalid URL should still continue
270
+ await routeHandler({
271
+ request: () => ({
272
+ isNavigationRequest: () => true,
273
+ frame: () => mockMainFrame,
274
+ url: () => "not-a-url",
275
+ }),
276
+ continue: continueMock,
277
+ abort: abortMock,
278
+ fetch: fetchMock,
279
+ fulfill: fulfillMock,
280
+ });
281
+ // First call uses fetch+fulfill (same host, allowed), second uses continue (unparseable URL)
282
+ expect(fulfillMock).toHaveBeenCalledTimes(1);
283
+ expect(continueMock).toHaveBeenCalledTimes(1);
284
+ expect(abortMock).not.toHaveBeenCalled();
285
+ });
286
+ it("should block subdomain redirect when blocking is enabled", async () => {
287
+ let routeHandler = async () => { };
288
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
289
+ routeHandler = handler;
290
+ });
291
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
292
+ await openPage("https://app.example.com", 10000, [], {
293
+ blockCrossDomainRedirect: true,
294
+ });
295
+ const mockResponse = { status: () => 200, headers: () => ({}) };
296
+ const continueMock = vi.fn(() => Promise.resolve());
297
+ const abortMock = vi.fn(() => Promise.resolve());
298
+ const fetchMock = vi.fn(() => Promise.resolve(mockResponse));
299
+ const fulfillMock = vi.fn(() => Promise.resolve());
300
+ await routeHandler({
301
+ request: () => ({
302
+ isNavigationRequest: () => true,
303
+ frame: () => mockMainFrame,
304
+ url: () => "https://app.example.com",
305
+ }),
306
+ continue: continueMock,
307
+ abort: abortMock,
308
+ fetch: fetchMock,
309
+ fulfill: fulfillMock,
310
+ });
311
+ await routeHandler({
312
+ request: () => ({
313
+ isNavigationRequest: () => true,
314
+ frame: () => mockMainFrame,
315
+ url: () => "https://cdn.example.com",
316
+ }),
317
+ continue: continueMock,
318
+ abort: abortMock,
319
+ fetch: fetchMock,
320
+ fulfill: fulfillMock,
321
+ });
322
+ expect(fulfillMock).toHaveBeenCalledTimes(1);
323
+ expect(abortMock).toHaveBeenCalledWith("blockedbyclient");
324
+ });
325
+ it("should block HTTP 301 redirect to cross-domain when blocking is enabled", async () => {
326
+ let routeHandler = async () => { };
327
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
328
+ routeHandler = handler;
329
+ });
330
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
331
+ await openPage("https://www.example.com", 10000, [], {
332
+ blockCrossDomainRedirect: true,
333
+ });
334
+ const mockResponse = {
335
+ status: () => 301,
336
+ headers: () => ({ location: "https://example.com/" }),
337
+ text: () => Promise.resolve(null),
338
+ };
339
+ const continueMock = vi.fn(() => Promise.resolve());
340
+ const abortMock = vi.fn(() => Promise.resolve());
341
+ const fetchMock = vi.fn(() => Promise.resolve(mockResponse));
342
+ const fulfillMock = vi.fn(() => Promise.resolve());
343
+ await routeHandler({
344
+ request: () => ({
345
+ isNavigationRequest: () => true,
346
+ frame: () => mockMainFrame,
347
+ url: () => "https://www.example.com",
348
+ }),
349
+ continue: continueMock,
350
+ abort: abortMock,
351
+ fetch: fetchMock,
352
+ fulfill: fulfillMock,
353
+ });
354
+ expect(fetchMock).toHaveBeenCalledWith({ maxRedirects: 0 });
355
+ expect(abortMock).toHaveBeenCalledWith("blockedbyclient");
356
+ expect(fulfillMock).not.toHaveBeenCalled();
357
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("Blocked cross-domain redirect: https://example.com/"));
358
+ });
359
+ it("should allow HTTP 301 redirect to same host", async () => {
360
+ let routeHandler = async () => { };
361
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
362
+ routeHandler = handler;
363
+ });
364
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
365
+ await openPage("https://example.com", 10000, [], {
366
+ blockCrossDomainRedirect: true,
367
+ });
368
+ const mockResponse = {
369
+ status: () => 301,
370
+ headers: () => ({ location: "https://example.com/top" }),
371
+ text: () => Promise.resolve(null),
372
+ };
373
+ const continueMock = vi.fn(() => Promise.resolve());
374
+ const abortMock = vi.fn(() => Promise.resolve());
375
+ const fetchMock = vi.fn(() => Promise.resolve(mockResponse));
376
+ const fulfillMock = vi.fn(() => Promise.resolve());
377
+ await routeHandler({
378
+ request: () => ({
379
+ isNavigationRequest: () => true,
380
+ frame: () => mockMainFrame,
381
+ url: () => "https://example.com",
382
+ }),
383
+ continue: continueMock,
384
+ abort: abortMock,
385
+ fetch: fetchMock,
386
+ fulfill: fulfillMock,
387
+ });
388
+ expect(fetchMock).toHaveBeenCalledWith({ maxRedirects: 0 });
389
+ expect(fulfillMock).toHaveBeenCalledWith({ response: mockResponse });
390
+ expect(abortMock).not.toHaveBeenCalled();
391
+ });
392
+ it("should fulfill response when Location header is malformed", async () => {
393
+ let routeHandler = async () => { };
394
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
395
+ routeHandler = handler;
396
+ });
397
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
398
+ await openPage("https://example.com", 10000, [], {
399
+ blockCrossDomainRedirect: true,
400
+ });
401
+ const mockResponse = {
402
+ status: () => 301,
403
+ headers: () => ({ location: "://broken" }),
404
+ text: () => Promise.resolve(null),
405
+ };
406
+ const continueMock = vi.fn(() => Promise.resolve());
407
+ const abortMock = vi.fn(() => Promise.resolve());
408
+ const fetchMock = vi.fn(() => Promise.resolve(mockResponse));
409
+ const fulfillMock = vi.fn(() => Promise.resolve());
410
+ await routeHandler({
411
+ request: () => ({
412
+ isNavigationRequest: () => true,
413
+ frame: () => mockMainFrame,
414
+ url: () => "https://example.com",
415
+ }),
416
+ continue: continueMock,
417
+ abort: abortMock,
418
+ fetch: fetchMock,
419
+ fulfill: fulfillMock,
420
+ });
421
+ expect(fetchMock).toHaveBeenCalledWith({ maxRedirects: 0 });
422
+ expect(fulfillMock).toHaveBeenCalledWith({ response: mockResponse });
423
+ expect(abortMock).not.toHaveBeenCalled();
424
+ });
425
+ it("should block HTTP 302 redirect to cross-domain when blocking is enabled", async () => {
426
+ let routeHandler = async () => { };
427
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
428
+ routeHandler = handler;
429
+ });
430
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
431
+ await openPage("https://example.com", 10000, [], {
432
+ blockCrossDomainRedirect: true,
433
+ });
434
+ const mockResponse = {
435
+ status: () => 302,
436
+ headers: () => ({ location: "https://other.example/login" }),
437
+ text: () => Promise.resolve(null),
438
+ };
439
+ const continueMock = vi.fn(() => Promise.resolve());
440
+ const abortMock = vi.fn(() => Promise.resolve());
441
+ const fetchMock = vi.fn(() => Promise.resolve(mockResponse));
442
+ const fulfillMock = vi.fn(() => Promise.resolve());
443
+ await routeHandler({
444
+ request: () => ({
445
+ isNavigationRequest: () => true,
446
+ frame: () => mockMainFrame,
447
+ url: () => "https://example.com",
448
+ }),
449
+ continue: continueMock,
450
+ abort: abortMock,
451
+ fetch: fetchMock,
452
+ fulfill: fulfillMock,
453
+ });
454
+ expect(fetchMock).toHaveBeenCalledWith({ maxRedirects: 0 });
455
+ expect(abortMock).toHaveBeenCalledWith("blockedbyclient");
456
+ expect(fulfillMock).not.toHaveBeenCalled();
457
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("Blocked cross-domain redirect"));
458
+ });
459
+ it("should abort when route.fetch() throws an error", async () => {
460
+ let routeHandler = async () => { };
461
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
462
+ routeHandler = handler;
463
+ });
464
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
465
+ await openPage("https://example.com", 10000, [], {
466
+ blockCrossDomainRedirect: true,
467
+ });
468
+ const continueMock = vi.fn(() => Promise.resolve());
469
+ const abortMock = vi.fn(() => Promise.resolve());
470
+ const fetchMock = vi.fn(() => Promise.reject(new Error("net::ERR_CONNECTION_REFUSED")));
471
+ const fulfillMock = vi.fn(() => Promise.resolve());
472
+ await routeHandler({
473
+ request: () => ({
474
+ isNavigationRequest: () => true,
475
+ frame: () => mockMainFrame,
476
+ url: () => "https://example.com",
477
+ }),
478
+ continue: continueMock,
479
+ abort: abortMock,
480
+ fetch: fetchMock,
481
+ fulfill: fulfillMock,
482
+ });
483
+ expect(fetchMock).toHaveBeenCalledWith({ maxRedirects: 0 });
484
+ expect(abortMock).toHaveBeenCalledWith("failed");
485
+ expect(fulfillMock).not.toHaveBeenCalled();
486
+ });
487
+ it("should record non-Error thrown by route.fetch() in urls", async () => {
488
+ let routeHandler = async () => { };
489
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
490
+ routeHandler = handler;
491
+ });
492
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
493
+ const result = await openPage("https://example.com", 10000, []);
494
+ const abortMock = vi.fn(() => Promise.resolve());
495
+ const fetchMock = vi.fn(() => Promise.reject("string error"));
496
+ await routeHandler({
497
+ request: () => ({
498
+ isNavigationRequest: () => true,
499
+ frame: () => mockMainFrame,
500
+ url: () => "https://example.com",
501
+ }),
502
+ continue: vi.fn(() => Promise.resolve()),
503
+ abort: abortMock,
504
+ fetch: fetchMock,
505
+ fulfill: vi.fn(() => Promise.resolve()),
506
+ });
507
+ expect(result.urls).toEqual([
508
+ { url: "https://example.com", error: "string error" },
509
+ ]);
510
+ });
511
+ it("should continue for already-inspected URLs on route re-entry", async () => {
512
+ let routeHandler = async () => { };
513
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
514
+ routeHandler = handler;
515
+ });
516
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
517
+ await openPage("https://example.com", 10000, [], {
518
+ blockCrossDomainRedirect: true,
519
+ });
520
+ // First call: 301 chain example.com -> example.com/page
521
+ const mockRedirectResponse = {
522
+ status: () => 301,
523
+ headers: () => ({ location: "https://example.com/page" }),
524
+ text: () => Promise.resolve(null),
525
+ };
526
+ const mock200Response = {
527
+ status: () => 200,
528
+ headers: () => ({}),
529
+ };
530
+ const fetchMock = vi
531
+ .fn()
532
+ .mockResolvedValueOnce(mockRedirectResponse)
533
+ .mockResolvedValueOnce(mock200Response);
534
+ const fulfillMock = vi.fn(() => Promise.resolve());
535
+ const abortMock = vi.fn(() => Promise.resolve());
536
+ await routeHandler({
537
+ request: () => ({
538
+ isNavigationRequest: () => true,
539
+ frame: () => mockMainFrame,
540
+ url: () => "https://example.com",
541
+ }),
542
+ continue: vi.fn(),
543
+ abort: abortMock,
544
+ fetch: fetchMock,
545
+ fulfill: fulfillMock,
546
+ });
547
+ // Simulate browser re-entry for the inspected redirect target
548
+ const continueMock = vi.fn(() => Promise.resolve());
549
+ await routeHandler({
550
+ request: () => ({
551
+ isNavigationRequest: () => true,
552
+ frame: () => mockMainFrame,
553
+ url: () => "https://example.com/page",
554
+ }),
555
+ continue: continueMock,
556
+ abort: vi.fn(),
557
+ fetch: vi.fn(),
558
+ fulfill: vi.fn(),
559
+ });
560
+ expect(continueMock).toHaveBeenCalled();
561
+ });
562
+ it("should abort after too many re-entries for inspected URLs", async () => {
563
+ let routeHandler = async () => { };
564
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
565
+ routeHandler = handler;
566
+ });
567
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
568
+ await openPage("https://example.com", 10000, [], {
569
+ blockCrossDomainRedirect: true,
570
+ });
571
+ // First call: build a chain so inspectedUrls is populated
572
+ const mockRedirectResponse = {
573
+ status: () => 301,
574
+ headers: () => ({ location: "https://example.com/page" }),
575
+ text: () => Promise.resolve(null),
576
+ };
577
+ const mock200Response = {
578
+ status: () => 200,
579
+ headers: () => ({}),
580
+ };
581
+ const fetchMock = vi
582
+ .fn()
583
+ .mockResolvedValueOnce(mockRedirectResponse)
584
+ .mockResolvedValueOnce(mock200Response);
585
+ await routeHandler({
586
+ request: () => ({
587
+ isNavigationRequest: () => true,
588
+ frame: () => mockMainFrame,
589
+ url: () => "https://example.com",
590
+ }),
591
+ continue: vi.fn(),
592
+ abort: vi.fn(),
593
+ fetch: fetchMock,
594
+ fulfill: vi.fn(() => Promise.resolve()),
595
+ });
596
+ // Simulate re-entries exceeding MAX_REDIRECT_HOPS (20)
597
+ for (let i = 0; i < 20; i++) {
598
+ await routeHandler({
599
+ request: () => ({
600
+ isNavigationRequest: () => true,
601
+ frame: () => mockMainFrame,
602
+ url: () => "https://example.com/page",
603
+ }),
604
+ continue: vi.fn(() => Promise.resolve()),
605
+ abort: vi.fn(() => Promise.resolve()),
606
+ fetch: vi.fn(),
607
+ fulfill: vi.fn(),
608
+ });
609
+ }
610
+ // The 21st re-entry should abort
611
+ const abortMock = vi.fn(() => Promise.resolve());
612
+ await routeHandler({
613
+ request: () => ({
614
+ isNavigationRequest: () => true,
615
+ frame: () => mockMainFrame,
616
+ url: () => "https://example.com/page",
617
+ }),
618
+ continue: vi.fn(() => Promise.resolve()),
619
+ abort: abortMock,
620
+ fetch: vi.fn(),
621
+ fulfill: vi.fn(),
622
+ });
623
+ expect(abortMock).toHaveBeenCalledWith("failed");
624
+ });
625
+ it("should abort when redirect chain fetch fails mid-chain", async () => {
626
+ let routeHandler = async () => { };
627
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
628
+ routeHandler = handler;
629
+ });
630
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
631
+ await openPage("https://example.com", 10000, [], {
632
+ blockCrossDomainRedirect: true,
633
+ });
634
+ const mockRedirectResponse = {
635
+ status: () => 301,
636
+ headers: () => ({ location: "https://example.com/page" }),
637
+ text: () => Promise.resolve(null),
638
+ };
639
+ const continueMock = vi.fn(() => Promise.resolve());
640
+ const abortMock = vi.fn(() => Promise.resolve());
641
+ const fetchMock = vi
642
+ .fn()
643
+ .mockResolvedValueOnce(mockRedirectResponse)
644
+ .mockRejectedValueOnce(new Error("net::ERR_CONNECTION_RESET"));
645
+ const fulfillMock = vi.fn(() => Promise.resolve());
646
+ await routeHandler({
647
+ request: () => ({
648
+ isNavigationRequest: () => true,
649
+ frame: () => mockMainFrame,
650
+ url: () => "https://example.com",
651
+ }),
652
+ continue: continueMock,
653
+ abort: abortMock,
654
+ fetch: fetchMock,
655
+ fulfill: fulfillMock,
656
+ });
657
+ expect(fetchMock).toHaveBeenCalledTimes(2);
658
+ expect(abortMock).toHaveBeenCalledWith("failed");
659
+ expect(fulfillMock).not.toHaveBeenCalled();
660
+ });
661
+ it("should record non-Error thrown by redirect chain fetch in urls", async () => {
662
+ let routeHandler = async () => { };
663
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
664
+ routeHandler = handler;
665
+ });
666
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
667
+ const result = await openPage("https://example.com", 10000, []);
668
+ const mockRedirectResponse = {
669
+ status: () => 301,
670
+ headers: () => ({ location: "https://example.com/page" }),
671
+ text: () => Promise.resolve(null),
672
+ };
673
+ const abortMock = vi.fn(() => Promise.resolve());
674
+ const fetchMock = vi
675
+ .fn()
676
+ .mockResolvedValueOnce(mockRedirectResponse)
677
+ .mockRejectedValueOnce("string error");
678
+ await routeHandler({
679
+ request: () => ({
680
+ isNavigationRequest: () => true,
681
+ frame: () => mockMainFrame,
682
+ url: () => "https://example.com",
683
+ }),
684
+ continue: vi.fn(() => Promise.resolve()),
685
+ abort: abortMock,
686
+ fetch: fetchMock,
687
+ fulfill: vi.fn(() => Promise.resolve()),
688
+ });
689
+ expect(result.urls).toEqual([
690
+ { url: "https://example.com", status: 301 },
691
+ { url: "https://example.com/page", error: "string error" },
692
+ ]);
693
+ });
694
+ it("should break loop when Location URL cannot be parsed", async () => {
695
+ let routeHandler = async () => { };
696
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
697
+ routeHandler = handler;
698
+ });
699
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
700
+ await openPage("https://example.com", 10000, [], {
701
+ blockCrossDomainRedirect: true,
702
+ });
703
+ // Location with an invalid base URL combination that triggers URL parse error
704
+ const mockResponse = {
705
+ status: () => 301,
706
+ headers: () => ({ location: "https://[invalid" }),
707
+ text: () => Promise.resolve(null),
708
+ };
709
+ const continueMock = vi.fn(() => Promise.resolve());
710
+ const abortMock = vi.fn(() => Promise.resolve());
711
+ const fetchMock = vi.fn(() => Promise.resolve(mockResponse));
712
+ const fulfillMock = vi.fn(() => Promise.resolve());
713
+ await routeHandler({
714
+ request: () => ({
715
+ isNavigationRequest: () => true,
716
+ frame: () => mockMainFrame,
717
+ url: () => "https://example.com",
718
+ }),
719
+ continue: continueMock,
720
+ abort: abortMock,
721
+ fetch: fetchMock,
722
+ fulfill: fulfillMock,
723
+ });
724
+ expect(fetchMock).toHaveBeenCalledWith({ maxRedirects: 0 });
725
+ expect(fulfillMock).toHaveBeenCalledWith({ response: mockResponse });
726
+ expect(abortMock).not.toHaveBeenCalled();
727
+ });
728
+ it("should capture 3xx response headers and body into responses array", async () => {
729
+ let routeHandler = async () => { };
730
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
731
+ routeHandler = handler;
732
+ });
733
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
734
+ const result = await openPage("https://example.com", 10000, []);
735
+ const mock301Response = {
736
+ status: () => 301,
737
+ headers: () => ({
738
+ location: "https://example.com/new",
739
+ server: "awselb/2.0",
740
+ }),
741
+ text: () => Promise.resolve("<html><body>Moved Permanently</body></html>"),
742
+ };
743
+ const mock200Response = {
744
+ status: () => 200,
745
+ headers: () => ({ server: "nginx" }),
746
+ };
747
+ const fetchMock = vi
748
+ .fn()
749
+ .mockResolvedValueOnce(mock301Response)
750
+ .mockResolvedValueOnce(mock200Response);
751
+ const fulfillMock = vi.fn(() => Promise.resolve());
752
+ await routeHandler({
753
+ request: () => ({
754
+ isNavigationRequest: () => true,
755
+ frame: () => mockMainFrame,
756
+ url: () => "https://example.com",
757
+ }),
758
+ continue: vi.fn(),
759
+ abort: vi.fn(),
760
+ fetch: fetchMock,
761
+ fulfill: fulfillMock,
762
+ });
763
+ expect(result.responses).toHaveLength(1);
764
+ expect(result.responses[0]).toEqual({
765
+ url: "https://example.com",
766
+ host: "example.com",
767
+ isFirstParty: true,
768
+ status: 301,
769
+ headers: { location: "https://example.com/new", server: "awselb/2.0" },
770
+ body: "<html><body>Moved Permanently</body></html>",
771
+ });
772
+ });
773
+ it("should capture multi-hop 3xx chain into responses array", async () => {
774
+ let routeHandler = async () => { };
775
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
776
+ routeHandler = handler;
777
+ });
778
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
779
+ const result = await openPage("https://example.com", 10000, []);
780
+ const mock301Response = {
781
+ status: () => 301,
782
+ headers: () => ({
783
+ location: "https://example.com/step2",
784
+ server: "awselb/2.0",
785
+ }),
786
+ text: () => Promise.resolve(null),
787
+ };
788
+ const mock302Response = {
789
+ status: () => 302,
790
+ headers: () => ({
791
+ location: "https://example.com/final",
792
+ server: "cloudflare",
793
+ }),
794
+ text: () => Promise.resolve("<html><body>Found</body></html>"),
795
+ };
796
+ const mock200Response = {
797
+ status: () => 200,
798
+ headers: () => ({ server: "nginx" }),
799
+ };
800
+ const fetchMock = vi
801
+ .fn()
802
+ .mockResolvedValueOnce(mock301Response)
803
+ .mockResolvedValueOnce(mock302Response)
804
+ .mockResolvedValueOnce(mock200Response);
805
+ const fulfillMock = vi.fn(() => Promise.resolve());
806
+ await routeHandler({
807
+ request: () => ({
808
+ isNavigationRequest: () => true,
809
+ frame: () => mockMainFrame,
810
+ url: () => "https://example.com",
811
+ }),
812
+ continue: vi.fn(),
813
+ abort: vi.fn(),
814
+ fetch: fetchMock,
815
+ fulfill: fulfillMock,
816
+ });
817
+ expect(result.responses).toHaveLength(2);
818
+ expect(result.responses[0]).toMatchObject({
819
+ url: "https://example.com",
820
+ status: 301,
821
+ headers: expect.objectContaining({ server: "awselb/2.0" }),
822
+ });
823
+ expect(result.responses[1]).toMatchObject({
824
+ url: "https://example.com/step2",
825
+ status: 302,
826
+ headers: expect.objectContaining({ server: "cloudflare" }),
827
+ body: "<html><body>Found</body></html>",
828
+ });
829
+ });
830
+ it("should not duplicate 3xx responses in page.on response listener", async () => {
831
+ let routeHandler = async () => { };
832
+ let responseListener = async () => { };
833
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
834
+ routeHandler = handler;
835
+ });
836
+ mockPage.on.mockImplementation((event, handler) => {
837
+ if (event === "response") {
838
+ responseListener = handler;
839
+ }
840
+ });
841
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
842
+ const result = await openPage("https://example.com", 10000, []);
843
+ const mock301Response = {
844
+ status: () => 301,
845
+ headers: () => ({
846
+ location: "https://example.com/new",
847
+ server: "awselb/2.0",
848
+ }),
849
+ text: () => Promise.resolve(null),
850
+ };
851
+ const mock200Response = {
852
+ status: () => 200,
853
+ headers: () => ({}),
854
+ };
855
+ const fetchMock = vi
856
+ .fn()
857
+ .mockResolvedValueOnce(mock301Response)
858
+ .mockResolvedValueOnce(mock200Response);
859
+ await routeHandler({
860
+ request: () => ({
861
+ isNavigationRequest: () => true,
862
+ frame: () => mockMainFrame,
863
+ url: () => "https://example.com",
864
+ }),
865
+ continue: vi.fn(),
866
+ abort: vi.fn(),
867
+ fetch: fetchMock,
868
+ fulfill: vi.fn(() => Promise.resolve()),
869
+ });
870
+ // Simulate browser re-delivering the same 3xx response via
871
+ // page.on("response") — it should be skipped as a duplicate.
872
+ await responseListener({
873
+ url: () => "https://example.com",
874
+ status: () => 301,
875
+ headers: () => ({
876
+ location: "https://example.com/new",
877
+ server: "awselb/2.0",
878
+ }),
879
+ text: () => Promise.resolve(null),
880
+ });
881
+ const matching301 = result.responses.filter((r) => r.url === "https://example.com" && r.status === 301);
882
+ expect(matching301).toHaveLength(1);
883
+ });
884
+ it("should omit body from 3xx response when body is empty", async () => {
885
+ let routeHandler = async () => { };
886
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
887
+ routeHandler = handler;
888
+ });
889
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
890
+ const result = await openPage("https://example.com", 10000, []);
891
+ const mock301Response = {
892
+ status: () => 301,
893
+ headers: () => ({ location: "https://example.com/new" }),
894
+ text: () => Promise.resolve(null),
895
+ };
896
+ const mock200Response = {
897
+ status: () => 200,
898
+ headers: () => ({}),
899
+ };
900
+ const fetchMock = vi
901
+ .fn()
902
+ .mockResolvedValueOnce(mock301Response)
903
+ .mockResolvedValueOnce(mock200Response);
904
+ await routeHandler({
905
+ request: () => ({
906
+ isNavigationRequest: () => true,
907
+ frame: () => mockMainFrame,
908
+ url: () => "https://example.com",
909
+ }),
910
+ continue: vi.fn(),
911
+ abort: vi.fn(),
912
+ fetch: fetchMock,
913
+ fulfill: vi.fn(() => Promise.resolve()),
914
+ });
915
+ expect(result.responses).toHaveLength(1);
916
+ expect(result.responses[0]).not.toHaveProperty("body");
917
+ });
918
+ it("should capture 3xx response without Location header", async () => {
919
+ let routeHandler = async () => { };
920
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
921
+ routeHandler = handler;
922
+ });
923
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
924
+ const result = await openPage("https://example.com", 10000, []);
925
+ // 3xx response without a Location header — the loop should capture it
926
+ // and then break.
927
+ const mock301Response = {
928
+ status: () => 301,
929
+ headers: () => ({ server: "awselb/2.0" }),
930
+ text: () => Promise.resolve(null),
931
+ };
932
+ const fetchMock = vi.fn().mockResolvedValueOnce(mock301Response);
933
+ const fulfillMock = vi.fn(() => Promise.resolve());
934
+ await routeHandler({
935
+ request: () => ({
936
+ isNavigationRequest: () => true,
937
+ frame: () => mockMainFrame,
938
+ url: () => "https://example.com",
939
+ }),
940
+ continue: vi.fn(),
941
+ abort: vi.fn(),
942
+ fetch: fetchMock,
943
+ fulfill: fulfillMock,
944
+ });
945
+ expect(result.responses).toHaveLength(1);
946
+ expect(result.responses[0]).toMatchObject({
947
+ url: "https://example.com",
948
+ status: 301,
949
+ headers: { server: "awselb/2.0" },
950
+ });
951
+ // Only the initial fetch should have been called (no redirect to follow)
952
+ expect(fetchMock).toHaveBeenCalledTimes(1);
953
+ expect(fulfillMock).toHaveBeenCalled();
954
+ });
955
+ it("should handle 3xx response where URL has no extractable host", async () => {
956
+ let routeHandler = async () => { };
957
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
958
+ routeHandler = handler;
959
+ });
960
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
961
+ const result = await openPage("https://example.com", 10000, []);
962
+ const mock301Response = {
963
+ status: () => 301,
964
+ headers: () => ({ location: "https://example.com/new" }),
965
+ text: () => Promise.resolve(null),
966
+ };
967
+ const mock200Response = {
968
+ status: () => 200,
969
+ headers: () => ({}),
970
+ };
971
+ const fetchMock = vi
972
+ .fn()
973
+ .mockResolvedValueOnce(mock301Response)
974
+ .mockResolvedValueOnce(mock200Response);
975
+ const fulfillMock = vi.fn(() => Promise.resolve());
976
+ // Use a data: URL that getHostFromUrl returns null for
977
+ await routeHandler({
978
+ request: () => ({
979
+ isNavigationRequest: () => true,
980
+ frame: () => mockMainFrame,
981
+ url: () => "https://example.com",
982
+ }),
983
+ continue: vi.fn(),
984
+ abort: vi.fn(),
985
+ fetch: fetchMock,
986
+ fulfill: fulfillMock,
987
+ });
988
+ // The 3xx response should still be captured
989
+ expect(result.responses).toHaveLength(1);
990
+ expect(result.responses[0]).toMatchObject({
991
+ status: 301,
992
+ host: "example.com",
993
+ isFirstParty: true,
994
+ });
995
+ });
996
+ });
117
997
  describe("timeout handling", () => {
118
998
  it("should set timeoutOccurred to true when timeout occurs", async () => {
119
999
  // Make goto never resolve, and sleep resolve immediately
@@ -131,6 +1011,58 @@ describe("openPage", () => {
131
1011
  await openPage("https://example.com", 10000, []);
132
1012
  expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Connection refused"));
133
1013
  });
1014
+ it("should not duplicate error in urls when route handler already recorded entries", async () => {
1015
+ let routeHandler = async () => { };
1016
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
1017
+ routeHandler = handler;
1018
+ });
1019
+ mockPage.goto.mockImplementation(async () => {
1020
+ // Route handler records a fetch error
1021
+ await routeHandler({
1022
+ request: () => ({
1023
+ isNavigationRequest: () => true,
1024
+ frame: () => mockMainFrame,
1025
+ url: () => "https://example.com",
1026
+ }),
1027
+ continue: vi.fn(() => Promise.resolve()),
1028
+ abort: vi.fn(() => Promise.resolve()),
1029
+ fetch: vi.fn(() => Promise.reject(new Error("net::ERR_NAME_NOT_RESOLVED"))),
1030
+ fulfill: vi.fn(() => Promise.resolve()),
1031
+ });
1032
+ throw new Error("page.goto: net::ERR_FAILED");
1033
+ });
1034
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
1035
+ const result = await openPage("https://example.com", 10000, []);
1036
+ // Only the route handler's error should be in urls, not the page.goto error
1037
+ expect(result.urls).toHaveLength(1);
1038
+ expect(result.urls[0].error).toContain("net::ERR_NAME_NOT_RESOLVED");
1039
+ });
1040
+ it("should not log error when navigation is blocked by redirect policy", async () => {
1041
+ let routeHandler = async () => { };
1042
+ mockPage.route.mockImplementation(async (_pattern, handler) => {
1043
+ routeHandler = handler;
1044
+ });
1045
+ mockPage.goto.mockImplementation(async () => {
1046
+ // Simulate the route handler blocking a cross-host redirect
1047
+ await routeHandler({
1048
+ request: () => ({
1049
+ isNavigationRequest: () => true,
1050
+ frame: () => mockMainFrame,
1051
+ url: () => "https://other.example",
1052
+ }),
1053
+ continue: vi.fn(() => Promise.resolve()),
1054
+ abort: vi.fn(() => Promise.resolve()),
1055
+ fetch: vi.fn(),
1056
+ fulfill: vi.fn(() => Promise.resolve()),
1057
+ });
1058
+ throw new Error("page.goto: net::ERR_BLOCKED_BY_CLIENT");
1059
+ });
1060
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
1061
+ await openPage("https://example.com", 10000, [], {
1062
+ blockCrossDomainRedirect: true,
1063
+ });
1064
+ expect(logger.error).not.toHaveBeenCalled();
1065
+ });
134
1066
  it("should handle cookie/JS extraction failure gracefully", async () => {
135
1067
  mockPage.goto.mockResolvedValue(undefined);
136
1068
  mockBrowserContext.cookies.mockRejectedValue(new Error("Context destroyed"));
@@ -225,6 +1157,10 @@ describe("openPage", () => {
225
1157
  status: () => 200,
226
1158
  headers: () => ({ "content-type": "application/json" }),
227
1159
  text: () => Promise.resolve('{"data": "test"}'),
1160
+ request: () => ({
1161
+ isNavigationRequest: () => false,
1162
+ frame: () => null,
1163
+ }),
228
1164
  });
229
1165
  });
230
1166
  vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
@@ -253,6 +1189,10 @@ describe("openPage", () => {
253
1189
  status: () => 200,
254
1190
  headers: () => ({ "content-type": "application/octet-stream" }),
255
1191
  text: () => Promise.reject(new Error("Cannot read binary")),
1192
+ request: () => ({
1193
+ isNavigationRequest: () => false,
1194
+ frame: () => null,
1195
+ }),
256
1196
  });
257
1197
  });
258
1198
  vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
@@ -280,6 +1220,10 @@ describe("openPage", () => {
280
1220
  status: () => 200,
281
1221
  headers: () => ({ "content-type": "text/javascript" }),
282
1222
  text: () => Promise.resolve("console.log('ok')"),
1223
+ request: () => ({
1224
+ isNavigationRequest: () => false,
1225
+ frame: () => null,
1226
+ }),
283
1227
  });
284
1228
  });
285
1229
  vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
@@ -289,6 +1233,56 @@ describe("openPage", () => {
289
1233
  isFirstParty: false,
290
1234
  });
291
1235
  });
1236
+ it("should record navigation response in urls", async () => {
1237
+ const mockMainFrame = { id: "main" };
1238
+ mockPage.mainFrame.mockReturnValue(mockMainFrame);
1239
+ let capturedCallback;
1240
+ mockPage.on.mockImplementation((event, callback) => {
1241
+ if (event === "response") {
1242
+ capturedCallback = callback;
1243
+ }
1244
+ });
1245
+ mockPage.goto.mockImplementation(async () => {
1246
+ await capturedCallback({
1247
+ url: () => "https://example.com/",
1248
+ status: () => 200,
1249
+ headers: () => ({ "content-type": "text/html" }),
1250
+ text: () => Promise.resolve("<html></html>"),
1251
+ request: () => ({
1252
+ isNavigationRequest: () => true,
1253
+ frame: () => mockMainFrame,
1254
+ }),
1255
+ });
1256
+ });
1257
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
1258
+ const result = await openPage("https://example.com", 10000, []);
1259
+ expect(result.urls).toEqual([
1260
+ { url: "https://example.com/", status: 200 },
1261
+ ]);
1262
+ });
1263
+ it("should not record non-navigation response in urls", async () => {
1264
+ let capturedCallback;
1265
+ mockPage.on.mockImplementation((event, callback) => {
1266
+ if (event === "response") {
1267
+ capturedCallback = callback;
1268
+ }
1269
+ });
1270
+ mockPage.goto.mockImplementation(async () => {
1271
+ await capturedCallback({
1272
+ url: () => "https://example.com/style.css",
1273
+ status: () => 200,
1274
+ headers: () => ({ "content-type": "text/css" }),
1275
+ text: () => Promise.resolve("body {}"),
1276
+ request: () => ({
1277
+ isNavigationRequest: () => false,
1278
+ frame: () => null,
1279
+ }),
1280
+ });
1281
+ });
1282
+ vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
1283
+ const result = await openPage("https://example.com", 10000, []);
1284
+ expect(result.urls).toEqual([]);
1285
+ });
292
1286
  });
293
1287
  });
294
1288
  //# sourceMappingURL=index.test.js.map