@@ -96,12 +96,136 @@ export function playwright(options: PlaywrightProviderOptions = {}): BrowserProv
9696 name : 'playwright' ,
9797 supportedBrowser : playwrightBrowsers ,
9898 options,
99+ prewarm ( ctx ) {
100+ prewarmBrowser ( ctx , options )
101+ } ,
99102 providerFactory ( project ) {
100103 return new PlaywrightBrowserProvider ( project , options )
101104 } ,
102105 } )
103106}
104107
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
227+ }
228+
105229export class PlaywrightBrowserProvider implements BrowserProvider {
106230 public name = 'playwright' as const
107231 public supportsParallelism = true
@@ -167,44 +291,26 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
167291 }
168292
169293 this . browserPromise = ( async ( ) => {
170- const options = this . project . config . browser
171-
172294 const playwright = await import ( 'playwright' )
173295
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+ )
182302
183303 const inspector = this . project . vitest . config . inspector
184304 if ( inspector . enabled ) {
185- // NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector
186305 const port = inspector . port || 9229
187306 const host = inspector . host || '127.0.0.1'
188307
189- launchOptions . args ||= [ ]
190- launchOptions . args . push ( `--remote-debugging-port=${ port } ` )
191-
192308 if ( host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' ) {
193309 this . project . vitest . logger . warn ( `Custom inspector host "${ host } " will be ignored. Chromium only allows remote debugging on localhost.` )
194310 }
195311 this . project . vitest . logger . log ( `Debugger listening on ws://127.0.0.1:${ port } ` )
196312 }
197313
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- }
207-
208314 debug ?.( '[%s] initializing the browser with launch options: %O' , this . browserName , launchOptions )
209315
210316 if ( this . options . connectOptions ) {
@@ -250,6 +356,20 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
250356 this . browser = this . persistentContext . browser ( ) !
251357 }
252358 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+ }
253373 this . browser = await playwright [ this . browserName ] . launch ( launchOptions )
254374 }
255375 this . browserPromise = null
@@ -553,6 +673,11 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
553673
554674 debug ?.( '[%s] closing provider' , this . browserName )
555675 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+ }
556681 if ( this . browserPromise ) {
557682 await this . browserPromise
558683 this . browserPromise = null
0 commit comments