Files
Benjamin WoodruffandGitHub 390eff3b86 [ci] Share a single browser instance across all test suites in a single job (#95589)
The idea here is that we waste a bunch of CPU setting up each test suite
by spawning an entirely new Chrome process, but playwright can share the
same browser across many tests. Tests are still isolated at the browser
level, just not at the OS process level.

Hoping to see some performance improvement in e2e tests. Compare to:
https://github.com/vercel/next.js/pull/95617

Somewhat unscientific (single sample) results:

```
Comparing the head-commit build-and-test runs — PR #95589 run [28981702219 ](https://github.com/vercel/next.js/actions/runs/28981702219)vs PR #95617 run [28981686692](https://github.com/vercel/next.js/actions/runs/28981686692). Both ran 101 jobs and both succeeded.

  ┌─────────────────────────┬────────────────┬───────────────────────┬───────────────────┐
  │         Metric          │ #95617 control │ #95589 shared browser │       Delta       │
  ├─────────────────────────┼────────────────┼───────────────────────┼───────────────────┤
  │ Raw wall-time sum       │ 616.2 min      │ 571.2 min             │ −45.0 min (−7.3%) │
  ├─────────────────────────┼────────────────┼───────────────────────┼───────────────────┤
  │ Billable (ceil per job) │ 658 min        │ 617 min               │ −41 min (−6.2%)   │
  └─────────────────────────┴────────────────┴───────────────────────┴───────────────────┘

  The shared-browser experiment is cheaper, and the savings land almost exactly where you'd expect — the browser-driven test suites — while non-browser jobs (rust, lint, unit, windows) are flat within noise:

  ┌─────────────────────────────────┬─────────┬────────┬───────┐
  │          Job category           │ control │ shared │ delta │
  ├─────────────────────────────────┼─────────┼────────┼───────┤
  │ test prod                       │   130.7 │  110.5 │ −20.2 │
  ├─────────────────────────────────┼─────────┼────────┼───────┤
  │ cache components dev            │    51.5 │   42.9 │  −8.7 │
  ├─────────────────────────────────┼─────────┼────────┼───────┤
  │ turbopack dev                   │    75.1 │   69.5 │  −5.5 │
  ├─────────────────────────────────┼─────────┼────────┼───────┤
  │ turbopack production            │    86.3 │   81.1 │  −5.2 │
  ├─────────────────────────────────┼─────────┼────────┼───────┤
  │ test dev                        │   110.4 │  105.5 │  −4.9 │
  ├─────────────────────────────────┼─────────┼────────┼───────┤
  │ cache components prod           │    48.7 │   49.0 │  +0.3 │
  ├─────────────────────────────────┼─────────┼────────┼───────┤
  │ firefox and safari              │     5.3 │    7.3 │  +2.0 │
  ├─────────────────────────────────┼─────────┼────────┼───────┤
  │ flake-detection jobs (combined) │     ~15 │    ~21 │   +~6 │
  └─────────────────────────────────┴─────────┴────────┴───────┘

  Takeaways:
  - Net savings of ~41 billable minutes per run (~6%), concentrated in the Chromium-driven test prod/dev, turbopack, and cache components suites — consistent with reusing one browser process instead of spawning per-suite.
  - The small regressions are in firefox and safari and the "new/changed tests for flakes" jobs (+~8 min combined). Worth a glance, though they may just be run-to-run variance.

  Caveat: this is a single run per PR, so there's real variance run-to-run. That said, the fact that the deltas track the browser-heavy jobs specifically — and not the Rust/lint/unit jobs — is a good signal the effect is genuine rather than noise. If you want a firmer number, re-running each PR 2–3× and averaging would tighten it up.
```
2026-07-21 01:36:10 +00:00

743 lines
23 KiB
TypeScript

import fs from 'fs-extra'
import { debugPrint } from 'next-test-utils'
import {
Browser,
BrowserContext,
Page,
ElementHandle,
devices,
Locator,
Request as PlaywrightRequest,
Response as PlaywrightResponse,
BrowserContextOptions,
} from 'playwright'
import path from 'path'
import { getBrowserLaunch } from './launch'
type EventType = 'request' | 'response'
type PageLog = { source: string; message: string; args: unknown[] }
export type Permissions = BrowserContextOptions['permissions']
let page: Page
let browser: Browser | undefined
let context: BrowserContext | undefined
let contextHasJSEnabled: boolean = true
let contextPermissions: Permissions = undefined
let pageLogs: Array<Promise<PageLog> | PageLog> = []
let websocketFrames: Array<{ payload: string | Buffer }> = []
const tracePlaywright = process.env.TRACE_PLAYWRIGHT
const defaultTimeout = process.env.NEXT_E2E_TEST_TIMEOUT
? parseInt(process.env.NEXT_E2E_TEST_TIMEOUT, 10)
: // In development mode, compilation can take longer due to lower CPU
// availability in GitHub Actions.
60 * 1000
// loose global to register teardown functions before quitting the browser instance.
// This is due to `quit` can be called anytime outside of Playwright's lifecycle,
// which can create corrupted state by terminating the context.
// [TODO] global `quit` might need to be removed, instead should introduce per-instance teardown
const pendingTeardown: Array<() => Promise<void>> = []
export async function quit() {
await Promise.all(pendingTeardown.map((fn) => fn()))
await context?.close()
await browser?.close()
context = undefined
browser = undefined
}
async function teardown(tearDownFn: () => Promise<void>) {
pendingTeardown.push(tearDownFn)
await tearDownFn()
pendingTeardown.splice(pendingTeardown.indexOf(tearDownFn), 1)
}
interface ElementHandleExt extends ElementHandle {
getComputedCss(prop: string): Promise<string>
text(): Promise<string>
}
export type ElementByCssOpts = {
timeout?: number
/**
* The state of the DOM element.
* @default 'visible'
*/
state?: 'attached' | 'visible' | 'hidden'
/**
* The state of the page.
* @default 'load'
*/
waitUntil?: false | 'load' | 'domcontentloaded' | 'networkidle'
}
export type PlaywrightNavigationWaitUntil =
| 'load'
| 'domcontentloaded'
| 'networkidle'
| 'commit'
export class Playwright<TCurrent = undefined> {
private activeTrace?: string
private eventCallbacks: Record<EventType, Set<(...args: any[]) => void>> = {
request: new Set(),
response: new Set(),
}
private async initContextTracing(url: string, context: BrowserContext) {
if (!tracePlaywright) {
return
}
try {
// Clean up if any previous traces are still active
await teardown(this.teardownTracing.bind(this))
await context.tracing.start({
screenshots: true,
snapshots: true,
sources: true,
})
this.activeTrace = encodeURIComponent(url)
} catch (e) {
this.activeTrace = undefined
}
}
private async teardownTracing() {
if (!this.activeTrace) {
return
}
try {
const traceDir = path.join(__dirname, '../../traces')
const traceOutputPath = path.join(
traceDir,
`${path
.relative(path.join(__dirname, '../../'), process.env.TEST_FILE_PATH!)
.replace(/\//g, '-')}`,
`playwright-${this.activeTrace}-${Date.now()}.zip`
)
await fs.remove(traceOutputPath)
await context!.tracing.stop({
path: traceOutputPath,
})
} catch (e) {
require('console').warn('Failed to teardown playwright tracing', e)
} finally {
this.activeTrace = undefined
}
}
on(
event: 'request',
cb: (request: PlaywrightRequest) => void | Promise<void>
): void
on(
event: 'response',
cb: (request: PlaywrightResponse) => void | Promise<void>
): void
on(event: EventType, cb: (...args: any[]) => void) {
if (!this.eventCallbacks[event]) {
throw new Error(
`Invalid event passed to browser.on, received ${event}. Valid events are ${Object.keys(
this.eventCallbacks
)}`
)
}
this.eventCallbacks[event]?.add(cb)
}
off(
event: 'request',
cb: (request: PlaywrightRequest) => void | Promise<void>
): void
off(
event: 'response',
cb: (request: PlaywrightResponse) => void | Promise<void>
): void
off(event: EventType, cb: (...args: any[]) => void) {
this.eventCallbacks[event]?.delete(cb)
}
async setup(
browserName: string,
locale: string,
javaScriptEnabled: boolean,
ignoreHTTPSErrors: boolean,
userAgent: string | undefined,
permissions: Permissions
) {
let device
if (process.env.DEVICE_NAME) {
device = devices[process.env.DEVICE_NAME]
if (!device) {
throw new Error(
`Invalid playwright device name ${process.env.DEVICE_NAME}`
)
}
}
if (browser) {
if (
contextHasJSEnabled !== javaScriptEnabled ||
// Even triggers on same set of permissions, but we don't want to deal
// with the complexity of diffing them, so we just always recreate the
// context when permissions are set.
contextPermissions !== permissions
) {
// If we have switched from having JS enable/disabled we need to recreate the context.
await teardown(this.teardownTracing.bind(this))
await context?.close()
context = await browser.newContext({
locale,
javaScriptEnabled,
ignoreHTTPSErrors,
...(userAgent ? { userAgent } : {}),
...device,
permissions,
})
contextHasJSEnabled = javaScriptEnabled
contextPermissions = permissions
}
return
}
browser = await this.launchBrowser(browserName)
context = await browser.newContext({
locale,
javaScriptEnabled,
ignoreHTTPSErrors,
...(userAgent ? { userAgent } : {}),
...device,
permissions,
})
contextHasJSEnabled = javaScriptEnabled
}
async close(): Promise<void> {
await teardown(this.teardownTracing.bind(this))
await page?.close()
}
async launchBrowser(browserName: string) {
// Headless mode is controlled exclusively by the HEADLESS env var. It
// can't be a per-test option because the browser is shared: either
// launched once per process, or via the shared browser server below.
const headless = !!process.env.HEADLESS
const { browserType, launchOptions } = getBrowserLaunch(browserName, {
headless,
})
// `run-tests.js` launches a browser server that's shared across all test
// suites. Connect to it if it's available, otherwise (e.g. when running a
// test directly via the jest CLI) launch a browser owned by this process.
// The shared server is always headless (`run-tests.js` sets HEADLESS=true
// for all test suites), so only connect when we're headless too.
const wsEndpoint = process.env.NEXT_TEST_BROWSER_WS_ENDPOINT
if (wsEndpoint && headless) {
return await browserType.connect(wsEndpoint)
}
return await browserType.launch(launchOptions)
}
async get(url: string): Promise<void> {
await page.goto(url)
}
async loadPage(
url: string,
opts?: {
disableCache?: boolean
cpuThrottleRate?: number
pushErrorAsConsoleLog?: boolean
/**
* Suppress the harness from echoing the browser's console output to the
* test's terminal (the `Browser Log:` lines). Browser logs are still
* collected and available via `browser.log()`. Useful when a test
* captures and asserts on terminal output, where the echo would be noise.
*/
disableBrowserLog?: boolean
beforePageLoad?: (page: Page) => void | Promise<void>
/**
* @see {@link https://playwright.dev/docs/api/class-page#page-set-extra-http-headers Playwright.Page.setExtraHTTPHeaders}
*/
extraHTTPHeaders?: Record<string, string>
waitUntil?: PlaywrightNavigationWaitUntil
}
) {
await this.close()
// clean-up existing pages
for (const oldPage of context!.pages()) {
await oldPage.close()
}
await this.initContextTracing(url, context!)
page = await context!.newPage()
page.setDefaultTimeout(defaultTimeout)
page.setDefaultNavigationTimeout(defaultTimeout)
const extraHTTPHeaders = opts?.extraHTTPHeaders
if (extraHTTPHeaders !== undefined) {
page.setExtraHTTPHeaders(extraHTTPHeaders)
}
pageLogs = []
websocketFrames = []
page.on('console', (msg) => {
if (!opts?.disableBrowserLog) {
debugPrint('Browser Log:', msg)
}
pageLogs.push(
Promise.all(
msg.args().map((handle) => handle.jsonValue().catch(() => {}))
).then((args) => ({ source: msg.type(), message: msg.text(), args }))
)
})
page.on('crash', () => {
console.error('page crashed')
})
page.on('pageerror', (error) => {
console.error('page error', error)
if (opts?.pushErrorAsConsoleLog) {
pageLogs.push({ source: 'error', message: error.message, args: [] })
}
})
page.on('request', (req) => {
this.eventCallbacks.request.forEach((cb) => cb(req))
})
page.on('response', (res) => {
this.eventCallbacks.response.forEach((cb) => cb(res))
})
if (opts?.disableCache) {
// TODO: this doesn't seem to work (dev tools does not check the box as expected)
const session = await context!.newCDPSession(page)
session.send('Network.setCacheDisabled', { cacheDisabled: true })
}
if (opts?.cpuThrottleRate) {
const session = await context!.newCDPSession(page)
// https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setCPUThrottlingRate
session.send('Emulation.setCPUThrottlingRate', {
rate: opts.cpuThrottleRate,
})
}
page.on('websocket', (ws) => {
const decoder = tracePlaywright ? new TextDecoder() : null
if (tracePlaywright) {
// We're just evaluating a string here so that it appears in Playwright
// traces.
// console.log spams CI logs. If you already have a browser open, you can
// see WebSocket messages in the network tab of dev tools.
// TODO: Revisit once https://github.com/microsoft/playwright/issues/10996 is resolved.
page.evaluate(`'connected to ws at ${ws.url()}'`).catch(() => {})
ws.on('close', () =>
page.evaluate(`'closed websocket ${ws.url()}'`).catch(() => {})
)
}
ws.on('framereceived', (frame) => {
websocketFrames.push({ payload: frame.payload })
if (tracePlaywright) {
const { payload } = frame
page
// Note that passing the payload as a an argument is 2 orders of magnitude more expensive in Playwright.
.evaluate(
`'received ws message ${JSON.stringify(typeof payload === 'string' ? payload : decoder!.decode(payload))}'`
)
.catch(() => {})
}
})
})
await opts?.beforePageLoad?.(page)
await page.goto(url, { waitUntil: opts?.waitUntil ?? 'load' })
}
back(options?: Parameters<Page['goBack']>[0]) {
// do not preserve the previous chained value, it might be invalid after a navigation.
return this.startChain(async () => {
await page.goBack(options)
})
}
forward(options?: Parameters<Page['goForward']>[0]) {
// do not preserve the previous chained value, it might be invalid after a navigation.
return this.startChain(async () => {
await page.goForward(options)
})
}
refresh(opts?: { waitUntil?: PlaywrightNavigationWaitUntil }) {
// do not preserve the previous chained value, it's likely to be invalid after a reload.
return this.startChain(async () => {
await page.reload({ waitUntil: opts?.waitUntil ?? 'load' })
})
}
/**
* Evict the browser HTTP cache via CDP (`Network.clearBrowserCache`). This is
* only supported in Chromium; gate the calling test on `global.browserName
* === 'chrome'` (Playwright's Firefox and WebKit don't expose CDP).
*/
clearBrowserCache() {
return this.startChain(async () => {
const session = await context!.newCDPSession(page)
try {
await session.send('Network.clearBrowserCache')
} finally {
await session.detach()
}
})
}
setDimensions({ width, height }: { height: number; width: number }) {
return this.startOrPreserveChain(() =>
page.setViewportSize({ width, height })
)
}
addCookie(opts: { name: string; value: string }) {
return this.startOrPreserveChain(async () =>
context!.addCookies([
{
path: '/',
domain: await page.evaluate('window.location.hostname'),
...opts,
},
])
)
}
deleteCookies() {
return this.startOrPreserveChain(async () => context!.clearCookies())
}
private wrapElement(el: ElementHandle, selector: string): ElementHandleExt {
function getComputedCss(prop: string) {
return page.evaluate(
function (args) {
const style = getComputedStyle(document.querySelector(args.selector)!)
return style[args.prop] || null
},
{ selector, prop }
)
}
return Object.assign(el, {
selector,
getComputedCss,
text: () => el.innerText(),
})
}
elementByCss(selector: string, opts?: ElementByCssOpts) {
return this.waitForElementByCss(selector, {
timeout: 5_000,
...opts,
})
}
/** A replacement for the default `browser.elementByCss` that doesn't wait for the page to fire "load". */
elementByCssInstant(selector: string, opts?: ElementByCssOpts) {
return this.waitForElementByCss(selector, {
timeout: 10,
waitUntil: false,
...opts,
})
}
hasElementByCss(selector: string) {
return this.startChain(() => page.locator(selector).isVisible())
}
elementById(id: string) {
return this.elementByCss(`#${id}`)
}
getValue(this: Playwright<ElementHandleExt>) {
return this.continueChain((el) => el.inputValue())
}
text(this: Playwright<ElementHandleExt>) {
return this.continueChain((el) => el.innerText())
}
type(this: Playwright<ElementHandleExt>, text: string) {
return this.continueChain(async (el) => {
await el.type(text)
return el
})
}
moveTo(this: Playwright<ElementHandleExt>) {
return this.continueChain(async (el) => {
await el.hover()
return el
})
}
async getComputedCss(this: Playwright<ElementHandleExt>, prop: string) {
return this.continueChain((el) => el.getComputedCss(prop))
}
async getAttribute(this: Playwright<ElementHandleExt>, attr: string) {
return this.continueChain((el) => el.getAttribute(attr))
}
hasElementByCssSelector(selector: string) {
return this.eval<boolean>(`!!document.querySelector('${selector}')`)
}
keydown(key: string) {
return this.startOrPreserveChain(() => page.keyboard.down(key))
}
keyup(key: string) {
return this.startOrPreserveChain(() => page.keyboard.up(key))
}
click(this: Playwright<ElementHandleExt>) {
return this.continueChain(async (el) => {
await el.click()
return el
})
}
touchStart(this: Playwright<ElementHandleExt>) {
return this.continueChain(async (el) => {
await el.dispatchEvent('touchstart')
return el
})
}
elementsByCss(selector: string) {
return this.startChain(() =>
page.$$(selector).then((els) => {
return els.map((el) => {
const origGetAttribute = el.getAttribute.bind(el)
el.getAttribute = (name) => {
// ensure getAttribute defaults to empty string to
// match selenium
return origGetAttribute(name).then((val) => val || '')
}
return el
})
})
)
}
waitForElementByCss(selector: string, opts: number | ElementByCssOpts = {}) {
const {
timeout = 10_000,
waitUntil = 'load', // TODO: we should get rid of this and fix the tests that implicitly rely on it
// Selected elements may be in a completed boundary that React hasn't revealed yet.
// We almost always want to wait for the reveal.
// This matches Playwright's default behavior.
// We don't care about visibility of metadata tags.
// Can hopefully be dropped if https://github.com/microsoft/playwright/pull/37265 is accepted
state = selector.startsWith('base') ||
selector.startsWith('link') ||
selector.startsWith('meta') ||
selector.startsWith('script') ||
selector.startsWith('source') ||
selector.startsWith('style') ||
selector.startsWith('title')
? 'attached'
: 'visible',
} = typeof opts === 'number' ? { timeout: opts } : opts
return this.startChain(async () => {
const el = await page.waitForSelector(selector, {
timeout,
state,
})
if (waitUntil !== false) {
// it seems selenium waits longer and tests rely on this behavior
// so we wait for the load event fire before returning
await page.waitForLoadState(waitUntil)
}
return this.wrapElement(
// Playwright has `null` as a possible return type in case `state` is `detached`,
// but we don't allow passing that here, so we can assume it's non-null
el!,
selector
)
})
}
waitForCondition(snippet: string, timeout?: number) {
return this.startOrPreserveChain(async () => {
await page.waitForFunction(snippet, { timeout })
})
}
// TODO: this should default to unknown, but a lot of tests use and rely on the result being `any`
eval<TFn extends (...args: any[]) => any>(
fn: TFn,
...args: Parameters<TFn>
): Playwright<ReturnType<TFn>> & Promise<ReturnType<TFn>>
// TODO: this is ugly, the type parameter is basically a hidden cast
eval<T = any>(fn: string, ...args: any[]): Playwright<T> & Promise<T>
eval<T = any>(
fn: string | ((...args: any[]) => any),
...args: any[]
): Playwright<T> & Promise<T>
eval(
fn: string | ((...args: any[]) => any),
...args: any[]
): Playwright<any> & Promise<any> {
return this.startChain(async () =>
page
.evaluate(fn, ...args)
.catch((err) => {
// TODO: gross, why are we doing this
console.error('eval error:', err)
return null!
})
.finally(async () => {
await page.waitForLoadState()
})
)
}
async log<T extends boolean = false>(options?: { includeArgs?: T }) {
return this.startChain(
() =>
options?.includeArgs
? Promise.all(pageLogs)
: Promise.all(pageLogs).then((logs) =>
logs.map(({ source, message }) => ({ source, message }))
)
// TODO: Starting with TypeScript 5.8 we might not need this type cast.
) as Promise<
T extends true
? { source: string; message: string; args: unknown[] }[]
: { source: string; message: string }[]
>
}
async websocketFrames() {
return this.startChain(() => websocketFrames)
}
async url() {
return this.startChain(() => page.url())
}
async waitForIdleNetwork() {
return this.startOrPreserveChain(() => {
return page.waitForLoadState('networkidle')
})
}
getByRole(
role: Parameters<(typeof page)['getByRole']>[0],
options?: Parameters<(typeof page)['getByRole']>[1]
) {
return page.getByRole(role, options)
}
locateRedbox(): Locator {
return page.locator(
'nextjs-portal [aria-labelledby="nextjs__container_errors_label"]'
)
}
locateDevToolsIndicator(): Locator {
return page.locator('nextjs-portal [data-nextjs-dev-tools-button]:visible')
}
locator(selector: string, options?: Parameters<(typeof page)['locator']>[1]) {
return page.locator(selector, options)
}
/** A call that expects to be chained after a previous call, because it needs its value. */
private continueChain<TNext>(nextCall: (value: TCurrent) => Promise<TNext>) {
return this._chain(true, nextCall)
}
/** Start a chain. If continuing, it overwrites the current chained value. */
private startChain<TNext>(nextCall: () => TNext | Promise<TNext>) {
return this._chain(false, nextCall)
}
/** Either start or continue a chain. If continuing, it preserves the current chained value. */
private startOrPreserveChain(nextCall: () => Promise<void>) {
return this._chain(false, async (value) => {
await nextCall()
return value
})
}
// necessary for the type of the function below
readonly [Symbol.toStringTag]: string = 'Playwright'
private _chain<TNext>(
this: Playwright<TCurrent>,
mustBeChained: boolean,
nextCall: (current: TCurrent) => TNext | Promise<TNext>
): Playwright<TNext> & Promise<TNext> {
const syncError = new Error('next-browser-base-chain-error')
// If `this` is actually a proxy created by a previous chained call, it'll act like it has a `promise` property.
// (see proxy code below)
type MaybeChained<T> = Playwright<T> & {
promise?: Promise<T>
}
const self = this as MaybeChained<TCurrent>
let currentPromise = self.promise
if (!currentPromise) {
if (mustBeChained) {
// Note that this should also be enforced by the type system
// by adding appropriate `(this: Playwright<PreviousValue>)` type annotations
// to methods that expect to be chained, but tests can bypass this (or not be checked because they use JS)
throw new Error(
'Expected this call to be chained after a previous call'
)
} else {
// We're handling a call that does not expect to be chained after a previous one,
// so it's safe to default the current value to undefined -- we don't need a value to invoke `nextCall`
currentPromise = Promise.resolve(undefined as TCurrent)
}
}
const promise = currentPromise.then(nextCall).catch((reason: unknown) => {
// TODO: only patch the stacktrace if the sync callstack is missing from it
if (reason && typeof reason === 'object' && 'stack' in reason) {
const syncCallStack = syncError.stack!.split(syncError.message)[1]
reason.stack += `\n${syncCallStack}`
}
throw reason
})
function get(target: Playwright<TCurrent>, p: string | symbol): any {
switch (p) {
case 'promise':
return promise
case 'then':
return promise.then.bind(promise)
case 'catch':
return promise.catch.bind(promise)
case 'finally':
return promise.finally.bind(promise)
default:
return target[p]
}
}
// @ts-expect-error: we're changing `TCurrent` into TNext via proxy hacks
return new Proxy(this, {
get,
})
}
}