···187187 private _promise: DeferPromise<void> | undefined
188188 private _providedContext: string | undefined
189189190190+ private _scaling: Promise<void> | undefined
191191+ // EMA of how long a single file takes in this pool; undefined until the
192192+ // first file finishes, which means "no signal yet — keep opening sessions"
193193+ private _fileCostEma: number | undefined
194194+ // refined with the real duration after every session open
195195+ private _sessionOpenCost = 250
196196+ // a session's first file pays the tester bootstrap on top of the test
197197+ // itself, so it would wildly overestimate the steady per-file cost
198198+ private _warmedUpSessions = new Set<string>()
199199+190200 private readySessions: Set<string>
191201192202 private _traces: Traces
···250260 return this._promise
251261 }
252262253253- // open the minimum amount of tabs
254254- // if there is only 1 file running, we don't need 8 tabs running
255255- const workerCount = Math.min(
256256- this.options.maxWorkers - this.orchestrators.size,
257257- files.length,
258258- )
259259-260260- const promises: Promise<void>[] = []
261261- for (let i = 0; i < workerCount; i++) {
262262- const sessionId = crypto.randomUUID()
263263- this.project.vitest._browserSessions.sessionIds.add(sessionId)
264264- const project = this.project.name
265265- debug?.('[%s] creating session for %s', sessionId, project)
266266- const page = this._traces.$(
267267- `vitest.browser.open`,
268268- {
269269- context: this._otel.context,
270270- attributes: {
271271- 'vitest.browser.session_id': sessionId,
272272- },
273273- },
274274- () => this.openPage(sessionId, { parallel: workerCount > 1 }),
275275- ).then(() => {
276276- // start running tests on the page when it's ready
277277- this.runNextTest(method, sessionId)
278278- })
279279- promises.push(page)
280280- }
281281- await Promise.all(promises)
282282- debug?.('all sessions are created')
263263+ this.scaleSessions(method)
283264 return this._promise
265265+ }
266266+267267+ // Sessions are opened one by one while the queue justifies another tab
268268+ // instead of `maxWorkers` tabs upfront: every tab pays a context + page +
269269+ // full module graph bring-up that competes with already-running sessions
270270+ // for the same Vite server, so for fast suites fewer tabs finish sooner.
271271+ private scaleSessions(method: 'run' | 'collect'): void {
272272+ if (this._scaling) {
273273+ return
274274+ }
275275+ this._scaling = (async () => {
276276+ while (
277277+ this._queue.length
278278+ && this.orchestrators.size < this.options.maxWorkers
279279+ && this.shouldOpenAnotherSession()
280280+ ) {
281281+ const sessionId = crypto.randomUUID()
282282+ this.project.vitest._browserSessions.sessionIds.add(sessionId)
283283+ debug?.('[%s] creating session for %s', sessionId, this.project.name)
284284+ const openStart = performance.now()
285285+ await this._traces.$(
286286+ `vitest.browser.open`,
287287+ {
288288+ context: this._otel.context,
289289+ attributes: {
290290+ 'vitest.browser.session_id': sessionId,
291291+ },
292292+ },
293293+ () => this.openPage(sessionId, {
294294+ parallel: this.options.maxWorkers > 1
295295+ && this.orchestrators.size + this._queue.length > 1,
296296+ }),
297297+ )
298298+ this._sessionOpenCost = performance.now() - openStart
299299+ // start running tests on the page when it's ready; a failure here
300300+ // already took a file off the queue, so it can never be swallowed
301301+ try {
302302+ this.runNextTest(method, sessionId)
303303+ }
304304+ catch (error) {
305305+ this.reject(error as Error)
306306+ return
307307+ }
308308+ }
309309+ debug?.('finished scaling sessions, %s sessions are running', this.orchestrators.size)
310310+ })()
311311+ this._scaling
312312+ .catch((error) => {
313313+ // a failure to open an extra session when the queue is already
314314+ // drained should not fail the run: the sessions that are still
315315+ // running have their own error and timeout handling
316316+ if (!this._queue.length && this.orchestrators.size > 0) {
317317+ debug?.('failed to open an extra session, ignoring: %s', error)
318318+ return
319319+ }
320320+ this.reject(error as Error)
321321+ })
322322+ .finally(() => {
323323+ this._scaling = undefined
324324+ // completion might have been blocked by the in-flight scaling
325325+ this.checkCompletion()
326326+ })
327327+ }
328328+329329+ private shouldOpenAnotherSession(): boolean {
330330+ // the first session always opens; without a per-file signal
331331+ // keep the old behavior of scaling up to maxWorkers
332332+ if (this.orchestrators.size === 0 || this._fileCostEma == null) {
333333+ return true
334334+ }
335335+ // only pay for another tab when the remaining work, split across the
336336+ // sessions we already have, still takes considerably longer than opening
337337+ // a tab costs — a new tab does not just cost its own bring-up, it also
338338+ // competes with the running sessions for the same Vite server
339339+ const projectedDrainMs
340340+ = (this._queue.length * this._fileCostEma) / this.orchestrators.size
341341+ return projectedDrainMs > Math.max(this._sessionOpenCost, 100) * 2
284342 }
285343286344 private async openPage(sessionId: string, options: { parallel: boolean }): Promise<void> {
···328386 private finishSession(sessionId: string): void {
329387 this.readySessions.add(sessionId)
330388331331- // the last worker finished running tests
332332- if (this.readySessions.size === this.orchestrators.size) {
333333- this._otel.span.end()
334334- this._promise?.resolve()
335335- this._promise = undefined
336336- debug?.('[%s] all tests finished running', sessionId)
337337- }
338338- else {
389389+ if (!this.checkCompletion()) {
339390 debug?.(
340391 `did not finish sessions for ${sessionId}: |ready - %s| |overall - %s|`,
341392 [...this.readySessions].join(', '),
342393 [...this.orchestrators.keys()].join(', '),
343394 )
344395 }
396396+ }
397397+398398+ private checkCompletion(): boolean {
399399+ // the run already finished (or was rejected) — nothing to resolve
400400+ if (!this._promise) {
401401+ return false
402402+ }
403403+ // the last worker finished running tests; a session that is still
404404+ // opening (this._scaling) will call this again once it settles
405405+ if (!this._scaling && this.readySessions.size === this.orchestrators.size) {
406406+ this._otel.span.end()
407407+ this._promise.resolve()
408408+ this._promise = undefined
409409+ debug?.('all tests finished running')
410410+ return true
411411+ }
412412+ return false
345413 }
346414347415 private runNextTest(method: 'run' | 'collect', sessionId: string): void {
···373441 const orchestrator = this.getOrchestrator(sessionId)
374442 debug?.('[%s] run test %s', sessionId, file)
375443444444+ // warm the transform cache while the iframe is booting so the test
445445+ // file import doesn't wait for the transform; mirrors the URL the
446446+ // tester will request (see `importFile` in the browser runner)
447447+ const fileUrl = `/${/^\w:/.test(file.filepath) ? '@fs/' : ''}${file.filepath}`.replace(/\/+/g, '/')
448448+ void this.project.vite.transformRequest(fileUrl).catch(() => {})
449449+376450 this.setBreakpoint(sessionId, file.filepath).then(() => {
451451+ const fileStart = performance.now()
377452 // this starts running tests inside the orchestrator
378453 const testersPromise = this._traces.$(
379454 `vitest.browser.run`,
···403478 )
404479 testersPromise
405480 .then(() => {
481481+ if (this._warmedUpSessions.has(sessionId)) {
482482+ const fileCost = performance.now() - fileStart
483483+ this._fileCostEma = this._fileCostEma == null
484484+ ? fileCost
485485+ : this._fileCostEma * 0.7 + fileCost * 0.3
486486+ }
487487+ else {
488488+ this._warmedUpSessions.add(sessionId)
489489+ }
406490 debug?.('[%s] test %s finished running', sessionId, file)
407491 this.runNextTest(method, sessionId)
408492 })