import type { BrowserContext } from 'patchright'; import { afterEach, describe, expect, it, vi } from 'vitest'; import wait from './wait'; const connect = vi.fn(); const connectOverCDP = vi.fn(); const launch = vi.fn(); let mockPage: any; let mockContext: any; let mockBrowser: any; const createBrowserMocks = () => { mockPage = { context: vi.fn(), goto: vi.fn(), on: vi.fn(), setExtraHTTPHeaders: vi.fn(), }; mockContext = { addCookies: vi.fn(), close: vi.fn(() => Promise.resolve()), cookies: vi.fn(), newPage: vi.fn(() => Promise.resolve(mockPage)), }; mockBrowser = { close: vi.fn(), newContext: vi.fn(() => Promise.resolve(mockContext)), }; }; const proxyMock = { proxyObj: { url_regex: '.*' }, proxyUrlHandler: new URL('http://proxy.local'), multiProxy: undefined as any, getCurrentProxy: vi.fn(), markProxyFailed: vi.fn(), getDispatcherForProxy: vi.fn(), }; createBrowserMocks(); const loadPlaywright = async () => { vi.resetModules(); vi.doMock('dotenv/config', () => ({})); vi.doMock('patchright', () => ({ chromium: { connect, connectOverCDP, launch, }, })); vi.doMock('@/utils/proxy', () => ({ default: proxyMock, })); vi.doMock('@/utils/logger', () => ({ default: { warn: vi.fn(), debug: vi.fn(), }, })); const mod = await import('@/utils/playwright'); return mod.getPlaywrightPage; }; const resetMocks = () => { createBrowserMocks(); connect.mockReset(); connectOverCDP.mockReset(); launch.mockReset(); proxyMock.multiProxy = undefined; proxyMock.getCurrentProxy.mockReset(); proxyMock.markProxyFailed.mockReset(); delete process.env.PLAYWRIGHT_WS_ENDPOINT; delete process.env.PUPPETEER_WS_ENDPOINT; delete process.env.PLAYWRIGHT_CDP_ENDPOINT; }; let context: BrowserContext | null = null; afterEach(async () => { if (context) { await context.close(); context = null; } delete process.env.PROXY_URI; delete process.env.PROXY_PROTOCOL; delete process.env.PROXY_HOST; delete process.env.PROXY_PORT; delete process.env.PROXY_AUTH; delete process.env.PROXY_URL_REGEX; vi.doUnmock('dotenv/config'); vi.doUnmock('patchright'); vi.doUnmock('@/utils/proxy'); vi.doUnmock('@/utils/logger'); vi.resetModules(); }); describe('playwright', () => { it('playwright run', async () => { const { default: playwright } = await import('./playwright'); context = await playwright(); const browser = context.browser(); const page = await context.newPage(); await page.goto('https://www.google.com', { waitUntil: 'domcontentloaded', }); const html = await page.evaluate(() => document.body.getHTML()); expect(html.length).toBeGreaterThan(0); expect(browser?.isConnected()).toBe(true); await browser?.close(); expect(browser?.isConnected()).toBe(false); }, 10000); it('getPlaywrightPage', async () => { const { getPlaywrightPage } = await import('./playwright'); const playwright = await getPlaywrightPage('https://www.google.com'); const page = playwright.page; context = playwright.context; const browser = context.browser(); const startTime = Date.now(); const html = await page.evaluate(() => document.body.getHTML()); expect(html.length).toBeGreaterThan(0); expect(browser?.isConnected()).toBe(true); const sleepTime = 31 * 1000 - (Date.now() - startTime); if (sleepTime > 0) { await wait(sleepTime); } expect(browser?.isConnected()).toBe(false); context = null; }, 45000); }); describe('getPlaywrightPage (mocked)', () => { it('connects via ws endpoint and runs onBeforeLoad', async () => { vi.useFakeTimers(); try { resetMocks(); connect.mockResolvedValue(mockBrowser); connectOverCDP.mockResolvedValue(mockBrowser); launch.mockResolvedValue(mockBrowser); mockPage.goto.mockResolvedValue(undefined); mockBrowser.close.mockResolvedValue(undefined); process.env.PLAYWRIGHT_WS_ENDPOINT = 'ws://localhost:3000/?token=abc'; proxyMock.getCurrentProxy.mockReturnValue(null); const getPlaywrightPage = await loadPlaywright(); const onBeforeLoad = vi.fn(); const browserClose = mockBrowser.close; const result = await getPlaywrightPage('https://example.com', { noGoto: true, onBeforeLoad, }); const endpoint = connect.mock.calls[0][0] as string; expect(connectOverCDP).not.toHaveBeenCalled(); expect(endpoint).toContain('launch='); expect(endpoint).not.toContain('launch-options='); const launchOptions = JSON.parse(new URL(endpoint).searchParams.get('launch') || '{}'); expect(launchOptions.args).not.toContainEqual(expect.stringContaining('--user-agent=')); expect(launchOptions.executablePath).toBeUndefined(); expect(launchOptions.ignoreHTTPSErrors).toBeUndefined(); expect(launchOptions.stealth).toBeUndefined(); expect(launchOptions.headless).toBe(true); expect(onBeforeLoad).toHaveBeenCalled(); await result.destroy(); expect(browserClose).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(30000); expect(browserClose).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); it('overrides an existing ws endpoint launch param, dropping invalid keys like stealth', async () => { resetMocks(); connect.mockResolvedValue(mockBrowser); launch.mockResolvedValue(mockBrowser); mockPage.goto.mockResolvedValue(undefined); mockBrowser.close.mockResolvedValue(undefined); process.env.PLAYWRIGHT_WS_ENDPOINT = `ws://localhost:3000/?token=abc&launch=${encodeURIComponent(JSON.stringify({ stealth: true }))}`; proxyMock.getCurrentProxy.mockReturnValue(null); const getPlaywrightPage = await loadPlaywright(); const result = await getPlaywrightPage('https://example.com', { noGoto: true }); const endpoint = connect.mock.calls[0][0] as string; const launchOptions = JSON.parse(new URL(endpoint).searchParams.get('launch') || '{}'); expect(launchOptions.stealth).toBeUndefined(); expect(launchOptions.headless).toBe(true); expect(Array.isArray(launchOptions.args)).toBe(true); await result.destroy(); }); // Playwright WS uses `proxy` object (w/auth), while CDP uses `--proxy-server` (w/o auth). it.each([ { name: 'WS route sends an unauthenticated proxy as a Playwright proxy object', route: 'ws', endpointVar: 'PLAYWRIGHT_WS_ENDPOINT', endpoint: 'ws://localhost:3000/?token=abc', proxyUri: 'http://proxy.local:8080', assertLaunch: (lo: any) => { expect(lo.proxy).toEqual({ server: 'http://proxy.local:8080' }); expect(lo.args).not.toContainEqual(expect.stringContaining('--proxy-server=')); }, }, { name: 'WS route sends an authenticated http proxy with credentials', route: 'ws', endpointVar: 'PLAYWRIGHT_WS_ENDPOINT', endpoint: 'ws://localhost:3000/?token=abc', proxyUri: 'http://user:pass@proxy.local:8080', assertLaunch: (lo: any) => { expect(lo.proxy).toEqual({ server: 'http://proxy.local:8080', username: 'user', password: 'pass' }); }, }, { name: 'CDP route sends an unauthenticated proxy via the --proxy-server arg', route: 'cdp', endpointVar: 'PLAYWRIGHT_CDP_ENDPOINT', endpoint: 'ws://localhost:3000/chromium?token=abc', proxyUri: 'http://proxy.local:8080', assertLaunch: (lo: any) => { expect(lo.proxy).toBeUndefined(); expect(lo.args).toContain('--proxy-server=http://proxy.local:8080'); expect(lo.stealth).toBe(true); }, }, { name: 'CDP route skips an authenticated proxy (--proxy-server cannot carry credentials)', route: 'cdp', endpointVar: 'PLAYWRIGHT_CDP_ENDPOINT', endpoint: 'ws://localhost:3000/chromium?token=abc', proxyUri: 'http://user:pass@proxy.local:8080', assertLaunch: (lo: any) => { expect(lo.proxy).toBeUndefined(); expect(lo.args).not.toContainEqual(expect.stringContaining('--proxy-server=')); }, }, ])('$name', async ({ route, endpointVar, endpoint, proxyUri, assertLaunch }) => { resetMocks(); connect.mockResolvedValue(mockBrowser); connectOverCDP.mockResolvedValue(mockBrowser); launch.mockResolvedValue(mockBrowser); mockPage.goto.mockResolvedValue(undefined); mockBrowser.close.mockResolvedValue(undefined); process.env[endpointVar] = endpoint; proxyMock.getCurrentProxy.mockReturnValue({ uri: proxyUri, urlHandler: new URL(proxyUri) }); const getPlaywrightPage = await loadPlaywright(); const result = await getPlaywrightPage('https://example.com', { noGoto: true }); const connectMock = route === 'cdp' ? connectOverCDP : connect; const endpointUrl = connectMock.mock.calls[0][0] as string; const launchOptions = JSON.parse(new URL(endpointUrl).searchParams.get('launch') || '{}'); assertLaunch(launchOptions); await result.destroy(); }); it('connects via connectOverCDP with stealth, taking priority over the WS endpoint when both are set', async () => { resetMocks(); connect.mockResolvedValue(mockBrowser); connectOverCDP.mockResolvedValue(mockBrowser); launch.mockResolvedValue(mockBrowser); mockPage.goto.mockResolvedValue(undefined); mockBrowser.close.mockResolvedValue(undefined); process.env.PLAYWRIGHT_WS_ENDPOINT = 'ws://localhost:3000/chromium/playwright?token=abc'; process.env.PLAYWRIGHT_CDP_ENDPOINT = 'ws://localhost:3000/chromium?token=abc'; proxyMock.getCurrentProxy.mockReturnValue(null); const getPlaywrightPage = await loadPlaywright(); const result = await getPlaywrightPage('https://example.com', { noGoto: true }); expect(connectOverCDP).toHaveBeenCalled(); expect(connect).not.toHaveBeenCalled(); expect(launch).not.toHaveBeenCalled(); const endpoint = connectOverCDP.mock.calls[0][0] as string; const launchOptions = JSON.parse(new URL(endpoint).searchParams.get('launch') || '{}'); expect(launchOptions.stealth).toBe(true); expect(launchOptions.headless).toBe(true); await result.destroy(); }); it('does override the default HeadlessChrome user agent', async () => { resetMocks(); launch.mockResolvedValue(mockBrowser); mockPage.goto.mockResolvedValue(undefined); proxyMock.getCurrentProxy.mockReturnValue(null); const getPlaywrightPage = await loadPlaywright(); await getPlaywrightPage('https://example.com'); const userAgentArg = expect.stringContaining('--user-agent='); expect(launch).toHaveBeenCalledWith( expect.objectContaining({ args: expect.not.arrayContaining([userAgentArg]), }) ); expect(mockBrowser.newContext).toHaveBeenCalledWith( expect.objectContaining({ userAgent: expect.any(String), }) ); const contextOptions = mockBrowser.newContext.mock.calls[0][0]; expect(contextOptions.userAgent).not.toMatch(/HeadlessChrome/i); }); it('supports extending the browser auto close timeout', async () => { vi.useFakeTimers(); try { resetMocks(); launch.mockResolvedValue(mockBrowser); mockBrowser.close.mockResolvedValue(undefined); mockPage.goto.mockResolvedValue(undefined); proxyMock.getCurrentProxy.mockReturnValue(null); const getPlaywrightPage = await loadPlaywright(); const close = mockBrowser.close; await getPlaywrightPage('https://example.com', { closeTimeout: 90000, noGoto: true, }); await vi.advanceTimersByTimeAsync(89999); expect(close).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(close).toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it('marks proxy failed when navigation throws with multi-proxy', async () => { resetMocks(); launch.mockResolvedValue(mockBrowser); mockBrowser.close.mockResolvedValue(undefined); mockPage.goto.mockRejectedValueOnce(new Error('fail')); const currentProxy = { uri: 'http://user:pass@proxy.local:8080', urlHandler: new URL('http://user:pass@proxy.local:8080'), }; proxyMock.multiProxy = {}; proxyMock.getCurrentProxy.mockReturnValue(currentProxy); const getPlaywrightPage = await loadPlaywright(); await expect(getPlaywrightPage('https://example.com')).rejects.toThrow('fail'); expect(proxyMock.markProxyFailed).toHaveBeenCalledWith(currentProxy.uri); expect(mockBrowser.close).toHaveBeenCalledTimes(1); }); });