Files
apify--crawlee/test/core/crawlers/playwright_crawler.test.ts
2026-07-13 13:23:39 +08:00

302 lines
11 KiB
TypeScript

import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import os from 'node:os';
import type {
Cheerio,
CheerioAPI,
CheerioRoot,
Element,
PlaywrightCrawlingContext,
PlaywrightGotoOptions,
PlaywrightRequestHandler,
Request,
} from '@crawlee/playwright';
import { PlaywrightCrawler, RequestList } from '@crawlee/playwright';
import express from 'express';
import playwright from 'playwright';
import log from '@apify/log';
import { startExpressAppPromise } from '../../shared/_helper';
import { MemoryStorageEmulator } from '../../shared/MemoryStorageEmulator';
if (os.platform() === 'win32') vitest.setConfig({ testTimeout: 2 * 60 * 1e3 });
describe('PlaywrightCrawler', () => {
let prevEnvHeadless: string | undefined;
let logLevel: number;
const localStorageEmulator = new MemoryStorageEmulator();
let requestList: RequestList;
const HOSTNAME = '127.0.0.1';
let port: number;
let server: Server;
beforeAll(async () => {
const app = express();
server = await startExpressAppPromise(app, 0);
port = (server.address() as AddressInfo).port;
app.get('/', (req, res) => {
res.send(`<html><head><title>Example Domain</title></head></html>`);
res.status(200);
});
app.get('/page-with-download', (_req, res) => {
res.status(200).send(
`<html><body><a id="download-link" href="/download-file" download="hello.txt">download</a></body></html>`,
);
});
app.get('/download-file', (_req, res) => {
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Disposition', 'attachment; filename="hello.txt"');
res.send('hello');
});
});
beforeAll(async () => {
prevEnvHeadless = process.env.CRAWLEE_HEADLESS;
process.env.CRAWLEE_HEADLESS = '1';
logLevel = log.getLevel();
log.setLevel(log.LEVELS.ERROR);
});
beforeEach(async () => {
await localStorageEmulator.init();
const sources = [`http://${HOSTNAME}:${[port]}/`];
requestList = await RequestList.open(`sources-${Math.random() * 10000}`, sources);
});
afterAll(async () => {
await localStorageEmulator.destroy();
});
afterAll(async () => {
log.setLevel(logLevel);
process.env.CRAWLEE_HEADLESS = prevEnvHeadless;
});
afterAll(async () => {
server.close();
});
vitest.setConfig({ testTimeout: 2 * 60 * 1e3 });
describe('should work', () => {
// @TODO: add webkit
test.each(['chromium', 'firefox'] as const)('with %s', async (browser) => {
const sourcesLarge = [
{ url: `http://${HOSTNAME}:${port}/?q=1` },
{ url: `http://${HOSTNAME}:${port}/?q=2` },
{ url: `http://${HOSTNAME}:${port}/?q=3` },
{ url: `http://${HOSTNAME}:${port}/?q=4` },
{ url: `http://${HOSTNAME}:${port}/?q=5` },
{ url: `http://${HOSTNAME}:${port}/?q=6` },
];
const sourcesCopy = JSON.parse(JSON.stringify(sourcesLarge));
const processed: Request[] = [];
const failed: Request[] = [];
const requestListLarge = await RequestList.open({ sources: sourcesLarge });
const requestHandler = async ({
page,
request,
response,
useState,
}: Parameters<PlaywrightRequestHandler>[0]) => {
const state = await useState([]);
expect(response!.status()).toBe(200);
request.userData.title = await page.title();
processed.push(request);
expect(response!.request().headers()['user-agent']).not.toMatch(/headless/i);
// firefox now also returns `webdriver: true` since playwright 1.45, we are masking this via fingerprints,
// but this test has them disabled, so we can check the default handling (= there is non-default UA even without them)
if (browser !== 'firefox') {
await expect(page.evaluate(() => window.navigator.webdriver)).resolves.toBeFalsy();
}
};
const playwrightCrawler = new PlaywrightCrawler({
launchContext: {
launcher: playwright[browser],
},
browserPoolOptions: { useFingerprints: false },
requestList: requestListLarge,
minConcurrency: 1,
maxConcurrency: 1,
requestHandler,
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
await playwrightCrawler.run();
expect(playwrightCrawler.autoscaledPool!.minConcurrency).toBe(1);
expect(processed).toHaveLength(6);
expect(failed).toHaveLength(0);
processed.forEach((request, id) => {
expect(request.url).toEqual(sourcesCopy[id].url);
expect(request.userData.title).toBe('Example Domain');
});
});
});
// https://github.com/apify/crawlee/issues/3670
test('should not silently drop requests when BrowserPool.newPage() times out', async () => {
const success: string[] = [];
const failure: string[] = [];
const crawler = new PlaywrightCrawler({
maxRequestRetries: 0,
browserPoolOptions: {
operationTimeoutSecs: 0.001,
},
requestHandler: async ({ request }) => {
success.push(request.url);
},
failedRequestHandler: async ({ request }) => {
failure.push(request.url);
},
});
const urls = [
`http://${HOSTNAME}:${port}/?q=1`,
`http://${HOSTNAME}:${port}/?q=2`,
`http://${HOSTNAME}:${port}/?q=3`,
`http://${HOSTNAME}:${port}/?q=4`,
`http://${HOSTNAME}:${port}/?q=5`,
];
const stats = await crawler.run(urls);
// Every request must be accounted for by either requestHandler or failedRequestHandler.
expect(success.length + failure.length).toBe(urls.length);
// With operationTimeoutSecs=0.001, no request can actually succeed, so every one must fail.
expect(stats.requestsFinished).toBe(0);
expect(stats.requestsFailed).toBe(urls.length);
});
test('should override goto timeout with navigationTimeoutSecs', async () => {
const timeoutSecs = 10;
let options: PlaywrightGotoOptions;
const playwrightCrawler = new PlaywrightCrawler({
requestList,
maxRequestRetries: 0,
maxConcurrency: 1,
requestHandler: () => {},
preNavigationHooks: [
(_context, gotoOptions) => {
options = gotoOptions;
},
],
navigationTimeoutSecs: timeoutSecs,
});
await playwrightCrawler.run();
expect(options!.timeout).toEqual(timeoutSecs * 1000);
});
test('shallow clones browserPoolOptions before normalization', () => {
const options = {
browserPoolOptions: {},
requestHandler: async () => {},
};
void new PlaywrightCrawler(options);
void new PlaywrightCrawler(options);
expect(Object.keys(options.browserPoolOptions).length).toBe(0);
});
test.each([
{ useIncognitoPages: true },
{ useIncognitoPages: false },
])('should apply launchOptions with useIncognitoPages: $useIncognitoPages', async ({ useIncognitoPages }) => {
// Some launch options apply to the browser, while some apply to the context.
// Here we use some context options to verify that those are actually applied.
const launchOptions = {
locale: 'cz-CZ',
reducedMotion: 'reduce' as const,
timezoneId: 'Pacific/Tahiti',
};
let [timezone, locale, reducedMotion] = ['', '', ''];
const playwrightCrawler = new PlaywrightCrawler({
maxConcurrency: 1,
launchContext: {
useIncognitoPages,
launchOptions,
},
browserPoolOptions: {
// don't overwrite locale with fingerprint's locale
useFingerprints: false,
},
requestHandler: async ({ page }) => {
[timezone, locale, reducedMotion] = await Promise.all([
page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone),
page.evaluate(() => navigator.language),
page.evaluate(() => {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'reduce'
: 'no-preference';
}),
]);
},
});
await playwrightCrawler.run([`http://${HOSTNAME}:${port}/`]);
expect(timezone).toBe(launchOptions.timezoneId);
expect(locale).toBe(launchOptions.locale);
expect(reducedMotion).toBe(launchOptions.reducedMotion);
});
test('exposes triggered downloads via listDownloads()', async () => {
let countBefore = -1;
let countAfter = -1;
let suggestedFilename: string | undefined;
const playwrightCrawler = new PlaywrightCrawler({
maxRequestRetries: 0,
maxConcurrency: 1,
requestHandler: async ({ page, listDownloads }) => {
countBefore = (await listDownloads()).length;
const downloadPromise = page.waitForEvent('download');
await page.click('a#download-link');
await downloadPromise;
const downloads = await listDownloads();
countAfter = downloads.length;
suggestedFilename = downloads[0]?.suggestedFilename();
},
});
await playwrightCrawler.run([`http://${HOSTNAME}:${port}/page-with-download`]);
expect(countBefore).toBe(0);
expect(countAfter).toBe(1);
expect(suggestedFilename).toBe('hello.txt');
});
test('should have correct types in crawling context', async () => {
const requestHandler = async (crawlingContext: PlaywrightCrawlingContext) => {
// Checking that types are correct
const $ = await crawlingContext.parseWithCheerio();
const _cheerioRootType: CheerioRoot = $;
const _apiType: CheerioAPI = $;
const _cheerioElementType: Cheerio<Element> = $('div');
};
const playwrightCrawler = new PlaywrightCrawler({
requestList,
maxRequestRetries: 0,
maxConcurrency: 1,
requestHandler,
});
await playwrightCrawler.run();
});
});