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.
- package/README.md +3 -2
- package/dist/analyzer/apply.d.ts +2 -2
- package/dist/analyzer/apply.d.ts.map +1 -1
- package/dist/analyzer/apply.js +60 -15
- package/dist/analyzer/apply.js.map +1 -1
- package/dist/analyzer/apply.test.js +143 -9
- package/dist/analyzer/apply.test.js.map +1 -1
- package/dist/analyzer/index.d.ts +2 -2
- package/dist/analyzer/index.d.ts.map +1 -1
- package/dist/analyzer/index.js +2 -2
- package/dist/analyzer/index.js.map +1 -1
- package/dist/analyzer/index.test.js +20 -7
- package/dist/analyzer/index.test.js.map +1 -1
- package/dist/analyzer/types.d.ts +0 -4
- package/dist/analyzer/types.d.ts.map +1 -1
- package/dist/analyzer/util.d.ts +0 -1
- package/dist/analyzer/util.d.ts.map +1 -1
- package/dist/analyzer/util.js +0 -8
- package/dist/analyzer/util.js.map +1 -1
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +174 -5
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/index.test.js +957 -2
- package/dist/browser/index.test.js.map +1 -1
- package/dist/browser/types.d.ts +6 -0
- package/dist/browser/types.d.ts.map +1 -1
- package/dist/browser/utils.d.ts +1 -0
- package/dist/browser/utils.d.ts.map +1 -1
- package/dist/browser/utils.js +3 -0
- package/dist/browser/utils.js.map +1 -1
- package/dist/browser/utils.test.js +9 -2
- package/dist/browser/utils.test.js.map +1 -1
- package/dist/commands/detect.d.ts.map +1 -1
- package/dist/commands/detect.js +12 -20
- package/dist/commands/detect.js.map +1 -1
- package/dist/commands/detect.test.js +31 -10
- package/dist/commands/detect.test.js.map +1 -1
- package/dist/commands/detect_types.d.ts +4 -2
- package/dist/commands/detect_types.d.ts.map +1 -1
- package/dist/commands/detect_utils.d.ts +2 -1
- package/dist/commands/detect_utils.d.ts.map +1 -1
- package/dist/commands/detect_utils.js +70 -54
- package/dist/commands/detect_utils.js.map +1 -1
- package/dist/commands/detect_utils.test.js +158 -35
- package/dist/commands/detect_utils.test.js.map +1 -1
- package/dist/commands/version.test.d.ts +2 -0
- package/dist/commands/version.test.d.ts.map +1 -0
- package/dist/commands/version.test.js +27 -0
- package/dist/commands/version.test.js.map +1 -0
- package/dist/e2e/cli.e2e.test.js +1 -1
- package/dist/e2e/cli.e2e.test.js.map +1 -1
- package/dist/logger/types.js +2 -2
- package/dist/logger/types.js.map +1 -1
- package/dist/signatures/_types.d.ts +2 -0
- package/dist/signatures/_types.d.ts.map +1 -1
- package/dist/signatures/index.d.ts.map +1 -1
- package/dist/signatures/index.js +2 -0
- package/dist/signatures/index.js.map +1 -1
- package/dist/signatures/nginx.d.ts.map +1 -1
- package/dist/signatures/nginx.js +7 -7
- package/dist/signatures/nginx.js.map +1 -1
- package/dist/signatures/signatures.test.js +10 -0
- package/dist/signatures/signatures.test.js.map +1 -1
- package/dist/signatures/technologies/cloudflare.d.ts +3 -0
- package/dist/signatures/technologies/cloudflare.d.ts.map +1 -0
- package/dist/signatures/technologies/cloudflare.js +20 -0
- package/dist/signatures/technologies/cloudflare.js.map +1 -0
- package/dist/signatures/technologies/nginx.d.ts.map +1 -1
- package/dist/signatures/technologies/nginx.js +1 -0
- package/dist/signatures/technologies/nginx.js.map +1 -1
- package/dist/signatures/technologies/wordpress.d.ts.map +1 -1
- package/dist/signatures/technologies/wordpress.js +1 -0
- package/dist/signatures/technologies/wordpress.js.map +1 -1
- package/package.json +1 -1
- package/dist/signatures/nextjs.d.ts +0 -3
- package/dist/signatures/nextjs.d.ts.map +0 -1
- package/dist/signatures/nextjs.js +0 -16
- package/dist/signatures/nextjs.js.map +0 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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
|