[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix: properly track transform, add `worker` time

Vladimir Sheremet (Jul 27, 2026, 12:55 PM +0200) 11cf6364 0df45c1a

+152 -93
+3 -2
docs/guide/improving-performance.md
··· 13 13 The phases map to configuration options: 14 14 15 15 - `environment` - creating the test environment (for example `jsdom`, `happy-dom`) for test files. 16 - - `transform` - transforming files with Vite. See [Caching Between Reruns](#caching-between-reruns). 17 - - `import` - importing test files and their modules. When files import mostly the same modules (typical for barrel-file imports), isolation re-evaluates that shared graph for every file. See [Test Isolation](#test-isolation). 16 + - `transform` - waiting for Vite to resolve and transform imported modules. See [Caching Between Reruns](#caching-between-reruns). 17 + - `import` - evaluating test files and their modules, excluding the transform wait tracked above. When files import mostly the same modules (typical for barrel-file imports), isolation re-evaluates that shared graph for every file. See [Test Isolation](#test-isolation). 18 18 - `setup` - running [`setupFiles`](/config/setupfiles). 19 + - `worker` - preparing the test runner in each worker. Isolation pays this cost for every test file. See [Test Isolation](#test-isolation). 19 20 - `tests` - running the tests themselves. A run dominated by this phase has little to gain from configuration changes. 20 21 21 22 ## Test Isolation
+1
packages/browser/src/client/tester/state.ts
··· 42 42 durations: { 43 43 environment: 0, 44 44 prepare: performance.now(), 45 + fetch: 0, 45 46 }, 46 47 providedContext: {}, 47 48 }
+2 -4
packages/vitest/src/node/core.ts
··· 255 255 this._resolver, 256 256 resolved, 257 257 this._fsCache, 258 - this.state, 259 258 this._traces, 260 259 this._tmpDir, 261 260 ) ··· 647 646 throw new Error('Cannot merge reports when `--reporter=blob` is used. Remove blob reporter from the config first.') 648 647 } 649 648 650 - const { files, errors, coverages, executionTimes, transformTimes } = await readBlobs(this.version, directory || this.config.mergeReports, this.projects) 651 - this.state.blobs = { files, errors, coverages, executionTimes, transformTimes } 652 - this.state.transformTime = transformTimes.reduce((a, b) => a + b, 0) 649 + const { files, errors, coverages, executionTimes } = await readBlobs(this.version, directory || this.config.mergeReports, this.projects) 650 + this.state.blobs = { files, errors, coverages, executionTimes } 653 651 654 652 await this.report('onInit', this) 655 653
+14 -34
packages/vitest/src/node/environments/fetchModule.ts
··· 18 18 const saveCachePromises = new Map<string, Promise<VitestFetchResult>>() 19 19 const readFilePromises = new Map<string, Promise<string | null>>() 20 20 21 - /** 22 - * Tracks the wall time during which at least one transform is running. 23 - * Durations of individual fetches cannot be summed instead: concurrent 24 - * fetches (parallel workers, the vm pool graph prewarm) all wait on the same 25 - * deduplicated in-flight transforms, so per-caller wall times overcount the 26 - * actual work by orders of magnitude. 27 - */ 28 - export interface TransformClock { 29 - transformStarted: () => void 30 - transformFinished: () => void 31 - } 32 - 33 21 class ModuleFetcher { 34 22 private tmpDirectories = new Set<string>() 35 23 private fsCacheEnabled: boolean ··· 42 30 private resolver: VitestResolver, 43 31 private config: ResolvedConfig, 44 32 private fsCache: FileSystemModuleCache, 45 - private clock: TransformClock, 46 33 private tmpProjectDir: string, 47 34 ) { 48 35 this.fsCacheEnabled = config.fsModuleCache === true ··· 325 312 moduleGraphModule: EnvironmentModuleNode, 326 313 options?: FetchFunctionOptions, 327 314 ): Promise<VitestFetchResult> { 328 - this.clock.transformStarted() 329 - try { 330 - const moduleRunnerModule = await fetchModule( 331 - environment, 332 - url, 333 - importer, 334 - { 335 - ...options, 336 - inlineSourceMap: false, 337 - }, 338 - ).catch(handleRollupError) 315 + const moduleRunnerModule = await fetchModule( 316 + environment, 317 + url, 318 + importer, 319 + { 320 + ...options, 321 + inlineSourceMap: false, 322 + }, 323 + ).catch(handleRollupError) 339 324 340 - const result: VitestFetchResult = processResultSource(environment, moduleRunnerModule) 341 - if ('code' in result) { 342 - result.moduleType = await this.cachedModuleType(result.file, result.code, moduleGraphModule.transformResult) 343 - } 344 - return result 345 - } 346 - finally { 347 - this.clock.transformFinished() 325 + const result: VitestFetchResult = processResultSource(environment, moduleRunnerModule) 326 + if ('code' in result) { 327 + result.moduleType = await this.cachedModuleType(result.file, result.code, moduleGraphModule.transformResult) 348 328 } 329 + return result 349 330 } 350 331 351 332 private sourceLoader(file: string | null): (() => Promise<string | null>) | undefined { ··· 434 415 resolver: VitestResolver, 435 416 config: ResolvedConfig, 436 417 fsCache: FileSystemModuleCache, 437 - clock: TransformClock, 438 418 traces: Traces, 439 419 tmpProjectDir: string, 440 420 ): VitestFetchFunction { 441 - const fetcher = new ModuleFetcher(resolver, config, fsCache, clock, tmpProjectDir) 421 + const fetcher = new ModuleFetcher(resolver, config, fsCache, tmpProjectDir) 442 422 return async (url, importer, environment, cacheFs, options, otelCarrier) => { 443 423 await traces.waitInit() 444 424 const context = otelCarrier
-1
packages/vitest/src/node/project.ts
··· 101 101 this._resolver, 102 102 this.config, 103 103 this.vitest._fsCache, 104 - this.vitest.state, 105 104 this.vitest._traces, 106 105 this.tmpDir, 107 106 )
-1
packages/vitest/src/node/reporters/base.ts
··· 641 641 642 642 const breakdown = computeDurationBreakdown({ 643 643 files, 644 - transformTime: this.ctx.state.transformTime, 645 644 typecheckTime: sum(this.ctx.projects, project => project.typechecker?.getResult().time), 646 645 }) 647 646
+2 -7
packages/vitest/src/node/reporters/blob.ts
··· 76 76 coverage, 77 77 executionTime, 78 78 environmentModules, 79 - this.ctx.state.transformTime, 80 79 ] satisfies MergeReport) 81 80 82 81 let outputFile = this.options.outputFile ?? getOutputFile(this.ctx.config, 'blob') ··· 128 127 ) 129 128 } 130 129 const content = await readFile(fullPath, 'utf-8') 131 - const [version, files, errors, coverage, executionTime, environmentModules, transformTime] = parse( 130 + const [version, files, errors, coverage, executionTime, environmentModules] = parse( 132 131 content, 133 132 ) as MergeReport 134 133 if (!version) { ··· 136 135 `vitest.mergeReports() expects all paths in "${blobsDirectory}" to be files generated by the blob reporter, but "${filename}" is not a valid blob file`, 137 136 ) 138 137 } 139 - return { version, files, errors, coverage, file: filename, executionTime, environmentModules, transformTime } 138 + return { version, files, errors, coverage, file: filename, executionTime, environmentModules } 140 139 }) 141 140 const blobs = await Promise.all(promises) 142 141 ··· 192 191 const errors = blobs.flatMap(blob => blob.errors) 193 192 const coverages = blobs.map(blob => blob.coverage) 194 193 const executionTimes = blobs.map(blob => blob.executionTime) 195 - const transformTimes = blobs.map(blob => blob.transformTime) 196 194 197 195 return { 198 196 files, 199 197 errors, 200 198 coverages, 201 199 executionTimes, 202 - transformTimes, 203 200 } 204 201 } 205 202 ··· 208 205 errors: unknown[] 209 206 coverages: unknown[] 210 207 executionTimes: number[] 211 - transformTimes: number[] 212 208 } 213 209 214 210 export type MergeReport = [ ··· 218 214 coverage: unknown, 219 215 executionTime: number, 220 216 environmentModules: MergeReportEnvironmentModules, 221 - transformTime: number, 222 217 ] 223 218 224 219 interface MergeReportEnvironmentModules {
+11 -5
packages/vitest/src/node/reporters/durationBreakdown.ts
··· 16 16 17 17 export interface DurationBreakdownInput { 18 18 files: File[] 19 - /** Total transform time across all projects. */ 20 - transformTime: number 21 19 /** Total typecheck time across all projects. */ 22 20 typecheckTime: number 23 21 } ··· 26 24 input: DurationBreakdownInput, 27 25 ): DurationBreakdown { 28 26 const sums = { 29 - transform: input.transformTime, 27 + transform: 0, 30 28 setup: 0, 31 29 import: 0, 32 30 tests: 0, 33 31 environment: 0, 32 + worker: 0, 34 33 typecheck: input.typecheckTime, 35 34 } 36 35 for (const file of input.files) { 37 - sums.setup += file.setupDuration || 0 38 - sums.import += file.collectDuration || 0 36 + // setup and collect wall times include the time the worker spent waiting 37 + // for module transforms; report that wait as its own "transform" phase so 38 + // every phase is a disjoint sum of per-worker time 39 + const setupFetch = file.setupFetchDuration || 0 40 + const collectFetch = file.collectFetchDuration || 0 41 + sums.transform += setupFetch + collectFetch 42 + sums.setup += Math.max((file.setupDuration || 0) - setupFetch, 0) 43 + sums.import += Math.max((file.collectDuration || 0) - collectFetch, 0) 39 44 sums.tests += file.result?.duration || 0 40 45 sums.environment += file.environmentLoad || 0 46 + sums.worker += file.prepareDuration || 0 41 47 } 42 48 43 49 const entries = Object.entries(sums)
+1 -25
packages/vitest/src/node/state.ts
··· 1 1 import type { File, FileSpecification, Task, TaskResultPack } from '../runtime/runner/types' 2 2 import type { AsyncLeak, UserConsoleLog } from '../types/general' 3 - import type { TransformClock } from './environments/fetchModule' 4 3 import type { TestProject } from './project' 5 4 import type { MergedBlobs } from './reporters/blob' 6 5 import type { OnUnhandledErrorCallback } from './types/config' ··· 16 15 return err instanceof Error && 'errors' in err 17 16 } 18 17 19 - export class StateManager implements TransformClock { 18 + export class StateManager { 20 19 filesMap: Map<string, File[]> = new Map() 21 20 pathsSet: Set<string> = new Set() 22 21 idMap: Map<string, Task> = new Map() ··· 25 24 leakSet: Set<AsyncLeak> = new Set() 26 25 reportedTasksMap: WeakMap<Task, TestModule | TestCase | TestSuite> = new WeakMap() 27 26 blobs?: MergedBlobs 28 - /** 29 - * Wall time during which the server's module transform pipeline was busy, 30 - * measured as the union of in-flight fetch intervals. Individual fetch 31 - * durations cannot be summed instead: concurrent fetches (parallel workers, 32 - * the vm pool graph prewarm) all wait on the same deduplicated in-flight 33 - * transforms, so per-caller wall times overcount the actual work by orders 34 - * of magnitude. 35 - */ 36 - transformTime = 0 37 - private _transformsInflight = 0 38 - private _transformsBusyStart = 0 39 - 40 - transformStarted(): void { 41 - if (this._transformsInflight++ === 0) { 42 - this._transformsBusyStart = performance.now() 43 - } 44 - } 45 - 46 - transformFinished(): void { 47 - if (--this._transformsInflight === 0) { 48 - this.transformTime += performance.now() - this._transformsBusyStart 49 - } 50 - } 51 27 52 28 metadata: Record<string, { 53 29 externalized: Record<string, string>
+22 -3
packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts
··· 43 43 getSafeWorkerState() || options.state 44 44 const rpc = () => state().rpc 45 45 46 + // Wall time the worker spends blocked on server round-trips, measured as the 47 + // union of in-flight intervals: sibling imports await fetches concurrently, 48 + // so summing individual call durations would overcount the blocked time. 49 + let fetchesInflight = 0 50 + let fetchesBusyStart = 0 51 + async function trackFetchTime<T>(fetchPromise: Promise<T>): Promise<T> { 52 + if (fetchesInflight++ === 0) { 53 + fetchesBusyStart = performance.now() 54 + } 55 + try { 56 + return await fetchPromise 57 + } 58 + finally { 59 + if (--fetchesInflight === 0) { 60 + state().durations.fetch += performance.now() - fetchesBusyStart 61 + } 62 + } 63 + } 64 + 46 65 const environment = () => { 47 66 const environment = state().environment 48 67 return environment.viteEnvironment || environment.name ··· 173 192 // its import graph is connected on the server, so the snapshot 174 193 // actually covers the file's transitive dependencies 175 194 if (importer != null) { 176 - const warm = await fetchWarmModules() 195 + const warm = await trackFetchTime(fetchWarmModules()) 177 196 // the null prototype is not preserved by the IPC serialization, so 178 197 // ids like "constructor" must not fall through to Object.prototype 179 198 const warmResult = warm && ( ··· 200 219 } 201 220 202 221 const otelCarrier = traces?.getContextCarrier() 203 - const result = await rpc().fetch( 222 + const result = await trackFetchTime(rpc().fetch( 204 223 id, 205 224 importer, 206 225 environment(), 207 226 options, 208 227 otelCarrier, 209 - ) 228 + )) 210 229 if ('cached' in result) { 211 230 const code = readFileSync(result.tmp, 'utf-8') 212 231 return { code, ...result }
+4 -1
packages/vitest/src/runtime/runVmTests.ts
··· 84 84 testRunner.cancel?.(reason) 85 85 }) 86 86 87 + // unlike other pools, the vm pool creates the environment inside the 88 + // prepare window; subtract it so `prepare` excludes the environment 89 + // load time in every pool 87 90 workerState.durations.prepare 88 - = performance.now() - workerState.durations.prepare 91 + = performance.now() - workerState.durations.prepare - workerState.durations.environment 89 92 90 93 const { vi } = VitestIndex 91 94
+10
packages/vitest/src/runtime/runner/collect.ts
··· 65 65 clearCollectorContext(file, runner) 66 66 67 67 const setupFiles = toArray(config.setupFiles) 68 + const fetchBeforeSetup = runner.getModuleFetchDuration?.() 68 69 if (setupFiles.length) { 69 70 const setupStart = now() 70 71 await runSetupFiles(config, setupFiles, runner) ··· 73 74 } 74 75 else { 75 76 file.setupDuration = 0 77 + } 78 + 79 + const fetchBeforeCollect = runner.getModuleFetchDuration?.() 80 + if (fetchBeforeSetup != null && fetchBeforeCollect != null) { 81 + file.setupFetchDuration = fetchBeforeCollect - fetchBeforeSetup 76 82 } 77 83 78 84 const collectStart = now() ··· 108 114 109 115 setHooks(file, fileHooks) 110 116 file.collectDuration = now() - collectStart 117 + const fetchAfterCollect = runner.getModuleFetchDuration?.() 118 + if (fetchBeforeCollect != null && fetchAfterCollect != null) { 119 + file.collectFetchDuration = fetchAfterCollect - fetchBeforeCollect 120 + } 111 121 } 112 122 catch (e) { 113 123 const errors = e instanceof AggregateError
+16
packages/vitest/src/runtime/runner/types.ts
··· 315 315 */ 316 316 collectDuration?: number 317 317 /** 318 + * The portion of `collectDuration` the worker spent waiting for the server 319 + * to resolve and transform modules. 320 + */ 321 + collectFetchDuration?: number 322 + /** 318 323 * The time it took to import the setup file. 319 324 */ 320 325 setupDuration?: number 326 + /** 327 + * The portion of `setupDuration` the worker spent waiting for the server 328 + * to resolve and transform modules. 329 + */ 330 + setupFetchDuration?: number 321 331 /** 322 332 * Whether the file is initiated without running any tests. 323 333 * This is done to populate state on the server side by Vitest. ··· 1717 1727 * Gets the time spent importing each individual non-externalized file that Vitest collected. 1718 1728 */ 1719 1729 getImportDurations?: () => Record<string, ImportDuration> 1730 + /** 1731 + * Gets the cumulative time the worker has spent waiting for the server to 1732 + * resolve and transform modules. Used to attribute the transform wait to the 1733 + * setup and collect phases. 1734 + */ 1735 + getModuleFetchDuration?: () => number 1720 1736 /** 1721 1737 * Publicly available configuration. 1722 1738 */
+4
packages/vitest/src/runtime/runners/test.ts
··· 279 279 return importDurations 280 280 } 281 281 282 + getModuleFetchDuration(): number { 283 + return this.workerState.durations.fetch 284 + } 285 + 282 286 trace = <T>(name: string, attributes: Record<string, any> | (() => T), cb?: () => T): T => { 283 287 const options: SpanOptions = typeof attributes === 'object' ? { attributes } : {} 284 288 return this._otel.$(`vitest.test.runner.${name}`, options, cb || attributes as () => T)
+1
packages/vitest/src/runtime/worker.ts
··· 39 39 durations: { 40 40 environment: 0, 41 41 prepare: prepareStart, 42 + fetch: 0, 42 43 }, 43 44 rpc, 44 45 onCancel,
+5
packages/vitest/src/types/worker.ts
··· 87 87 durations: { 88 88 environment: number 89 89 prepare: number 90 + /** 91 + * Wall time the worker spent blocked on module fetch requests to the 92 + * server, measured as the union of in-flight intervals. 93 + */ 94 + fetch: number 90 95 } 91 96 onFilterStackTrace?: (trace: string) => string 92 97 }
+3 -1
test/e2e/test/reporters/merge-reports.test.ts
··· 263 263 '', 264 264 ) 265 265 file.tasks.push(createTest('some test', file)) 266 + file.collectDuration = 2000 * index 267 + file.collectFetchDuration = 2000 * index 266 268 267 269 await writeBlob( 268 - [version, [file], [], undefined, 1500 * index, {}, 2000 * index], 270 + [version, [file], [], undefined, 1500 * index, {}], 269 271 resolve(`./fixtures/reporters/merge-reports/.vitest/blob/blob-${index}-2.json`), 270 272 ) 271 273 }
+53 -9
test/unit/test/duration-breakdown.test.ts
··· 7 7 8 8 function makeFile(overrides: { 9 9 setup?: number 10 + setupFetch?: number 10 11 import?: number 12 + importFetch?: number 11 13 tests?: number 12 14 environment?: number 15 + worker?: number 13 16 }): File { 14 17 return { 15 18 setupDuration: overrides.setup ?? 0, 19 + setupFetchDuration: overrides.setupFetch ?? 0, 16 20 collectDuration: overrides.import ?? 0, 21 + collectFetchDuration: overrides.importFetch ?? 0, 17 22 environmentLoad: overrides.environment ?? 0, 23 + prepareDuration: overrides.worker ?? 0, 18 24 result: { state: 'pass', duration: overrides.tests ?? 0 }, 19 25 } as unknown as File 20 26 } ··· 23 29 it('computes shares relative to the sum of tracked phases', () => { 24 30 const breakdown = computeDurationBreakdown({ 25 31 files: [ 26 - makeFile({ environment: 600, import: 200, tests: 50 }), 32 + makeFile({ environment: 600, import: 200, tests: 50, setup: 150 }), 27 33 ], 28 - transformTime: 150, 29 34 typecheckTime: 0, 30 35 }) 31 36 ··· 33 38 expect(breakdown.phases).toEqual([ 34 39 { name: 'environment', time: 600, percent: 60 }, 35 40 { name: 'import', time: 200, percent: 20 }, 36 - { name: 'transform', time: 150, percent: 15 }, 41 + { name: 'setup', time: 150, percent: 15 }, 37 42 { name: 'tests', time: 50, percent: 5 }, 38 43 ]) 39 44 }) 40 45 46 + it('reports the transform wait as its own phase, excluded from setup and import', () => { 47 + const breakdown = computeDurationBreakdown({ 48 + files: [ 49 + makeFile({ setup: 200, setupFetch: 100, import: 500, importFetch: 300, tests: 100 }), 50 + ], 51 + typecheckTime: 0, 52 + }) 53 + 54 + expect(breakdown.total).toBe(800) 55 + expect(breakdown.phases).toEqual([ 56 + { name: 'transform', time: 400, percent: 50 }, 57 + { name: 'import', time: 200, percent: 25 }, 58 + { name: 'setup', time: 100, percent: 12.5 }, 59 + { name: 'tests', time: 100, percent: 12.5 }, 60 + ]) 61 + }) 62 + 63 + it('never reports negative setup and import times', () => { 64 + const breakdown = computeDurationBreakdown({ 65 + files: [ 66 + makeFile({ setup: 100, setupFetch: 150, import: 200, importFetch: 250, tests: 100 }), 67 + ], 68 + typecheckTime: 0, 69 + }) 70 + 71 + expect(breakdown.phases).toEqual([ 72 + { name: 'transform', time: 400, percent: 80 }, 73 + { name: 'tests', time: 100, percent: 20 }, 74 + ]) 75 + }) 76 + 41 77 it('sums phases across all files and projects', () => { 42 78 const breakdown = computeDurationBreakdown({ 43 79 files: [ 44 80 makeFile({ environment: 900, tests: 100 }), 45 - makeFile({ import: 500, tests: 300 }), 81 + makeFile({ import: 700, importFetch: 200, tests: 300 }), 46 82 ], 47 - transformTime: 200, 48 83 typecheckTime: 0, 49 84 }) 50 85 51 86 expect(breakdown.total).toBe(2000) 52 87 expect(breakdown.phases[0]).toEqual({ name: 'environment', time: 900, percent: 45 }) 88 + expect(breakdown.phases).toContainEqual({ name: 'transform', time: 200, percent: 10 }) 53 89 }) 54 90 55 91 it('drops phases below half a percent', () => { 56 92 const breakdown = computeDurationBreakdown({ 57 93 files: [makeFile({ import: 1000, setup: 1 })], 58 - transformTime: 0, 59 94 typecheckTime: 0, 60 95 }) 61 96 62 97 expect(breakdown.phases.map(phase => phase.name)).toEqual(['import']) 63 98 }) 64 99 100 + it('includes worker preparation time', () => { 101 + const breakdown = computeDurationBreakdown({ 102 + files: [makeFile({ worker: 250, tests: 750 })], 103 + typecheckTime: 0, 104 + }) 105 + 106 + expect(breakdown.phases).toEqual([ 107 + { name: 'tests', time: 750, percent: 75 }, 108 + { name: 'worker', time: 250, percent: 25 }, 109 + ]) 110 + }) 111 + 65 112 it('includes typecheck time', () => { 66 113 const breakdown = computeDurationBreakdown({ 67 114 files: [makeFile({ tests: 100 })], 68 - transformTime: 0, 69 115 typecheckTime: 300, 70 116 }) 71 117 ··· 75 121 it('returns no phases when nothing was tracked', () => { 76 122 const breakdown = computeDurationBreakdown({ 77 123 files: [], 78 - transformTime: 0, 79 124 typecheckTime: 0, 80 125 }) 81 126 expect(breakdown.total).toBe(0) ··· 87 132 it('formats phases as rounded percentages', () => { 88 133 const breakdown = computeDurationBreakdown({ 89 134 files: [makeFile({ environment: 856, import: 137, tests: 7 })], 90 - transformTime: 0, 91 135 typecheckTime: 0, 92 136 }) 93 137