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