diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts index 05d5342fb..3da10a054 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -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 + launchOptionsJson: string + pending: Set +} + +// 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() +const pendingWarmBrowsers = new WeakMap>() + +// 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() + 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): Promise { + 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 diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 05810c53b..31467e22b 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -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 diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 4bd40393d..6bcc24043 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -283,9 +283,11 @@ export class Vitest { */ async _attachRootServer(): Promise { 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 diff --git a/packages/vitest/src/node/plugins/browserLoader.ts b/packages/vitest/src/node/plugins/browserLoader.ts index 3e15b030f..81fa6bc13 100644 --- a/packages/vitest/src/node/plugins/browserLoader.ts +++ b/packages/vitest/src/node/plugins/browserLoader.ts @@ -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) diff --git a/packages/vitest/src/node/projects/resolveProjects.ts b/packages/vitest/src/node/projects/resolveProjects.ts index 1f92fe3aa..87ea2bfb5 100644 --- a/packages/vitest/src/node/projects/resolveProjects.ts +++ b/packages/vitest/src/node/projects/resolveProjects.ts @@ -202,14 +202,15 @@ async function applyBrowserOptimizeDeps( harness: PluginHarness, entries: ResolvedProjectEntry[], ): Promise { - const groups = new Map() - for (const { viteConfig, projectConfig } of entries) { + const groups = new Map() + 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() + const childrenByViteConfig = new Map() + 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) { diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index c1f8fff01..4c8c2fdfd 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -24,6 +24,16 @@ export interface BrowserProviderOption { name: string supportedBrowser?: ReadonlyArray 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 } diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 582cceb26..5d87d7e4f 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -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. diff --git a/test/browser/specs/prewarm.test.ts b/test/browser/specs/prewarm.test.ts new file mode 100644 index 000000000..c89d5c62f --- /dev/null +++ b/test/browser/specs/prewarm.test.ts @@ -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']) +})