diff --git a/src/__tests__/browser-recycle.test.ts b/src/__tests__/browser-recycle.test.ts new file mode 100644 index 0000000..cbb1da6 --- /dev/null +++ b/src/__tests__/browser-recycle.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest"; + +// Don't use the global mock — we test the real recyclePage +vi.unmock("../services/browser.js"); + +// Mock puppeteer so initBrowser doesn't launch real browsers +vi.mock("puppeteer", () => ({ + default: { + launch: vi.fn(), + }, +})); + +describe("recyclePage", () => { + it("cleans up request interception listeners before navigating to about:blank", async () => { + // Dynamic import to get the real (unmocked) module + const { recyclePage } = await import("../services/browser.js"); + + const callOrder: string[] = []; + + const mockPage = { + createCDPSession: vi.fn().mockResolvedValue({ + send: vi.fn().mockResolvedValue(undefined), + detach: vi.fn().mockResolvedValue(undefined), + }), + removeAllListeners: vi.fn().mockImplementation((event: string) => { + callOrder.push(`removeAllListeners:${event}`); + return mockPage; + }), + setRequestInterception: vi.fn().mockImplementation((val: boolean) => { + callOrder.push(`setRequestInterception:${val}`); + return Promise.resolve(); + }), + cookies: vi.fn().mockResolvedValue([]), + deleteCookie: vi.fn(), + goto: vi.fn().mockImplementation((url: string) => { + callOrder.push(`goto:${url}`); + return Promise.resolve(); + }), + }; + + await recyclePage(mockPage as any); + + // Verify request interception cleanup happens + expect(mockPage.removeAllListeners).toHaveBeenCalledWith("request"); + expect(mockPage.setRequestInterception).toHaveBeenCalledWith(false); + + // Verify cleanup happens BEFORE navigation to about:blank + const removeIdx = callOrder.indexOf("removeAllListeners:request"); + const interceptIdx = callOrder.indexOf("setRequestInterception:false"); + const gotoIdx = callOrder.indexOf("goto:about:blank"); + + expect(removeIdx).toBeGreaterThanOrEqual(0); + expect(interceptIdx).toBeGreaterThanOrEqual(0); + expect(gotoIdx).toBeGreaterThanOrEqual(0); + expect(removeIdx).toBeLessThan(gotoIdx); + expect(interceptIdx).toBeLessThan(gotoIdx); + }); +}); diff --git a/src/services/browser.ts b/src/services/browser.ts index 3b79ff1..becf34c 100644 --- a/src/services/browser.ts +++ b/src/services/browser.ts @@ -40,11 +40,14 @@ export function getPoolStats() { }; } -async function recyclePage(page: Page): Promise { +export async function recyclePage(page: Page): Promise { try { const client = await page.createCDPSession(); await client.send("Network.clearBrowserCache").catch(() => {}); await client.detach().catch(() => {}); + // Clean up request interception (set by renderUrlPdf for SSRF protection) + page.removeAllListeners("request"); + await page.setRequestInterception(false).catch(() => {}); const cookies = await page.cookies(); if (cookies.length > 0) { await page.deleteCookie(...cookies);