mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
[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. ```
This commit is contained in:
@@ -9,6 +9,7 @@ const { promisify } = require('util')
|
||||
const { Sema } = require('async-sema')
|
||||
const { spawn, exec: execOrig } = require('child_process')
|
||||
const { createNextInstall } = require('./test/lib/create-next-install')
|
||||
const { getBrowserLaunch } = require('./test/lib/browsers/launch')
|
||||
const glob = promisify(_glob)
|
||||
const exec = promisify(execOrig)
|
||||
const core = require('@actions/core')
|
||||
@@ -39,6 +40,7 @@ class TestProfile {
|
||||
'NEXT_E2E_TEST_TIMEOUT',
|
||||
'NEXT_TURBOPACK_IO_CONCURRENCY',
|
||||
'NEXT_TEST_PASSED_FILE',
|
||||
'NEXT_TEST_BROWSER_WS_ENDPOINT',
|
||||
'TURBO_TASKS_AVAILABLE_PARALLELISM',
|
||||
])
|
||||
|
||||
@@ -297,6 +299,14 @@ ${output}
|
||||
|
||||
let exiting = false
|
||||
|
||||
/**
|
||||
* Browser server shared across all test suites. Suites connect to it via
|
||||
* `test/lib/browsers/playwright.ts` instead of each launching their own
|
||||
* browser process.
|
||||
* @type {import('playwright').BrowserServer | undefined}
|
||||
*/
|
||||
let sharedBrowserServer
|
||||
|
||||
const cleanUpAndExit = async (code) => {
|
||||
if (exiting) {
|
||||
return
|
||||
@@ -304,6 +314,11 @@ const cleanUpAndExit = async (code) => {
|
||||
exiting = true
|
||||
console.log(`exiting with code ${code}`)
|
||||
|
||||
if (sharedBrowserServer) {
|
||||
await sharedBrowserServer.close().catch((err) => {
|
||||
console.error('Failed to close shared browser server:', err)
|
||||
})
|
||||
}
|
||||
if (process.env.NEXT_TEST_STARTER) {
|
||||
await fsp.rm(process.env.NEXT_TEST_STARTER, {
|
||||
recursive: true,
|
||||
@@ -624,6 +639,24 @@ ${ENDGROUP}`)
|
||||
console.log(`${ENDGROUP}`)
|
||||
}
|
||||
|
||||
// best-effort, don't spawn a browser for unit tests, if we don't spawn a
|
||||
// browser but should've, that's okay, `next-webdriver` will still set it up
|
||||
if (
|
||||
!options.dry &&
|
||||
((options.type && options.type !== 'unit') ||
|
||||
tests.some((test) => !testFilters.unit.test(test.file)))
|
||||
) {
|
||||
// Launch a single browser server shared by all test suites, instead of
|
||||
// each suite launching its own browser process. Suites connect to it via
|
||||
// the ws endpoint env var in `test/lib/browsers/playwright.ts`.
|
||||
const { browserType, launchOptions } = getBrowserLaunch(
|
||||
process.env.BROWSER_NAME || 'chrome',
|
||||
{ headless: true } // matches per-test env below
|
||||
)
|
||||
sharedBrowserServer = await browserType.launchServer(launchOptions)
|
||||
process.env.NEXT_TEST_BROWSER_WS_ENDPOINT = sharedBrowserServer.wsEndpoint()
|
||||
}
|
||||
|
||||
const sema = new Sema(options.concurrency, { capacity: tests.length })
|
||||
const outputSema = new Sema(1, { capacity: tests.length })
|
||||
const children = new Set()
|
||||
|
||||
@@ -91,8 +91,9 @@ describe('app dir - prefetching', () => {
|
||||
})
|
||||
|
||||
itHeaded('should not suppress prefetches after navigating back', async () => {
|
||||
// Force headed mode, as bfcache is not available in headless mode.
|
||||
const browser = await next.browser('/', { headless: false })
|
||||
// Requires headed mode, as bfcache is not available in headless mode
|
||||
// (`itHeaded` skips this test when the HEADLESS env var is set).
|
||||
const browser = await next.browser('/')
|
||||
|
||||
// Trigger a hard navigation.
|
||||
await browser.elementById('to-static-page-hard').click()
|
||||
|
||||
@@ -10,7 +10,9 @@ describeHeaded('fetch-abort-on-refresh', () => {
|
||||
it('should not show abort error in global error boundary when restoring from bfcache', async () => {
|
||||
// This test ensures that when restoring a page from the browser bfcache that was pending RSC data,
|
||||
// that the abort does not propagate to a user's error boundary.
|
||||
const browser = await next.browser('/', { headless: false })
|
||||
// Requires headed mode, as bfcache is not available in headless mode
|
||||
// (`describeHeaded` skips this suite when the HEADLESS env var is set).
|
||||
const browser = await next.browser('/')
|
||||
|
||||
await browser.elementById('trigger-navigation').click()
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//@ts-check
|
||||
|
||||
const { chromium, firefox, webkit } = require('playwright')
|
||||
|
||||
/**
|
||||
* Maps a test browser name (`BROWSER_NAME`) to a Playwright `BrowserType` and
|
||||
* the launch options we always use for it.
|
||||
*
|
||||
* This is a plain CommonJS module so it can be shared between `run-tests.js`
|
||||
* (which launches a browser server that's reused across all test suites) and
|
||||
* `test/lib/browsers/playwright.ts` (which launches its own browser when no
|
||||
* shared server is available, e.g. when running a test directly via the jest
|
||||
* CLI). Both must launch the browser with identical options.
|
||||
*
|
||||
* @param {string} browserName
|
||||
* @param {{ headless: boolean }} options
|
||||
* @returns {{
|
||||
* browserType: import('playwright').BrowserType,
|
||||
* launchOptions: import('playwright').LaunchOptions,
|
||||
* }}
|
||||
*/
|
||||
function getBrowserLaunch(browserName, { headless }) {
|
||||
if (browserName === 'safari') {
|
||||
return { browserType: webkit, launchOptions: { headless } }
|
||||
} else if (browserName === 'firefox') {
|
||||
return {
|
||||
browserType: firefox,
|
||||
launchOptions: {
|
||||
headless,
|
||||
firefoxUserPrefs: {
|
||||
// The "fission.webContentIsolationStrategy" pref must be
|
||||
// set to 1 on Firefox due to the bug where a new history
|
||||
// state is pushed on a page reload.
|
||||
// See https://github.com/microsoft/playwright/issues/22640
|
||||
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1832341
|
||||
'fission.webContentIsolationStrategy': 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
browserType: chromium,
|
||||
launchOptions: {
|
||||
headless,
|
||||
args: headless ? [] : ['--auto-open-devtools-for-tabs'],
|
||||
ignoreDefaultArgs: ['--disable-back-forward-cache'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getBrowserLaunch }
|
||||
@@ -1,9 +1,6 @@
|
||||
import fs from 'fs-extra'
|
||||
import { debugPrint } from 'next-test-utils'
|
||||
import {
|
||||
chromium,
|
||||
webkit,
|
||||
firefox,
|
||||
Browser,
|
||||
BrowserContext,
|
||||
Page,
|
||||
@@ -15,6 +12,7 @@ import {
|
||||
BrowserContextOptions,
|
||||
} from 'playwright'
|
||||
import path from 'path'
|
||||
import { getBrowserLaunch } from './launch'
|
||||
|
||||
type EventType = 'request' | 'response'
|
||||
|
||||
@@ -170,7 +168,6 @@ export class Playwright<TCurrent = undefined> {
|
||||
locale: string,
|
||||
javaScriptEnabled: boolean,
|
||||
ignoreHTTPSErrors: boolean,
|
||||
headless: boolean,
|
||||
userAgent: string | undefined,
|
||||
permissions: Permissions
|
||||
) {
|
||||
@@ -211,7 +208,7 @@ export class Playwright<TCurrent = undefined> {
|
||||
return
|
||||
}
|
||||
|
||||
browser = await this.launchBrowser(browserName, { headless })
|
||||
browser = await this.launchBrowser(browserName)
|
||||
context = await browser.newContext({
|
||||
locale,
|
||||
javaScriptEnabled,
|
||||
@@ -228,35 +225,25 @@ export class Playwright<TCurrent = undefined> {
|
||||
await page?.close()
|
||||
}
|
||||
|
||||
async launchBrowser(
|
||||
browserName: string,
|
||||
launchOptions: { headless: boolean }
|
||||
) {
|
||||
if (browserName === 'safari') {
|
||||
return await webkit.launch(launchOptions)
|
||||
} else if (browserName === 'firefox') {
|
||||
return await firefox.launch({
|
||||
...launchOptions,
|
||||
firefoxUserPrefs: {
|
||||
// The "fission.webContentIsolationStrategy" pref must be
|
||||
// set to 1 on Firefox due to the bug where a new history
|
||||
// state is pushed on a page reload.
|
||||
// See https://github.com/microsoft/playwright/issues/22640
|
||||
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1832341
|
||||
'fission.webContentIsolationStrategy': 1,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
let launchArgs: string[] = []
|
||||
if (!launchOptions.headless) {
|
||||
launchArgs.push('--auto-open-devtools-for-tabs')
|
||||
}
|
||||
return await chromium.launch({
|
||||
...launchOptions,
|
||||
args: launchArgs,
|
||||
ignoreDefaultArgs: ['--disable-back-forward-cache'],
|
||||
})
|
||||
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> {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { debugPrint, getFullUrl } from 'next-test-utils'
|
||||
import os from 'os'
|
||||
import {
|
||||
Permissions,
|
||||
Playwright,
|
||||
@@ -13,26 +12,8 @@ if (!process.env.TEST_FILE_PATH) {
|
||||
process.env.TEST_FILE_PATH = module.parent!.filename
|
||||
}
|
||||
|
||||
let deviceIP: string
|
||||
const isBrowserStack = !!process.env.BROWSERSTACK
|
||||
;(global as any).browserName = process.env.BROWSER_NAME || 'chrome'
|
||||
|
||||
if (isBrowserStack) {
|
||||
const nets = os.networkInterfaces()
|
||||
for (const key of Object.keys(nets)) {
|
||||
let done = false
|
||||
|
||||
for (const item of nets[key]!) {
|
||||
if (item.family === 'IPv4' && !item.internal) {
|
||||
deviceIP = item.address
|
||||
done = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (done) break
|
||||
}
|
||||
}
|
||||
|
||||
let browserTeardown: (() => Promise<void>)[] = []
|
||||
let browserQuit: (() => Promise<void>) | undefined
|
||||
|
||||
@@ -84,7 +65,6 @@ export interface WebdriverOptions {
|
||||
* disable javascript
|
||||
*/
|
||||
disableJavaScript?: boolean
|
||||
headless?: boolean
|
||||
/**
|
||||
* ignore https errors
|
||||
*/
|
||||
@@ -138,7 +118,6 @@ export default async function webdriver(
|
||||
disableJavaScript,
|
||||
permissions,
|
||||
ignoreHTTPSErrors,
|
||||
headless,
|
||||
cpuThrottleRate,
|
||||
pushErrorAsConsoleLog,
|
||||
disableBrowserLog,
|
||||
@@ -160,18 +139,12 @@ export default async function webdriver(
|
||||
locale!,
|
||||
!disableJavaScript,
|
||||
Boolean(ignoreHTTPSErrors),
|
||||
// allow headless to be overwritten for a particular test
|
||||
typeof headless !== 'undefined' ? headless : !!process.env.HEADLESS,
|
||||
userAgent,
|
||||
permissions
|
||||
)
|
||||
;(global as any).browserName = browserName
|
||||
|
||||
const fullUrl = getFullUrl(
|
||||
appPortOrUrl,
|
||||
url,
|
||||
isBrowserStack ? deviceIP : 'localhost'
|
||||
)
|
||||
const fullUrl = getFullUrl(appPortOrUrl, url, 'localhost')
|
||||
|
||||
debugPrint(`Loading browser with ${fullUrl}`)
|
||||
|
||||
|
||||
@@ -31,12 +31,12 @@ describe('bfcache-routing', () => {
|
||||
itHeaded(
|
||||
'should not suspend indefinitely when page is restored from bfcache after an mpa navigation',
|
||||
async () => {
|
||||
// bfcache is not currently supported by CDP, so we need to run this particular test in headed mode
|
||||
// bfcache is not currently supported by CDP, so this test only runs in
|
||||
// headed mode (`itHeaded` skips it when the HEADLESS env var is set)
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1317959
|
||||
|
||||
const browser = await next.browser('/index.html', {
|
||||
baseUrl: port,
|
||||
headless: false,
|
||||
})
|
||||
|
||||
// we overwrite the typical waitUntil: 'load' option here as the event is never being triggered if we hit the bfcache
|
||||
|
||||
Reference in New Issue
Block a user