diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 7139d0da8..069a5227b 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -127,6 +127,11 @@ export function setupBrowserRpc( } wss.handleUpgrade(request, socket, head, (ws) => { + // the pool closed this instance, its pages can still reconnect while they close + if (!project.browser?.provider) { + ws.terminate() + return + } wss.emit('connection', ws, request) const { rpc, offCancel } = setupClient(project, rpcId, ws, { sessionId }) diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 1283c48b7..c6a5e4619 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -125,7 +125,7 @@ export function createBrowserPool(vitest: Vitest): BrowserPool { if (!worker) { debug?.('creating a worker for project %s', task.project.name) worker = new BrowserWorker(task.project, { - maxPages: getThreadsCount(task.project), + getMaxPages: getThreadsCount, getPageAllowance, }) workers.set(task.project, worker) @@ -335,6 +335,8 @@ class BrowserWorker { private busySessions = new Set() private openingPages = 0 private readySessions: Set + private started = false + private _maxPages = 1 private _traces: Traces private _otel: { @@ -345,7 +347,7 @@ class BrowserWorker { constructor( public project: TestProject, private options: { - maxPages: number + getMaxPages: (project: TestProject) => number getPageAllowance: (worker: BrowserWorker) => number }, ) { @@ -353,7 +355,7 @@ class BrowserWorker { this._otel = this._traces.startContextSpan('vitest.browser') this._otel.span.setAttributes({ 'vitest.project': project.name, - 'vitest.browser.provider': this.project.browser!.provider.name, + 'vitest.browser.provider': project.config.browser.provider?.name, }) this.readySessions = project._browserReadySessions } @@ -384,23 +386,34 @@ class BrowserWorker { } get maxPages(): number { - return this.options.maxPages + return this._maxPages } async runTests(method: 'run' | 'collect', files: FileSpecification[]): Promise { - this._promise ??= createDefer() + const promise = (this._promise ??= createDefer()) if (!files.length) { debug?.('no tests found, finishing test run immediately') - this._promise.resolve() - return this._promise + promise.resolve() + return promise } this._method = method - this._providedContext = stringify(this.project.getProvidedContext()) - this._queue.push(...files) + // the provider is closed when the pool frees the slot of an idle instance, + // so a worker that reopens the instance starts a new one + await this.project._initBrowserProvider() + if (!this._queue.length) { + debug?.('the run was cancelled while the provider was starting') + this._promise = undefined + promise.resolve() + return promise + } + this.started = true + this._maxPages = this.options.getMaxPages(this.project) + this._providedContext = stringify(this.project.getProvidedContext()) + for (const sessionId of [...this.readySessions]) { if (!this._queue.length) { break @@ -417,12 +430,12 @@ class BrowserWorker { await this.openPages() debug?.('all sessions are created') - return this._promise + return promise } // opens more pages when the budget allows it, e.g. after another instance finished grow(): void { - if (!this._promise || !this._queue.length) { + if (!this.started || !this._promise || !this._queue.length) { return } this.openPages().catch((error) => this.reject(error)) diff --git a/test/browser/specs/instance-concurrency.test.ts b/test/browser/specs/instance-concurrency.test.ts index e90e3991e..f9baef96b 100644 --- a/test/browser/specs/instance-concurrency.test.ts +++ b/test/browser/specs/instance-concurrency.test.ts @@ -143,3 +143,36 @@ test.runIf(provider.name === 'playwright')( expect(openPages).toEqual([1, 1, 1]) }, ) + +test.runIf(provider.name === 'playwright')( + 'reruns instances that were closed to free a worker slot', + 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 { fs, vitest } = await runInlineBrowserTests(files, { + watch: true, + maxWorkers: 1, + browser: { instances: createInstances() }, + reporters: ['default'], + }) + + await vitest.waitForStdout(`Test Files ${names.length} passed`) + + vitest.resetOutput() + fs.editFile('label.ts', (content) => `${content}\n`) + await vitest.waitForStdout(`Test Files ${names.length} passed`) + + expect(vitest.stderr).toBe('') + }, +)