mirror of
https://github.com/hanzoai/vitest.git
synced 2026-09-24 17:04:25 +00:00
perf(browser): prewarm the browser while the Vite server starts (#10727)
This commit is contained in:
@@ -96,12 +96,136 @@ export function playwright(options: PlaywrightProviderOptions = {}): BrowserProv
|
||||
name: 'playwright',
|
||||
supportedBrowser: playwrightBrowsers,
|
||||
options,
|
||||
prewarm(ctx) {
|
||||
prewarmBrowser(ctx, options)
|
||||
},
|
||||
providerFactory(project) {
|
||||
return new PlaywrightBrowserProvider(project, options)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface WarmBrowser {
|
||||
promise: Promise<Browser>
|
||||
launchOptionsJson: string
|
||||
pending: Set<WarmBrowser>
|
||||
}
|
||||
|
||||
// the subset of `TestProject` the launch-option resolution needs; `prewarm`
|
||||
// runs before the project exists and receives the same shape
|
||||
interface LaunchContext {
|
||||
config: TestProject['config']
|
||||
vitest: TestProject['vitest']
|
||||
}
|
||||
|
||||
// The resolved config object is passed unchanged to the eventual TestProject,
|
||||
// so it identifies the browser this project can adopt.
|
||||
const warmBrowsers = new WeakMap<LaunchContext['config'], WarmBrowser>()
|
||||
const pendingWarmBrowsers = new WeakMap<LaunchContext['vitest'], Set<WarmBrowser>>()
|
||||
|
||||
// starts importing playwright and launching the browser while the node side
|
||||
// is still creating the vite server, so the launch latency overlaps it. The
|
||||
// launch options are resolved by the same code as the real launch — if they
|
||||
// still differ by the time the provider opens the browser, the warm instance
|
||||
// is discarded, so this is always safe
|
||||
function prewarmBrowser(project: LaunchContext, options: PlaywrightProviderOptions): void {
|
||||
const browserName = project.config.browser.name
|
||||
if (
|
||||
options.connectOptions
|
||||
|| options.persistentContext
|
||||
// don't speculate on debugging flows
|
||||
|| project.vitest.config.inspector.enabled
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!browserName || !(playwrightBrowsers as readonly string[]).includes(browserName)) {
|
||||
return
|
||||
}
|
||||
if (warmBrowsers.has(project.config)) {
|
||||
return
|
||||
}
|
||||
let pending = pendingWarmBrowsers.get(project.vitest)
|
||||
if (!pending) {
|
||||
const pendingBrowsers = new Set<WarmBrowser>()
|
||||
pending = pendingBrowsers
|
||||
pendingWarmBrowsers.set(project.vitest, pendingBrowsers)
|
||||
// Browsers whose projects never initialize a provider (they have no test
|
||||
// files to run) are cleaned up when Vitest closes.
|
||||
project.vitest.onClose(() => closeWarmBrowsers(pendingBrowsers))
|
||||
}
|
||||
const launchOptions = resolveLaunchOptions(project.config.browser, project.vitest.config.inspector, options, browserName)
|
||||
const entry: WarmBrowser = {
|
||||
launchOptionsJson: JSON.stringify(launchOptions),
|
||||
pending,
|
||||
promise: (async () => {
|
||||
debug?.('[%s] prewarming the browser', browserName)
|
||||
const playwright = await import('playwright')
|
||||
return playwright[browserName as PlaywrightBrowser].launch(launchOptions)
|
||||
})(),
|
||||
}
|
||||
// if the warm launch fails, drop it so the real launch retries
|
||||
// and surfaces the error through the normal path
|
||||
entry.promise.catch(() => {
|
||||
if (warmBrowsers.get(project.config) === entry) {
|
||||
warmBrowsers.delete(project.config)
|
||||
entry.pending.delete(entry)
|
||||
}
|
||||
})
|
||||
pending.add(entry)
|
||||
warmBrowsers.set(project.config, entry)
|
||||
}
|
||||
|
||||
function takeWarmBrowser(config: LaunchContext['config']): WarmBrowser | undefined {
|
||||
const warm = warmBrowsers.get(config)
|
||||
if (warm) {
|
||||
warmBrowsers.delete(config)
|
||||
warm.pending.delete(warm)
|
||||
}
|
||||
return warm
|
||||
}
|
||||
|
||||
async function closeWarmBrowsers(pending: Set<WarmBrowser>): Promise<void> {
|
||||
const closing = Array.from(pending, warm => warm.promise.then(browser => browser.close()).catch(() => {}))
|
||||
pending.clear()
|
||||
await Promise.all(closing)
|
||||
}
|
||||
|
||||
function resolveLaunchOptions(
|
||||
browser: TestProject['config']['browser'],
|
||||
inspector: TestProject['vitest']['config']['inspector'],
|
||||
providerOptions: PlaywrightProviderOptions,
|
||||
browserName: string,
|
||||
): LaunchOptions {
|
||||
const launchOptions: LaunchOptions = {
|
||||
...providerOptions.launchOptions,
|
||||
headless: browser.headless,
|
||||
}
|
||||
|
||||
if (typeof browser.trace === 'object' && browser.trace.tracesDir) {
|
||||
launchOptions.tracesDir = browser.trace.tracesDir
|
||||
}
|
||||
|
||||
if (inspector.enabled) {
|
||||
// NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector
|
||||
const port = inspector.port || 9229
|
||||
|
||||
launchOptions.args ||= []
|
||||
launchOptions.args.push(`--remote-debugging-port=${port}`)
|
||||
}
|
||||
|
||||
// start Vitest UI maximized only on supported browsers
|
||||
if (browser.ui && browserName === 'chromium') {
|
||||
if (!launchOptions.args) {
|
||||
launchOptions.args = []
|
||||
}
|
||||
if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) {
|
||||
launchOptions.args.push('--start-maximized')
|
||||
}
|
||||
}
|
||||
|
||||
return launchOptions
|
||||
}
|
||||
|
||||
export class PlaywrightBrowserProvider implements BrowserProvider {
|
||||
public name = 'playwright' as const
|
||||
public supportsParallelism = true
|
||||
@@ -167,44 +291,26 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
|
||||
}
|
||||
|
||||
this.browserPromise = (async () => {
|
||||
const options = this.project.config.browser
|
||||
|
||||
const playwright = await import('playwright')
|
||||
|
||||
const launchOptions: LaunchOptions = {
|
||||
...this.options.launchOptions,
|
||||
headless: options.headless,
|
||||
}
|
||||
|
||||
if (typeof options.trace === 'object' && options.trace.tracesDir) {
|
||||
launchOptions.tracesDir = options.trace?.tracesDir
|
||||
}
|
||||
const launchOptions = resolveLaunchOptions(
|
||||
this.project.config.browser,
|
||||
this.project.vitest.config.inspector,
|
||||
this.options,
|
||||
this.browserName,
|
||||
)
|
||||
|
||||
const inspector = this.project.vitest.config.inspector
|
||||
if (inspector.enabled) {
|
||||
// NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector
|
||||
const port = inspector.port || 9229
|
||||
const host = inspector.host || '127.0.0.1'
|
||||
|
||||
launchOptions.args ||= []
|
||||
launchOptions.args.push(`--remote-debugging-port=${port}`)
|
||||
|
||||
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') {
|
||||
this.project.vitest.logger.warn(`Custom inspector host "${host}" will be ignored. Chromium only allows remote debugging on localhost.`)
|
||||
}
|
||||
this.project.vitest.logger.log(`Debugger listening on ws://127.0.0.1:${port}`)
|
||||
}
|
||||
|
||||
// start Vitest UI maximized only on supported browsers
|
||||
if (this.project.config.browser.ui && this.browserName === 'chromium') {
|
||||
if (!launchOptions.args) {
|
||||
launchOptions.args = []
|
||||
}
|
||||
if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) {
|
||||
launchOptions.args.push('--start-maximized')
|
||||
}
|
||||
}
|
||||
|
||||
debug?.('[%s] initializing the browser with launch options: %O', this.browserName, launchOptions)
|
||||
|
||||
if (this.options.connectOptions) {
|
||||
@@ -250,6 +356,20 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
|
||||
this.browser = this.persistentContext.browser()!
|
||||
}
|
||||
else {
|
||||
const warm = takeWarmBrowser(this.project.config)
|
||||
if (warm && warm.launchOptionsJson === JSON.stringify(launchOptions)) {
|
||||
const browser = await warm.promise.catch(() => null)
|
||||
if (browser?.isConnected()) {
|
||||
debug?.('[%s] adopting the prewarmed browser', this.browserName)
|
||||
this.browser = browser
|
||||
this.browserPromise = null
|
||||
return this.browser
|
||||
}
|
||||
}
|
||||
else if (warm) {
|
||||
debug?.('[%s] discarding the prewarmed browser, launch options changed', this.browserName)
|
||||
void warm.promise.then(browser => browser.close()).catch(() => {})
|
||||
}
|
||||
this.browser = await playwright[this.browserName].launch(launchOptions)
|
||||
}
|
||||
this.browserPromise = null
|
||||
@@ -553,6 +673,11 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
|
||||
|
||||
debug?.('[%s] closing provider', this.browserName)
|
||||
this.closing = true
|
||||
// a prewarmed browser that was never adopted must not outlive the provider
|
||||
const warm = takeWarmBrowser(this.project.config)
|
||||
if (warm) {
|
||||
void warm.promise.then(browser => browser.close()).catch(() => {})
|
||||
}
|
||||
if (this.browserPromise) {
|
||||
await this.browserPromise
|
||||
this.browserPromise = null
|
||||
|
||||
@@ -416,6 +416,7 @@ export function resolveTestConfig(
|
||||
+ `Use a single provider for the project, or move the instances into separate projects.`,
|
||||
)
|
||||
}
|
||||
browser.provider ??= browser.instances.find(instance => instance.provider)?.provider
|
||||
|
||||
// use `chromium` by default when the preview provider is specified
|
||||
// for a smoother experience. if chromium is not available, it will
|
||||
|
||||
@@ -283,9 +283,11 @@ export class Vitest {
|
||||
*/
|
||||
async _attachRootServer(): Promise<void> {
|
||||
const resolved = this.config
|
||||
const children = resolved.resolvedProjects
|
||||
.filter(entry => entry.viteConfig === this.viteConfig)
|
||||
// For a root-level browser config (no `projects`) this builds the single
|
||||
// browser server; otherwise it just creates the Vite server.
|
||||
const { server, parent } = await createClusterServer(this, this.viteConfig, resolved)
|
||||
const { server, parent } = await createClusterServer(this, this.viteConfig, resolved, children)
|
||||
this.vite = server
|
||||
this._rootBrowserParent = parent
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
BrowserServerContribution,
|
||||
ParentProjectBrowser,
|
||||
} from '../types/browser'
|
||||
import type { ResolvedConfig } from '../types/config'
|
||||
import type { ResolvedConfig, ResolvedProjectEntry } from '../types/config'
|
||||
import { createViteServer } from '../vite'
|
||||
|
||||
export interface BrowserContributionHolder {
|
||||
@@ -111,6 +111,7 @@ export async function createClusterServer(
|
||||
vitest: Vitest,
|
||||
viteConfig: ResolvedViteConfig,
|
||||
config: ResolvedConfig,
|
||||
children: readonly ResolvedProjectEntry[],
|
||||
): Promise<{ server: ViteDevServer; parent?: ParentProjectBrowser }> {
|
||||
const contribution = config._browserContribution
|
||||
|
||||
@@ -125,6 +126,23 @@ export async function createClusterServer(
|
||||
const parent = contribution.createParent({ config, vitest })
|
||||
contribution.parent = parent
|
||||
|
||||
// Start browser launches now so their latency overlaps Vite server creation.
|
||||
// Entries that cannot run browser tests are skipped because they will never
|
||||
// initialize a provider that could adopt and close the prepared browser.
|
||||
for (const child of children) {
|
||||
if (
|
||||
child.hidden
|
||||
|| child.hasTestFiles === false
|
||||
|| (child.projectConfig.typecheck.enabled && child.projectConfig.typecheck.only)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// The Vite server is shared, but each child carries its own resolved
|
||||
// provider and browser options, so it must be prewarmed independently.
|
||||
const projectConfig = child.projectConfig
|
||||
projectConfig.browser.provider?.prewarm?.({ config: projectConfig, vitest })
|
||||
}
|
||||
|
||||
const server = await createViteServer(viteConfig)
|
||||
await server.listen(config.api.port)
|
||||
contribution.setupRpc(parent)
|
||||
|
||||
@@ -202,14 +202,15 @@ async function applyBrowserOptimizeDeps(
|
||||
harness: PluginHarness,
|
||||
entries: ResolvedProjectEntry[],
|
||||
): Promise<void> {
|
||||
const groups = new Map<ResolvedViteConfig, ResolvedConfig[]>()
|
||||
for (const { viteConfig, projectConfig } of entries) {
|
||||
const groups = new Map<ResolvedViteConfig, ResolvedProjectEntry[]>()
|
||||
for (const entry of entries) {
|
||||
const { viteConfig } = entry
|
||||
let group = groups.get(viteConfig)
|
||||
if (!group) {
|
||||
group = []
|
||||
groups.set(viteConfig, group)
|
||||
}
|
||||
group.push(projectConfig)
|
||||
group.push(entry)
|
||||
}
|
||||
|
||||
// Most projects in a group share identical glob inputs (the `dir`/`root` is
|
||||
@@ -228,12 +229,16 @@ async function applyBrowserOptimizeDeps(
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from(groups, async ([viteConfig, projectConfigs]) => {
|
||||
Array.from(groups, async ([viteConfig, projectEntries]) => {
|
||||
const projectConfigs = projectEntries.map(entry => entry.projectConfig)
|
||||
const contribution = projectConfigs.find(config => config._browserContribution)?._browserContribution
|
||||
if (!contribution) {
|
||||
return
|
||||
}
|
||||
const fileLists = await Promise.all(projectConfigs.map(globTestFiles))
|
||||
projectEntries.forEach((entry, index) => {
|
||||
entry.hasTestFiles = fileLists[index].length > 0
|
||||
})
|
||||
const testFiles = [...new Set(fileLists.flat())]
|
||||
const optimizeDeps = await contribution.resolveOptimizeDeps(projectConfigs, testFiles, harness)
|
||||
// the browser runs in the `client` environment, but Vite's dep scanner
|
||||
@@ -453,9 +458,9 @@ function expandBrowserInstancesInEntries(
|
||||
|
||||
for (const entry of browserEntries) {
|
||||
const { projectConfig, viteConfig } = entry
|
||||
const instances = projectConfig.browser.instances ?? []
|
||||
const parentName = projectConfig.name
|
||||
|
||||
const instances = projectConfig.browser.instances ?? []
|
||||
if (instances.length === 0 || isExcludedByProjectFilter(globalConfig.project, parentName)) {
|
||||
continue
|
||||
}
|
||||
@@ -916,6 +921,12 @@ export async function attachProjectsFromEntries(
|
||||
// provider). Siblings (browser instance variants, benchmark variants) share
|
||||
// these resources by linking to the primary via `_parent`.
|
||||
const primaryByViteConfig = new Map<ResolvedViteConfig, TestProject>()
|
||||
const childrenByViteConfig = new Map<ResolvedViteConfig, ResolvedProjectEntry[]>()
|
||||
for (const entry of entries) {
|
||||
const children = childrenByViteConfig.get(entry.viteConfig) ?? []
|
||||
children.push(entry)
|
||||
childrenByViteConfig.set(entry.viteConfig, children)
|
||||
}
|
||||
|
||||
// The root Vite config can also serve as a project's `viteConfig` — either
|
||||
// the default no-`projects` case or browser/benchmark variants of it.
|
||||
@@ -963,7 +974,8 @@ export async function attachProjectsFromEntries(
|
||||
// Workspace project with its own `viteConfig`: own a fresh Vite server. For
|
||||
// a browser cluster this is the single server shared by `project.vite` and
|
||||
// `project.browser.vite`.
|
||||
const { server, parent } = await createClusterServer(vitest, viteConfig, projectConfig)
|
||||
const children = childrenByViteConfig.get(viteConfig) ?? []
|
||||
const { server, parent } = await createClusterServer(vitest, viteConfig, projectConfig, children)
|
||||
const project = new TestProject(vitest, server, viteConfig, projectConfig)
|
||||
project._initializeRunners(server)
|
||||
if (parent) {
|
||||
|
||||
@@ -24,6 +24,16 @@ export interface BrowserProviderOption<Options extends object = object> {
|
||||
name: string
|
||||
supportedBrowser?: ReadonlyArray<string>
|
||||
options: Options
|
||||
/**
|
||||
* Called once for every resolved browser project right before its shared
|
||||
* Vite server is created, so the provider can start preparing the browser
|
||||
* (e.g. launching it) concurrently. Optional, fire-and-forget: errors must
|
||||
* surface through the normal provider flow.
|
||||
*/
|
||||
prewarm?: (ctx: {
|
||||
config: ResolvedConfig
|
||||
vitest: Vitest
|
||||
}) => void
|
||||
providerFactory: (project: TestProject) => BrowserProvider
|
||||
serverFactory: BrowserServerFactory
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,14 @@ export interface ResolvedConfig
|
||||
export interface ResolvedProjectEntry {
|
||||
viteConfig: ResolvedViteConfig
|
||||
projectConfig: ResolvedConfig
|
||||
/**
|
||||
* Whether test files were found while resolving browser dependencies. This
|
||||
* early result is used only to decide whether prewarming is useful; runtime
|
||||
* discovery still globs after plugins have configured the server.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
hasTestFiles?: boolean
|
||||
/**
|
||||
* When set, this entry exists only so browser-instance siblings can attach
|
||||
* to a parent that owns the Vite server and (later) the browser provider.
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { BrowserProviderOption } from 'vitest/node'
|
||||
import { expect, test } from 'vitest'
|
||||
import { runInlineTests } from '../../test-utils'
|
||||
import { instances, provider, runInlineBrowserTests } from './utils'
|
||||
|
||||
function spyOnPrewarm() {
|
||||
const prewarmed: (string | undefined)[] = []
|
||||
const spyProvider: BrowserProviderOption = {
|
||||
...provider,
|
||||
prewarm(ctx) {
|
||||
prewarmed.push(ctx.config.name)
|
||||
provider.prewarm?.(ctx)
|
||||
},
|
||||
}
|
||||
// config resolution assigns `name` on the instance objects,
|
||||
// so the shared settings array cannot be reused between runs
|
||||
const freshInstances = instances.map(instance => ({ ...instance }))
|
||||
return { prewarmed, spyProvider, freshInstances }
|
||||
}
|
||||
|
||||
const basicTest = `
|
||||
import { test } from 'vitest'
|
||||
test('works', () => {})
|
||||
`
|
||||
|
||||
test('prewarm receives only the instances matching the --project filter', async () => {
|
||||
const { prewarmed, spyProvider, freshInstances } = spyOnPrewarm()
|
||||
const target = instances[0].browser!
|
||||
|
||||
const result = await runInlineBrowserTests({
|
||||
'basic.test.ts': basicTest,
|
||||
}, {
|
||||
project: [target],
|
||||
browser: { provider: spyProvider, instances: freshInstances },
|
||||
})
|
||||
|
||||
expect(result.stderr).toBe('')
|
||||
expect(prewarmed).toEqual([target])
|
||||
})
|
||||
|
||||
test('prewarm receives only the matching instances of a workspace project', async () => {
|
||||
const { prewarmed, spyProvider, freshInstances } = spyOnPrewarm()
|
||||
const target = `browser (${instances[0].browser})`
|
||||
|
||||
const { stderr } = await runInlineTests({
|
||||
'basic.test.ts': basicTest,
|
||||
}, {
|
||||
watch: false,
|
||||
reporters: 'none',
|
||||
project: [target],
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'browser',
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: spyProvider,
|
||||
instances: freshInstances,
|
||||
headless: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(prewarmed).toEqual([target])
|
||||
})
|
||||
|
||||
test('prewarm uses the provider from the resolved instance project', async () => {
|
||||
const prewarmed: { browser: string | undefined; name: string }[] = []
|
||||
const instanceProvider: BrowserProviderOption = {
|
||||
...provider,
|
||||
prewarm(ctx) {
|
||||
prewarmed.push({
|
||||
browser: ctx.config.browser.name,
|
||||
name: ctx.config.name,
|
||||
})
|
||||
provider.prewarm?.(ctx)
|
||||
},
|
||||
}
|
||||
const target = instances[0].browser!
|
||||
const freshInstances = instances.map((instance, index) => ({
|
||||
...instance,
|
||||
provider: index === 0 ? instanceProvider : undefined,
|
||||
}))
|
||||
|
||||
const result = await runInlineBrowserTests({
|
||||
'basic.test.ts': basicTest,
|
||||
}, {
|
||||
project: [target],
|
||||
browser: { provider: undefined, instances: freshInstances },
|
||||
})
|
||||
|
||||
expect(result.stderr).toBe('')
|
||||
expect(prewarmed).toEqual([{ browser: target, name: target }])
|
||||
})
|
||||
|
||||
test('does not prewarm a project without test files', async () => {
|
||||
const { prewarmed, spyProvider } = spyOnPrewarm()
|
||||
let projectNames: string[] = []
|
||||
const browser = instances[0].browser!
|
||||
|
||||
const result = await runInlineBrowserTests({
|
||||
'basic.test.ts': basicTest,
|
||||
}, {
|
||||
browser: {
|
||||
provider: spyProvider,
|
||||
instances: [
|
||||
{ browser, name: 'with tests', include: ['basic.test.ts'] },
|
||||
{ browser, name: 'without tests', include: ['missing.test.ts'] },
|
||||
],
|
||||
},
|
||||
$viteConfig: {
|
||||
plugins: [
|
||||
{
|
||||
name: 'capture-projects',
|
||||
configureVitest({ project, vitest }) {
|
||||
projectNames = vitest.projects.map(project => project.name)
|
||||
if (project.name === 'without tests') {
|
||||
project.config.include = ['basic.test.ts']
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.stderr).toBe('')
|
||||
expect(prewarmed).toEqual(['with tests'])
|
||||
expect(projectNames).toEqual(['with tests', 'without tests'])
|
||||
expect(result.ctx!.state.getFiles().map(file => file.projectName).sort()).toEqual(['with tests', 'without tests'])
|
||||
})
|
||||
Reference in New Issue
Block a user