[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

perf(browser): prewarm the browser while the Vite server starts (#10727)

authored by

Vladimir and committed by
GitHub
(Jul 23, 2026, 10:39 AM +0200) c17677a8 86c700ed

+341 -32
+149 -24
packages/browser-playwright/src/playwright.ts
··· 96 96 name: 'playwright', 97 97 supportedBrowser: playwrightBrowsers, 98 98 options, 99 + prewarm(ctx) { 100 + prewarmBrowser(ctx, options) 101 + }, 99 102 providerFactory(project) { 100 103 return new PlaywrightBrowserProvider(project, options) 101 104 }, 102 105 }) 106 + } 107 + 108 + interface WarmBrowser { 109 + promise: Promise<Browser> 110 + launchOptionsJson: string 111 + pending: Set<WarmBrowser> 112 + } 113 + 114 + // the subset of `TestProject` the launch-option resolution needs; `prewarm` 115 + // runs before the project exists and receives the same shape 116 + interface LaunchContext { 117 + config: TestProject['config'] 118 + vitest: TestProject['vitest'] 119 + } 120 + 121 + // The resolved config object is passed unchanged to the eventual TestProject, 122 + // so it identifies the browser this project can adopt. 123 + const warmBrowsers = new WeakMap<LaunchContext['config'], WarmBrowser>() 124 + const pendingWarmBrowsers = new WeakMap<LaunchContext['vitest'], Set<WarmBrowser>>() 125 + 126 + // starts importing playwright and launching the browser while the node side 127 + // is still creating the vite server, so the launch latency overlaps it. The 128 + // launch options are resolved by the same code as the real launch — if they 129 + // still differ by the time the provider opens the browser, the warm instance 130 + // is discarded, so this is always safe 131 + function prewarmBrowser(project: LaunchContext, options: PlaywrightProviderOptions): void { 132 + const browserName = project.config.browser.name 133 + if ( 134 + options.connectOptions 135 + || options.persistentContext 136 + // don't speculate on debugging flows 137 + || project.vitest.config.inspector.enabled 138 + ) { 139 + return 140 + } 141 + if (!browserName || !(playwrightBrowsers as readonly string[]).includes(browserName)) { 142 + return 143 + } 144 + if (warmBrowsers.has(project.config)) { 145 + return 146 + } 147 + let pending = pendingWarmBrowsers.get(project.vitest) 148 + if (!pending) { 149 + const pendingBrowsers = new Set<WarmBrowser>() 150 + pending = pendingBrowsers 151 + pendingWarmBrowsers.set(project.vitest, pendingBrowsers) 152 + // Browsers whose projects never initialize a provider (they have no test 153 + // files to run) are cleaned up when Vitest closes. 154 + project.vitest.onClose(() => closeWarmBrowsers(pendingBrowsers)) 155 + } 156 + const launchOptions = resolveLaunchOptions(project.config.browser, project.vitest.config.inspector, options, browserName) 157 + const entry: WarmBrowser = { 158 + launchOptionsJson: JSON.stringify(launchOptions), 159 + pending, 160 + promise: (async () => { 161 + debug?.('[%s] prewarming the browser', browserName) 162 + const playwright = await import('playwright') 163 + return playwright[browserName as PlaywrightBrowser].launch(launchOptions) 164 + })(), 165 + } 166 + // if the warm launch fails, drop it so the real launch retries 167 + // and surfaces the error through the normal path 168 + entry.promise.catch(() => { 169 + if (warmBrowsers.get(project.config) === entry) { 170 + warmBrowsers.delete(project.config) 171 + entry.pending.delete(entry) 172 + } 173 + }) 174 + pending.add(entry) 175 + warmBrowsers.set(project.config, entry) 176 + } 177 + 178 + function takeWarmBrowser(config: LaunchContext['config']): WarmBrowser | undefined { 179 + const warm = warmBrowsers.get(config) 180 + if (warm) { 181 + warmBrowsers.delete(config) 182 + warm.pending.delete(warm) 183 + } 184 + return warm 185 + } 186 + 187 + async function closeWarmBrowsers(pending: Set<WarmBrowser>): Promise<void> { 188 + const closing = Array.from(pending, warm => warm.promise.then(browser => browser.close()).catch(() => {})) 189 + pending.clear() 190 + await Promise.all(closing) 191 + } 192 + 193 + function resolveLaunchOptions( 194 + browser: TestProject['config']['browser'], 195 + inspector: TestProject['vitest']['config']['inspector'], 196 + providerOptions: PlaywrightProviderOptions, 197 + browserName: string, 198 + ): LaunchOptions { 199 + const launchOptions: LaunchOptions = { 200 + ...providerOptions.launchOptions, 201 + headless: browser.headless, 202 + } 203 + 204 + if (typeof browser.trace === 'object' && browser.trace.tracesDir) { 205 + launchOptions.tracesDir = browser.trace.tracesDir 206 + } 207 + 208 + if (inspector.enabled) { 209 + // NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector 210 + const port = inspector.port || 9229 211 + 212 + launchOptions.args ||= [] 213 + launchOptions.args.push(`--remote-debugging-port=${port}`) 214 + } 215 + 216 + // start Vitest UI maximized only on supported browsers 217 + if (browser.ui && browserName === 'chromium') { 218 + if (!launchOptions.args) { 219 + launchOptions.args = [] 220 + } 221 + if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) { 222 + launchOptions.args.push('--start-maximized') 223 + } 224 + } 225 + 226 + return launchOptions 103 227 } 104 228 105 229 export class PlaywrightBrowserProvider implements BrowserProvider { ··· 167 291 } 168 292 169 293 this.browserPromise = (async () => { 170 - const options = this.project.config.browser 171 - 172 294 const playwright = await import('playwright') 173 295 174 - const launchOptions: LaunchOptions = { 175 - ...this.options.launchOptions, 176 - headless: options.headless, 177 - } 178 - 179 - if (typeof options.trace === 'object' && options.trace.tracesDir) { 180 - launchOptions.tracesDir = options.trace?.tracesDir 181 - } 296 + const launchOptions = resolveLaunchOptions( 297 + this.project.config.browser, 298 + this.project.vitest.config.inspector, 299 + this.options, 300 + this.browserName, 301 + ) 182 302 183 303 const inspector = this.project.vitest.config.inspector 184 304 if (inspector.enabled) { 185 - // NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector 186 305 const port = inspector.port || 9229 187 306 const host = inspector.host || '127.0.0.1' 188 - 189 - launchOptions.args ||= [] 190 - launchOptions.args.push(`--remote-debugging-port=${port}`) 191 307 192 308 if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') { 193 309 this.project.vitest.logger.warn(`Custom inspector host "${host}" will be ignored. Chromium only allows remote debugging on localhost.`) 194 310 } 195 311 this.project.vitest.logger.log(`Debugger listening on ws://127.0.0.1:${port}`) 196 - } 197 - 198 - // start Vitest UI maximized only on supported browsers 199 - if (this.project.config.browser.ui && this.browserName === 'chromium') { 200 - if (!launchOptions.args) { 201 - launchOptions.args = [] 202 - } 203 - if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) { 204 - launchOptions.args.push('--start-maximized') 205 - } 206 312 } 207 313 208 314 debug?.('[%s] initializing the browser with launch options: %O', this.browserName, launchOptions) ··· 250 356 this.browser = this.persistentContext.browser()! 251 357 } 252 358 else { 359 + const warm = takeWarmBrowser(this.project.config) 360 + if (warm && warm.launchOptionsJson === JSON.stringify(launchOptions)) { 361 + const browser = await warm.promise.catch(() => null) 362 + if (browser?.isConnected()) { 363 + debug?.('[%s] adopting the prewarmed browser', this.browserName) 364 + this.browser = browser 365 + this.browserPromise = null 366 + return this.browser 367 + } 368 + } 369 + else if (warm) { 370 + debug?.('[%s] discarding the prewarmed browser, launch options changed', this.browserName) 371 + void warm.promise.then(browser => browser.close()).catch(() => {}) 372 + } 253 373 this.browser = await playwright[this.browserName].launch(launchOptions) 254 374 } 255 375 this.browserPromise = null ··· 553 673 554 674 debug?.('[%s] closing provider', this.browserName) 555 675 this.closing = true 676 + // a prewarmed browser that was never adopted must not outlive the provider 677 + const warm = takeWarmBrowser(this.project.config) 678 + if (warm) { 679 + void warm.promise.then(browser => browser.close()).catch(() => {}) 680 + } 556 681 if (this.browserPromise) { 557 682 await this.browserPromise 558 683 this.browserPromise = null
+133
test/browser/specs/prewarm.test.ts
··· 1 + import type { BrowserProviderOption } from 'vitest/node' 2 + import { expect, test } from 'vitest' 3 + import { runInlineTests } from '../../test-utils' 4 + import { instances, provider, runInlineBrowserTests } from './utils' 5 + 6 + function spyOnPrewarm() { 7 + const prewarmed: (string | undefined)[] = [] 8 + const spyProvider: BrowserProviderOption = { 9 + ...provider, 10 + prewarm(ctx) { 11 + prewarmed.push(ctx.config.name) 12 + provider.prewarm?.(ctx) 13 + }, 14 + } 15 + // config resolution assigns `name` on the instance objects, 16 + // so the shared settings array cannot be reused between runs 17 + const freshInstances = instances.map(instance => ({ ...instance })) 18 + return { prewarmed, spyProvider, freshInstances } 19 + } 20 + 21 + const basicTest = ` 22 + import { test } from 'vitest' 23 + test('works', () => {}) 24 + ` 25 + 26 + test('prewarm receives only the instances matching the --project filter', async () => { 27 + const { prewarmed, spyProvider, freshInstances } = spyOnPrewarm() 28 + const target = instances[0].browser! 29 + 30 + const result = await runInlineBrowserTests({ 31 + 'basic.test.ts': basicTest, 32 + }, { 33 + project: [target], 34 + browser: { provider: spyProvider, instances: freshInstances }, 35 + }) 36 + 37 + expect(result.stderr).toBe('') 38 + expect(prewarmed).toEqual([target]) 39 + }) 40 + 41 + test('prewarm receives only the matching instances of a workspace project', async () => { 42 + const { prewarmed, spyProvider, freshInstances } = spyOnPrewarm() 43 + const target = `browser (${instances[0].browser})` 44 + 45 + const { stderr } = await runInlineTests({ 46 + 'basic.test.ts': basicTest, 47 + }, { 48 + watch: false, 49 + reporters: 'none', 50 + project: [target], 51 + projects: [ 52 + { 53 + test: { 54 + name: 'browser', 55 + browser: { 56 + enabled: true, 57 + provider: spyProvider, 58 + instances: freshInstances, 59 + headless: true, 60 + }, 61 + }, 62 + }, 63 + ], 64 + }) 65 + 66 + expect(stderr).toBe('') 67 + expect(prewarmed).toEqual([target]) 68 + }) 69 + 70 + test('prewarm uses the provider from the resolved instance project', async () => { 71 + const prewarmed: { browser: string | undefined; name: string }[] = [] 72 + const instanceProvider: BrowserProviderOption = { 73 + ...provider, 74 + prewarm(ctx) { 75 + prewarmed.push({ 76 + browser: ctx.config.browser.name, 77 + name: ctx.config.name, 78 + }) 79 + provider.prewarm?.(ctx) 80 + }, 81 + } 82 + const target = instances[0].browser! 83 + const freshInstances = instances.map((instance, index) => ({ 84 + ...instance, 85 + provider: index === 0 ? instanceProvider : undefined, 86 + })) 87 + 88 + const result = await runInlineBrowserTests({ 89 + 'basic.test.ts': basicTest, 90 + }, { 91 + project: [target], 92 + browser: { provider: undefined, instances: freshInstances }, 93 + }) 94 + 95 + expect(result.stderr).toBe('') 96 + expect(prewarmed).toEqual([{ browser: target, name: target }]) 97 + }) 98 + 99 + test('does not prewarm a project without test files', async () => { 100 + const { prewarmed, spyProvider } = spyOnPrewarm() 101 + let projectNames: string[] = [] 102 + const browser = instances[0].browser! 103 + 104 + const result = await runInlineBrowserTests({ 105 + 'basic.test.ts': basicTest, 106 + }, { 107 + browser: { 108 + provider: spyProvider, 109 + instances: [ 110 + { browser, name: 'with tests', include: ['basic.test.ts'] }, 111 + { browser, name: 'without tests', include: ['missing.test.ts'] }, 112 + ], 113 + }, 114 + $viteConfig: { 115 + plugins: [ 116 + { 117 + name: 'capture-projects', 118 + configureVitest({ project, vitest }) { 119 + projectNames = vitest.projects.map(project => project.name) 120 + if (project.name === 'without tests') { 121 + project.config.include = ['basic.test.ts'] 122 + } 123 + }, 124 + }, 125 + ], 126 + }, 127 + }) 128 + 129 + expect(result.stderr).toBe('') 130 + expect(prewarmed).toEqual(['with tests']) 131 + expect(projectNames).toEqual(['with tests', 'without tests']) 132 + expect(result.ctx!.state.getFiles().map(file => file.projectName).sort()).toEqual(['with tests', 'without tests']) 133 + })
+3 -1
packages/vitest/src/node/core.ts
··· 283 283 */ 284 284 async _attachRootServer(): Promise<void> { 285 285 const resolved = this.config 286 + const children = resolved.resolvedProjects 287 + .filter(entry => entry.viteConfig === this.viteConfig) 286 288 // For a root-level browser config (no `projects`) this builds the single 287 289 // browser server; otherwise it just creates the Vite server. 288 - const { server, parent } = await createClusterServer(this, this.viteConfig, resolved) 290 + const { server, parent } = await createClusterServer(this, this.viteConfig, resolved, children) 289 291 this.vite = server 290 292 this._rootBrowserParent = parent 291 293
+1
packages/vitest/src/node/config/resolveConfig.ts
··· 416 416 + `Use a single provider for the project, or move the instances into separate projects.`, 417 417 ) 418 418 } 419 + browser.provider ??= browser.instances.find(instance => instance.provider)?.provider 419 420 420 421 // use `chromium` by default when the preview provider is specified 421 422 // for a smoother experience. if chromium is not available, it will
+19 -1
packages/vitest/src/node/plugins/browserLoader.ts
··· 9 9 BrowserServerContribution, 10 10 ParentProjectBrowser, 11 11 } from '../types/browser' 12 - import type { ResolvedConfig } from '../types/config' 12 + import type { ResolvedConfig, ResolvedProjectEntry } from '../types/config' 13 13 import { createViteServer } from '../vite' 14 14 15 15 export interface BrowserContributionHolder { ··· 111 111 vitest: Vitest, 112 112 viteConfig: ResolvedViteConfig, 113 113 config: ResolvedConfig, 114 + children: readonly ResolvedProjectEntry[], 114 115 ): Promise<{ server: ViteDevServer; parent?: ParentProjectBrowser }> { 115 116 const contribution = config._browserContribution 116 117 ··· 124 125 125 126 const parent = contribution.createParent({ config, vitest }) 126 127 contribution.parent = parent 128 + 129 + // Start browser launches now so their latency overlaps Vite server creation. 130 + // Entries that cannot run browser tests are skipped because they will never 131 + // initialize a provider that could adopt and close the prepared browser. 132 + for (const child of children) { 133 + if ( 134 + child.hidden 135 + || child.hasTestFiles === false 136 + || (child.projectConfig.typecheck.enabled && child.projectConfig.typecheck.only) 137 + ) { 138 + continue 139 + } 140 + // The Vite server is shared, but each child carries its own resolved 141 + // provider and browser options, so it must be prewarmed independently. 142 + const projectConfig = child.projectConfig 143 + projectConfig.browser.provider?.prewarm?.({ config: projectConfig, vitest }) 144 + } 127 145 128 146 const server = await createViteServer(viteConfig) 129 147 await server.listen(config.api.port)
+18 -6
packages/vitest/src/node/projects/resolveProjects.ts
··· 202 202 harness: PluginHarness, 203 203 entries: ResolvedProjectEntry[], 204 204 ): Promise<void> { 205 - const groups = new Map<ResolvedViteConfig, ResolvedConfig[]>() 206 - for (const { viteConfig, projectConfig } of entries) { 205 + const groups = new Map<ResolvedViteConfig, ResolvedProjectEntry[]>() 206 + for (const entry of entries) { 207 + const { viteConfig } = entry 207 208 let group = groups.get(viteConfig) 208 209 if (!group) { 209 210 group = [] 210 211 groups.set(viteConfig, group) 211 212 } 212 - group.push(projectConfig) 213 + group.push(entry) 213 214 } 214 215 215 216 // Most projects in a group share identical glob inputs (the `dir`/`root` is ··· 228 229 } 229 230 230 231 await Promise.all( 231 - Array.from(groups, async ([viteConfig, projectConfigs]) => { 232 + Array.from(groups, async ([viteConfig, projectEntries]) => { 233 + const projectConfigs = projectEntries.map(entry => entry.projectConfig) 232 234 const contribution = projectConfigs.find(config => config._browserContribution)?._browserContribution 233 235 if (!contribution) { 234 236 return 235 237 } 236 238 const fileLists = await Promise.all(projectConfigs.map(globTestFiles)) 239 + projectEntries.forEach((entry, index) => { 240 + entry.hasTestFiles = fileLists[index].length > 0 241 + }) 237 242 const testFiles = [...new Set(fileLists.flat())] 238 243 const optimizeDeps = await contribution.resolveOptimizeDeps(projectConfigs, testFiles, harness) 239 244 // the browser runs in the `client` environment, but Vite's dep scanner ··· 453 458 454 459 for (const entry of browserEntries) { 455 460 const { projectConfig, viteConfig } = entry 456 - const instances = projectConfig.browser.instances ?? [] 457 461 const parentName = projectConfig.name 458 462 463 + const instances = projectConfig.browser.instances ?? [] 459 464 if (instances.length === 0 || isExcludedByProjectFilter(globalConfig.project, parentName)) { 460 465 continue 461 466 } ··· 916 921 // provider). Siblings (browser instance variants, benchmark variants) share 917 922 // these resources by linking to the primary via `_parent`. 918 923 const primaryByViteConfig = new Map<ResolvedViteConfig, TestProject>() 924 + const childrenByViteConfig = new Map<ResolvedViteConfig, ResolvedProjectEntry[]>() 925 + for (const entry of entries) { 926 + const children = childrenByViteConfig.get(entry.viteConfig) ?? [] 927 + children.push(entry) 928 + childrenByViteConfig.set(entry.viteConfig, children) 929 + } 919 930 920 931 // The root Vite config can also serve as a project's `viteConfig` — either 921 932 // the default no-`projects` case or browser/benchmark variants of it. ··· 963 974 // Workspace project with its own `viteConfig`: own a fresh Vite server. For 964 975 // a browser cluster this is the single server shared by `project.vite` and 965 976 // `project.browser.vite`. 966 - const { server, parent } = await createClusterServer(vitest, viteConfig, projectConfig) 977 + const children = childrenByViteConfig.get(viteConfig) ?? [] 978 + const { server, parent } = await createClusterServer(vitest, viteConfig, projectConfig, children) 967 979 const project = new TestProject(vitest, server, viteConfig, projectConfig) 968 980 project._initializeRunners(server) 969 981 if (parent) {
+10
packages/vitest/src/node/types/browser.ts
··· 24 24 name: string 25 25 supportedBrowser?: ReadonlyArray<string> 26 26 options: Options 27 + /** 28 + * Called once for every resolved browser project right before its shared 29 + * Vite server is created, so the provider can start preparing the browser 30 + * (e.g. launching it) concurrently. Optional, fire-and-forget: errors must 31 + * surface through the normal provider flow. 32 + */ 33 + prewarm?: (ctx: { 34 + config: ResolvedConfig 35 + vitest: Vitest 36 + }) => void 27 37 providerFactory: (project: TestProject) => BrowserProvider 28 38 serverFactory: BrowserServerFactory 29 39 }
+8
packages/vitest/src/node/types/config.ts
··· 1299 1299 viteConfig: ResolvedViteConfig 1300 1300 projectConfig: ResolvedConfig 1301 1301 /** 1302 + * Whether test files were found while resolving browser dependencies. This 1303 + * early result is used only to decide whether prewarming is useful; runtime 1304 + * discovery still globs after plugins have configured the server. 1305 + * 1306 + * @internal 1307 + */ 1308 + hasTestFiles?: boolean 1309 + /** 1302 1310 * When set, this entry exists only so browser-instance siblings can attach 1303 1311 * to a parent that owns the Vite server and (later) the browser provider. 1304 1312 * The resulting `TestProject` is created and kept alive (so siblings can