From e10b204913d96d20644a1fe9f37551c67ad64f2f Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 3 Jun 2026 17:58:13 +0200 Subject: [PATCH 01/12] feat: expose `concurrencyId`/`workerId` on TestModule's diagnostic --- packages/browser/src/client/channel.ts | 2 ++ packages/browser/src/client/tester/state.ts | 1 + packages/browser/src/client/tester/tester.ts | 4 ++- packages/vitest/src/integrations/vi.ts | 25 ++++++++++++++++++- packages/vitest/src/node/pools/browser.ts | 5 ++-- .../src/node/reporters/reported-tasks.ts | 12 +++++++++ packages/vitest/src/runtime/runner/types.ts | 3 +++ packages/vitest/src/runtime/utils.ts | 25 +------------------ packages/vitest/src/runtime/workers/init.ts | 6 +++-- packages/vitest/src/types/worker.ts | 1 + packages/vitest/src/utils/tasks.ts | 5 ++++ test/e2e/test/reporters/junit.test.ts | 5 ++-- test/e2e/test/reporters/utils.ts | 6 ++--- 13 files changed, 63 insertions(+), 37 deletions(-) diff --git a/packages/browser/src/client/channel.ts b/packages/browser/src/client/channel.ts index 9829a42b39d4..9dc9b33abfa9 100644 --- a/packages/browser/src/client/channel.ts +++ b/packages/browser/src/client/channel.ts @@ -32,6 +32,8 @@ export interface IframeExecuteEvent { files: FileSpecification[] iframeId: string context: string + concurrencyId: number + workerId: number } export interface IframeCleanupEvent { diff --git a/packages/browser/src/client/tester/state.ts b/packages/browser/src/client/tester/state.ts index 66a0e1850108..5374a4898182 100644 --- a/packages/browser/src/client/tester/state.ts +++ b/packages/browser/src/client/tester/state.ts @@ -11,6 +11,7 @@ const state: WorkerGlobalState = { rpc: null as any, pool: 'browser', workerId: 1, + concurrencyId: 1, config, projectName: config.name || '', files: [], diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index 1bb118591083..7efe5dbed034 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -53,10 +53,12 @@ channel.addEventListener('message', async (e) => { switch (data.event) { case 'execute': { - const { method, files, context } = data + const { method, files, context, concurrencyId, workerId } = data const state = getWorkerState() const parsedContext = parse(context) + state.ctx.concurrencyId = concurrencyId + state.ctx.workerId = workerId state.ctx.providedContext = parsedContext state.providedContext = parsedContext diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index c640df5caa82..87988ebe0c84 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -12,7 +12,8 @@ import type { VitestMocker } from '../runtime/moduleRunner/moduleMocker' import type { MockFactoryWithHelper, MockOptions } from '../types/mocker' import { clearAllMocks, fn, isMockFunction, resetAllMocks, restoreAllMocks, spyOn } from '@vitest/spy' import { assertTypes, createSimpleStackTrace } from '@vitest/utils/helpers' -import { getWorkerState, isChildProcess, resetModules, waitForImportsToResolve } from '../runtime/utils' +import { getSafeTimers } from '@vitest/utils/timers' +import { getWorkerState, isChildProcess, resetModules } from '../runtime/utils' import { parseSingleStack } from '../utils/source-map' import { FakeTimers } from './mock/timers' import { waitFor, waitUntil } from './wait' @@ -875,3 +876,25 @@ function copyStackTrace(target: Error, source: Error) { } return target } + +function waitNextTick() { + const { setTimeout } = getSafeTimers() + return new Promise(resolve => setTimeout(resolve, 0)) +} + +async function waitForImportsToResolve(): Promise { + await waitNextTick() + const state = getWorkerState() + const promises: Promise[] = [] + const resolvingCount = state.resolvingModules.size + for (const [_, mod] of state.evaluatedModules.idToModuleMap) { + if (mod.promise && !mod.evaluated) { + promises.push(mod.promise) + } + } + if (!promises.length && !resolvingCount) { + return + } + await Promise.allSettled(promises) + await waitForImportsToResolve() +} diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index ab01e1143390..168023c8ee3f 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -264,7 +264,7 @@ class BrowserPool { this.project.vitest._browserSessions.sessionIds.add(sessionId) const project = this.project.name debug?.('[%s] creating session for %s', sessionId, project) - let page = this._traces.$( + const page = this._traces.$( `vitest.browser.open`, { context: this._otel.context, @@ -273,8 +273,7 @@ class BrowserPool { }, }, () => this.openPage(sessionId, { parallel: workerCount > 1 }), - ) - page = page.then(() => { + ).then(() => { // start running tests on the page when it's ready this.runNextTest(method, sessionId) }) diff --git a/packages/vitest/src/node/reporters/reported-tasks.ts b/packages/vitest/src/node/reporters/reported-tasks.ts index 33915898c6f9..e6d448dab668 100644 --- a/packages/vitest/src/node/reporters/reported-tasks.ts +++ b/packages/vitest/src/node/reporters/reported-tasks.ts @@ -578,6 +578,8 @@ export class TestModule extends SuiteImplementation { duration, heap, importDurations, + concurrencyId: this.task.concurrencyId, + workerId: this.task.workerId, } } } @@ -741,6 +743,16 @@ export interface ModuleDiagnostic { * The time spent importing every non-externalized dependency that Vitest has processed. */ readonly importDurations: Record + /** + * The id of the worker that ran this file. This value cannot be higher than `maxWorkers`. + * If file did not run yet, this will be 0. + */ + readonly concurrencyId: number + /** + * Incremental number of the worker that ran this file. This number increases with each worker. + * If file did not run yet, this will be 0. + */ + readonly workerId: number } function storeTask( diff --git a/packages/vitest/src/runtime/runner/types.ts b/packages/vitest/src/runtime/runner/types.ts index 59d15cd71771..f96eff64cbdd 100644 --- a/packages/vitest/src/runtime/runner/types.ts +++ b/packages/vitest/src/runtime/runner/types.ts @@ -330,6 +330,9 @@ export interface File extends Suite { prepareDuration?: number environmentLoad?: number + + concurrencyId: number + workerId: number } export interface Test extends TaskPopulated { diff --git a/packages/vitest/src/runtime/utils.ts b/packages/vitest/src/runtime/utils.ts index e19c86bb9fe5..9abed5491044 100644 --- a/packages/vitest/src/runtime/utils.ts +++ b/packages/vitest/src/runtime/utils.ts @@ -1,6 +1,5 @@ import type { EvaluatedModules } from 'vite/module-runner' import type { WorkerGlobalState } from '../types/worker' -import { getSafeTimers } from '@vitest/utils/timers' const NAME_WORKER_STATE = '__vitest_worker__' @@ -42,7 +41,7 @@ export function provideWorkerState(context: any, state: WorkerGlobalState): Work export function getCurrentEnvironment(): string { const state = getWorkerState() - return state?.environment.name + return state.environment.name } export function isChildProcess(): boolean { @@ -71,25 +70,3 @@ export function resetModules(modules: EvaluatedModules, resetMocks = false): voi node.importers.clear() }) } - -function waitNextTick() { - const { setTimeout } = getSafeTimers() - return new Promise(resolve => setTimeout(resolve, 0)) -} - -export async function waitForImportsToResolve(): Promise { - await waitNextTick() - const state = getWorkerState() - const promises: Promise[] = [] - const resolvingCount = state.resolvingModules.size - for (const [_, mod] of state.evaluatedModules.idToModuleMap) { - if (mod.promise && !mod.evaluated) { - promises.push(mod.promise) - } - } - if (!promises.length && !resolvingCount) { - return - } - await Promise.allSettled(promises) - await waitForImportsToResolve() -} diff --git a/packages/vitest/src/runtime/workers/init.ts b/packages/vitest/src/runtime/workers/init.ts index 7a9392345e4b..a0984b225ed3 100644 --- a/packages/vitest/src/runtime/workers/init.ts +++ b/packages/vitest/src/runtime/workers/init.ts @@ -30,6 +30,7 @@ export function init(worker: Options): void { let isRunning = false let workerTeardown: (() => Promise) | undefined | void let setupContext!: WorkerSetupContext + let poolId!: number function send(response: WorkerResponse) { worker.post(worker.serialize ? worker.serialize(response) : response) @@ -49,6 +50,7 @@ export function init(worker: Options): void { process.env.VITEST_POOL_ID = String(message.poolId) process.env.VITEST_WORKER_ID = String(message.workerId) reportMemory = message.options.reportMemory + poolId = message.poolId if (message.context.config.disableColors) { disableDefaultColors() @@ -128,7 +130,7 @@ export function init(worker: Options): void { 'vitest.worker.id': message.context.workerId, }, }, - () => entrypoint.run({ ...setupContext, ...message.context }, worker, traces) + () => entrypoint.run({ ...setupContext, ...message.context, concurrencyId: poolId }, worker, traces) .catch(error => serializeError(error)), ) const error = await runPromise @@ -186,7 +188,7 @@ export function init(worker: Options): void { 'vitest.worker.id': message.context.workerId, }, }, - () => entrypoint.collect({ ...setupContext, ...message.context }, worker, traces) + () => entrypoint.collect({ ...setupContext, ...message.context, concurrencyId: poolId }, worker, traces) .catch(error => serializeError(error)), ) const error = await runPromise diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index 879a7a8d67d6..384e1197c6d6 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -43,6 +43,7 @@ export interface ContextRPC { /** Exposed to test runner as `VITEST_WORKER_ID`. Value is unique per each isolated worker. */ workerId: number + concurrencyId: number } export interface WorkerSetupContext { diff --git a/packages/vitest/src/utils/tasks.ts b/packages/vitest/src/utils/tasks.ts index 634aac915e87..72ef45f10c31 100644 --- a/packages/vitest/src/utils/tasks.ts +++ b/packages/vitest/src/utils/tasks.ts @@ -173,6 +173,9 @@ export function createFileTask( meta?: HashMeta, ): File { const path = relative(root, filepath) + // this can be called outside of the test run, so worker might not be there + // @ts-expect-error injected global + const workerState = globalThis.__vitest_worker__ const file: File = { id: generateFileHash(path, projectName, meta), name: path, @@ -186,6 +189,8 @@ export function createFileTask( file: undefined!, pool, viteEnvironment, + concurrencyId: workerState?.ctx.concurrencyId ?? 0, + workerId: workerState?.ctx.workerId ?? 0, } file.file = file return file diff --git a/test/e2e/test/reporters/junit.test.ts b/test/e2e/test/reporters/junit.test.ts index bcfc3c033c3e..03ef50a31bd9 100644 --- a/test/e2e/test/reporters/junit.test.ts +++ b/test/e2e/test/reporters/junit.test.ts @@ -1,15 +1,14 @@ import type { RunnerTaskResult, RunnerTestCase, RunnerTestFile, RunnerTestSuite, RunnerTask as Task } from 'vitest' import { runVitest, runVitestCli } from '#test-utils' import { resolve } from 'pathe' -import { expect, test } from 'vitest' +import { expect, test, TestRunner } from 'vitest' import { rolldownVersion } from 'vitest/node' -import { createFileTask } from '../../../../packages/vitest/src/utils/tasks' const root = resolve(import.meta.dirname, '../../fixtures/reporters') test('calc the duration used by junit', () => { const result: RunnerTaskResult = { state: 'pass', duration: 0 } - const file: RunnerTestFile = createFileTask('/test.ts', '/', 'test') + const file: RunnerTestFile = TestRunner.createFileTask('/test.ts', '/', 'test') const suiteName = 'suite' const suite: RunnerTestSuite = { diff --git a/test/e2e/test/reporters/utils.ts b/test/e2e/test/reporters/utils.ts index 36189bc94ea2..552d2c0d2283 100644 --- a/test/e2e/test/reporters/utils.ts +++ b/test/e2e/test/reporters/utils.ts @@ -5,7 +5,7 @@ import type { Logger } from 'vitest/src/node/logger.js' import type { StateManager } from 'vitest/src/node/state.js' import type { ResolvedConfig } from 'vitest/src/node/types/config.js' import type { RunnerTestFile } from 'vitest/src/public/index.js' -import { createFileTask } from '../../../../packages/vitest/src/utils/tasks' +import { TestRunner } from 'vitest' export function trimReporterOutput(report: string) { const rows = report.replace(/\d+ms/g, '[...]ms').split('\n') @@ -66,7 +66,7 @@ export function getContext(): Context { } } -const file = createFileTask( +const file = TestRunner.createFileTask( '/vitest/test/unit/test/basic.test.ts', '/vitest/test/unit/test', '', @@ -91,7 +91,7 @@ const suite: RunnerTestSuite = { tasks: [], } -const passedFile = createFileTask( +const passedFile = TestRunner.createFileTask( '/vitest/test/unit/test/basic.test.ts', '/vitest/test/unit/test', '', From 7f6ca7ab231b26e7b4fb739787907e39c9b33a99 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 3 Jun 2026 19:38:43 +0200 Subject: [PATCH 02/12] feat: expose concurrency id to browser pool --- packages/browser/src/client/orchestrator.ts | 4 ++ packages/vitest/src/node/browser/sessions.ts | 2 + packages/vitest/src/node/pools/browser.ts | 32 ++++++++++++ packages/vitest/src/node/types/browser.ts | 1 + packages/vitest/src/types/browser.ts | 2 + test/browser/specs/concurrency-id.test.ts | 53 ++++++++++++++++++++ 6 files changed, 94 insertions(+) create mode 100644 test/browser/specs/concurrency-id.test.ts diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 73449c10890a..48a76e82416a 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -170,6 +170,8 @@ export class IframeOrchestrator { files: options.files, method: options.method, context: options.providedContext, + concurrencyId: options.concurrencyId, + workerId: options.workerId, }) debug('finished running tests', options.files.join(', ')) // we don't cleanup here because in non-isolated mode @@ -207,6 +209,8 @@ export class IframeOrchestrator { method: options.method, iframeId: file, context: options.providedContext, + concurrencyId: options.concurrencyId, + workerId: options.workerId, }) // perform "cleanup" to cleanup resources and calculate the coverage await this.sendEventToIframe({ diff --git a/packages/vitest/src/node/browser/sessions.ts b/packages/vitest/src/node/browser/sessions.ts index 2c23862c0495..7bfe5bc16a86 100644 --- a/packages/vitest/src/node/browser/sessions.ts +++ b/packages/vitest/src/node/browser/sessions.ts @@ -41,6 +41,8 @@ export class BrowserSessions { this.sessions.set(sessionId, { project, otelCarrier: options?.otelCarrier, + // assigned by the pool on the session's first run, freed when it disconnects + concurrencyId: 0, connected: () => { isConnected = true resolveIfReady() diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 168023c8ee3f..cfb3acdde4bb 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -291,6 +291,33 @@ class BrowserPool { }) } + // stable slot id (1..maxWorkers) assigned to each session/orchestrator on its + // first run, exposed to the test runner as both `concurrencyId` and `workerId`. + // the id lives on the session, so it is freed when the session disconnects, and + // the used set is derived from the live orchestrators, so it stays within maxWorkers + private getConcurrencyId(sessionId: string): number { + const sessions = this.project.vitest._browserSessions + const session = sessions.getSession(sessionId) + if (session?.concurrencyId) { + return session.concurrencyId + } + const used = new Set() + for (const id of this.orchestrators.keys()) { + const concurrencyId = sessions.getSession(id)?.concurrencyId + if (concurrencyId) { + used.add(concurrencyId) + } + } + let concurrencyId = 1 + while (used.has(concurrencyId)) { + concurrencyId++ + } + if (session) { + session.concurrencyId = concurrencyId + } + return concurrencyId + } + private getOrchestrator(sessionId: string) { const orchestrator = this.orchestrators.get(sessionId) if (!orchestrator) { @@ -358,6 +385,7 @@ class BrowserPool { }, }, async () => { + const concurrencyId = this.getConcurrencyId(sessionId) return orchestrator.createTesters( { method, @@ -366,6 +394,10 @@ class BrowserPool { // so we need to stringify it first to avoid double serialization providedContext: this._providedContext || '[{}]', otelCarrier: this._traces.getContextCarrier(), + concurrencyId, + // in the browser there is a single tab per orchestrator, + // so the worker id matches the concurrency slot + workerId: concurrencyId, }, ) }, diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index b3ff7f07aecf..06ef6c766b55 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -375,6 +375,7 @@ export interface BrowserCommandContext { export interface BrowserServerStateSession { project: TestProject otelCarrier?: OTELCarrier + concurrencyId: number connected: () => void ready: () => void fail: (v: Error) => void diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 57a3c0163550..04545204fa34 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -7,4 +7,6 @@ export interface BrowserTesterOptions { files: FileSpecification[] providedContext: string otelCarrier?: OTELCarrier + concurrencyId: number + workerId: number } diff --git a/test/browser/specs/concurrency-id.test.ts b/test/browser/specs/concurrency-id.test.ts new file mode 100644 index 000000000000..d87e19b64c06 --- /dev/null +++ b/test/browser/specs/concurrency-id.test.ts @@ -0,0 +1,53 @@ +import type { ModuleDiagnostic } from 'vitest/node' +import { expect, test } from 'vitest' +import { instances, runInlineBrowserTests } from './utils' + +const [firstInstance] = instances + +test('exposes concurrencyId/workerId bounded by maxWorkers', async () => { + const maxWorkers = 2 + const fileCount = 4 + + const files: Record = {} + for (let i = 0; i < fileCount; i++) { + files[`test/file-${i}.test.ts`] = ` + import { expect, test } from 'vitest' + test('reads worker state', () => { + const ctx = globalThis.__vitest_worker__.ctx + expect(ctx.concurrencyId).toBeGreaterThanOrEqual(1) + expect(ctx.workerId).toBe(ctx.concurrencyId) + }) + ` + } + + const diagnostics: ModuleDiagnostic[] = [] + + const { stderr } = await runInlineBrowserTests(files, { + maxWorkers, + browser: { + fileParallelism: true, + instances: [firstInstance], + }, + reporters: [ + { + onTestModuleEnd(module) { + diagnostics.push(module.diagnostic()) + }, + }, + ], + }) + + expect(stderr).toBe('') + expect(diagnostics).toHaveLength(fileCount) + + const used = new Set() + for (const diagnostic of diagnostics) { + expect(diagnostic.workerId).toBe(diagnostic.concurrencyId) + expect(diagnostic.concurrencyId).toBeGreaterThanOrEqual(1) + expect(diagnostic.concurrencyId).toBeLessThanOrEqual(maxWorkers) + used.add(diagnostic.concurrencyId) + } + + // the pool opens one tab per slot, so both slots are used and stay within range + expect([...used].sort()).toEqual([1, 2]) +}) From 4217866ef72dd3cff9af4b2f51d51c3a3dda54e5 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 3 Jun 2026 19:51:17 +0200 Subject: [PATCH 03/12] feat: inject VITEST_POOL_ID/VITEST_WORKER_ID --- packages/browser/src/client/tester/tester.ts | 2 ++ test/browser/specs/concurrency-id.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index 7efe5dbed034..5c1f559fb318 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -61,6 +61,8 @@ channel.addEventListener('message', async (e) => { state.ctx.workerId = workerId state.ctx.providedContext = parsedContext state.providedContext = parsedContext + state.metaEnv.VITEST_POOL_ID = String(concurrencyId) + state.metaEnv.VITEST_WORKER_ID = String(workerId) if (method === 'collect') { await executeTests('collect', files).catch(err => unhandledError(err, 'Collect Error')) diff --git a/test/browser/specs/concurrency-id.test.ts b/test/browser/specs/concurrency-id.test.ts index d87e19b64c06..e58add8c8810 100644 --- a/test/browser/specs/concurrency-id.test.ts +++ b/test/browser/specs/concurrency-id.test.ts @@ -16,6 +16,8 @@ test('exposes concurrencyId/workerId bounded by maxWorkers', async () => { const ctx = globalThis.__vitest_worker__.ctx expect(ctx.concurrencyId).toBeGreaterThanOrEqual(1) expect(ctx.workerId).toBe(ctx.concurrencyId) + expect(ctx.workerId).toBe(import.meta.env.VITEST_WORKER_ID) + expect(ctx.concurrencyId).toBe(import.meta.env.VITEST_POOL_ID) }) ` } From 4ae55d5bd2ab35bc0bd5e23630914d2bae245872 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 3 Jun 2026 19:52:29 +0200 Subject: [PATCH 04/12] test: fix test assertion --- test/browser/specs/concurrency-id.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/browser/specs/concurrency-id.test.ts b/test/browser/specs/concurrency-id.test.ts index e58add8c8810..bf594505f9ad 100644 --- a/test/browser/specs/concurrency-id.test.ts +++ b/test/browser/specs/concurrency-id.test.ts @@ -16,8 +16,8 @@ test('exposes concurrencyId/workerId bounded by maxWorkers', async () => { const ctx = globalThis.__vitest_worker__.ctx expect(ctx.concurrencyId).toBeGreaterThanOrEqual(1) expect(ctx.workerId).toBe(ctx.concurrencyId) - expect(ctx.workerId).toBe(import.meta.env.VITEST_WORKER_ID) - expect(ctx.concurrencyId).toBe(import.meta.env.VITEST_POOL_ID) + expect(String(ctx.workerId)).toBe(import.meta.env.VITEST_WORKER_ID) + expect(String(ctx.concurrencyId)).toBe(import.meta.env.VITEST_POOL_ID) }) ` } @@ -31,6 +31,7 @@ test('exposes concurrencyId/workerId bounded by maxWorkers', async () => { instances: [firstInstance], }, reporters: [ + 'default', { onTestModuleEnd(module) { diagnostics.push(module.diagnostic()) From 7353f3ebfcde36f9ad989af31a260fe61d22ed6b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 3 Jun 2026 19:54:47 +0200 Subject: [PATCH 05/12] test: fix test assertion --- test/browser/specs/concurrency-id.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/browser/specs/concurrency-id.test.ts b/test/browser/specs/concurrency-id.test.ts index bf594505f9ad..edbf1b777993 100644 --- a/test/browser/specs/concurrency-id.test.ts +++ b/test/browser/specs/concurrency-id.test.ts @@ -1,4 +1,4 @@ -import type { ModuleDiagnostic } from 'vitest/node' +import type { TestModule } from 'vitest/node' import { expect, test } from 'vitest' import { instances, runInlineBrowserTests } from './utils' @@ -22,9 +22,9 @@ test('exposes concurrencyId/workerId bounded by maxWorkers', async () => { ` } - const diagnostics: ModuleDiagnostic[] = [] + const testModules: TestModule[] = [] - const { stderr } = await runInlineBrowserTests(files, { + const { stderr, stdout } = await runInlineBrowserTests(files, { maxWorkers, browser: { fileParallelism: true, @@ -34,20 +34,25 @@ test('exposes concurrencyId/workerId bounded by maxWorkers', async () => { 'default', { onTestModuleEnd(module) { - diagnostics.push(module.diagnostic()) + testModules.push(module) }, }, ], }) expect(stderr).toBe('') - expect(diagnostics).toHaveLength(fileCount) + expect(testModules).toHaveLength(fileCount) const used = new Set() - for (const diagnostic of diagnostics) { + for (const module of testModules) { + const diagnostic = module.diagnostic() + + expect(stdout).toReportPassedTest(module.relativeModuleId, firstInstance.browser) + expect(diagnostic.workerId).toBe(diagnostic.concurrencyId) expect(diagnostic.concurrencyId).toBeGreaterThanOrEqual(1) expect(diagnostic.concurrencyId).toBeLessThanOrEqual(maxWorkers) + used.add(diagnostic.concurrencyId) } From 9edd24d0098032ae79a7064a2762a7fcb2cb711d Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 3 Jun 2026 20:09:10 +0200 Subject: [PATCH 06/12] test: use `TestRunner.createFileTask` instead of manual creation --- .../components/views/ViewReport.spec.ts | 102 +++++++----------- 1 file changed, 36 insertions(+), 66 deletions(-) diff --git a/packages/ui/client/components/views/ViewReport.spec.ts b/packages/ui/client/components/views/ViewReport.spec.ts index b7604a08b161..ea78806b2c51 100644 --- a/packages/ui/client/components/views/ViewReport.spec.ts +++ b/packages/ui/client/components/views/ViewReport.spec.ts @@ -1,6 +1,5 @@ -import type { RunnerTestFile } from 'vitest' import { faker } from '@faker-js/faker' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, TestRunner } from 'vitest' import { config } from '~/composables/client' import { page, render } from '~/test' import ViewReport from './ViewReport.vue' @@ -42,23 +41,16 @@ const error = { diff, } -const fileWithTextStacks: RunnerTestFile = { - id: 'f-1', - name: 'test/plain-stack-trace.ts', - type: 'suite', - mode: 'run', - filepath: 'test/plain-stack-trace.ts', - fullName: 'test/plain-stack-trace.ts', - meta: {}, - result: { - state: 'fail', - errors: [error], - }, - tasks: [], - projectName: '', - file: null!, +const fileWithTextStacks = TestRunner.createFileTask( + 'test/plain-stack-trace.ts', + '', + '', +) +fileWithTextStacks.mode = 'run' +fileWithTextStacks.result = { + state: 'fail', + errors: [error], } -fileWithTextStacks.file = fileWithTextStacks describe.todo('ViewReport', () => { describe('RunnerTestFile where stacks are in text', () => { @@ -93,31 +85,20 @@ describe.todo('ViewReport', () => { }) it('test html stack trace without html message', async () => { - const file: RunnerTestFile = { - id: 'f-1', - name: 'test/plain-stack-trace.ts', - type: 'suite', - mode: 'run', - filepath: 'test/plain-stack-trace.ts', - fullName: 'test/plain-stack-trace.ts', - meta: {}, - result: { - state: 'fail', - errors: [ - { - name: 'Do some test', - stacks: [], - stack: '\x1B[33mtest/plain-stack-trace.ts\x1B[0m', - message: 'Error: Transform failed with 1 error:', - diff, - }, - ], - }, - tasks: [], - projectName: '', - file: null!, + const file = TestRunner.createFileTask('test/plain-stack-trace.ts', '', '') + file.mode = 'run' + file.result = { + state: 'fail', + errors: [ + { + name: 'Do some test', + stacks: [], + stack: '\x1B[33mtest/plain-stack-trace.ts\x1B[0m', + message: 'Error: Transform failed with 1 error:', + diff, + }, + ], } - file.file = file const container = await render(ViewReport, { props: { file }, }) @@ -153,31 +134,20 @@ describe.todo('ViewReport', () => { }) it('test html stack trace and message', async () => { - const file: RunnerTestFile = { - id: 'f-1', - name: 'test/plain-stack-trace.ts', - type: 'suite', - mode: 'run', - filepath: 'test/plain-stack-trace.ts', - fullName: 'test/plain-stack-trace.ts', - meta: {}, - result: { - state: 'fail', - errors: [ - { - name: 'Do some test', - stack: '\x1B[33mtest/plain-stack-trace.ts\x1B[0m', - stacks: [], - message: '\x1B[44mError: Transform failed with 1 error:\x1B[0m', - diff, - }, - ], - }, - tasks: [], - projectName: '', - file: null!, + const file = TestRunner.createFileTask('test/plain-stack-trace.ts', '', '') + file.mode = 'run' + file.result = { + state: 'fail', + errors: [ + { + name: 'Do some test', + stack: '\x1B[33mtest/plain-stack-trace.ts\x1B[0m', + stacks: [], + message: '\x1B[44mError: Transform failed with 1 error:\x1B[0m', + diff, + }, + ], } - file.file = file const container = await render(ViewReport, { props: { file }, }) From e1c1824e37a90a7c8a87130db1c859908b0aecc2 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 4 Jun 2026 10:43:46 +0200 Subject: [PATCH 07/12] test: add test for concurrencyId/workerId --- test/e2e/test/reported-tasks.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/e2e/test/reported-tasks.test.ts b/test/e2e/test/reported-tasks.test.ts index 4769fe082b27..d3145c3ff9f1 100644 --- a/test/e2e/test/reported-tasks.test.ts +++ b/test/e2e/test/reported-tasks.test.ts @@ -99,6 +99,8 @@ it('correctly reports a file', ({ testModule, files, project }) => { expect(diagnostic.prepareDuration).toBeGreaterThan(0) expect(diagnostic.collectDuration).toBeGreaterThan(0) expect(diagnostic.duration).toBeGreaterThan(0) + expect(diagnostic.concurrencyId).toBeGreaterThan(0) + expect(diagnostic.workerId).toBeGreaterThan(0) // doesn't have a setup file expect(diagnostic.setupDuration).toBe(0) }) From 1b1393fad7890f5f981020ab28defaa301a879bb Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 4 Jun 2026 11:15:27 +0200 Subject: [PATCH 08/12] fix: make pool and worker 1-based --- packages/vitest/src/node/pool.ts | 2 +- packages/vitest/src/node/pools/pool.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index 7cb8fa65027d..781125776f12 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -84,7 +84,7 @@ export function createPool(ctx: Vitest): ProcessPool { // browser pool has a more complex logic, so we keep it separately for now browserSpecs: TestSpecification[] }[] = [] - let workerId = 0 + let workerId = 1 const sorted = await sequencer.sort(specs) const { environments, tags } = await getSpecificationsOptions(specs) diff --git a/packages/vitest/src/node/pools/pool.ts b/packages/vitest/src/node/pools/pool.ts index bb6cab29c8b8..ad1ce518e105 100644 --- a/packages/vitest/src/node/pools/pool.ts +++ b/packages/vitest/src/node/pools/pool.ts @@ -262,7 +262,7 @@ export class Pool { } private getWorkerId() { - let workerId = 0 + let workerId = 1 this.workerIds.forEach((state, id) => { if (state && !workerId) { From 18e927a7197c7311ecf01034c18d28c7da84e957 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 4 Jun 2026 11:31:15 +0200 Subject: [PATCH 09/12] refactor: more validation with workerId --- packages/vitest/src/node/pools/pool.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/vitest/src/node/pools/pool.ts b/packages/vitest/src/node/pools/pool.ts index ad1ce518e105..56d268649ab8 100644 --- a/packages/vitest/src/node/pools/pool.ts +++ b/packages/vitest/src/node/pools/pool.ts @@ -262,15 +262,19 @@ export class Pool { } private getWorkerId() { - let workerId = 1 + let workerId: number | undefined this.workerIds.forEach((state, id) => { - if (state && !workerId) { + if (state && workerId == null) { workerId = id this.workerIds.set(id, false) } }) + if (workerId == null) { + throw new Error('Cannot set worker id because there are no valid free ids.') + } + return workerId } From ae9543b177c5cc636cdae48abe01df4afa3bc093 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 4 Jun 2026 11:31:59 +0200 Subject: [PATCH 10/12] chore: rename --- packages/vitest/src/node/pools/pool.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/vitest/src/node/pools/pool.ts b/packages/vitest/src/node/pools/pool.ts index 56d268649ab8..3c6897037c65 100644 --- a/packages/vitest/src/node/pools/pool.ts +++ b/packages/vitest/src/node/pools/pool.ts @@ -75,7 +75,7 @@ export class Pool { let isMemoryLimitReached = false const runner = this.getPoolRunner(task, method) - const poolId = runner.poolId ?? this.getWorkerId() + const poolId = runner.poolId ?? this.getConcurrencyId() runner.poolId = poolId const activeTask = { task, resolver, method, cancelTask } @@ -261,21 +261,21 @@ export class Pool { throw new Error(`Runner ${task.worker} is not supported. Test files: ${formatFiles(task)}.`) } - private getWorkerId() { - let workerId: number | undefined + private getConcurrencyId() { + let concurrencyId: number | undefined this.workerIds.forEach((state, id) => { - if (state && workerId == null) { - workerId = id + if (state && concurrencyId == null) { + concurrencyId = id this.workerIds.set(id, false) } }) - if (workerId == null) { - throw new Error('Cannot set worker id because there are no valid free ids.') + if (concurrencyId == null) { + throw new Error('Cannot set concurrency id because there are no valid free ids.') } - return workerId + return concurrencyId } private freeWorkerId(id: number) { From 5593468551a8143422a3230e832adc255f20fc8c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 4 Jun 2026 12:45:02 +0200 Subject: [PATCH 11/12] test: only run playwright --- test/browser/specs/concurrency-id.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/browser/specs/concurrency-id.test.ts b/test/browser/specs/concurrency-id.test.ts index edbf1b777993..33758488f0f5 100644 --- a/test/browser/specs/concurrency-id.test.ts +++ b/test/browser/specs/concurrency-id.test.ts @@ -1,10 +1,10 @@ import type { TestModule } from 'vitest/node' import { expect, test } from 'vitest' -import { instances, runInlineBrowserTests } from './utils' +import { instances, provider, runInlineBrowserTests } from './utils' const [firstInstance] = instances -test('exposes concurrencyId/workerId bounded by maxWorkers', async () => { +test.runIf(provider.name === 'playwright')('exposes concurrencyId/workerId bounded by maxWorkers', async () => { const maxWorkers = 2 const fileCount = 4 From d2a57935ae8cf99363d66b56ef11bbff0f23aa9c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 5 Jun 2026 15:08:42 +0200 Subject: [PATCH 12/12] docs: add concurrencyId/workerId --- docs/api/advanced/test-module.md | 18 ++++++++++++++++++ .../src/node/reporters/reported-tasks.ts | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/docs/api/advanced/test-module.md b/docs/api/advanced/test-module.md index 673d4c175a52..1596b564dbc5 100644 --- a/docs/api/advanced/test-module.md +++ b/docs/api/advanced/test-module.md @@ -117,6 +117,24 @@ interface ModuleDiagnostic { * The time spent importing every non-externalized dependency that Vitest has processed. */ readonly importDurations: Record + /** + * The id of the worker that ran this file. This value cannot be higher than `maxWorkers`. + * If file did not run yet, this will be 0. + * + * **Warning**: Node.js tests and browser tests run in different pools and do not share `concurrencyId`. + * It is possible to have multiple modules with the same `concurrencyId` because of that. + * Use `project.isBrowserEnabled()` to distinguish the concurrency. + */ + readonly concurrencyId: number + /** + * Incremental number of the worker that ran this file. This number increases with each worker. + * If file did not run yet, this will be 0. + * + * **Warning**: Node.js tests and browser tests run in different pools and do not share `workerId`. + * It is possible to have multiple modules with the same `workerId` because of that. + * Use `project.isBrowserEnabled()` to distinguish the concurrency. + */ + readonly workerId: number } /** The time spent importing & executing a non-externalized file. */ diff --git a/packages/vitest/src/node/reporters/reported-tasks.ts b/packages/vitest/src/node/reporters/reported-tasks.ts index e6d448dab668..bf97a2d39277 100644 --- a/packages/vitest/src/node/reporters/reported-tasks.ts +++ b/packages/vitest/src/node/reporters/reported-tasks.ts @@ -746,11 +746,21 @@ export interface ModuleDiagnostic { /** * The id of the worker that ran this file. This value cannot be higher than `maxWorkers`. * If file did not run yet, this will be 0. + * + * **Warning**: Node.js tests and browser tests run in different pools and do not share `concurrencyId`. + * It is possible to have multiple modules with the same `concurrencyId` because of that. + * Use `project.isBrowserEnabled()` to distinguish the concurrency. + * @since 5.0.0 */ readonly concurrencyId: number /** * Incremental number of the worker that ran this file. This number increases with each worker. * If file did not run yet, this will be 0. + * + * **Warning**: Node.js tests and browser tests run in different pools and do not share `workerId`. + * It is possible to have multiple modules with the same `workerId` because of that. + * Use `project.isBrowserEnabled()` to distinguish the concurrency. + * @since 5.0.0 */ readonly workerId: number }