mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
390eff3b86
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. ```
217 lines
5.5 KiB
TypeScript
217 lines
5.5 KiB
TypeScript
import { debugPrint, getFullUrl } from 'next-test-utils'
|
|
import {
|
|
Permissions,
|
|
Playwright,
|
|
PlaywrightNavigationWaitUntil,
|
|
} from './browsers/playwright'
|
|
import { Page } from 'playwright'
|
|
|
|
export type { Playwright }
|
|
|
|
if (!process.env.TEST_FILE_PATH) {
|
|
process.env.TEST_FILE_PATH = module.parent!.filename
|
|
}
|
|
|
|
;(global as any).browserName = process.env.BROWSER_NAME || 'chrome'
|
|
|
|
let browserTeardown: (() => Promise<void>)[] = []
|
|
let browserQuit: (() => Promise<void>) | undefined
|
|
|
|
if (typeof afterAll === 'function') {
|
|
afterAll(async () => {
|
|
await Promise.all(browserTeardown.map((f) => f())).catch((e) =>
|
|
console.error('browser teardown', e)
|
|
)
|
|
|
|
if (browserQuit) {
|
|
await browserQuit()
|
|
}
|
|
})
|
|
}
|
|
|
|
export interface WebdriverOptions {
|
|
permissions?: Permissions
|
|
/**
|
|
* whether to wait for React hydration to finish
|
|
*/
|
|
waitHydration?: boolean
|
|
/**
|
|
* allow retrying hydration wait if reload occurs
|
|
*/
|
|
retryWaitHydration?: boolean
|
|
/**
|
|
* The browser event to wait for during the initial page load. Passed through to `browser.loadPage`
|
|
* */
|
|
waitUntil?: PlaywrightNavigationWaitUntil
|
|
/**
|
|
* disable cache for page load
|
|
*/
|
|
disableCache?: boolean
|
|
/**
|
|
* the callback receiving page instance before loading page
|
|
* @param page
|
|
* @returns
|
|
*/
|
|
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>
|
|
/**
|
|
* browser locale
|
|
*/
|
|
locale?: string
|
|
/**
|
|
* disable javascript
|
|
*/
|
|
disableJavaScript?: boolean
|
|
/**
|
|
* ignore https errors
|
|
*/
|
|
ignoreHTTPSErrors?: 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()`.
|
|
*/
|
|
disableBrowserLog?: boolean
|
|
|
|
/**
|
|
* Override the user agent
|
|
*/
|
|
userAgent?: string
|
|
|
|
/**
|
|
* Override the base URL/port that `url` is resolved against. Useful when the
|
|
* test needs to drive a proxy or a separate server in front of Next.js.
|
|
*/
|
|
baseUrl?: string | number
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param appPortOrUrl can either be the port or the full URL
|
|
* @param url the path/query to append when using appPort
|
|
* @returns thenable browser instance
|
|
*/
|
|
export default async function webdriver(
|
|
appPortOrUrl: string | number,
|
|
url: string,
|
|
options?: WebdriverOptions
|
|
): Promise<Playwright> {
|
|
const defaultOptions = {
|
|
waitHydration: true,
|
|
retryWaitHydration: false,
|
|
disableCache: false,
|
|
}
|
|
options = Object.assign(defaultOptions, options)
|
|
const {
|
|
waitHydration,
|
|
retryWaitHydration,
|
|
disableCache,
|
|
beforePageLoad,
|
|
extraHTTPHeaders,
|
|
locale,
|
|
disableJavaScript,
|
|
permissions,
|
|
ignoreHTTPSErrors,
|
|
cpuThrottleRate,
|
|
pushErrorAsConsoleLog,
|
|
disableBrowserLog,
|
|
userAgent,
|
|
waitUntil,
|
|
baseUrl,
|
|
} = options
|
|
if (baseUrl !== undefined) {
|
|
appPortOrUrl = baseUrl
|
|
}
|
|
|
|
const { Playwright, quit } = await import('./browsers/playwright')
|
|
browserQuit = quit
|
|
|
|
const browser = new Playwright()
|
|
const browserName = process.env.BROWSER_NAME || 'chrome'
|
|
await browser.setup(
|
|
browserName,
|
|
locale!,
|
|
!disableJavaScript,
|
|
Boolean(ignoreHTTPSErrors),
|
|
userAgent,
|
|
permissions
|
|
)
|
|
;(global as any).browserName = browserName
|
|
|
|
const fullUrl = getFullUrl(appPortOrUrl, url, 'localhost')
|
|
|
|
debugPrint(`Loading browser with ${fullUrl}`)
|
|
|
|
await browser.loadPage(fullUrl, {
|
|
disableCache,
|
|
cpuThrottleRate,
|
|
beforePageLoad,
|
|
extraHTTPHeaders,
|
|
pushErrorAsConsoleLog,
|
|
disableBrowserLog,
|
|
waitUntil,
|
|
})
|
|
debugPrint(`Loaded browser with ${fullUrl}`)
|
|
|
|
browserTeardown.push(browser.close.bind(browser))
|
|
|
|
// Wait for application to hydrate
|
|
if (!disableJavaScript && waitHydration) {
|
|
debugPrint(`Waiting hydration for ${fullUrl}`)
|
|
|
|
const checkHydrated = async () => {
|
|
await browser.eval(() => {
|
|
return new Promise<void>((callback) => {
|
|
// if it's not a Next.js app return
|
|
if (
|
|
!document.documentElement.innerHTML.includes('__NEXT_DATA__') &&
|
|
// @ts-ignore next exists on window if it's a Next.js page.
|
|
typeof ((window as any).next && (window as any).next.version) ===
|
|
'undefined'
|
|
) {
|
|
console.log('Not a next.js page, resolving hydrate check')
|
|
callback()
|
|
}
|
|
|
|
// TODO: should we also ensure router.isReady is true
|
|
// by default before resolving?
|
|
if ((window as any).__NEXT_HYDRATED) {
|
|
console.log('Next.js page already hydrated')
|
|
callback()
|
|
} else {
|
|
let timeout = setTimeout(callback, 10 * 1000)
|
|
;(window as any).__NEXT_HYDRATED_CB = function () {
|
|
clearTimeout(timeout)
|
|
console.log('Next.js hydrate callback fired')
|
|
callback()
|
|
}
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
try {
|
|
await checkHydrated()
|
|
} catch (err) {
|
|
if (retryWaitHydration) {
|
|
// re-try in case the page reloaded during check
|
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
await checkHydrated()
|
|
} else {
|
|
console.error('failed to check hydration')
|
|
throw err
|
|
}
|
|
}
|
|
|
|
debugPrint(`Hydration complete for ${fullUrl}`)
|
|
}
|
|
|
|
return browser
|
|
}
|