[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.

feat: add `vitest doctor`

Measures how much faster the suite runs under alternative configurations
by running it under each of them, and recommends the fastest with a
ready-to-paste config snippet. Candidates run through a generated runner
script so the happy-dom candidate can swap the environment of jsdom
projects only; every measured run includes browser projects.

Vladimir Sheremet (Jul 23, 2026, 6:09 PM +0200) 54b77d09 fd3342a6

+929 -1
+2
docs/config/experimental.md
··· 461 461 462 462 Hints never suggest changing an option that was set explicitly: if the config defines `pool`, other pools are not suggested, and an explicitly configured `isolate` is never suggested to be disabled. Hints are also printed in CI. Set the option to `false` to disable all hints, or disable them individually. 463 463 464 + To measure the impact of a configuration change instead of estimating it, run [`vitest doctor`](/guide/cli#vitest-doctor). 465 + 464 466 ### experimental.diagnostics.isolate {#experimental-diagnostics-isolate} 465 467 466 468 - **Type:** `boolean`
+42
docs/guide/cli.md
··· 123 123 124 124 Since Vitest 4.1, you may pass `--static-parse` to [parse test files](/api/advanced/vitest#parsespecifications) instead of running them to collect tests. Vitest parses test files with limited concurrency, defaulting to `os.availableParallelism()`. You can change it via the `--static-parse-concurrency` option. 125 125 126 + ### `vitest doctor` 127 + 128 + `vitest doctor` measures how much faster the test suite would run under alternative configurations by running it under each of them. The candidates are picked based on the current config: 129 + 130 + ```bash 131 + vitest doctor 132 + ``` 133 + 134 + ``` 135 + Results (min of 3 runs each) 136 + 137 + baseline (pool: forks · isolate: true) 4.08s 138 + pool: 'threads' 3.64s (-11%) 139 + pool: 'vmThreads' 1.33s (-67%) 140 + isolate: false 1.28s (-69%) 141 + 142 + Recommendation: pool: 'vmThreads' (-67%) 143 + 144 + // vitest.config.ts 145 + import { defineConfig } from 'vitest/config' 146 + 147 + export default defineConfig({ 148 + test: { 149 + pool: 'vmThreads', // measured -67% on this suite 150 + }, 151 + }) 152 + ``` 153 + 154 + The `isolate: false` candidate is additionally validated by running the suite twice with a shuffled file order: if any test depends on isolation, the candidate is reported as failed instead of recommended. When several candidates are close to the fastest, doctor prefers the one that keeps per-file isolation. 155 + 156 + Doctor also probes lower [`maxWorkers`](/config/maxworkers) values on top of the winning configuration: every worker funnels its transform requests through the single main-thread Vite server, so past a certain count more workers make the run slower, not faster. Starting from half the current worker count, doctor keeps halving while the suite gets at least 5% faster, and includes the winning value in the recommendation. 157 + 158 + Projects running `jsdom` are also measured under `environment: 'happy-dom'` when the package is installed. The swap is applied per project; projects on other environments keep them. happy-dom implements the DOM differently than jsdom, so tests that depend on layout or navigation should be verified before adopting the swap. When the [fs module cache](/config/fsmodulecache) is off, doctor measures `fsModuleCache: true` after an untimed priming run that populates the cache, so the reported time is what repeated runs pay. 159 + 160 + Every measurement runs the full suite, including browser projects: `isolate: false` also affects browser mode. Candidates that cannot affect browser projects (`pool`, `environment`, the fs module cache) are picked based on the node-side projects only. 161 + 162 + Failing candidates are reported with an excerpt of their errors. If the suite fails under the current configuration, doctor aborts and shows the errors: it needs a passing baseline to compare against. 163 + 164 + Short suites are measured multiple times and the best time is reported, so the comparison reflects a warm steady state. Doctor runs the full suite several times, so it takes a multiple of a normal run's time. See [Improving Performance](/guide/improving-performance) for the trade-offs behind every candidate. 165 + 166 + Doctor measures and reports the baseline even when there are no candidates to compare. Configurations on a `vm` pool are additionally compared against `pool: 'threads'` with `isolate: false`, which also reuses workers but shares module state between files. 167 + 126 168 ## Shell Autocompletions 127 169 128 170 Vitest provides shell autocompletions for commands, options, and option values powered by [`@bomb.sh/tab`](https://github.com/bombshell-dev/tab).
+3 -1
docs/guide/improving-performance.md
··· 20 20 21 21 When the collected timings show that a configuration change would make the run significantly faster, Vitest also prints a hint after the summary, see [`experimental.diagnostics`](/config/experimental#experimental-diagnostics). Hints never suggest changing an option that was set explicitly. 22 22 23 + [`vitest doctor`](/guide/cli#vitest-doctor) measures the alternative configurations instead of estimating them: it runs the suite under each candidate and reports the comparison, including whether the tests pass with `isolate: false`. 24 + 23 25 ## Test Isolation 24 26 25 27 By default Vitest runs every test file in an isolated environment based on the [pool](/config/pool): ··· 103 105 |---|---|---|---| 104 106 | `pool: 'forks'`/`'threads'` + `isolate: true` (default) | once per file | fresh process/thread and environment per file | safest, slowest | 105 107 | `pool: 'vmThreads'` | once per worker | fresh VM context and `window` per file | test code runs in a VM realm: cross-realm `instanceof` edge cases with externalized packages, and memory is not reclaimed as reliably (see [`vmMemoryLimit`](/config/vmmemorylimit)) | 106 - | `isolate: false` | once per worker | none - files in the same worker share the environment and module state | tests must not depend on a clean `window` or module state | 108 + | `isolate: false` | once per worker | none - files in the same worker share the environment and module state | tests must not depend on a clean `window` or module state; run `vitest doctor` to check | 107 109 108 110 ```ts [vitest.config.js] 109 111 import { defineConfig } from 'vitest/config'
+67
test/e2e/test/doctor.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + import { runVitestCli } from '../../test-utils' 3 + 4 + test('doctor measures alternative configurations and reports a table', async () => { 5 + const { vitest, exitCode, waitForClose } = await runVitestCli( 6 + { nodeOptions: { cwd: 'fixtures/doctor' } }, 7 + 'doctor', 8 + ) 9 + 10 + await waitForClose() 11 + 12 + expect(vitest.stderr).toBe('') 13 + expect(exitCode).toBe(0) 14 + 15 + const stdout = vitest.stdout 16 + expect(stdout).toContain('measuring baseline (pool: forks · isolate: true)') 17 + // node environment on forks: threads and isolate:false are worth measuring 18 + expect(stdout).toContain(`measuring pool: 'threads'`) 19 + expect(stdout).toContain('measuring isolate: false') 20 + // the fs module cache is off, so persisting transforms is worth measuring 21 + expect(stdout).toContain('measuring fsModuleCache: true') 22 + // the DOM candidates are not: there is nothing to amortize or swap 23 + expect(stdout).not.toContain('vmThreads') 24 + expect(stdout).not.toContain('happy-dom') 25 + 26 + expect(stdout).toContain('Results (min of 3 runs each)') 27 + expect(stdout).toMatch(/Recommendation: /) 28 + }, 120_000) 29 + 30 + test('doctor swaps the environment per project', async () => { 31 + const { vitest, exitCode, waitForClose } = await runVitestCli( 32 + { nodeOptions: { cwd: 'fixtures/doctor-projects' } }, 33 + 'doctor', 34 + ) 35 + 36 + await waitForClose() 37 + 38 + expect(vitest.stderr).toBe('') 39 + expect(exitCode).toBe(0) 40 + 41 + const stdout = vitest.stdout 42 + // only the jsdom project is swapped: the node project asserts that no DOM 43 + // leaked into it, so a workspace-wide override would fail this candidate 44 + expect(stdout).toContain(`measuring environment: 'happy-dom'`) 45 + expect(stdout).not.toContain(`environment: 'happy-dom' failed`) 46 + expect(stdout).toMatch(/environment: 'happy-dom'\s+\d/) 47 + }, 120_000) 48 + 49 + test('doctor reports the errors of failing candidates', async () => { 50 + const { vitest, exitCode, waitForClose } = await runVitestCli( 51 + { nodeOptions: { cwd: 'fixtures/doctor-failing' } }, 52 + 'doctor', 53 + ) 54 + 55 + await waitForClose() 56 + 57 + expect(exitCode).toBe(0) 58 + 59 + const stdout = vitest.stdout 60 + // the DOM environment makes vmThreads a candidate, and the fixture fails under it 61 + expect(stdout).toContain(`pool: 'vmThreads' failed with:`) 62 + expect(stdout).toContain('FAIL vm-hostile.test.ts > does not run under a vm pool') 63 + // the failing candidate is never recommended 64 + expect(stdout).not.toMatch(/Recommendation: pool: 'vmThreads'/) 65 + // an all-jsdom suite also measures the happy-dom swap 66 + expect(stdout).toContain(`measuring environment: 'happy-dom'`) 67 + }, 120_000)
+97
test/unit/test/doctor-candidates.test.ts
··· 1 + import type { DoctorCandidateOptions, DoctorProjectSummary } from '../../../packages/vitest/src/node/cli/doctor' 2 + import { describe, expect, it } from 'vitest' 3 + import { resolveDoctorCandidates } from '../../../packages/vitest/src/node/cli/doctor' 4 + 5 + function project(overrides: Partial<DoctorProjectSummary>): DoctorProjectSummary { 6 + return { 7 + name: '', 8 + pool: 'forks', 9 + environment: 'node', 10 + isolate: true, 11 + browser: false, 12 + fsModuleCache: false, 13 + ...overrides, 14 + } 15 + } 16 + 17 + function candidateIds(projects: DoctorProjectSummary[], options?: DoctorCandidateOptions): string[] { 18 + return resolveDoctorCandidates(projects, options).map(candidate => candidate.id) 19 + } 20 + 21 + describe('resolveDoctorCandidates', () => { 22 + it('suggests threads, no-isolate and the fs cache for the default configuration', () => { 23 + expect(candidateIds([project({})])).toEqual(['threads', 'no-isolate', 'fs-cache']) 24 + }) 25 + 26 + it('adds vmThreads for DOM environments', () => { 27 + expect(candidateIds([project({ environment: 'jsdom' })])) 28 + .toEqual(['threads', 'vmThreads', 'no-isolate', 'fs-cache']) 29 + }) 30 + 31 + it('does not repeat what the config already uses', () => { 32 + expect(candidateIds([project({ pool: 'threads', isolate: false, fsModuleCache: true })])).toEqual([]) 33 + }) 34 + 35 + it('compares vm pools against reused workers with shared state', () => { 36 + expect(candidateIds([project({ pool: 'vmThreads', environment: 'jsdom', isolate: false, fsModuleCache: true })])) 37 + .toEqual(['threads-no-isolate']) 38 + }) 39 + 40 + it('offers no-isolate to isolating browser projects', () => { 41 + expect(candidateIds([project({ browser: true, environment: 'jsdom' })])).toEqual(['no-isolate']) 42 + }) 43 + 44 + it('has nothing to measure for browser projects without isolation', () => { 45 + expect(candidateIds([project({ browser: true, isolate: false })])).toEqual([]) 46 + }) 47 + 48 + it('considers every project of a workspace', () => { 49 + expect(candidateIds([ 50 + project({ pool: 'threads', isolate: false }), 51 + project({ environment: 'happy-dom' }), 52 + ])).toEqual(['threads', 'vmThreads', 'no-isolate', 'fs-cache']) 53 + }) 54 + 55 + it('offers happy-dom for a jsdom project when the package is available', () => { 56 + expect(candidateIds([project({ environment: 'jsdom' })], { happyDomAvailable: true })) 57 + .toEqual(['threads', 'vmThreads', 'happy-dom', 'no-isolate', 'fs-cache']) 58 + }) 59 + 60 + it('does not offer happy-dom when the package cannot be resolved', () => { 61 + expect(candidateIds([project({ environment: 'jsdom' })], { happyDomAvailable: false })) 62 + .not 63 + .toContain('happy-dom') 64 + }) 65 + 66 + it('offers the per-project swap to mixed-environment workspaces', () => { 67 + const candidates = resolveDoctorCandidates([ 68 + project({ environment: 'jsdom' }), 69 + project({ environment: 'node' }), 70 + ], { happyDomAvailable: true }) 71 + const happyDom = candidates.find(candidate => candidate.id === 'happy-dom') 72 + expect(happyDom?.envSwap).toEqual({ from: 'jsdom', to: 'happy-dom' }) 73 + }) 74 + 75 + it('does not offer happy-dom to suites already running it', () => { 76 + expect(candidateIds([project({ environment: 'happy-dom' })], { happyDomAvailable: true })) 77 + .not 78 + .toContain('happy-dom') 79 + }) 80 + 81 + it('does not offer the fs cache when a project already enables it', () => { 82 + expect(candidateIds([ 83 + project({ fsModuleCache: true }), 84 + project({}), 85 + ])).not.toContain('fs-cache') 86 + }) 87 + 88 + it('primes the fs cache before measuring it', () => { 89 + const fsCache = resolveDoctorCandidates([project({})]).find(candidate => candidate.id === 'fs-cache') 90 + expect(fsCache?.primeRuns).toBe(1) 91 + expect(fsCache?.overrides).toEqual({ fsModuleCache: true }) 92 + }) 93 + 94 + it('does not offer the fs cache to browser-only workspaces', () => { 95 + expect(candidateIds([project({ browser: true })])).not.toContain('fs-cache') 96 + }) 97 + })
+5
test/e2e/fixtures/doctor-failing/ok.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + 3 + test('adds', () => { 4 + expect(1 + 1).toBe(2) 5 + })
+8
test/e2e/fixtures/doctor-failing/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config' 2 + 3 + export default defineConfig({ 4 + test: { 5 + environment: 'jsdom', 6 + watch: false, 7 + }, 8 + })
+6
test/e2e/fixtures/doctor-failing/vm-hostile.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + 3 + // fails only under vm pools, so `vitest doctor` has a failing candidate to report 4 + test('does not run under a vm pool', () => { 5 + expect(process.execArgv.join(' ')).not.toContain('experimental-vm-modules') 6 + })
+5
test/e2e/fixtures/doctor-projects/dom.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + 3 + test('runs in a DOM environment', () => { 4 + expect(typeof document).toBe('object') 5 + })
+6
test/e2e/fixtures/doctor-projects/node.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + 3 + // fails if the doctor environment swap leaks beyond the jsdom project 4 + test('keeps the node environment', () => { 5 + expect(typeof document).toBe('undefined') 6 + })
+28
test/e2e/fixtures/doctor-projects/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config' 2 + 3 + export default defineConfig({ 4 + test: { 5 + watch: false, 6 + fsModuleCache: true, 7 + projects: [ 8 + { 9 + test: { 10 + name: 'dom', 11 + environment: 'jsdom', 12 + pool: 'threads', 13 + isolate: false, 14 + include: ['dom.test.ts'], 15 + }, 16 + }, 17 + { 18 + test: { 19 + name: 'node', 20 + environment: 'node', 21 + pool: 'threads', 22 + isolate: false, 23 + include: ['node.test.ts'], 24 + }, 25 + }, 26 + ], 27 + }, 28 + })
+5
test/e2e/fixtures/doctor/basic-1.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + 3 + test('adds', () => { 4 + expect(1 + 1).toBe(2) 5 + })
+5
test/e2e/fixtures/doctor/basic-2.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + 3 + test('multiplies', () => { 4 + expect(2 * 2).toBe(4) 5 + })
+7
test/e2e/fixtures/doctor/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config' 2 + 3 + export default defineConfig({ 4 + test: { 5 + watch: false, 6 + }, 7 + })
+21
packages/vitest/src/node/cli/cac.ts
··· 186 186 .command('init <project>', undefined, options) 187 187 .action(init) 188 188 189 + cli 190 + .command('doctor [...filters]', undefined, options) 191 + .action(doctorCommand) 192 + 189 193 addCliOptions( 190 194 cli 191 195 .command('list [...filters]', undefined, options) ··· 317 321 318 322 process.exitCode ??= 1 319 323 324 + process.exit() 325 + } 326 + } 327 + 328 + async function doctorCommand(cliFilters: string[], options: CliOptions): Promise<void> { 329 + try { 330 + const { doctor } = await import('./doctor') 331 + await doctor(cliFilters.map(normalize), normalizeCliOptions(cliFilters, options)) 332 + process.exit() 333 + } 334 + catch (e) { 335 + const { errorBanner } = await import('../reporters/renderers/utils') 336 + console.error(`\n${errorBanner('Doctor Error')}`) 337 + console.error(e) 338 + console.error('\n\n') 339 + 340 + process.exitCode ??= 1 320 341 process.exit() 321 342 } 322 343 }
+622
packages/vitest/src/node/cli/doctor.ts
··· 1 + import type { CliOptions } from './cli-api' 2 + import { spawn } from 'node:child_process' 3 + import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' 4 + import { availableParallelism, tmpdir } from 'node:os' 5 + import { join } from 'node:path' 6 + import { performance } from 'node:perf_hooks' 7 + import { pathToFileURL } from 'node:url' 8 + import { toArray } from '@vitest/utils/helpers' 9 + import { resolve } from 'pathe' 10 + import c from 'tinyrainbow' 11 + import { distDir } from '../../paths' 12 + 13 + export interface DoctorProjectSummary { 14 + name: string 15 + pool: string 16 + environment: string 17 + isolate: boolean 18 + browser: boolean 19 + fsModuleCache: boolean 20 + } 21 + 22 + export interface DoctorCandidateOptions { 23 + /** `happy-dom` is resolvable, so the environment candidate can actually run. */ 24 + happyDomAvailable?: boolean 25 + } 26 + 27 + export interface DoctorCandidate { 28 + id: string 29 + /** Human readable config change, e.g. `pool: 'threads'`. */ 30 + title: string 31 + /** Config overrides applied to the measured run. */ 32 + overrides: CliOptions 33 + /** 34 + * Swap `test.environment` of every project currently using `from` to `to`. 35 + * Applied per project by the runner script, so projects running other 36 + * environments keep them. 37 + */ 38 + envSwap?: { from: string; to: string } 39 + /** Config entries to show in the recommended snippet, one per line. */ 40 + configLines: string[] 41 + /** Keeps every test file in its own fresh environment. */ 42 + preservesIsolation: boolean 43 + /** Run extra shuffled passes to check that tests survive shared state. */ 44 + validateIsolation?: boolean 45 + /** 46 + * Untimed runs before the measured ones. Used for candidates whose benefit 47 + * only shows once a persistent cache is populated. 48 + */ 49 + primeRuns?: number 50 + } 51 + 52 + const DOM_ENVIRONMENTS = new Set(['jsdom', 'happy-dom']) 53 + 54 + /** 55 + * Builds the list of configurations worth measuring for this config. The 56 + * candidates are pruned by what the config already uses - there is no point 57 + * in measuring `threads` when the config already runs in `threads`. 58 + */ 59 + export function resolveDoctorCandidates( 60 + projects: DoctorProjectSummary[], 61 + options: DoctorCandidateOptions = {}, 62 + ): DoctorCandidate[] { 63 + const candidates: DoctorCandidate[] = [] 64 + // `pool`, `environment` and the fs module cache don't reach the browser 65 + // runtime, so those candidates are driven by the node-side projects only; 66 + // isolation applies to browser projects as well 67 + const testProjects = projects.filter(project => !project.browser) 68 + 69 + const pools = new Set(testProjects.map(project => project.pool)) 70 + const usesVmPool = pools.has('vmThreads') || pools.has('vmForks') 71 + const runsDom = testProjects.some(project => 72 + DOM_ENVIRONMENTS.has(project.environment), 73 + ) 74 + const isolates = projects.some( 75 + project => project.isolate 76 + && (project.browser || (project.pool !== 'vmThreads' && project.pool !== 'vmForks')), 77 + ) 78 + 79 + if (pools.has('forks')) { 80 + candidates.push({ 81 + id: 'threads', 82 + title: `pool: 'threads'`, 83 + overrides: { pool: 'threads' }, 84 + configLines: [`pool: 'threads'`], 85 + preservesIsolation: true, 86 + }) 87 + } 88 + if (runsDom && !pools.has('vmThreads')) { 89 + candidates.push({ 90 + id: 'vmThreads', 91 + title: `pool: 'vmThreads'`, 92 + overrides: { pool: 'vmThreads' }, 93 + configLines: [`pool: 'vmThreads'`], 94 + preservesIsolation: true, 95 + }) 96 + } 97 + // jsdom -> happy-dom is the only swap with a speed upside; it is applied per 98 + // project, so projects running other environments keep them 99 + if (options.happyDomAvailable && testProjects.some(project => project.environment === 'jsdom')) { 100 + candidates.push({ 101 + id: 'happy-dom', 102 + title: `environment: 'happy-dom'`, 103 + overrides: {}, 104 + envSwap: { from: 'jsdom', to: 'happy-dom' }, 105 + configLines: [`environment: 'happy-dom'`], 106 + preservesIsolation: true, 107 + }) 108 + } 109 + if (isolates) { 110 + candidates.push({ 111 + id: 'no-isolate', 112 + title: 'isolate: false', 113 + overrides: { isolate: false }, 114 + configLines: ['isolate: false'], 115 + preservesIsolation: false, 116 + validateIsolation: true, 117 + }) 118 + } 119 + if (usesVmPool) { 120 + // the honest competitor of a vm pool: reused workers with shared state 121 + candidates.push({ 122 + id: 'threads-no-isolate', 123 + title: `pool: 'threads' + isolate: false`, 124 + overrides: { pool: 'threads', isolate: false }, 125 + configLines: [`pool: 'threads'`, 'isolate: false'], 126 + preservesIsolation: false, 127 + validateIsolation: true, 128 + }) 129 + } 130 + if (testProjects.length > 0 && testProjects.every(project => !project.fsModuleCache)) { 131 + // an untimed priming run populates the cache first: the candidate measures 132 + // what repeated runs pay, which is what doctor compares everywhere else 133 + candidates.push({ 134 + id: 'fs-cache', 135 + title: 'fsModuleCache: true', 136 + overrides: { fsModuleCache: true }, 137 + configLines: ['fsModuleCache: true'], 138 + preservesIsolation: true, 139 + primeRuns: 1, 140 + }) 141 + } 142 + 143 + return candidates 144 + } 145 + 146 + interface MeasuredRun { 147 + wall: number 148 + ok: boolean 149 + stderr: string 150 + } 151 + 152 + interface MeasuredCandidate { 153 + candidate: DoctorCandidate 154 + wall: number 155 + ok: boolean 156 + stderr: string 157 + isolationVerdict?: 'passed' | 'failed' 158 + } 159 + 160 + function log(...args: string[]): void { 161 + console.log(...args) 162 + } 163 + 164 + export async function doctor(cliFilters: string[], options: CliOptions): Promise<void> { 165 + const { prepareVitest } = await import('./cli-api') 166 + 167 + log() 168 + log(c.inverse(c.bold(c.blue(' DOCTOR '))), 'resolving the current configuration...') 169 + 170 + const ctx = await prepareVitest( 171 + { ...options, watch: false, run: true }, 172 + undefined, 173 + undefined, 174 + cliFilters, 175 + ) 176 + const projects: DoctorProjectSummary[] = ctx.projects.map(project => ({ 177 + name: project.name, 178 + pool: project.config.pool, 179 + environment: project.config.environment, 180 + isolate: project.config.isolate, 181 + browser: project.config.browser.enabled, 182 + fsModuleCache: project.config.fsModuleCache === true, 183 + })) 184 + const fileCount = (await ctx.getRelevantTestSpecifications(cliFilters)).length 185 + const configuredMaxWorkers = ctx.config.maxWorkers 186 + const effectiveMaxWorkers = typeof configuredMaxWorkers === 'number' && configuredMaxWorkers > 0 187 + ? configuredMaxWorkers 188 + : Math.max(1, availableParallelism() - 1) 189 + await ctx.close() 190 + 191 + // the environments import 'happy-dom' relative to the vitest package (it is 192 + // a peer dependency), so resolving from here mirrors what a run would do 193 + let happyDomAvailable = false 194 + try { 195 + import.meta.resolve('happy-dom') 196 + happyDomAvailable = true 197 + } 198 + catch {} 199 + 200 + const candidates = resolveDoctorCandidates(projects, { happyDomAvailable }) 201 + 202 + const baselineTitle = `baseline (${describeProjects(projects)})` 203 + 204 + // every measured run goes through one generated runner script: candidates 205 + // may need per-project overrides (the happy-dom swap), which the CLI cannot 206 + // express, and routing the baseline through the same entry keeps the 207 + // process cost identical across measurements 208 + const runnerDir = mkdtempSync(join(tmpdir(), 'vitest-doctor-')) 209 + const runnerPath = join(runnerDir, 'runner.mjs') 210 + writeFileSync(runnerPath, createRunnerScript()) 211 + process.once('exit', () => rmSync(runnerDir, { recursive: true, force: true })) 212 + 213 + const childProjects = toArray(options.project).map(String) 214 + const childOptions: CliOptions = { 215 + watch: false, 216 + ...(options.root ? { root: String(options.root) } : {}), 217 + ...(options.config ? { config: String(options.config) } : {}), 218 + ...(childProjects.length ? { project: childProjects } : {}), 219 + } 220 + 221 + // make sure an interrupted doctor doesn't leave a test suite running 222 + let activeChild: ReturnType<typeof spawn> | undefined 223 + const killActiveChild = (): never => { 224 + activeChild?.kill('SIGKILL') 225 + process.exit(130) 226 + } 227 + process.once('SIGINT', killActiveChild) 228 + process.once('SIGTERM', killActiveChild) 229 + 230 + interface RunOverrides { 231 + overrides?: CliOptions 232 + envSwap?: DoctorCandidate['envSwap'] 233 + } 234 + 235 + const runVitest = (run: RunOverrides, timeoutMs?: number): Promise<MeasuredRun> => { 236 + return new Promise((resolve) => { 237 + const start = performance.now() 238 + const payload = JSON.stringify({ 239 + filters: cliFilters, 240 + options: { ...childOptions, ...run.overrides }, 241 + envSwap: run.envSwap, 242 + }) 243 + const child = spawn( 244 + process.execPath, 245 + [runnerPath, payload], 246 + { 247 + env: { ...process.env, NO_COLOR: '1' }, 248 + stdio: ['ignore', 'ignore', 'pipe'], 249 + }, 250 + ) 251 + activeChild = child 252 + let timedOut = false 253 + const timer = timeoutMs 254 + ? setTimeout(() => { 255 + timedOut = true 256 + child.kill('SIGKILL') 257 + }, timeoutMs) 258 + : undefined 259 + let stderr = '' 260 + child.stderr!.on('data', (chunk) => { 261 + stderr = (stderr + String(chunk)).slice(-4_000) 262 + }) 263 + child.on('close', (code) => { 264 + if (timer) { 265 + clearTimeout(timer) 266 + } 267 + activeChild = undefined 268 + if (timedOut) { 269 + stderr += `\n(the run was killed after exceeding ${Math.round((timeoutMs || 0) / 1000)}s - several times the baseline duration)` 270 + } 271 + resolve({ wall: performance.now() - start, ok: code === 0 && !timedOut, stderr }) 272 + }) 273 + }) 274 + } 275 + 276 + log() 277 + if (candidates.length === 0) { 278 + log('The configuration already uses the fastest setup Vitest knows how to compare - measuring it for reference.') 279 + } 280 + else { 281 + log('Measuring alternative configurations by running the test suite under each of them.') 282 + log(c.dim('Close other heavy programs - the comparison is only as good as the machine is quiet.')) 283 + } 284 + log() 285 + 286 + // The first baseline run also warms up caches for every following run, so 287 + // candidates are compared in a warm steady state. 288 + process.stdout.write(c.dim(` measuring ${baselineTitle}...`)) 289 + const firstRun = await runVitest({}) 290 + if (!firstRun.ok) { 291 + process.stdout.write('\n') 292 + log(c.red('The test suite fails with the current configuration. Fix the failures first - doctor needs a green suite to compare configurations.')) 293 + if (firstRun.stderr.trim()) { 294 + log(c.dim(firstRun.stderr.trim())) 295 + } 296 + process.exitCode = 1 297 + return 298 + } 299 + 300 + // scale repetitions to the suite duration: short suites are noisy and can 301 + // afford more runs, long suites cannot 302 + const reps = firstRun.wall < 10_000 ? 3 : firstRun.wall < 60_000 ? 2 : 1 303 + // the first run is cold and mostly warms up caches: always take at least one 304 + // more baseline run so the reported baseline is a warm one, like the 305 + // candidates that run after it 306 + const baselineRuns = Math.max(reps, 2) 307 + const baselineWalls = [firstRun.wall] 308 + for (let i = 1; i < baselineRuns; i++) { 309 + const run = await runVitest({}) 310 + if (!run.ok) { 311 + process.stdout.write('\n') 312 + log(c.red(`The test suite passed once but failed on repetition ${i + 1} with the same configuration - doctor needs a stable green suite to compare configurations.`)) 313 + if (run.stderr.trim()) { 314 + log(c.dim(run.stderr.trim())) 315 + } 316 + process.exitCode = 1 317 + return 318 + } 319 + baselineWalls.push(run.wall) 320 + } 321 + const baselineWall = Math.min(...baselineWalls) 322 + process.stdout.write(` ${formatSeconds(baselineWall)}\n`) 323 + 324 + // a candidate that takes several times the baseline is a regression, not a 325 + // recommendation - don't let it stall doctor indefinitely 326 + const candidateTimeout = Math.max(60_000, baselineWall * 4) 327 + 328 + const measured: MeasuredCandidate[] = [] 329 + for (const candidate of candidates) { 330 + process.stdout.write(c.dim(` measuring ${candidate.title}...`)) 331 + let wall = Number.POSITIVE_INFINITY 332 + let ok = true 333 + let stderr = '' 334 + const candidateRun: RunOverrides = { overrides: candidate.overrides, envSwap: candidate.envSwap } 335 + for (let i = 0; i < (candidate.primeRuns ?? 0) && ok; i++) { 336 + const run = await runVitest(candidateRun, candidateTimeout) 337 + ok = run.ok 338 + stderr = run.stderr 339 + } 340 + for (let i = 0; i < reps && ok; i++) { 341 + const run = await runVitest(candidateRun, candidateTimeout) 342 + wall = Math.min(wall, run.wall) 343 + ok = run.ok 344 + stderr = run.stderr 345 + } 346 + 347 + let isolationVerdict: MeasuredCandidate['isolationVerdict'] 348 + if (ok && candidate.validateIsolation) { 349 + // tests can pass under shared state by accident of ordering: shuffle the 350 + // file order twice to catch the common cross-file couplings; the explicit 351 + // distinct seeds guarantee two different orders even when the user pinned 352 + // `sequence.seed` in their config 353 + isolationVerdict = 'passed' 354 + for (let i = 0; i < 2; i++) { 355 + const run = await runVitest( 356 + { 357 + overrides: { 358 + ...candidate.overrides, 359 + sequence: { shuffle: { files: true }, seed: 271828 + i }, 360 + }, 361 + envSwap: candidate.envSwap, 362 + }, 363 + candidateTimeout, 364 + ) 365 + if (!run.ok) { 366 + isolationVerdict = 'failed' 367 + stderr = run.stderr 368 + break 369 + } 370 + } 371 + } 372 + 373 + measured.push({ candidate, wall, ok, stderr, isolationVerdict }) 374 + process.stdout.write(ok ? ` ${formatSeconds(wall)}\n` : ` ${c.red('failed')}\n`) 375 + } 376 + 377 + const viable = measured.filter(result => 378 + result.ok 379 + && result.isolationVerdict !== 'failed' 380 + && result.wall < baselineWall * 0.9, 381 + ) 382 + 383 + // among candidates close to the fastest, prefer the one that keeps per-file 384 + // isolation - equal speed with stronger guarantees wins 385 + const fastest = viable.length > 0 386 + ? viable.reduce((a, b) => (b.wall < a.wall ? b : a)) 387 + : undefined 388 + const preferred = fastest 389 + ? viable 390 + .filter(result => result.wall <= fastest.wall * 1.05) 391 + .sort((a, b) => Number(b.candidate.preservesIsolation) - Number(a.candidate.preservesIsolation))[0] 392 + : undefined 393 + 394 + // Past a certain worker count the single main-thread Vite server becomes the 395 + // bottleneck, so FEWER workers can be faster. Greedily descend by halving on 396 + // top of the winning configuration, keeping a step only when it is a real 397 + // (>= 5%) improvement. 398 + const workerStack: RunOverrides = preferred 399 + ? { overrides: preferred.candidate.overrides, envSwap: preferred.candidate.envSwap } 400 + : {} 401 + const workerSuffix = preferred ? ` (with ${preferred.candidate.title})` : '' 402 + interface WorkerProbe { workers: number; wall: number } 403 + const workerProbes: WorkerProbe[] = [] 404 + let bestWorkers: WorkerProbe | undefined 405 + { 406 + let currentBest = preferred ? preferred.wall : baselineWall 407 + let probe = Math.floor(effectiveMaxWorkers / 2) 408 + while (probe >= 2 && probe < fileCount) { 409 + process.stdout.write(c.dim(` measuring maxWorkers: ${probe}${workerSuffix}...`)) 410 + let wall = Number.POSITIVE_INFINITY 411 + let ok = true 412 + for (let i = 0; i < reps && ok; i++) { 413 + const run = await runVitest( 414 + { overrides: { ...workerStack.overrides, maxWorkers: probe }, envSwap: workerStack.envSwap }, 415 + candidateTimeout, 416 + ) 417 + wall = Math.min(wall, run.wall) 418 + ok = run.ok 419 + } 420 + process.stdout.write(ok ? ` ${formatSeconds(wall)}\n` : ` ${c.red('failed')}\n`) 421 + if (!ok) { 422 + break 423 + } 424 + workerProbes.push({ workers: probe, wall }) 425 + if (wall >= currentBest * 0.95) { 426 + break 427 + } 428 + currentBest = wall 429 + bestWorkers = { workers: probe, wall } 430 + probe = Math.floor(probe / 2) 431 + } 432 + } 433 + 434 + log() 435 + log(c.bold('Results') + c.dim(` (min of ${reps} run${reps === 1 ? '' : 's'} each)`)) 436 + log() 437 + const rowTitles = [ 438 + ...measured.map(m => m.candidate.title), 439 + ...workerProbes.map(p => `maxWorkers: ${p.workers}${workerSuffix}`), 440 + ] 441 + const width = Math.max(baselineTitle.length, ...rowTitles.map(title => title.length)) + 2 442 + log(` ${baselineTitle.padEnd(width)}${formatSeconds(baselineWall)}`) 443 + for (const result of measured) { 444 + const title = result.candidate.title.padEnd(width) 445 + if (!result.ok) { 446 + log(` ${title}${c.red('failed')}`) 447 + } 448 + else if (result.isolationVerdict === 'failed') { 449 + log(` ${title}${formatSeconds(result.wall)} ${c.red('(fails with a shuffled file order - tests depend on isolation)')}`) 450 + } 451 + else { 452 + log(` ${title}${formatSeconds(result.wall)} ${formatDelta(result.wall, baselineWall)}`) 453 + } 454 + } 455 + for (const probeResult of workerProbes) { 456 + const title = `maxWorkers: ${probeResult.workers}${workerSuffix}`.padEnd(width) 457 + log(` ${title}${formatSeconds(probeResult.wall)} ${formatDelta(probeResult.wall, baselineWall)}`) 458 + } 459 + 460 + // failing candidates are as informative as fast ones: show what broke, 461 + // so "vmThreads is not an option for this suite" comes with the reason 462 + for (const result of measured) { 463 + if (!result.ok) { 464 + logFailureExcerpt(`${result.candidate.title} failed with:`, result.stderr) 465 + } 466 + else if (result.isolationVerdict === 'failed') { 467 + logFailureExcerpt(`${result.candidate.title} failed with a shuffled file order:`, result.stderr) 468 + } 469 + } 470 + 471 + log() 472 + if (!preferred && !bestWorkers) { 473 + if (candidates.length === 0) { 474 + log(`${c.bold('Recommendation:')} keep the current configuration (${describeProjects(projects)}) - it measured ${c.yellow(formatSeconds(baselineWall))} and Vitest has no faster candidate to suggest for it.`) 475 + } 476 + else { 477 + log(`${c.bold('Recommendation:')} keep the current configuration (${describeProjects(projects)}) - no measured candidate was more than 10% faster.`) 478 + } 479 + return 480 + } 481 + 482 + const finalWall = bestWorkers ? bestWorkers.wall : preferred!.wall 483 + const recommendationTitle = [ 484 + preferred?.candidate.title, 485 + bestWorkers ? `maxWorkers: ${bestWorkers.workers}` : undefined, 486 + ].filter(Boolean).join(' + ') 487 + const configLines = [ 488 + ...(preferred ? preferred.candidate.configLines : []), 489 + ...(bestWorkers ? [`maxWorkers: ${bestWorkers.workers}`] : []), 490 + ] 491 + 492 + log(`${c.bold('Recommendation:')} ${c.yellow(recommendationTitle)} ${formatDelta(finalWall, baselineWall)}`) 493 + log() 494 + log(c.dim(' // vitest.config.ts')) 495 + log(c.dim(` import { defineConfig } from 'vitest/config'`)) 496 + log() 497 + log(c.dim(' export default defineConfig({')) 498 + log(c.dim(' test: {')) 499 + for (const [index, line] of configLines.entries()) { 500 + const comment = index === configLines.length - 1 501 + ? c.dim(` // measured ${Math.round(((finalWall - baselineWall) / baselineWall) * 100)}% on this suite`) 502 + : '' 503 + log(` ${line},${comment}`) 504 + } 505 + log(c.dim(' },')) 506 + log(c.dim(' })')) 507 + log() 508 + if (preferred) { 509 + for (const note of candidateNotes(preferred)) { 510 + log(c.dim(` ${note}`)) 511 + } 512 + } 513 + if (bestWorkers) { 514 + log(c.dim(` Fewer workers can be faster because every worker funnels transforms through the`)) 515 + log(c.dim(` single main-thread Vite server; a lower count also reduces memory pressure.`)) 516 + } 517 + if (projects.length > 1) { 518 + const note = preferred?.candidate.envSwap 519 + ? `Doctor swapped the environment of every project running ${preferred.candidate.envSwap.from}; other projects were left unchanged.` 520 + : 'Doctor overrides options for all projects at once; apply the change per project if they need different settings.' 521 + log(c.dim(` ${note}`)) 522 + } 523 + log(c.dim(' Trade-offs of every option: https://vitest.dev/guide/improving-performance')) 524 + } 525 + 526 + /** 527 + * The measured runs execute this script instead of the CLI binary: it accepts 528 + * the overrides as JSON and can apply per-project changes (the environment 529 + * swap) that CLI flags cannot express. `createVitest` attaches the projects, 530 + * so the swap mutates each resolved project config before the run starts. 531 + */ 532 + function createRunnerScript(): string { 533 + const nodeEntry = pathToFileURL(resolve(distDir, 'node.js')).href 534 + return `import { createVitest } from ${JSON.stringify(nodeEntry)} 535 + 536 + const { filters, options, envSwap } = JSON.parse(process.argv[2]) 537 + const ctx = await createVitest(options) 538 + if (envSwap) { 539 + for (const project of ctx.projects) { 540 + if (!project.config.browser.enabled && project.config.environment === envSwap.from) { 541 + project.config.environment = envSwap.to 542 + } 543 + } 544 + } 545 + try { 546 + await ctx.start(filters) 547 + } 548 + catch (error) { 549 + console.error(error?.stack || String(error)) 550 + process.exitCode = 1 551 + } 552 + await ctx.close() 553 + process.exit(process.exitCode || 0) 554 + ` 555 + } 556 + 557 + const FAILURE_EXCERPT_LINES = 15 558 + 559 + function logFailureExcerpt(title: string, stderr: string): void { 560 + log() 561 + log(` ${c.red(title)}`) 562 + if (!stderr.trim()) { 563 + log(c.dim(' (the run produced no error output - rerun with the same options to inspect it)')) 564 + return 565 + } 566 + const lines = stderr.trim().split('\n') 567 + const excerpt = lines.slice(-FAILURE_EXCERPT_LINES) 568 + if (lines.length > excerpt.length) { 569 + log(c.dim(` … (last ${excerpt.length} lines)`)) 570 + } 571 + for (const line of excerpt) { 572 + log(c.dim(` ${line}`)) 573 + } 574 + } 575 + 576 + function candidateNotes(result: MeasuredCandidate): string[] { 577 + switch (result.candidate.id) { 578 + case 'vmThreads': 579 + return [ 580 + `vm pools keep per-file isolation but run test code in a VM context: cross-realm`, 581 + `instanceof edge cases and higher memory usage are possible (see vmMemoryLimit).`, 582 + ] 583 + case 'no-isolate': 584 + case 'threads-no-isolate': 585 + return [ 586 + `The suite passed twice with a shuffled file order under shared state, so it is`, 587 + `likely - but not guaranteed - that no test depends on isolation.`, 588 + ] 589 + case 'happy-dom': 590 + return [ 591 + `The swap was applied only to projects running jsdom; other projects kept their`, 592 + `environment. happy-dom implements the DOM differently than jsdom: the suite`, 593 + `passed under it, but double-check tests that depend on layout, navigation or`, 594 + `other DOM edge cases.`, 595 + ] 596 + case 'fs-cache': 597 + return [ 598 + `The fs module cache persists transformed modules on disk: repeated runs skip`, 599 + `the transforms, the first run after a file change still pays them.`, 600 + ] 601 + default: 602 + return [] 603 + } 604 + } 605 + 606 + function describeProjects(projects: DoctorProjectSummary[]): string { 607 + const pools = [...new Set(projects.map(project => project.browser ? 'browser' : project.pool))].join(', ') 608 + const isolate = projects.some(project => project.isolate) 609 + return `pool: ${pools} · isolate: ${isolate}` 610 + } 611 + 612 + function formatSeconds(time: number): string { 613 + return `${(time / 1000).toFixed(2)}s` 614 + } 615 + 616 + function formatDelta(wall: number, baseline: number): string { 617 + const delta = Math.round(((wall - baseline) / baseline) * 100) 618 + if (delta === 0) { 619 + return c.dim('(±0%)') 620 + } 621 + return delta < 0 ? c.green(`(${delta}%)`) : c.yellow(`(+${delta}%)`) 622 + }