Skip to content

Commit bf04b9c

Browse files
authored
fix(browser): run browser instances as workers (#11525)
1 parent 3e3624c commit bf04b9c

19 files changed

Lines changed: 1194 additions & 239 deletions

File tree

‎docs/config/maxworkers.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,9 @@ vitest --maxWorkers=50%
5858
:::
5959

6060
Vitest uses [`os.availableParallelism`](https://nodejs.org/api/os.html#osavailableparallelism) to know the maximum amount of parallelism available.
61+
62+
## Browser Mode
63+
64+
In [Browser Mode](/guide/browser/), every browser instance (a [project](/guide/projects) with `browser.enabled`, or an entry in [`browser.instances`](/config/browser/instances)) is a worker, so at most `maxWorkers` instances run at the same time. When another instance needs a slot, an idle headless instance is closed and opens again on its next run. An instance that is not headless stays open for the whole session, so its window is not closed between runs. The same number is the budget of pages shared by the open instances: a single instance runs up to `maxWorkers` test files in parallel in its own pages, and several instances split the budget between them, with at least one page each.
65+
66+
An instance runs a single test file at a time when the browser is not headless, or when the provider does not support parallelism.

‎packages/browser-playwright/src/playwright.ts‎

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
BrowserCommand,
2525
BrowserModuleMocker,
2626
BrowserProvider,
27+
BrowserProviderCloseOptions,
2728
BrowserProviderOption,
2829
CDPSession,
2930
TestProject,
@@ -102,6 +103,7 @@ export function playwright(
102103
}
103104

104105
interface WarmBrowser {
106+
config: LaunchContext['config']
105107
promise: Promise<Browser>
106108
launchOptionsJson: string
107109
pending: Set<WarmBrowser>
@@ -140,23 +142,16 @@ function prewarmBrowser(project: LaunchContext, options: PlaywrightProviderOptio
140142
if (warmBrowsers.has(project.config)) {
141143
return
142144
}
143-
let pending = pendingWarmBrowsers.get(project.vitest)
144-
if (!pending) {
145-
const pendingBrowsers = new Set<WarmBrowser>()
146-
pending = pendingBrowsers
147-
pendingWarmBrowsers.set(project.vitest, pendingBrowsers)
148-
// Browsers whose projects never initialize a provider (they have no test
149-
// files to run) are cleaned up when Vitest closes.
150-
project.vitest.onClose(() => closeWarmBrowsers(pendingBrowsers))
151-
}
145+
const pending = getPendingWarmBrowsers(project.vitest)
152146
const launchOptions = resolveLaunchOptions(
153147
project.config.browser,
154148
project.vitest.config.inspector,
155149
options,
156150
browserName,
157151
)
158152
const entry: WarmBrowser = {
159-
launchOptionsJson: JSON.stringify(launchOptions),
153+
config: project.config,
154+
launchOptionsJson: getWarmBrowserKey(browserName, launchOptions),
160155
pending,
161156
promise: (async () => {
162157
debug?.('[%s] prewarming the browser', browserName)
@@ -176,6 +171,41 @@ function prewarmBrowser(project: LaunchContext, options: PlaywrightProviderOptio
176171
warmBrowsers.set(project.config, entry)
177172
}
178173

174+
function getWarmBrowserKey(browserName: string, launchOptions: LaunchOptions): string {
175+
return JSON.stringify([browserName, launchOptions])
176+
}
177+
178+
function getPendingWarmBrowsers(vitest: LaunchContext['vitest']): Set<WarmBrowser> {
179+
let pending = pendingWarmBrowsers.get(vitest)
180+
if (!pending) {
181+
const pendingBrowsers = new Set<WarmBrowser>()
182+
pending = pendingBrowsers
183+
pendingWarmBrowsers.set(vitest, pendingBrowsers)
184+
// Browsers whose projects never initialize a provider (they have no test
185+
// files to run) are cleaned up when Vitest closes.
186+
vitest.onClose(() => closeWarmBrowsers(pendingBrowsers))
187+
}
188+
return pending
189+
}
190+
191+
// the pool closes an idle instance to start another project; the browser
192+
// process stays warm so the next project adopts it instead of launching one
193+
function keepWarmBrowser(
194+
project: LaunchContext,
195+
browser: Promise<Browser>,
196+
launchOptionsJson: string,
197+
) {
198+
const pending = getPendingWarmBrowsers(project.vitest)
199+
const entry: WarmBrowser = {
200+
config: project.config,
201+
launchOptionsJson,
202+
pending,
203+
promise: browser,
204+
}
205+
pending.add(entry)
206+
warmBrowsers.set(project.config, entry)
207+
}
208+
179209
function takeWarmBrowser(config: LaunchContext['config']): WarmBrowser | undefined {
180210
const warm = warmBrowsers.get(config)
181211
if (warm) {
@@ -185,6 +215,26 @@ function takeWarmBrowser(config: LaunchContext['config']): WarmBrowser | undefin
185215
return warm
186216
}
187217

218+
// fewer browsers are prewarmed than there are projects and the pool starts
219+
// projects in test order, so a browser prepared for another project is
220+
// adopted when it is the same browser launched with the same options
221+
function takeAnyWarmBrowser(
222+
vitest: LaunchContext['vitest'],
223+
launchOptionsJson: string,
224+
): WarmBrowser | undefined {
225+
const pending = pendingWarmBrowsers.get(vitest)
226+
if (!pending) {
227+
return
228+
}
229+
for (const warm of pending) {
230+
if (warm.launchOptionsJson === launchOptionsJson) {
231+
pending.delete(warm)
232+
warmBrowsers.delete(warm.config)
233+
return warm
234+
}
235+
}
236+
}
237+
188238
async function closeWarmBrowsers(pending: Set<WarmBrowser>): Promise<void> {
189239
const closing = Array.from(pending, (warm) =>
190240
warm.promise.then((browser) => browser.close()).catch(() => {}),
@@ -245,6 +295,8 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
245295
public browserName: PlaywrightBrowser
246296

247297
private browserPromise: Promise<Browser> | null = null
298+
// set for a locally launched browser, which can be kept warm for another project
299+
private launchOptionsJson: string | null = null
248300
private closing = false
249301
private armedContexts = new WeakMap<BrowserContext, Promise<void>>()
250302

@@ -369,20 +421,26 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
369421
)
370422
this.browser = this.persistentContext.browser()!
371423
} else {
372-
const warm = takeWarmBrowser(this.project.config)
373-
if (warm && warm.launchOptionsJson === JSON.stringify(launchOptions)) {
424+
const launchOptionsJson = getWarmBrowserKey(this.browserName, launchOptions)
425+
let warm = takeWarmBrowser(this.project.config)
426+
if (warm && warm.launchOptionsJson !== launchOptionsJson) {
427+
debug?.('[%s] discarding the prewarmed browser, launch options changed', this.browserName)
428+
void warm.promise.then((browser) => browser.close()).catch(() => {})
429+
warm = undefined
430+
}
431+
warm ??= takeAnyWarmBrowser(this.project.vitest, launchOptionsJson)
432+
if (warm) {
374433
const browser = await warm.promise.catch(() => null)
375434
if (browser?.isConnected()) {
376435
debug?.('[%s] adopting the prewarmed browser', this.browserName)
377436
this.browser = browser
378437
this.browserPromise = null
438+
this.launchOptionsJson = launchOptionsJson
379439
return this.browser
380440
}
381-
} else if (warm) {
382-
debug?.('[%s] discarding the prewarmed browser, launch options changed', this.browserName)
383-
void warm.promise.then((browser) => browser.close()).catch(() => {})
384441
}
385442
this.browser = await playwright[this.browserName].launch(launchOptions)
443+
this.launchOptionsJson = launchOptionsJson
386444
}
387445
this.browserPromise = null
388446
return this.browser
@@ -731,10 +789,6 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
731789
String(disposable),
732790
)
733791
await disposable?.close()
734-
this.pages.clear()
735-
this.contexts.clear()
736-
this.browser = null
737-
this.browserPromise = null
738792
throw new Error(`[vitest] The provider was closed.`)
739793
}
740794
}
@@ -752,7 +806,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
752806
} as any // overloaded CDPSession type is too tricky in monorepo
753807
}
754808

755-
async close(): Promise<void> {
809+
async close(options?: BrowserProviderCloseOptions): Promise<void> {
756810
process.off('SIGTERM', this.onSIGTERM)
757811

758812
debug?.('[%s] closing provider', this.browserName)
@@ -768,6 +822,28 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
768822
}
769823
const browser = this.browser
770824
this.browser = null
825+
const closed = this.closeContexts()
826+
if (options?.keepWarm && browser && this.launchOptionsJson && browser.isConnected()) {
827+
// registered before the contexts are closed, so the project that takes
828+
// over the pool slot finds the browser right away and waits for it
829+
const kept = closed.then(
830+
() => browser,
831+
async (error) => {
832+
await browser.close().catch(() => {})
833+
throw error
834+
},
835+
)
836+
keepWarmBrowser(this.project, kept, this.launchOptionsJson)
837+
await kept
838+
debug?.('[%s] provider is closed, the browser is kept warm', this.browserName)
839+
return
840+
}
841+
await closed
842+
await browser?.close()
843+
debug?.('[%s] provider is closed', this.browserName)
844+
}
845+
846+
private async closeContexts(): Promise<void> {
771847
await Promise.all(Array.from(this.pages.values(), (p) => p.close()))
772848
this.pages.clear()
773849
if (this.persistentContext) {
@@ -776,8 +852,6 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
776852
await Promise.all(Array.from(this.contexts.values(), (c) => c.close()))
777853
}
778854
this.contexts.clear()
779-
await browser?.close()
780-
debug?.('[%s] provider is closed', this.browserName)
781855
}
782856
}
783857

‎packages/browser/src/client/orchestrator.ts‎

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { getUiAPI } from './ui'
1818
import { getBrowserState, getConfig } from './utils'
1919

2020
const ID_ALL = '__vitest_all__'
21+
// the next document gets this long to fire "load" before the unload counts as a broken iframe
22+
const IFRAME_UNLOAD_TIMEOUT = 5_000
2123

2224
export class IframeOrchestrator {
2325
private cancelled = false
@@ -243,17 +245,9 @@ export class IframeOrchestrator {
243245
iframe.onload = () => {
244246
const href = this.getIframeHref(iframe)
245247
debug('iframe loaded with href', href)
248+
this.iframeLoads.set(iframe, (this.iframeLoads.get(iframe) ?? 0) + 1)
246249
if (href !== iframe.src) {
247-
reject(
248-
this.dispatchIframeError(
249-
new Error(
250-
`Cannot connect to the iframe. ` +
251-
`Did you change the location or submitted a form? ` +
252-
"If so, don't forget to call `event.preventDefault()` to avoid reloading the page.\n\n" +
253-
`Received URL: ${href || 'unknown due to CORS'}\nExpected: ${iframe.src}`,
254-
),
255-
),
256-
)
250+
reject(this.dispatchIframeError(this.createConnectError(iframe, href)))
257251
} else if (this.iframes.has(iframeId)) {
258252
const events = this.iframeEvents.get(iframe)
259253
if (events?.size) {
@@ -262,9 +256,11 @@ export class IframeOrchestrator {
262256
)
263257
} else {
264258
this.warnReload(iframe, iframeId)
259+
this.watchUnload(iframe, iframeId)
265260
}
266261
} else {
267262
this.iframes.set(iframeId, iframe)
263+
this.watchUnload(iframe, iframeId)
268264
this.waitForReady(iframeId)
269265
.then(() =>
270266
this.sendEventToIframe({
@@ -346,6 +342,39 @@ export class IframeOrchestrator {
346342
}
347343

348344
private loggedIframe = new WeakSet<HTMLIFrameElement>()
345+
private iframeLoads = new WeakMap<HTMLIFrameElement, number>()
346+
347+
private createConnectError(iframe: HTMLIFrameElement, href: string | undefined) {
348+
return new Error(
349+
`Cannot connect to the iframe. ` +
350+
`Did you change the location or submitted a form? ` +
351+
"If so, don't forget to call `event.preventDefault()` to avoid reloading the page.\n\n" +
352+
`Received URL: ${href || 'unknown due to CORS'}\nExpected: ${iframe.src}`,
353+
)
354+
}
355+
356+
// Firefox and WebKit fire no "load" for a document that failed to load
357+
// (an empty response, a refused connection), so the tester that navigated
358+
// away during a test would keep the run waiting forever
359+
private watchUnload(iframe: HTMLIFrameElement, iframeId: string) {
360+
const loads = this.iframeLoads.get(iframe) ?? 0
361+
iframe.contentWindow?.addEventListener(
362+
'pagehide',
363+
() => {
364+
setTimeout(() => {
365+
if (
366+
this.iframes.get(iframeId) !== iframe ||
367+
!this.iframeEvents.get(iframe)?.size ||
368+
this.iframeLoads.get(iframe) !== loads
369+
) {
370+
return
371+
}
372+
this.dispatchIframeError(this.createConnectError(iframe, this.getIframeHref(iframe)))
373+
}, IFRAME_UNLOAD_TIMEOUT)
374+
},
375+
{ once: true },
376+
)
377+
}
349378

350379
private createWarningMessage(iframeId: string, location: string) {
351380
return (

‎packages/browser/src/node/middlewares/testerMiddleware.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function createTesterMiddleware(
1111
return next()
1212
}
1313
const url = new URL(req.url, 'http://localhost')
14-
if (url.pathname !== browserServer.prefixTesterUrl || !url.searchParams.has('sessionId')) {
14+
if (url.pathname !== browserServer.prefixTesterUrl) {
1515
return next()
1616
}
1717

‎packages/browser/src/node/project.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
BrowserCommand,
77
BrowserCommandContext,
88
BrowserProvider,
9+
BrowserProviderCloseOptions,
910
ProjectBrowser as IProjectBrowser,
1011
ResolvedConfig,
1112
TestProject,
@@ -27,7 +28,7 @@ export class ProjectBrowser implements IProjectBrowser {
2728
// transformIndexHtml plugin pipeline each time
2829
public testerHtmlTransformed: Promise<string> | undefined
2930

30-
public provider!: BrowserProvider
31+
public provider: BrowserProvider | undefined
3132
public vitest: Vitest
3233
public vite: ViteDevServer
3334
public config: ResolvedConfig
@@ -80,7 +81,9 @@ export class ProjectBrowser implements IProjectBrowser {
8081
if (name in this.parent.commands) {
8182
return this.parent.commands[name](context, ...args)
8283
}
83-
throw new Error(`Provider ${this.provider.name} does not support command "${name}".`)
84+
throw new Error(
85+
`Provider ${this.config.browser.provider?.name} does not support command "${name}".`,
86+
)
8487
}) as any
8588

8689
wrapSerializedConfig(): SerializedConfig {
@@ -107,6 +110,16 @@ export class ProjectBrowser implements IProjectBrowser {
107110
}
108111
}
109112

113+
async closeBrowserProvider(options?: BrowserProviderCloseOptions): Promise<void> {
114+
const provider = this.provider
115+
if (!provider) {
116+
return
117+
}
118+
// detach first so the next run can create a new provider while this one closes
119+
this.provider = undefined
120+
await provider.close(options)
121+
}
122+
110123
public parseErrorStacktrace(e: TestError, options: StackTraceParserOptions = {}): ParsedStack[] {
111124
return this.parent.parseErrorStacktrace(e, options)
112125
}

0 commit comments

Comments
 (0)