From 0d1d6c4d36dfba4437fa9c4ab8944c9c5553b38e Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 7 Jul 2026 21:30:06 -0700 Subject: [PATCH] feat(test-runner): add httpCache config option Records browser responses to disk and replays them on later runs. When `httpCache: { dir, match }` is set, the runner starts a single caching MITM proxy and routes all browsers through it. GET/2xx responses are stored verbatim (raw bytes + headers) and matched by an optional URL glob/regex; https is intercepted with a self-signed cert. One proxy owns the cache for the whole run, so all workers share it and concurrent identical misses coalesce into one upstream fetch. --- docs/src/test-api/class-fullconfig.md | 8 + docs/src/test-api/class-testconfig.md | 37 +++ packages/playwright/src/common/config.ts | 8 + packages/playwright/src/index.ts | 4 +- packages/playwright/src/plugins/DEPS.list | 1 + .../src/plugins/cacheProxy/cache.ts | 179 +++++++++++++++ .../src/plugins/cacheProxy/server.ts | 217 ++++++++++++++++++ .../src/plugins/cacheProxyPlugin.ts | 41 ++++ packages/playwright/src/runner/testRunner.ts | 3 + packages/playwright/types/test.d.ts | 71 ++++++ tests/playwright-test/cache-proxy.spec.ts | 200 ++++++++++++++++ 11 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 packages/playwright/src/plugins/cacheProxy/cache.ts create mode 100644 packages/playwright/src/plugins/cacheProxy/server.ts create mode 100644 packages/playwright/src/plugins/cacheProxyPlugin.ts create mode 100644 tests/playwright-test/cache-proxy.spec.ts diff --git a/docs/src/test-api/class-fullconfig.md b/docs/src/test-api/class-fullconfig.md index e3097cb16fa8b..1b86d063063d9 100644 --- a/docs/src/test-api/class-fullconfig.md +++ b/docs/src/test-api/class-fullconfig.md @@ -15,6 +15,14 @@ arguments — for example, args supplied after the `--` separator these; consumers are responsible for slicing and interpreting them with any argument-parsing library. +## property: FullConfig.httpCache +* since: v1.62 +- type: ?<[Object]|[Array]<[Object]>> + - `dir` <[string]> + - `match` ?<[string]|[RegExp]> + +See [`property: TestConfig.httpCache`]. + ## property: FullConfig.configFile * since: v1.20 - type: ?<[string]> diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 169b8c943f11e..b122bf638b651 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -68,6 +68,43 @@ The structure of the git commit metadata is subject to change. ::: +## property: TestConfig.httpCache +* since: v1.62 +- type: ?<[Object]|[Array]<[Object]>> + - `dir` <[string]> Directory to store and replay the cache. Resolved relative to the configuration file. + - `match` ?<[string]|[RegExp]> Glob pattern or regular expression matched against the request URL to decide which requests are cached. When omitted, all requests are cached. + +Records responses to disk and replays them on subsequent runs. When set, Playwright +starts a caching proxy for the run and routes all browser traffic through it: on the +first run matching responses are recorded to `dir`, and on subsequent runs those requests +are served from disk without hitting the network. All workers share the single proxy, so +a resource fetched by one worker is a cache hit for the others. + +Pass an array of caches to route different requests to different directories. Each request +is handled by the first cache whose `match` matches its URL, so list more specific caches +first; requests that match no cache are passed through without caching. + +**Usage** + +```js title="playwright.config.ts" +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + httpCache: { dir: './.network-cache', match: '**/assets/**' }, +}); +``` + +```js title="playwright.config.ts" +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + httpCache: [ + { dir: './.cache/api', match: '**/api/**' }, + { dir: './.cache/assets', match: '**/assets/**' }, + ], +}); +``` + ## property: TestConfig.expect * since: v1.10 - type: ?<[Object]> diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index fc4e378625ea5..79148bdcfccba 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -109,6 +109,7 @@ export class FullConfigInternal { version: packageJSON.version, workers: resolveWorkers(takeFirst((configCLIOverrides.debug || configCLIOverrides.pause) ? 1 : undefined, configCLIOverrides.workers, userConfig.workers, '50%')), webServer: null, + httpCache: resolveHttpCache(userConfig.httpCache, configDir), }; for (const key in userConfig) { if (key.startsWith('@')) @@ -218,6 +219,13 @@ function resolveReporters(reporters: Config['reporter'], rootDir: string): Repor }); } +function resolveHttpCache(httpCache: Config['httpCache'], configDir: string): FullConfig['httpCache'] { + if (!httpCache) + return undefined; + const resolve = (entry: T): T => ({ ...entry, dir: path.resolve(configDir, entry.dir) }); + return Array.isArray(httpCache) ? httpCache.map(resolve) : resolve(httpCache); +} + function resolveWorkers(workers: string | number): number { if (typeof workers === 'string') { if (workers.endsWith('%')) { diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 34f9d6dedaba3..f77919995b048 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -237,6 +237,8 @@ const playwrightFixtures: Fixtures' }; playwright._defaultLaunchOptions = options; await use(options); @@ -277,7 +279,7 @@ const playwrightFixtures: Fixtures use(contextOptions.geolocation), { option: true, box: true }], hasTouch: [({ contextOptions }, use) => use(contextOptions.hasTouch ?? false), { option: true, box: true }], httpCredentials: [({ contextOptions }, use) => use(contextOptions.httpCredentials), { option: true, box: true }], - ignoreHTTPSErrors: [({ contextOptions }, use) => use(contextOptions.ignoreHTTPSErrors ?? false), { option: true, box: true }], + ignoreHTTPSErrors: [({ contextOptions }, use) => use(process.env.PLAYWRIGHT_TEST_CACHE_PROXY ? true : (contextOptions.ignoreHTTPSErrors ?? false)), { option: true, box: true }], isMobile: [({ contextOptions }, use) => use(contextOptions.isMobile ?? false), { option: true, box: true }], javaScriptEnabled: [({ contextOptions }, use) => use(contextOptions.javaScriptEnabled ?? true), { option: true, box: true }], locale: [({ contextOptions }, use) => use(contextOptions.locale ?? 'en-US'), { option: true, box: true }], diff --git a/packages/playwright/src/plugins/DEPS.list b/packages/playwright/src/plugins/DEPS.list index af3c8c1c201b2..d8fe07caf0cdf 100644 --- a/packages/playwright/src/plugins/DEPS.list +++ b/packages/playwright/src/plugins/DEPS.list @@ -1,5 +1,6 @@ [*] @isomorphic/** @utils/** +cacheProxy/** node_modules/colors/safe node_modules/debug diff --git a/packages/playwright/src/plugins/cacheProxy/cache.ts b/packages/playwright/src/plugins/cacheProxy/cache.ts new file mode 100644 index 0000000000000..fb5003b596074 --- /dev/null +++ b/packages/playwright/src/plugins/cacheProxy/cache.ts @@ -0,0 +1,179 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +const CACHE_VERSION = 1; +const INLINE_THRESHOLD = 8 * 1024; + +export type CachedResponse = { + status: number; + statusText: string; + headers: [string, string][]; + body: Buffer; +}; + +type CacheRecord = { + k: string; // key: sha1(method + '\n' + url) + u: string; // url, verbatim so the index stays greppable + s: number; // status + st: string; // statusText + hh: [string, string][]; // headers, verbatim (incl. content-encoding) + ts: number; // record time, epoch seconds + c?: string; // inline body (base64), or... + f?: string; // ...content-addressed blob (sha1) +}; + +export class ResponseCache { + private _dir: string; + private _blobsDir: string; + private _indexFile: string; + private _metaFile: string; + private _index = new Map(); + private _writeChain: Promise = Promise.resolve(); + private _initialized = false; + + constructor(dir: string) { + this._dir = path.resolve(dir); + this._blobsDir = path.join(this._dir, 'blobs'); + this._indexFile = path.join(this._dir, 'index.jsonl'); + this._metaFile = path.join(this._dir, 'meta.json'); + } + + async load() { + let content: string; + try { + content = await fs.promises.readFile(this._indexFile, 'utf8'); + } catch { + return; // No cache yet. + } + // Discard an incompatible cache instead of mis-parsing it. + if (await this._onDiskVersion() !== CACHE_VERSION) { + await fs.promises.rm(this._indexFile, { force: true }).catch(() => {}); + await fs.promises.rm(this._blobsDir, { recursive: true, force: true }).catch(() => {}); + return; + } + for (const line of content.split('\n')) { + if (!line.trim()) + continue; + try { + const record = JSON.parse(line) as CacheRecord; + this._index.set(record.k, record); + } catch { + // Ignore a malformed line rather than failing the whole cache. + } + } + } + + static key(method: string, url: string): string { + return crypto.createHash('sha1').update(`${method}\n${url}`).digest('hex'); + } + + async get(key: string): Promise { + const record = this._index.get(key); + if (!record) + return undefined; + try { + const body = record.c !== undefined ? Buffer.from(record.c, 'base64') : await fs.promises.readFile(this._blobPath(record.f!)); + return { status: record.s, statusText: record.st, headers: record.hh, body }; + } catch { + return undefined; // Corrupt/missing blob - treat as a miss. + } + } + + async set(key: string, url: string, response: CachedResponse) { + if (this._index.has(key)) + return; + const record: CacheRecord = { + k: key, + u: url, + s: response.status, + st: response.statusText, + hh: response.headers, + ts: Math.floor(Date.now() / 1000), + }; + if (response.body.length >= INLINE_THRESHOLD) { + const hash = contentHash(response.body); + await this._writeBlob(hash, response.body); + record.f = hash; + } else { + record.c = response.body.toString('base64'); + } + this._index.set(key, record); + await this._append(record); + } + + async flush() { + await this._writeChain; + } + + private _blobPath(hash: string): string { + return path.join(this._blobsDir, hash.slice(0, 2), hash); + } + + private async _writeBlob(hash: string, body: Buffer) { + const dest = this._blobPath(hash); + if (await exists(dest)) + return; + await fs.promises.mkdir(path.dirname(dest), { recursive: true }); + const tmp = `${dest}.tmp-${crypto.randomBytes(8).toString('hex')}`; + await fs.promises.writeFile(tmp, body); + try { + await fs.promises.rename(tmp, dest); + } catch { + await fs.promises.rm(tmp, { force: true }).catch(() => {}); + } + } + + private _append(record: CacheRecord): Promise { + const line = JSON.stringify(record) + '\n'; + const run = async () => { + if (!this._initialized) { + await fs.promises.mkdir(this._dir, { recursive: true }); + await fs.promises.writeFile(this._metaFile, JSON.stringify({ version: CACHE_VERSION }, null, 2), { flag: 'w' }); + this._initialized = true; + } + await fs.promises.appendFile(this._indexFile, line); + }; + const result = this._writeChain.then(run, run); + this._writeChain = result.catch(() => {}); + return result; + } + + private async _onDiskVersion(): Promise { + try { + const meta = JSON.parse(await fs.promises.readFile(this._metaFile, 'utf8')); + return typeof meta.version === 'number' ? meta.version : undefined; + } catch { + return undefined; + } + } +} + +function contentHash(body: Buffer): string { + return crypto.createHash('sha1').update(body).digest('hex'); +} + +async function exists(file: string): Promise { + try { + await fs.promises.access(file); + return true; + } catch { + return false; + } +} diff --git a/packages/playwright/src/plugins/cacheProxy/server.ts b/packages/playwright/src/plugins/cacheProxy/server.ts new file mode 100644 index 0000000000000..df23d68731dea --- /dev/null +++ b/packages/playwright/src/plugins/cacheProxy/server.ts @@ -0,0 +1,217 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import http from 'http'; +import https from 'https'; +import net from 'net'; + +import { generateSelfSignedCertificate } from '@utils/crypto'; +import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '@utils/happyEyeballs'; +import { createHttpServer, createHttpsServer, startHttpServer } from '@utils/network'; +import { urlMatches } from '@isomorphic/urlMatch'; + +import { ResponseCache } from './cache'; + +import type { CachedResponse } from './cache'; +import type { URLMatch } from '@isomorphic/urlMatch'; + +const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'proxy-connection']); + +export type CacheEntry = { dir: string, match: URLMatch | undefined }; + +export class CacheProxy { + private _caches: { cache: ResponseCache, match: URLMatch | undefined }[]; + private _httpServer: http.Server; + private _httpsServer: https.Server; + private _inflight = new Map>(); + + constructor(entries: CacheEntry[]) { + this._caches = entries.map(entry => ({ cache: new ResponseCache(entry.dir), match: entry.match })); + const { cert, key } = generateSelfSignedCertificate(); + + this._httpServer = createHttpServer((req, res) => this._onRequest(req, res, false)); + this._httpServer.on('connect', (req, socket, head) => this._onConnect(req, socket as net.Socket, head)); + this._httpServer.on('upgrade', (req, socket, head) => this._onUpgrade(req, socket as net.Socket, head, false)); + this._httpsServer = createHttpsServer({ cert, key }, (req, res) => this._onRequest(req, res, true)); + this._httpsServer.on('upgrade', (req, socket, head) => this._onUpgrade(req, socket as net.Socket, head, true)); + } + + async start(): Promise { + await Promise.all(this._caches.map(entry => entry.cache.load())); + await startHttpServer(this._httpServer, { host: '127.0.0.1', port: 0 }); + const address = this._httpServer.address() as net.AddressInfo; + return `http://127.0.0.1:${address.port}`; + } + + async stop() { + await Promise.all([ + new Promise(resolve => this._httpServer.close(() => resolve())), + new Promise(resolve => this._httpsServer.close(() => resolve())), + ]); + await Promise.all(this._caches.map(entry => entry.cache.flush())); + } + + private _onConnect(req: http.IncomingMessage, socket: net.Socket, head: Buffer) { + socket.on('error', () => {}); + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head && head.length) + socket.unshift(head); + this._httpsServer.emit('connection', socket); + } + + private async _onRequest(req: http.IncomingMessage, res: http.ServerResponse, isTls: boolean) { + const url = isTls ? `https://${req.headers.host}${req.url}` : (req.url || ''); + const method = req.method || 'GET'; + + const cache = method === 'GET' ? this._cacheForUrl(url) : undefined; + if (!cache) { + this._passThrough(req, res, url, method); + return; + } + + const key = ResponseCache.key('GET', url); + const cached = await cache.get(key); + if (cached) { + writeResponse(res, cached); + return; + } + + let response: CachedResponse; + try { + response = await this._fetchCoalesced(key, url, req.headers); + } catch { + res.writeHead(502); + res.end(); + return; + } + if (response.status >= 200 && response.status <= 299) + await cache.set(key, url, response).catch(() => {}); + writeResponse(res, response); + } + + private _cacheForUrl(url: string): ResponseCache | undefined { + // First matching cache wins, so more specific caches should be listed first. + return this._caches.find(entry => urlMatches(undefined, url, entry.match))?.cache; + } + + private _fetchCoalesced(key: string, url: string, headers: http.IncomingHttpHeaders): Promise { + let promise = this._inflight.get(key); + if (!promise) { + promise = fetchUpstream(url, headers).finally(() => this._inflight.delete(key)); + this._inflight.set(key, promise); + } + return promise; + } + + private _passThrough(req: http.IncomingMessage, res: http.ServerResponse, url: string, method: string) { + const upstream = requestUpstream(url, method, forwardHeaders(req.headers)); + upstream.on('response', proxyRes => { + res.writeHead(proxyRes.statusCode || 502, filterHeadersFlat(proxyRes.rawHeaders)); + proxyRes.pipe(res); + }); + upstream.on('error', () => { + res.writeHead(502); + res.end(); + }); + req.pipe(upstream); + } + + private _onUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer, isTls: boolean) { + // WebSockets and other upgrades are tunnelled, never cached. + socket.on('error', () => {}); + const target = new URL(isTls ? `https://${req.headers.host}${req.url}` : (req.url || '')); + const upstream = net.connect({ host: target.hostname, port: Number(target.port) || (isTls ? 443 : 80) }, () => { + const lines = [`${req.method} ${target.pathname}${target.search} HTTP/1.1`]; + for (const [name, value] of Object.entries(req.headers)) + lines.push(`${name}: ${Array.isArray(value) ? value.join(', ') : value}`); + upstream.write(lines.join('\r\n') + '\r\n\r\n'); + if (head && head.length) + upstream.write(head); + socket.pipe(upstream); + upstream.pipe(socket); + }); + upstream.on('error', () => socket.destroy()); + } +} + +function forwardHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders { + const result: http.OutgoingHttpHeaders = {}; + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined && !HOP_BY_HOP.has(name.toLowerCase())) + result[name] = value; + } + return result; +} + +function fetchUpstream(url: string, headers: http.IncomingHttpHeaders): Promise { + return new Promise((resolve, reject) => { + const request = requestUpstream(url, 'GET', forwardHeaders(headers)); + request.on('response', res => { + const chunks: Buffer[] = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolve({ + status: res.statusCode || 502, + statusText: res.statusMessage || '', + headers: pairsFromRaw(res.rawHeaders).filter(([name]) => !HOP_BY_HOP.has(name.toLowerCase())), + body: Buffer.concat(chunks), + })); + res.on('error', reject); + }); + request.on('error', reject); + request.end(); + }); +} + +function requestUpstream(url: string, method: string, headers: http.OutgoingHttpHeaders): http.ClientRequest { + const parsed = new URL(url); + const isHttps = parsed.protocol === 'https:'; + const mod = isHttps ? https : http; + return mod.request(parsed, { + method, + headers, + agent: isHttps ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent, + rejectUnauthorized: false, + }); +} + +function writeResponse(res: http.ServerResponse, cached: CachedResponse) { + const raw: string[] = []; + for (const [name, value] of cached.headers) { + const lower = name.toLowerCase(); + if (HOP_BY_HOP.has(lower) || lower === 'content-length') + continue; + raw.push(name, value); + } + raw.push('Content-Length', String(cached.body.length)); + res.writeHead(cached.status, cached.statusText || undefined, raw); + res.end(cached.body); +} + +function filterHeadersFlat(rawHeaders: string[]): string[] { + const result: string[] = []; + for (const [name, value] of pairsFromRaw(rawHeaders)) { + if (!HOP_BY_HOP.has(name.toLowerCase())) + result.push(name, value); + } + return result; +} + +function pairsFromRaw(rawHeaders: string[]): [string, string][] { + const pairs: [string, string][] = []; + for (let i = 0; i + 1 < rawHeaders.length; i += 2) + pairs.push([rawHeaders[i], rawHeaders[i + 1]]); + return pairs; +} diff --git a/packages/playwright/src/plugins/cacheProxyPlugin.ts b/packages/playwright/src/plugins/cacheProxyPlugin.ts new file mode 100644 index 0000000000000..3bacaee66b05d --- /dev/null +++ b/packages/playwright/src/plugins/cacheProxyPlugin.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CacheProxy } from './cacheProxy/server'; + +import type { TestRunnerPlugin } from '.'; +import type { FullConfigInternal } from '../common'; + +export const cacheProxyPluginForConfig = (config: FullConfigInternal): TestRunnerPlugin[] => { + const httpCache = config.config.httpCache; + const entries = Array.isArray(httpCache) ? httpCache : (httpCache ? [httpCache] : []); + if (!entries.length) + return []; + + let proxy: CacheProxy | undefined; + return [{ + name: 'playwright:cache-proxy', + setup: async () => { + proxy = new CacheProxy(entries.map(entry => ({ dir: entry.dir, match: entry.match }))); + process.env.PLAYWRIGHT_TEST_CACHE_PROXY = await proxy.start(); + }, + teardown: async () => { + delete process.env.PLAYWRIGHT_TEST_CACHE_PROXY; + await proxy?.stop(); + proxy = undefined; + }, + }]; +}; diff --git a/packages/playwright/src/runner/testRunner.ts b/packages/playwright/src/runner/testRunner.ts index 9f8b9c37d5fb9..65fc0cbde0b8e 100644 --- a/packages/playwright/src/runner/testRunner.ts +++ b/packages/playwright/src/runner/testRunner.ts @@ -29,6 +29,7 @@ import { FSWatcher } from './fsWatcher'; import { baseFullConfig } from '../isomorphic/teleReceiver'; import { addGitCommitInfoPlugin } from '../plugins/gitCommitInfoPlugin'; import { webServerPluginsForConfig } from '../plugins/webServerPlugin'; +import { cacheProxyPluginForConfig } from '../plugins/cacheProxyPlugin'; import { internalScreen } from '../reporters/base'; import { InternalReporter } from '../reporters/internalReporter'; import { serializeError } from '../util'; @@ -394,6 +395,7 @@ export class TestRunner extends EventEmitter { // Preserve plugin instances between setup and build. if (!this._plugins) { webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); + cacheProxyPluginForConfig(config).forEach(p => config.plugins.push({ factory: p })); addGitCommitInfoPlugin(config); this._plugins = config.plugins || []; } else { @@ -449,6 +451,7 @@ export async function runAllTestsWithConfig(config: FullConfigInternal, options: // Legacy webServer support. webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); + cacheProxyPluginForConfig(config).forEach(p => config.plugins.push({ factory: p })); const filteredProjects = filterProjects(config.projects, options.projectFilter); const reporters = await createReporters(config, options.listMode ? 'list' : 'test', undefined, options); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 70a6d41cd09a7..ff240dab5d147 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -1414,6 +1414,64 @@ interface TestConfig { */ grepInvert?: RegExp|Array; + /** + * Records responses to disk and replays them on subsequent runs. When set, Playwright starts a caching proxy for the + * run and routes all browser traffic through it: on the first run matching responses are recorded to `dir`, and on + * subsequent runs those requests are served from disk without hitting the network. All workers share the single + * proxy, so a resource fetched by one worker is a cache hit for the others. + * + * Pass an array of caches to route different requests to different directories. Each request is handled by the first + * cache whose `match` matches its URL, so list more specific caches first; requests that match no cache are passed + * through without caching. + * + * **Usage** + * + * ```js + * // playwright.config.ts + * import { defineConfig } from '@playwright/test'; + * + * export default defineConfig({ + * httpCache: { dir: './.network-cache', match: '**\/assets/**' }, + * }); + * ``` + * + * ```js + * // playwright.config.ts + * import { defineConfig } from '@playwright/test'; + * + * export default defineConfig({ + * httpCache: [ + * { dir: './.cache/api', match: '**\/api/**' }, + * { dir: './.cache/assets', match: '**\/assets/**' }, + * ], + * }); + * ``` + * + */ + httpCache?: { + /** + * Directory to store and replay the cache. Resolved relative to the configuration file. + */ + dir: string; + + /** + * Glob pattern or regular expression matched against the request URL to decide which requests are cached. When + * omitted, all requests are cached. + */ + match?: string|RegExp; + }|Array<{ + /** + * Directory to store and replay the cache. Resolved relative to the configuration file. + */ + dir: string; + + /** + * Glob pattern or regular expression matched against the request URL to decide which requests are cached. When + * omitted, all requests are cached. + */ + match?: string|RegExp; + }>; + /** * Whether to skip snapshot expectations, such as `expect(value).toMatchSnapshot()` and `await * expect(page).toHaveScreenshot()`. @@ -2096,6 +2154,19 @@ export interface FullConfig { */ grepInvert: null|RegExp|Array; + /** + * See [testConfig.httpCache](https://playwright.dev/docs/api/class-testconfig#test-config-http-cache). + */ + httpCache?: { + dir: string; + + match?: string|RegExp; + }|Array<{ + dir: string; + + match?: string|RegExp; + }>; + /** * See [testConfig.maxFailures](https://playwright.dev/docs/api/class-testconfig#test-config-max-failures). */ diff --git a/tests/playwright-test/cache-proxy.spec.ts b/tests/playwright-test/cache-proxy.spec.ts new file mode 100644 index 0000000000000..03641d6851421 --- /dev/null +++ b/tests/playwright-test/cache-proxy.spec.ts @@ -0,0 +1,200 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './playwright-test-fixtures'; +import fs from 'fs'; +import path from 'path'; + +test('should cache http responses across runs', async ({ runInlineTest, server }, testInfo) => { + const cacheDir = testInfo.outputPath('.network-cache'); + let hits = 0; + server.setRoute('/data', (req, res) => { + ++hits; + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('cached payload'); + }); + + const files = { + 'playwright.config.ts': `export default { httpCache: { dir: ${JSON.stringify(cacheDir)} } };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('load', async ({ page }) => { + const response = await page.goto('${server.PREFIX}/data'); + expect(await response.text()).toBe('cached payload'); + }); + `, + }; + + const result1 = await runInlineTest(files, { workers: 1 }); + expect(result1.exitCode).toBe(0); + expect(hits).toBe(1); + expect(fs.existsSync(path.join(cacheDir, 'index.jsonl'))).toBe(true); + expect(JSON.parse(fs.readFileSync(path.join(cacheDir, 'meta.json'), 'utf8')).version).toBe(1); + + const hitsAfterRecord = hits; + const result2 = await runInlineTest(files, { workers: 1 }); + expect(result2.exitCode).toBe(0); + expect(hits).toBe(hitsAfterRecord); // Served entirely from cache, no new server hit. + + fs.writeFileSync(path.join(cacheDir, 'meta.json'), JSON.stringify({ version: 999 })); + const result3 = await runInlineTest(files, { workers: 1 }); + expect(result3.exitCode).toBe(0); + expect(hits).toBe(hitsAfterRecord + 1); // Went back to the server. +}); + +test('should cache https responses via MITM', async ({ runInlineTest, httpsServer }, testInfo) => { + const cacheDir = testInfo.outputPath('.network-cache'); + let hits = 0; + httpsServer.setRoute('/secure', (req, res) => { + ++hits; + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('secure payload'); + }); + + const files = { + 'playwright.config.ts': `export default { httpCache: { dir: ${JSON.stringify(cacheDir)} } };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('load', async ({ page }) => { + const response = await page.goto('${httpsServer.PREFIX}/secure'); + expect(await response.text()).toBe('secure payload'); + }); + `, + }; + + const result1 = await runInlineTest(files, { workers: 1 }); + expect(result1.exitCode).toBe(0); + expect(hits).toBe(1); // Proxy terminated TLS, fetched once, cached. + + const hitsAfterRecord = hits; + const result2 = await runInlineTest(files, { workers: 1 }); + expect(result2.exitCode).toBe(0); + expect(hits).toBe(hitsAfterRecord); // Replayed from cache over HTTPS, no new hit. +}); + +test('should coalesce concurrent identical requests into one upstream fetch', async ({ runInlineTest, server }, testInfo) => { + const cacheDir = testInfo.outputPath('.network-cache'); + let hits = 0; + server.setRoute('/slow', (req, res) => { + ++hits; + setTimeout(() => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('slow payload'); + }, 300); + }); + + const result = await runInlineTest({ + 'playwright.config.ts': `export default { httpCache: { dir: ${JSON.stringify(cacheDir)} } };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('parallel', async ({ page }) => { + await page.goto('${server.EMPTY_PAGE}'); + const bodies = await page.evaluate(() => Promise.all( + Array.from({ length: 8 }, () => fetch('${server.PREFIX}/slow').then(r => r.text())))); + expect(bodies).toEqual(Array(8).fill('slow payload')); + }); + `, + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(hits).toBe(1); // 8 concurrent misses coalesced into a single upstream fetch. +}); + +test('should only cache requests matching the filter', async ({ runInlineTest, server }, testInfo) => { + const cacheDir = testInfo.outputPath('.network-cache'); + let assets = 0; + let api = 0; + server.setRoute('/assets/logo', (req, res) => { ++assets; res.end('logo'); }); + server.setRoute('/api/data', (req, res) => { ++api; res.end('data'); }); + + const files = { + 'playwright.config.ts': `export default { httpCache: { dir: ${JSON.stringify(cacheDir)}, match: '**/assets/**' } };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('load', async ({ page }) => { + await page.goto('${server.EMPTY_PAGE}'); + await page.evaluate(async () => { + await fetch('${server.PREFIX}/assets/logo').then(r => r.text()); + await fetch('${server.PREFIX}/api/data').then(r => r.text()); + }); + }); + `, + }; + + await runInlineTest(files, { workers: 1 }); + expect(assets).toBe(1); + expect(api).toBe(1); + + await runInlineTest(files, { workers: 1 }); + expect(assets).toBe(1); // Matched the filter -> served from cache. + expect(api).toBe(2); // Not matched -> always hits the network. +}); + +test('should not start the proxy without httpCache', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': `export default {};`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('no proxy', async ({ page }) => { + expect(process.env.PLAYWRIGHT_TEST_CACHE_PROXY).toBeUndefined(); + }); + `, + }, { workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should route requests to multiple caches by match', async ({ runInlineTest, server }, testInfo) => { + const apiDir = testInfo.outputPath('.cache-api'); + const assetsDir = testInfo.outputPath('.cache-assets'); + let assets = 0; + let api = 0; + let other = 0; + server.setRoute('/assets/logo', (req, res) => { ++assets; res.end('logo'); }); + server.setRoute('/api/data', (req, res) => { ++api; res.end('data'); }); + server.setRoute('/other', (req, res) => { ++other; res.end('other'); }); + + const files = { + 'playwright.config.ts': `export default { httpCache: [ + { dir: ${JSON.stringify(apiDir)}, match: '**/api/**' }, + { dir: ${JSON.stringify(assetsDir)}, match: '**/assets/**' }, + ] };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('load', async ({ page }) => { + await page.goto('${server.EMPTY_PAGE}'); + await page.evaluate(async () => { + await fetch('${server.PREFIX}/assets/logo').then(r => r.text()); + await fetch('${server.PREFIX}/api/data').then(r => r.text()); + await fetch('${server.PREFIX}/other').then(r => r.text()); + }); + }); + `, + }; + + await runInlineTest(files, { workers: 1 }); + expect(assets).toBe(1); + expect(api).toBe(1); + expect(other).toBe(1); + // Each matching request landed in its own cache directory. + expect(fs.existsSync(path.join(apiDir, 'index.jsonl'))).toBe(true); + expect(fs.existsSync(path.join(assetsDir, 'index.jsonl'))).toBe(true); + + await runInlineTest(files, { workers: 1 }); + expect(assets).toBe(1); // Served from the assets cache. + expect(api).toBe(1); // Served from the api cache. + expect(other).toBe(2); // Matched no cache -> hits the network again. +});