whopper 0.1.9 → 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.
Files changed (79) hide show
  1. package/README.md +3 -2
  2. package/dist/analyzer/apply.d.ts +2 -2
  3. package/dist/analyzer/apply.d.ts.map +1 -1
  4. package/dist/analyzer/apply.js +60 -15
  5. package/dist/analyzer/apply.js.map +1 -1
  6. package/dist/analyzer/apply.test.js +143 -9
  7. package/dist/analyzer/apply.test.js.map +1 -1
  8. package/dist/analyzer/index.d.ts +2 -2
  9. package/dist/analyzer/index.d.ts.map +1 -1
  10. package/dist/analyzer/index.js +2 -2
  11. package/dist/analyzer/index.js.map +1 -1
  12. package/dist/analyzer/index.test.js +20 -7
  13. package/dist/analyzer/index.test.js.map +1 -1
  14. package/dist/analyzer/types.d.ts +0 -4
  15. package/dist/analyzer/types.d.ts.map +1 -1
  16. package/dist/analyzer/util.d.ts +0 -1
  17. package/dist/analyzer/util.d.ts.map +1 -1
  18. package/dist/analyzer/util.js +0 -8
  19. package/dist/analyzer/util.js.map +1 -1
  20. package/dist/browser/index.d.ts +1 -1
  21. package/dist/browser/index.d.ts.map +1 -1
  22. package/dist/browser/index.js +174 -5
  23. package/dist/browser/index.js.map +1 -1
  24. package/dist/browser/index.test.js +957 -2
  25. package/dist/browser/index.test.js.map +1 -1
  26. package/dist/browser/types.d.ts +6 -0
  27. package/dist/browser/types.d.ts.map +1 -1
  28. package/dist/browser/utils.d.ts +1 -0
  29. package/dist/browser/utils.d.ts.map +1 -1
  30. package/dist/browser/utils.js +3 -0
  31. package/dist/browser/utils.js.map +1 -1
  32. package/dist/browser/utils.test.js +9 -2
  33. package/dist/browser/utils.test.js.map +1 -1
  34. package/dist/commands/detect.d.ts.map +1 -1
  35. package/dist/commands/detect.js +12 -20
  36. package/dist/commands/detect.js.map +1 -1
  37. package/dist/commands/detect.test.js +31 -10
  38. package/dist/commands/detect.test.js.map +1 -1
  39. package/dist/commands/detect_types.d.ts +4 -2
  40. package/dist/commands/detect_types.d.ts.map +1 -1
  41. package/dist/commands/detect_utils.d.ts +2 -1
  42. package/dist/commands/detect_utils.d.ts.map +1 -1
  43. package/dist/commands/detect_utils.js +70 -54
  44. package/dist/commands/detect_utils.js.map +1 -1
  45. package/dist/commands/detect_utils.test.js +158 -35
  46. package/dist/commands/detect_utils.test.js.map +1 -1
  47. package/dist/commands/version.test.d.ts +2 -0
  48. package/dist/commands/version.test.d.ts.map +1 -0
  49. package/dist/commands/version.test.js +27 -0
  50. package/dist/commands/version.test.js.map +1 -0
  51. package/dist/e2e/cli.e2e.test.js +1 -1
  52. package/dist/e2e/cli.e2e.test.js.map +1 -1
  53. package/dist/logger/types.js +2 -2
  54. package/dist/logger/types.js.map +1 -1
  55. package/dist/signatures/_types.d.ts +2 -0
  56. package/dist/signatures/_types.d.ts.map +1 -1
  57. package/dist/signatures/index.d.ts.map +1 -1
  58. package/dist/signatures/index.js +2 -0
  59. package/dist/signatures/index.js.map +1 -1
  60. package/dist/signatures/nginx.d.ts.map +1 -1
  61. package/dist/signatures/nginx.js +7 -7
  62. package/dist/signatures/nginx.js.map +1 -1
  63. package/dist/signatures/signatures.test.js +10 -0
  64. package/dist/signatures/signatures.test.js.map +1 -1
  65. package/dist/signatures/technologies/cloudflare.d.ts +3 -0
  66. package/dist/signatures/technologies/cloudflare.d.ts.map +1 -0
  67. package/dist/signatures/technologies/cloudflare.js +20 -0
  68. package/dist/signatures/technologies/cloudflare.js.map +1 -0
  69. package/dist/signatures/technologies/nginx.d.ts.map +1 -1
  70. package/dist/signatures/technologies/nginx.js +1 -0
  71. package/dist/signatures/technologies/nginx.js.map +1 -1
  72. package/dist/signatures/technologies/wordpress.d.ts.map +1 -1
  73. package/dist/signatures/technologies/wordpress.js +1 -0
  74. package/dist/signatures/technologies/wordpress.js.map +1 -1
  75. package/package.json +1 -1
  76. package/dist/signatures/nextjs.d.ts +0 -3
  77. package/dist/signatures/nextjs.d.ts.map +0 -1
  78. package/dist/signatures/nextjs.js +0 -16
  79. package/dist/signatures/nextjs.js.map +0 -1
@@ -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(() => {
@@ -82,6 +89,13 @@ describe("openPage", () => {
82
89
  ignoreHTTPSErrors: true,
83
90
  });
84
91
  });
92
+ it("should pass custom userAgent to browser context", async () => {
93
+ await openPage("https://example.com", 10000, [], "MyCustomAgent/1.0");
94
+ expect(mockBrowser.newContext).toHaveBeenCalledWith({
95
+ ignoreHTTPSErrors: true,
96
+ userAgent: "MyCustomAgent/1.0",
97
+ });
98
+ });
85
99
  it("should navigate to the specified URL", async () => {
86
100
  await openPage("https://example.com", 10000, []);
87
101
  expect(mockPage.goto).toHaveBeenCalledWith("https://example.com", {
@@ -107,6 +121,835 @@ describe("openPage", () => {
107
121
  expect(result).toHaveProperty("timeoutOccurred", false);
108
122
  });
109
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
+ });
110
953
  describe("timeout handling", () => {
111
954
  it("should set timeoutOccurred to true when timeout occurs", async () => {
112
955
  // Make goto never resolve, and sleep resolve immediately
@@ -124,6 +967,56 @@ describe("openPage", () => {
124
967
  await openPage("https://example.com", 10000, []);
125
968
  expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Connection refused"));
126
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
+ });
127
1020
  it("should handle cookie/JS extraction failure gracefully", async () => {
128
1021
  mockPage.goto.mockResolvedValue(undefined);
129
1022
  mockBrowserContext.cookies.mockRejectedValue(new Error("Context destroyed"));
@@ -218,6 +1111,10 @@ describe("openPage", () => {
218
1111
  status: () => 200,
219
1112
  headers: () => ({ "content-type": "application/json" }),
220
1113
  text: () => Promise.resolve('{"data": "test"}'),
1114
+ request: () => ({
1115
+ isNavigationRequest: () => false,
1116
+ frame: () => null,
1117
+ }),
221
1118
  });
222
1119
  });
223
1120
  vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
@@ -231,7 +1128,7 @@ describe("openPage", () => {
231
1128
  headers: { "content-type": "application/json" },
232
1129
  body: '{"data": "test"}',
233
1130
  });
234
- expect(logger.debug).toHaveBeenCalledWith("Received response: https://example.com/api/data - 200");
1131
+ expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/^Received response \[.*200.*\] https:\/\/example\.com\/api\/data$/));
235
1132
  });
236
1133
  it("should capture responses without body when text() fails", async () => {
237
1134
  let capturedCallback;
@@ -246,6 +1143,10 @@ describe("openPage", () => {
246
1143
  status: () => 200,
247
1144
  headers: () => ({ "content-type": "application/octet-stream" }),
248
1145
  text: () => Promise.reject(new Error("Cannot read binary")),
1146
+ request: () => ({
1147
+ isNavigationRequest: () => false,
1148
+ frame: () => null,
1149
+ }),
249
1150
  });
250
1151
  });
251
1152
  vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
@@ -273,6 +1174,10 @@ describe("openPage", () => {
273
1174
  status: () => 200,
274
1175
  headers: () => ({ "content-type": "text/javascript" }),
275
1176
  text: () => Promise.resolve("console.log('ok')"),
1177
+ request: () => ({
1178
+ isNavigationRequest: () => false,
1179
+ frame: () => null,
1180
+ }),
276
1181
  });
277
1182
  });
278
1183
  vi.mocked(sleep).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
@@ -282,6 +1187,56 @@ describe("openPage", () => {
282
1187
  isFirstParty: false,
283
1188
  });
284
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
+ });
285
1240
  });
286
1241
  });
287
1242
  //# sourceMappingURL=index.test.js.map