diff --git a/src/__tests__/browser-pool.test.ts b/src/__tests__/browser-pool.test.ts new file mode 100644 index 0000000..1252f4c --- /dev/null +++ b/src/__tests__/browser-pool.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Don't use the global mock — we test the real browser service +vi.unmock("../services/browser.js"); + +// Mock logger +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function createMockPage() { + const page: any = { + setJavaScriptEnabled: vi.fn().mockResolvedValue(undefined), + setContent: vi.fn().mockResolvedValue(undefined), + addStyleTag: vi.fn().mockResolvedValue(undefined), + pdf: vi.fn().mockResolvedValue(Buffer.from("%PDF-1.4 test")), + goto: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + setRequestInterception: vi.fn().mockResolvedValue(undefined), + removeAllListeners: vi.fn().mockReturnThis(), + createCDPSession: vi.fn().mockResolvedValue({ + send: vi.fn().mockResolvedValue(undefined), + detach: vi.fn().mockResolvedValue(undefined), + }), + cookies: vi.fn().mockResolvedValue([]), + deleteCookie: vi.fn(), + on: vi.fn(), + newPage: vi.fn(), + }; + return page; +} + +function createMockBrowser(pagesPerBrowser = 8) { + const pages = Array.from({ length: pagesPerBrowser }, () => createMockPage()); + let pageIndex = 0; + const browser: any = { + newPage: vi.fn().mockImplementation(() => Promise.resolve(pages[pageIndex++] || createMockPage())), + close: vi.fn().mockResolvedValue(undefined), + _pages: pages, + }; + return browser; +} + +// We need to set env vars before importing +process.env.BROWSER_COUNT = "2"; +process.env.PAGES_PER_BROWSER = "2"; // small for testing + +let mockBrowsers: any[] = []; +let launchCallCount = 0; + +vi.mock("puppeteer", () => ({ + default: { + launch: vi.fn().mockImplementation(() => { + const b = createMockBrowser(2); + mockBrowsers.push(b); + launchCallCount++; + return Promise.resolve(b); + }), + }, +})); + +describe("browser pool", () => { + let browserModule: typeof import("../services/browser.js"); + + beforeEach(async () => { + mockBrowsers = []; + launchCallCount = 0; + // Fresh import each test to reset module state (instances array) + vi.resetModules(); + // Re-apply mocks after resetModules + vi.doMock("puppeteer", () => ({ + default: { + launch: vi.fn().mockImplementation(() => { + const b = createMockBrowser(2); + mockBrowsers.push(b); + launchCallCount++; + return Promise.resolve(b); + }), + }, + })); + vi.doMock("../services/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + })); + browserModule = await import("../services/browser.js"); + }); + + afterEach(async () => { + try { + await browserModule.closeBrowser(); + } catch {} + }); + + describe("initBrowser / closeBrowser", () => { + it("launches BROWSER_COUNT browser instances", async () => { + await browserModule.initBrowser(); + expect(launchCallCount).toBe(2); + expect(mockBrowsers).toHaveLength(2); + }); + + it("creates PAGES_PER_BROWSER pages per browser", async () => { + await browserModule.initBrowser(); + for (const b of mockBrowsers) { + expect(b.newPage).toHaveBeenCalledTimes(2); + } + }); + + it("closeBrowser closes all pages and browsers", async () => { + await browserModule.initBrowser(); + const allPages = mockBrowsers.flatMap((b: any) => b._pages.slice(0, 2)); + await browserModule.closeBrowser(); + for (const page of allPages) { + expect(page.close).toHaveBeenCalled(); + } + for (const b of mockBrowsers) { + expect(b.close).toHaveBeenCalled(); + } + }); + }); + + describe("getPoolStats", () => { + it("returns correct structure after init", async () => { + await browserModule.initBrowser(); + const stats = browserModule.getPoolStats(); + expect(stats).toMatchObject({ + poolSize: 4, // 2 browsers × 2 pages + totalPages: 4, + availablePages: 4, + queueDepth: 0, + pdfCount: 0, + restarting: false, + }); + expect(stats.browsers).toHaveLength(2); + expect(stats.browsers[0]).toMatchObject({ + id: 0, + available: 2, + pdfCount: 0, + restarting: false, + }); + }); + + it("returns empty stats before init", () => { + const stats = browserModule.getPoolStats(); + expect(stats.poolSize).toBe(0); + expect(stats.availablePages).toBe(0); + expect(stats.browsers).toHaveLength(0); + }); + }); + + describe("renderPdf", () => { + it("generates a PDF buffer from HTML", async () => { + await browserModule.initBrowser(); + const result = await browserModule.renderPdf("
test
"); + // After render + recycle, page should be available again (async recycle) + // pdfCount should have incremented + const statsAfter = browserModule.getPoolStats(); + expect(statsAfter.pdfCount).toBe(1); + }); + + it("passes options correctly to page.pdf()", async () => { + await browserModule.initBrowser(); + await browserModule.renderPdf("test
", { + format: "Letter", + landscape: true, + scale: 0.8, + margin: { top: "10mm", bottom: "10mm", left: "5mm", right: "5mm" }, + displayHeaderFooter: true, + headerTemplate: "1
"); + const p2 = browserModule.renderPdf("2
"); + const p3 = browserModule.renderPdf("3
"); + const p4 = browserModule.renderPdf("4
"); + + // Stats should show queue or reduced availability + // The 5th request should queue + // But since our mock pages resolve instantly, the first 4 may already be done + // Let's make pages hang to truly test queuing + await Promise.all([p1, p2, p3, p4]); + + // Verify all rendered successfully + const stats = browserModule.getPoolStats(); + expect(stats.pdfCount).toBe(4); + }); + + it("rejects with QUEUE_FULL after 30s timeout when all pages busy", async () => { + await browserModule.initBrowser(); + + // Make all pages hang + for (const b of mockBrowsers) { + for (const p of b._pages) { + p.setContent.mockImplementation(() => new Promise(() => {})); + } + } + + // Consume all 4 pages (these will hang) + browserModule.renderPdf("1
"); + browserModule.renderPdf("2
"); + browserModule.renderPdf("3
"); + browserModule.renderPdf("4
"); + + vi.useFakeTimers(); + // 5th request should queue + const queued = browserModule.renderPdf("5
"); + + // Advance past queue timeout + await vi.advanceTimersByTimeAsync(30_001); + + await expect(queued).rejects.toThrow("QUEUE_FULL"); + + vi.useRealTimers(); + }); + }); +});