Skip to content
Merged
18 changes: 18 additions & 0 deletions docs/api/advanced/test-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,24 @@ interface ModuleDiagnostic {
* The time spent importing every non-externalized dependency that Vitest has processed.
*/
readonly importDurations: Record<string, ImportDuration>
/**
* 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. */
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/src/client/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface IframeExecuteEvent {
files: FileSpecification[]
iframeId: string
context: string
concurrencyId: number
workerId: number
}

export interface IframeCleanupEvent {
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/client/tester/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const state: WorkerGlobalState = {
rpc: null as any,
pool: 'browser',
workerId: 1,
concurrencyId: 1,
config,
projectName: config.name || '',
files: [],
Expand Down
6 changes: 5 additions & 1 deletion packages/browser/src/client/tester/tester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,16 @@ 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
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'))
Expand Down
102 changes: 36 additions & 66 deletions packages/ui/client/components/views/ViewReport.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 },
})
Expand Down Expand Up @@ -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 },
})
Expand Down
25 changes: 24 additions & 1 deletion packages/vitest/src/integrations/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<void> {
await waitNextTick()
const state = getWorkerState()
const promises: Promise<unknown>[] = []
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()
}
2 changes: 2 additions & 0 deletions packages/vitest/src/node/browser/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 34 additions & 3 deletions packages/vitest/src/node/pools/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
})
Expand All @@ -292,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<number>()
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) {
Expand Down Expand Up @@ -359,6 +385,7 @@ class BrowserPool {
},
},
async () => {
const concurrencyId = this.getConcurrencyId(sessionId)
return orchestrator.createTesters(
{
method,
Expand All @@ -367,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,

@hi-ogawa hi-ogawa Jun 4, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it intended to have workerId being independently starting from zero for node pool and browser pool?

This may feel non intuitive for node/browser mixed projects run since the same diagnostic.workerId === 0 in reporter metadata can come from different node and browser project.

@sheremet-va sheremet-va Jun 4, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's intended. They are separate pools with separate workers and run independently from each other. Until we combine them, they must have different poolId/workerId

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the use case of OP #10306 (comment), concurrencyId isn't meant to be overlapped when running node/browser projects running in parallel. Don't remember mixed pol case, but can overlap this happen depending on project groupOrder? Not blocking, but just a question I wanted to confirm.

@sheremet-va sheremet-va Jun 5, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

groupOrder happens within the pool, so ids stay within maxWorkers

For the purpose of OP’s use case, they need to take into account that browser pool runs independently, they can do it based on project.isBrowserEnabled (and add a browser prefix, for example)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a warning to docs

},
)
},
Expand Down
16 changes: 10 additions & 6 deletions packages/vitest/src/node/pools/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -261,17 +261,21 @@ export class Pool {
throw new Error(`Runner ${task.worker} is not supported. Test files: ${formatFiles(task)}.`)
}

private getWorkerId() {
let workerId = 0
private getConcurrencyId() {
let concurrencyId: number | undefined

this.workerIds.forEach((state, id) => {
if (state && !workerId) {
workerId = id
if (state && concurrencyId == null) {
concurrencyId = id
this.workerIds.set(id, false)
}
})

return workerId
if (concurrencyId == null) {
throw new Error('Cannot set concurrency id because there are no valid free ids.')
}

return concurrencyId
}

private freeWorkerId(id: number) {
Expand Down
Loading
Loading