Files
RSSHub/lib/utils/playwright.test.ts
T
dependabot[bot]andTony 873618e99d chore(deps-dev): bump eslint-plugin-unicorn from 65.0.1 to 66.0.0 (#22274)
* chore(deps-dev): bump eslint-plugin-unicorn from 65.0.1 to 66.0.0

Bumps [eslint-plugin-unicorn](https://github.com/sindresorhus/eslint-plugin-unicorn) from 65.0.1 to 66.0.0.
- [Release notes](https://github.com/sindresorhus/eslint-plugin-unicorn/releases)
- [Commits](https://github.com/sindresorhus/eslint-plugin-unicorn/compare/v65.0.1...v66.0.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-unicorn
  dependency-version: 66.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* style: fix unicorn-js/prefer-url-href

* style: oxlint disable

* style: fix unicorn-js/no-useless-else

* style: no-unsafe-string-replacement

* style: fix no-duplicate-loops

* style: fix prefer-number-is-safe-integer

* style: fix prefer-single-array-predicate

* style: fix no-object-methods-with-collections

* style: fix no-useless-boolean-cast

* style: fix no-useless-template-literals

* style: fix no-negated-array-predicate

* style: fix prefer-direct-iteration

* style: fix consistent-optional-chaining

* style: fix prefer-simple-sort-comparator

* style: fix prefer-global-number-constants

* style: fix prefer-type-literal-last

* style: fix require-array-sort-compare.

* style: fix prefer-early-return

* style: fix prefer-short-arrow-method

* style: fix prefer-array-from-map

* style: fix prefer-scoped-selector

* style: fix no-global-object-property-assignment

* style: fix no-unnecessary-global-this

* style: fix no-unreadable-object-destructuring

* style: fix no-invalid-argument-count

* style: fix prefer-smaller-scope

* style: fix prefer-iterator-to-array.

* style: fix prefer-object-iterable-methods

* style: fix typo

* style: fix max-nested-calls

* style: fix no-incorrect-template-string-interpolation

* style: fix no-break-in-nested-loop

* style: fix prefer-await

* style: fix no-computed-property-existence-check

* style: fix prefer-number-coercion

* style: fix no-empty-file

* style: update eslint unicorn v66 recommended preset

* style: add unicorn/prefer-spread to oxlint-disable comments

* style: fix no-array-reduce

* style: delete github/no-then as it's covered by unicorn/prefer-await

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tony <TonyRL@users.noreply.github.com>
2026-06-16 14:43:42 +08:00

360 lines
13 KiB
TypeScript

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.innerHTML);
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.innerHTML);
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 () => {
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 contextClose = mockContext.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(contextClose).toHaveBeenCalled();
});
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);
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);
});
});