From 4515023cbb7c51ff7b4e70574062b1ff44feee16 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 8 Oct 2026 17:02:29 +0200 Subject: [PATCH] fix(browser): keep headed instances open - an instance with `headless: false` is never closed to free a worker slot, so its window stays open between runs; it still counts toward the running instances - `standalone()` opens pages up front only for headed instances --- docs/config/maxworkers.md | 2 +- packages/vitest/src/node/pools/browser.ts | 21 ++++++-- packages/vitest/src/node/project.ts | 3 +- .../specs/instance-concurrency.test.ts | 53 ++++++++++++++++++- 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/docs/config/maxworkers.md b/docs/config/maxworkers.md index 41525518d..dda66c932 100644 --- a/docs/config/maxworkers.md +++ b/docs/config/maxworkers.md @@ -61,6 +61,6 @@ Vitest uses [`os.availableParallelism`](https://nodejs.org/api/os.html#osavailab ## Browser Mode -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 are open at the same time. 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. +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. An instance runs a single test file at a time when the browser is not headless, or when the provider does not support parallelism. diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 9073f42b5..524596bd1 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -91,10 +91,25 @@ export function createBrowserPool(vitest: Vitest): BrowserPool { exitPromises.add(exit) } + // the user watches the pages of a headed instance, so it stays open + function canEvict(project: TestProject): boolean { + return project.config.browser.headless + } + + function countEvictableWorkers(): number { + let count = 0 + for (const worker of workers.values()) { + if (canEvict(worker.project)) { + count++ + } + } + return count + } + function evictIdleWorker(except: BrowserWorker | undefined): boolean { // the map keeps the least recently used worker first for (const worker of workers.values()) { - if (worker !== except && !activeWorkers.has(worker)) { + if (worker !== except && !activeWorkers.has(worker) && canEvict(worker.project)) { debug?.('closing the idle browser of %s to free a worker slot', worker.project.name) stopWorker(worker) return true @@ -130,8 +145,8 @@ export function createBrowserPool(vitest: Vitest): BrowserPool { return } - const required = worker ? 0 : 1 - while (workers.size + required > maxWorkers) { + const required = worker || !canEvict(task.project) ? 0 : 1 + while (countEvictableWorkers() + required > maxWorkers) { if (!evictIdleWorker(worker)) { break } diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 3606c42df..132939f2a 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -499,7 +499,8 @@ export class TestProject { /** @internal */ public async _standalone(): Promise { - if (!this.isBrowserEnabled()) { + // headless pages are opened by the first run, within the `maxWorkers` limit + if (!this.isBrowserEnabled() || this.config.browser.headless) { return } diff --git a/test/browser/specs/instance-concurrency.test.ts b/test/browser/specs/instance-concurrency.test.ts index 9b8b73364..e54415ca0 100644 --- a/test/browser/specs/instance-concurrency.test.ts +++ b/test/browser/specs/instance-concurrency.test.ts @@ -1,4 +1,4 @@ -import type { TestModule, Vitest } from 'vitest/node' +import type { TestModule, TestProject, Vitest } from 'vitest/node' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { playwright } from '@vitest/browser-playwright' @@ -417,3 +417,54 @@ test.runIf(provider.name === 'playwright')( expect(names.filter((name) => first[name] === second[name])).toHaveLength(2) }, ) + +test.runIf(provider.name === 'playwright' && browser === 'chromium')( + 'keeps headed instances open across reruns', + async () => { + const files: Record = { + 'label.ts': `export const label = 'label'`, + } + for (const name of names) { + files[`${name}.test.ts`] = ` + import { expect, test } from 'vitest' + import { label } from './label' + test('reads the label in ${name}', () => { + expect(label).toBe('label') + }) + ` + } + + const opened: string[] = [] + const { fs, vitest } = await runInlineBrowserTests(files, { + watch: true, + maxWorkers: 1, + browser: { + headless: false, + ui: false, + instances: createInstances(), + // the window is hidden by chromium itself, so the pool sees a headed instance + provider: playwright({ launchOptions: { args: ['--headless=new'] } }), + }, + reporters: [ + 'default', + { + onBrowserInit(project: TestProject) { + opened.push(project.name) + }, + }, + ], + }) + await vitest.waitForStdout(`Test Files ${names.length} passed`) + expect(opened.sort()).toEqual([...names].sort()) + + for (let i = 0; i < 2; i++) { + opened.length = 0 + vitest.resetOutput() + fs.editFile('label.ts', (content) => `${content}\n`) + await vitest.waitForStdout(`Test Files ${names.length} passed`) + // every instance stays open, so none of them is initialized again + expect(opened).toEqual([]) + } + expect(vitest.stderr).toBe('') + }, +) -- 2.51.2