···413413test('my test', { tags }, () => {})
414414```
415415:::
416416+417417+## experimental.diagnostics <Version type="experimental">5.0.0</Version> {#experimental-diagnostics}
418418+419419+- **Type:**
420420+421421+```ts
422422+interface DiagnosticsOptions {
423423+ /**
424424+ * Hint when `isolate: true` spends a significant amount of time spawning
425425+ * a fresh worker (and re-creating the environment) for every test file,
426426+ * estimating how much `isolate: false` could save.
427427+ * @default true
428428+ */
429429+ isolate?: boolean
430430+ /**
431431+ * Hint when re-creating a DOM environment for every test file dominates
432432+ * the run and a `vm` pool would set it up once per worker.
433433+ * @default true
434434+ */
435435+ environment?: boolean
436436+ /**
437437+ * Hint when test files repeatedly evaluate the same module graph
438438+ * (typical for barrel-file imports) and `isolate: false` would
439439+ * evaluate it once per worker.
440440+ * @default true
441441+ */
442442+ import?: boolean
443443+ /**
444444+ * Hint when transforming modules dominates the run and
445445+ * `fsModuleCache` would persist the results across runs.
446446+ * @default true
447447+ */
448448+ transform?: boolean
449449+}
450450+```
451451+452452+- **Default:** `true`
453453+454454+Print performance hints after the run when the collected timings show that a configuration change would make the run significantly faster:
455455+456456+```
457457+Environment jsdom was created 40 times · 23.80s total, 79% of tracked time
458458+ create it once per worker with pool: 'vmThreads' (keeps per-file isolation) or isolate: false (shares it across files)
459459+ learn more: https://vitest.dev/guide/improving-performance#test-environments
460460+```
461461+462462+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.
463463+464464+### experimental.diagnostics.isolate {#experimental-diagnostics-isolate}
465465+466466+- **Type:** `boolean`
467467+- **Default:** `true`
468468+469469+Hint when `isolate: true` spends a significant amount of time spawning a fresh worker (and re-creating the environment) for every test file, estimating how much `isolate: false` could save. Reused workers also keep evaluated modules alive, so files stop re-evaluating the module graph they share. Per-module evaluation times are only collected when [`experimental.importDurations`](#experimental-importdurations) is enabled; without it the estimate counts the worker startups alone and is reported as a lower bound ("at least").
470470+471471+### experimental.diagnostics.environment {#experimental-diagnostics-environment}
472472+473473+- **Type:** `boolean`
474474+- **Default:** `true`
475475+476476+Hint when re-creating a DOM environment for every test file dominates the run and a `vm` pool would set it up once per worker.
477477+478478+### experimental.diagnostics.import {#experimental-diagnostics-import}
479479+480480+- **Type:** `boolean`
481481+- **Default:** `true`
482482+483483+Hint when test files repeatedly evaluate the same module graph and `isolate: false` would evaluate it once per worker. This is typical for barrel-file imports: every test file imports a few symbols through an index file and evaluates the whole graph behind it. The duplication is measured from how often each module was served to the workers, so suites whose test files import mostly disjoint modules stay quiet: reusing workers would not reduce their import work.
484484+485485+```
486486+Import 837 modules were evaluated 16740 times · 15.69s total, 64% of tracked time
487487+ ~850ms faster with isolate: false — shared modules are evaluated once per worker instead of once per file
488488+ learn more: https://vitest.dev/guide/improving-performance#test-isolation
489489+```
490490+491491+### experimental.diagnostics.transform {#experimental-diagnostics-transform}
492492+493493+- **Type:** `boolean`
494494+- **Default:** `true`
495495+496496+Hint when transforming modules dominates the run. Without a persistent cache every `vitest run` transforms the whole module graph from scratch; [`fsModuleCache`](/config/fsmodulecache) stores the results on disk so repeated runs skip them. The hint estimates the time the next run would save. On CI the hint includes a note that the cache directory must be persisted between runs for the cache to take effect.
+28
docs/guide/cli-generated.md
···992992- **Config:** [experimental.preParse](/config/experimental#experimental-preparse)
993993994994Parse test specifications before running them. This will apply `.only` flag and test name pattern across all files without running them. (default: `false`)
995995+996996+### experimental.diagnostics.isolate
997997+998998+- **CLI:** `--experimental.diagnostics.isolate`
999999+- **Config:** [experimental.diagnostics.isolate](/config/experimental#experimental-diagnostics-isolate)
10001000+10011001+Print a hint estimating how much time `isolate: false` would save when `isolate: true` spends a significant amount of time spawning a worker per test file. (default: `true`)
10021002+10031003+### experimental.diagnostics.environment
10041004+10051005+- **CLI:** `--experimental.diagnostics.environment`
10061006+- **Config:** [experimental.diagnostics.environment](/config/experimental#experimental-diagnostics-environment)
10071007+10081008+Print a hint when re-creating a DOM environment for every test file dominates the run and a `vm` pool would set it up once per worker. (default: `true`)
10091009+10101010+### experimental.diagnostics.import
10111011+10121012+- **CLI:** `--experimental.diagnostics.import`
10131013+- **Config:** [experimental.diagnostics.import](/config/experimental#experimental-diagnostics-import)
10141014+10151015+Print a hint when test files repeatedly evaluate the same module graph (typical for barrel-file imports) and `isolate: false` would evaluate it once per worker. (default: `true`)
10161016+10171017+### experimental.diagnostics.transform
10181018+10191019+- **CLI:** `--experimental.diagnostics.transform`
10201020+- **Config:** [experimental.diagnostics.transform](/config/experimental#experimental-diagnostics-transform)
10211021+10221022+Print a hint when transforming modules dominates the run and `fsModuleCache` would persist the results across runs. (default: `true`)
+29-2
docs/guide/improving-performance.md
···88Duration 3.76s (environment 79%, import 14%, transform 6%, tests 1%)
99```
10101111-The percentages are relative to the sum of all tracked phases, not to the wall-clock time: phases run in parallel workers, so their sum is usually larger than the run itself. In a multi-project setup the percentages aggregate over all [projects](/guide/projects), so a phase that dominates one project can be diluted by the others.
1111+The percentages are relative to the sum of all tracked phases, not to the wall-clock time: phases run in parallel workers, so their sum is usually larger than the run itself. In a multi-project setup the percentages aggregate over all [projects](/guide/projects), so a phase that dominates one project can be diluted by the others; the performance hints below analyze each project separately.
12121313The phases map to configuration options:
14141515-- `environment` - creating the test environment (`jsdom`, `happy-dom`) for test files.
1515+- `environment` - creating the test environment (`jsdom`, `happy-dom`) for test files. See [Test Environments](#test-environments).
1616- `transform` - transforming files with Vite. See [Caching Between Reruns](#caching-between-reruns).
1717- `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).
1818- `setup` - running [`setupFiles`](/config/setupfiles).
1919- `tests` - running the tests themselves. A run dominated by this phase has little to gain from configuration changes.
2020+2121+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.
20222123## Test Isolation
2224···9092})
9193```
9294:::
9595+9696+## Test Environments
9797+9898+DOM environments are expensive to create: `jsdom` costs roughly 200-500ms per import and `happy-dom` roughly 90-200ms, plus the time to construct the window. With an isolating pool (the default), that cost is paid for every test file, because every file gets a fresh worker. On DOM-heavy suites this is often the largest cost of the run; it appears as the `environment` share of the `Duration` breakdown.
9999+100100+Three configurations reduce this cost:
101101+102102+| configuration | environment created | isolation | trade-off |
103103+|---|---|---|---|
104104+| `pool: 'forks'`/`'threads'` + `isolate: true` (default) | once per file | fresh process/thread and environment per file | safest, slowest |
105105+| `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)) |
106106+| `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 |
107107+108108+```ts [vitest.config.js]
109109+import { defineConfig } from 'vitest/config'
110110+111111+export default defineConfig({
112112+ test: {
113113+ environment: 'jsdom',
114114+ pool: 'vmThreads', // environment per worker, fresh window per file
115115+ },
116116+})
117117+```
118118+119119+Prefer `isolate: false` with `threads` if the tests tolerate shared state: it is the fastest option and keeps memory behavior simple. Use `vmThreads` when every file needs a fresh `window` and the per-file environment cost dominates the run. `happy-dom` is cheaper to create than `jsdom` in every setup.
9312094121## Limiting Directory Search
95122
+5-1
test/test-utils/index.ts
···212212 },
213213 // override cache config with the one that was used to run `vitest` from the CLI
214214 fsModuleCache: rest.fsModuleCache ?? currentConfig.fsModuleCache,
215215- ...(cliOptions?.experimental ? { experimental: cliOptions.experimental } : {}),
215215+ experimental: {
216216+ // keep performance hints out of captured test output unless a test opts in
217217+ diagnostics: rest.experimental?.diagnostics ?? false,
218218+ ...cliOptions?.experimental,
219219+ },
216220 }, {
217221 ...viteConfig,
218222 plugins: [
+184
test/unit/test/environment-diagnostics.test.ts
···11+import { describe, expect, it } from 'vitest'
22+import { getEnvironmentDiagnostics, isSavingWorthHinting } from '../../../packages/vitest/src/node/reporters/diagnostics'
33+44+const domProject = {
55+ name: 'dom',
66+ environment: 'jsdom',
77+ pool: 'forks',
88+ isolate: true,
99+ browser: false,
1010+ poolProvided: false,
1111+ isolateProvided: false,
1212+ environmentTime: 20_000,
1313+ environmentCount: 40,
1414+ trackedTime: 30_000,
1515+ // estimated saving: 20s/8 - 20s/40 = 2s
1616+ parallelism: 8,
1717+ executionTime: 10_000,
1818+}
1919+2020+describe('getEnvironmentDiagnostics', () => {
2121+ it('fires for an isolating DOM project dominated by environment setup', () => {
2222+ expect(getEnvironmentDiagnostics([domProject])).toEqual([
2323+ {
2424+ name: 'dom',
2525+ environment: 'jsdom',
2626+ environmentTime: 20_000,
2727+ environmentCount: 40,
2828+ share: 20_000 / 30_000,
2929+ suggestIsolate: true,
3030+ },
3131+ ])
3232+ })
3333+3434+ it('fires for happy-dom the same way', () => {
3535+ expect(getEnvironmentDiagnostics([{ ...domProject, environment: 'happy-dom' }])).toEqual([
3636+ {
3737+ name: 'dom',
3838+ environment: 'happy-dom',
3939+ environmentTime: 20_000,
4040+ environmentCount: 40,
4141+ share: 20_000 / 30_000,
4242+ suggestIsolate: true,
4343+ },
4444+ ])
4545+ })
4646+4747+ it('fires for the threads pool', () => {
4848+ expect(getEnvironmentDiagnostics([{ ...domProject, pool: 'threads' }])).toHaveLength(1)
4949+ })
5050+5151+ it('fires exactly at the minimum time and share thresholds', () => {
5252+ // 2s of setups over 8s of tracked time is exactly the 2s / 25% minimum;
5353+ // saving: 2s/4 - 2s/40 = 450ms, above the 200ms (5% of 4s) floor
5454+ expect(getEnvironmentDiagnostics([{
5555+ ...domProject,
5656+ environmentTime: 2_000,
5757+ trackedTime: 8_000,
5858+ parallelism: 4,
5959+ executionTime: 4_000,
6060+ }])).toHaveLength(1)
6161+ })
6262+6363+ it('fires for a serial run - all setups but one are avoidable', () => {
6464+ expect(getEnvironmentDiagnostics([{ ...domProject, parallelism: 1 }])).toHaveLength(1)
6565+ })
6666+6767+ it('reports every affected project with its own numbers', () => {
6868+ const components = {
6969+ ...domProject,
7070+ name: 'components',
7171+ environment: 'happy-dom',
7272+ environmentTime: 9_000,
7373+ environmentCount: 30,
7474+ trackedTime: 12_000,
7575+ }
7676+ expect(getEnvironmentDiagnostics([domProject, components])).toEqual([
7777+ {
7878+ name: 'dom',
7979+ environment: 'jsdom',
8080+ environmentTime: 20_000,
8181+ environmentCount: 40,
8282+ share: 20_000 / 30_000,
8383+ suggestIsolate: true,
8484+ },
8585+ {
8686+ name: 'components',
8787+ environment: 'happy-dom',
8888+ environmentTime: 9_000,
8989+ environmentCount: 30,
9090+ share: 9_000 / 12_000,
9191+ suggestIsolate: true,
9292+ },
9393+ ])
9494+ })
9595+9696+ it('does not suggest disabling isolation the user explicitly enabled', () => {
9797+ const diagnostics = getEnvironmentDiagnostics([{ ...domProject, isolateProvided: true }])
9898+ expect(diagnostics).toHaveLength(1)
9999+ expect(diagnostics[0].suggestIsolate).toBe(false)
100100+ })
101101+102102+ it('stays quiet when the user explicitly configured the pool', () => {
103103+ expect(getEnvironmentDiagnostics([{ ...domProject, poolProvided: true }])).toEqual([])
104104+ })
105105+106106+ it('stays quiet for vm pools - the environment is already per worker', () => {
107107+ expect(getEnvironmentDiagnostics([{ ...domProject, pool: 'vmThreads', isolate: false }])).toEqual([])
108108+ })
109109+110110+ it('stays quiet without isolation - the environment is already per worker', () => {
111111+ expect(getEnvironmentDiagnostics([{ ...domProject, isolate: false }])).toEqual([])
112112+ })
113113+114114+ it('stays quiet for the node environment', () => {
115115+ expect(getEnvironmentDiagnostics([{ ...domProject, environment: 'node' }])).toEqual([])
116116+ })
117117+118118+ it('stays quiet for browser projects', () => {
119119+ expect(getEnvironmentDiagnostics([{ ...domProject, browser: true }])).toEqual([])
120120+ })
121121+122122+ it('stays quiet when the setup cost is small in absolute terms', () => {
123123+ expect(getEnvironmentDiagnostics([{
124124+ ...domProject,
125125+ environmentTime: 1_000,
126126+ trackedTime: 1_200,
127127+ }])).toEqual([])
128128+ })
129129+130130+ it('stays quiet when the setup cost is small relative to the run', () => {
131131+ expect(getEnvironmentDiagnostics([{
132132+ ...domProject,
133133+ environmentTime: 3_000,
134134+ trackedTime: 60_000,
135135+ }])).toEqual([])
136136+ })
137137+138138+ it('stays quiet for a single file - there is nothing to amortize', () => {
139139+ expect(getEnvironmentDiagnostics([{ ...domProject, environmentCount: 1 }])).toEqual([])
140140+ })
141141+142142+ it('stays quiet when the saving is negligible relative to a long run', () => {
143143+ // ~2s estimated saving is below 5% of a 5-minute run and below 10s absolute
144144+ expect(getEnvironmentDiagnostics([{ ...domProject, executionTime: 300_000 }])).toEqual([])
145145+ })
146146+147147+ it('fires on long runs when the absolute saving is large', () => {
148148+ // 100s/8 - 100s/50 = 10.5s: only 2.6% of the run, but 10s+ is worth attention
149149+ expect(getEnvironmentDiagnostics([{
150150+ ...domProject,
151151+ environmentTime: 100_000,
152152+ environmentCount: 50,
153153+ trackedTime: 150_000,
154154+ executionTime: 400_000,
155155+ }])).toHaveLength(1)
156156+ })
157157+158158+ it('reports only the affected projects', () => {
159159+ const diagnostics = getEnvironmentDiagnostics([
160160+ domProject,
161161+ { ...domProject, name: 'unit', environment: 'node' },
162162+ ])
163163+ expect(diagnostics.map(diagnostic => diagnostic.name)).toEqual(['dom'])
164164+ })
165165+})
166166+167167+describe('isSavingWorthHinting', () => {
168168+ it('requires a quarter of a second in absolute terms', () => {
169169+ expect(isSavingWorthHinting(200, 1_000)).toBe(false)
170170+ })
171171+172172+ it('accepts savings above 5% of the run', () => {
173173+ expect(isSavingWorthHinting(300, 1_000)).toBe(true)
174174+ expect(isSavingWorthHinting(500, 10_000)).toBe(true)
175175+ })
176176+177177+ it('rejects savings below the noise floor of a long run', () => {
178178+ expect(isSavingWorthHinting(300, 60_000)).toBe(false)
179179+ })
180180+181181+ it('accepts 10s+ savings regardless of percentage', () => {
182182+ expect(isSavingWorthHinting(12_000, 400_000)).toBe(true)
183183+ })
184184+})
+208
test/unit/test/import-diagnostics.test.ts
···11+import { describe, expect, it } from 'vitest'
22+import { estimateModuleEvaluationSaving, getImportDiagnostics, getTransformDiagnostics } from '../../../packages/vitest/src/node/reporters/diagnostics'
33+44+// the barrel-file shape: 20 test files that each re-evaluate the same
55+// 800-module graph to use a few symbols from it
66+const barrelProject = {
77+ name: 'barrel',
88+ pool: 'forks',
99+ isolate: true,
1010+ browser: false,
1111+ isolateProvided: false,
1212+ importTime: 15_000,
1313+ trackedTime: 20_000,
1414+ fetchCounts: Array.from({ length: 800 }, () => 20),
1515+ fileCount: 20,
1616+ // duplication: (20 - 8) / 20 = 0.6, estimated saving: 15s * 0.6 / 8 = 1.125s
1717+ parallelism: 8,
1818+ executionTime: 4_000,
1919+}
2020+2121+describe('getImportDiagnostics', () => {
2222+ it('fires when test files repeatedly evaluate a shared module graph', () => {
2323+ expect(getImportDiagnostics([barrelProject])).toEqual([
2424+ {
2525+ name: 'barrel',
2626+ importTime: 15_000,
2727+ share: 15_000 / 20_000,
2828+ totalFetches: 16_000,
2929+ uniqueModules: 800,
3030+ duplication: 0.6,
3131+ estimatedSaving: (15_000 * 0.6) / 8,
3232+ },
3333+ ])
3434+ })
3535+3636+ it('fires for the threads pool', () => {
3737+ expect(getImportDiagnostics([{ ...barrelProject, pool: 'threads' }])).toHaveLength(1)
3838+ })
3939+4040+ it('counts only the fetches above the worker parallelism as avoidable', () => {
4141+ // 100 modules fetched 20 times (12 avoidable each on 8 lanes) and 100
4242+ // fetched twice (0 avoidable): duplication = 1200 / 2200
4343+ const fetchCounts = [
4444+ ...Array.from({ length: 100 }, () => 20),
4545+ ...Array.from({ length: 100 }, () => 2),
4646+ ]
4747+ expect(getImportDiagnostics([{ ...barrelProject, fetchCounts }])).toEqual([
4848+ {
4949+ name: 'barrel',
5050+ importTime: 15_000,
5151+ share: 15_000 / 20_000,
5252+ totalFetches: 2_200,
5353+ uniqueModules: 200,
5454+ duplication: 1_200 / 2_200,
5555+ estimatedSaving: (15_000 * (1_200 / 2_200)) / 8,
5656+ },
5757+ ])
5858+ })
5959+6060+ it('fires exactly at the minimum duplication', () => {
6161+ // 10 fetches per module on 8 lanes: duplication (10 - 8) / 10 = 0.2
6262+ expect(getImportDiagnostics([{
6363+ ...barrelProject,
6464+ fetchCounts: Array.from({ length: 800 }, () => 10),
6565+ }])).toHaveLength(1)
6666+ })
6767+6868+ it('fires exactly at the minimum import time and share', () => {
6969+ // 2s of imports over 8s of tracked time is exactly the 2s / 25% minimum;
7070+ // saving on 4 lanes: 2s * 0.8 / 4 = 400ms, above the 250ms floor
7171+ expect(getImportDiagnostics([{
7272+ ...barrelProject,
7373+ importTime: 2_000,
7474+ trackedTime: 8_000,
7575+ parallelism: 4,
7676+ }])).toHaveLength(1)
7777+ })
7878+7979+ it('treats every repeated fetch as avoidable on a single lane', () => {
8080+ const diagnostics = getImportDiagnostics([{ ...barrelProject, parallelism: 1 }])
8181+ expect(diagnostics).toHaveLength(1)
8282+ expect(diagnostics[0].duplication).toBe(19 / 20)
8383+ expect(diagnostics[0].estimatedSaving).toBe(15_000 * (19 / 20))
8484+ })
8585+8686+ it('reports every affected project', () => {
8787+ const diagnostics = getImportDiagnostics([barrelProject, { ...barrelProject, name: 'ui' }])
8888+ expect(diagnostics.map(diagnostic => diagnostic.name)).toEqual(['barrel', 'ui'])
8989+ })
9090+9191+ it('stays quiet for disjoint per-file graphs - reused workers would not help', () => {
9292+ // every module belongs to one or two test files: no fetch count exceeds
9393+ // the parallelism, so nothing is re-evaluated beyond what lanes require
9494+ expect(getImportDiagnostics([{
9595+ ...barrelProject,
9696+ fetchCounts: Array.from({ length: 800 }, (_, i) => (i % 2 === 0 ? 1 : 2)),
9797+ }])).toEqual([])
9898+ })
9999+100100+ it('stays quiet when the tests dominate the run', () => {
101101+ expect(getImportDiagnostics([{
102102+ ...barrelProject,
103103+ importTime: 2_500,
104104+ trackedTime: 30_000,
105105+ }])).toEqual([])
106106+ })
107107+108108+ it('stays quiet when the import time is small in absolute terms', () => {
109109+ expect(getImportDiagnostics([{
110110+ ...barrelProject,
111111+ importTime: 1_500,
112112+ trackedTime: 2_000,
113113+ }])).toEqual([])
114114+ })
115115+116116+ it('does not suggest disabling isolation the user explicitly enabled', () => {
117117+ expect(getImportDiagnostics([{ ...barrelProject, isolateProvided: true }])).toEqual([])
118118+ })
119119+120120+ it('stays quiet without isolation - the graph is already shared', () => {
121121+ expect(getImportDiagnostics([{ ...barrelProject, isolate: false }])).toEqual([])
122122+ })
123123+124124+ it('stays quiet for vm pools - they re-create the graph per context regardless', () => {
125125+ expect(getImportDiagnostics([{ ...barrelProject, pool: 'vmThreads' }])).toEqual([])
126126+ })
127127+128128+ it('stays quiet for browser projects', () => {
129129+ expect(getImportDiagnostics([{ ...barrelProject, browser: true }])).toEqual([])
130130+ })
131131+132132+ it('stays quiet when files do not outnumber the workers', () => {
133133+ expect(getImportDiagnostics([{ ...barrelProject, fileCount: 8 }])).toEqual([])
134134+ })
135135+136136+ it('stays quiet when the saving is negligible relative to a long run', () => {
137137+ // ~1.1s estimated saving is below 5% of a 5-minute run and below 10s absolute
138138+ expect(getImportDiagnostics([{ ...barrelProject, executionTime: 300_000 }])).toEqual([])
139139+ })
140140+})
141141+142142+const coldProject = {
143143+ name: 'cold',
144144+ transformTime: 8_000,
145145+ trackedTime: 20_000,
146146+ fsModuleCache: false,
147147+ fsModuleCacheProvided: false,
148148+ executionTime: 10_000,
149149+}
150150+151151+describe('getTransformDiagnostics', () => {
152152+ it('fires when transforms dominate and nothing persists them', () => {
153153+ expect(getTransformDiagnostics([coldProject])).toEqual([
154154+ {
155155+ name: 'cold',
156156+ transformTime: 8_000,
157157+ share: 8_000 / 20_000,
158158+ },
159159+ ])
160160+ })
161161+162162+ it('stays quiet when the fs module cache is already enabled', () => {
163163+ expect(getTransformDiagnostics([{ ...coldProject, fsModuleCache: true }])).toEqual([])
164164+ })
165165+166166+ it('does not suggest a cache the user explicitly configured away', () => {
167167+ expect(getTransformDiagnostics([{ ...coldProject, fsModuleCacheProvided: true }])).toEqual([])
168168+ })
169169+170170+ it('stays quiet when the transform time is small in absolute terms', () => {
171171+ expect(getTransformDiagnostics([{ ...coldProject, transformTime: 1_500 }])).toEqual([])
172172+ })
173173+174174+ it('stays quiet when the tests dominate the run', () => {
175175+ expect(getTransformDiagnostics([{
176176+ ...coldProject,
177177+ transformTime: 3_000,
178178+ trackedTime: 30_000,
179179+ }])).toEqual([])
180180+ })
181181+182182+ it('stays quiet when the saving is negligible relative to a long run', () => {
183183+ expect(getTransformDiagnostics([{
184184+ ...coldProject,
185185+ transformTime: 2_500,
186186+ trackedTime: 9_000,
187187+ executionTime: 300_000,
188188+ }])).toEqual([])
189189+ })
190190+})
191191+192192+describe('estimateModuleEvaluationSaving', () => {
193193+ it('spreads the avoidable evaluations across the worker lanes', () => {
194194+ // a module evaluated by 20 files at 10ms each on 8 lanes keeps 8
195195+ // evaluations: (200 * 12/20) / 8 = 15ms saved per module
196196+ const modules = Array.from({ length: 10 }, () => Array.from({ length: 20 }, () => 10))
197197+ expect(estimateModuleEvaluationSaving(modules, 8)).toBe(150)
198198+ })
199199+200200+ it('saves nothing when no module is evaluated more often than the lanes', () => {
201201+ const modules = [Array.from({ length: 8 }, () => 10), [10, 10]]
202202+ expect(estimateModuleEvaluationSaving(modules, 8)).toBe(0)
203203+ })
204204+205205+ it('treats every evaluation past the first as avoidable on a single lane', () => {
206206+ expect(estimateModuleEvaluationSaving([[10, 10, 10, 10]], 1)).toBe(30)
207207+ })
208208+})
+29-2
packages/vitest/src/node/state.ts
···3434 * of magnitude.
3535 */
3636 transformTime = 0
3737+ /**
3838+ * Transform busy time split by project name, measured the same way as
3939+ * `transformTime`. Used by performance diagnostics to relate a project's
4040+ * transform cost to the rest of its tracked time.
4141+ */
4242+ transformTimes: Map<string, number> = new Map()
4343+ /**
4444+ * Total time spent starting test workers (spawning the process/thread, loading
4545+ * the worker bundle and setting up the test environment). Used to surface the
4646+ * cost of `isolate: true`, which spawns a fresh worker per test file.
4747+ */
4848+ startupTime = 0
4949+ /** Number of test workers that were started during the run. */
5050+ workersSpawned = 0
3751 private _transformsInflight = 0
3852 private _transformsBusyStart = 0
5353+ private _projectTransformsInflight: Map<string, number> = new Map()
5454+ private _projectTransformsBusyStart: Map<string, number> = new Map()
39554040- transformStarted(): void {
5656+ transformStarted(projectName: string): void {
4157 if (this._transformsInflight++ === 0) {
4258 this._transformsBusyStart = performance.now()
4359 }
6060+ const inflight = this._projectTransformsInflight.get(projectName) || 0
6161+ this._projectTransformsInflight.set(projectName, inflight + 1)
6262+ if (inflight === 0) {
6363+ this._projectTransformsBusyStart.set(projectName, performance.now())
6464+ }
4465 }
45664646- transformFinished(): void {
6767+ transformFinished(projectName: string): void {
4768 if (--this._transformsInflight === 0) {
4869 this.transformTime += performance.now() - this._transformsBusyStart
7070+ }
7171+ const inflight = (this._projectTransformsInflight.get(projectName) || 1) - 1
7272+ this._projectTransformsInflight.set(projectName, inflight)
7373+ if (inflight === 0) {
7474+ const busy = performance.now() - this._projectTransformsBusyStart.get(projectName)!
7575+ this.transformTimes.set(projectName, (this.transformTimes.get(projectName) || 0) + busy)
4976 }
5077 }
5178
+18
packages/vitest/src/node/cli/cli-config.ts
···949949 preParse: {
950950 description: 'Parse test specifications before running them. This will apply `.only` flag and test name pattern across all files without running them. (default: `false`)',
951951 },
952952+ diagnostics: {
953953+ description: 'Print performance hints after the run when a configuration change would make it significantly faster. Hints never suggest changing options that were set explicitly. (default: `true`)',
954954+ argument: '',
955955+ subcommands: {
956956+ isolate: {
957957+ description: 'Print a hint estimating how much time `isolate: false` would save when `isolate: true` spends a significant amount of time spawning a worker per test file. (default: `true`)',
958958+ },
959959+ environment: {
960960+ description: 'Print a hint when re-creating a DOM environment for every test file dominates the run and a `vm` pool would set it up once per worker. (default: `true`)',
961961+ },
962962+ import: {
963963+ description: 'Print a hint when test files repeatedly evaluate the same module graph (typical for barrel-file imports) and `isolate: false` would evaluate it once per worker. (default: `true`)',
964964+ },
965965+ transform: {
966966+ description: 'Print a hint when transforming modules dominates the run and `fsModuleCache` would persist the results across runs. (default: `true`)',
967967+ },
968968+ },
969969+ },
952970 },
953971 },
954972 // disable CLI options
+39
packages/vitest/src/node/config/resolveConfig.ts
···170170 }
171171}
172172173173+/**
174174+ * Records which options the user provided explicitly. Must be computed from
175175+ * the raw user config sources BEFORE `configDefaults` is merged in - the
176176+ * merged object cannot distinguish a default from a user-provided value.
177177+ */
178178+export function captureProvidedOptions(
179179+ ...sources: (UserConfig | undefined)[]
180180+): ResolvedConfig['providedOptions'] {
181181+ return {
182182+ pool: sources.some(source => source?.pool != null),
183183+ isolate: sources.some(source => source?.isolate != null),
184184+ environment: sources.some(source => source?.environment != null || source?.dom),
185185+ fsModuleCache: sources.some(source =>
186186+ source?.fsModuleCache != null
187187+ || (source?.experimental as { fsModuleCache?: boolean } | undefined)?.fsModuleCache != null),
188188+ }
189189+}
190190+173191// warn only once, check one PER PROCESS, not per instance,
174192// that's why it's on a module-level
175193let warnedTypeCheck = false
···205223 options.environment = 'happy-dom'
206224 }
207225226226+ // provenance must be captured from the raw options BEFORE `configDefaults`
227227+ // is merged in - the merged object cannot distinguish a default from a
228228+ // user-provided value; `viteConfig.test` is not resolved yet at this point,
229229+ // the call sites assign the resolved config to it after this function returns
230230+ const providedOptions = captureProvidedOptions(
231231+ options,
232232+ viteConfig.test as UserConfig | undefined,
233233+ )
234234+208235 const resolved = deepMerge({}, configDefaults, options) as ResolvedConfig
209236 resolved.root = viteConfig.root
237237+ resolved.providedOptions = providedOptions
210238211239 // Coverage is collected once for the whole run using the root config, so projects
212240 // share its resolved coverage. Each project's setup/test/config files are then
···9911019 resolved.experimental.importDurations.thresholds ??= {} as any
9921020 resolved.experimental.importDurations.thresholds.warn ??= 100
9931021 resolved.experimental.importDurations.thresholds.danger ??= 500
10221022+10231023+ const diagnostics = (resolved.experimental.diagnostics as boolean | { isolate?: boolean; environment?: boolean; import?: boolean; transform?: boolean } | undefined)
10241024+ ?? true
10251025+ resolved.experimental.diagnostics = typeof diagnostics === 'boolean'
10261026+ ? { isolate: diagnostics, environment: diagnostics, import: diagnostics, transform: diagnostics }
10271027+ : {
10281028+ isolate: diagnostics.isolate ?? true,
10291029+ environment: diagnostics.environment ?? true,
10301030+ import: diagnostics.import ?? true,
10311031+ transform: diagnostics.transform ?? true,
10321032+ }
99410339951034 if (typeof resolved.experimental.vcsProvider === 'string' && resolved.experimental.vcsProvider !== 'git') {
9961035 resolved.experimental.vcsProvider = resolvePath(resolved.experimental.vcsProvider, resolved.root)
···2626 * actual work by orders of magnitude.
2727 */
2828export interface TransformClock {
2929- transformStarted: () => void
3030- transformFinished: () => void
2929+ transformStarted: (projectName: string) => void
3030+ transformFinished: (projectName: string) => void
3131}
32323333class ModuleFetcher {
3434 private tmpDirectories = new Set<string>()
3535 private fsCacheEnabled: boolean
3636+ private projectName: string
3637 // the module type is only needed by the evaluator to decide if CJS
3738 // variables should be provided to the module, so don't waste time
3839 // on the detection when every module receives them
···4748 ) {
4849 this.fsCacheEnabled = config.fsModuleCache === true
4950 this.detectModuleType = config.injectCjsGlobals === false
5151+ this.projectName = config.name || ''
5052 }
51535254 async fetch(
···325327 moduleGraphModule: EnvironmentModuleNode,
326328 options?: FetchFunctionOptions,
327329 ): Promise<VitestFetchResult> {
328328- this.clock.transformStarted()
330330+ this.clock.transformStarted(this.projectName)
329331 try {
330332 const moduleRunnerModule = await fetchModule(
331333 environment,
···344346 return result
345347 }
346348 finally {
347347- this.clock.transformFinished()
349349+ this.clock.transformFinished(this.projectName)
348350 }
349351 }
350352
+7
packages/vitest/src/node/pools/poolRunner.ts
···184184 this._operationLock = createDefer()
185185186186 let startSpan: Span | undefined
187187+ const startedAt = performance.now()
187188 try {
188189 this._state = RunnerState.STARTING
189190···233234 await startPromise
234235235236 this._state = RunnerState.STARTED
237237+238238+ // record how long it took to spawn this worker, load its bundle and set up the
239239+ // environment, so the reporter can surface the cost of `isolate: true`
240240+ const { state } = this.project.vitest
241241+ state.startupTime += performance.now() - startedAt
242242+ state.workersSpawned += 1
236243 }
237244 catch (error: any) {
238245 this._state = RunnerState.START_FAILURE
+343-1
packages/vitest/src/node/reporters/base.ts
···66import type { Reporter, TestRunEndReason } from '../types/reporter'
77import type { TestCase, TestCollection, TestModule, TestModuleState, TestResult, TestSuite, TestSuiteState } from './reported-tasks'
88import { readFileSync } from 'node:fs'
99+import { availableParallelism } from 'node:os'
910import { performance } from 'node:perf_hooks'
1011import { toArray } from '@vitest/utils/helpers'
1112import { parseStacktrace } from '@vitest/utils/source-map'
1213import { relative } from 'pathe'
1314import c from 'tinyrainbow'
1415import { groupBy } from '../../utils/base'
1515-import { isTTY } from '../../utils/env'
1616+import { isCI, isTTY } from '../../utils/env'
1617import { getSuites, getTestName, getTests, hasFailed, hasFailedSnapshot } from '../../utils/tasks'
1718import { generateCodeFrame, printStack } from '../printError'
1919+import { estimateModuleEvaluationSaving, getEnvironmentDiagnostics, getImportDiagnostics, getTransformDiagnostics, isSavingWorthHinting } from './diagnostics'
1820import { computeDurationBreakdown, formatDurationBreakdown } from './durationBreakdown'
1921import { BENCH_TABLE_HEAD, computeBenchColumnWidths, padBenchRow, renderBenchmarkRow } from './renderers/benchmark-table'
2022import { F_CHECK, F_DOWN_RIGHT, F_POINTER } from './renderers/figures'
···657659658660 this.reportImportDurations()
659661662662+ // at most one hint per family: the environment hint is the most specific,
663663+ // the import hint explains the same `isolate` remedy through module data,
664664+ // and the transform hint (a cache, helping the *next* run) only speaks up
665665+ // when neither applies
666666+ const hinted = this.reportEnvironmentDiagnostic(files)
667667+ || this.reportImportDiagnostic(files)
668668+ || this.reportTransformDiagnostic(files)
669669+ if (!hinted) {
670670+ this.reportIsolateDiagnostic(files)
671671+ }
672672+660673 this.log()
674674+ }
675675+676676+ private getEffectiveMaxWorkers(): number {
677677+ const configured = this.ctx.config.maxWorkers
678678+ return typeof configured === 'number' && configured > 0
679679+ ? configured
680680+ : Math.max(1, availableParallelism() - 1)
681681+ }
682682+683683+ /**
684684+ * Surfaces the cost of re-creating a DOM environment for every test file:
685685+ * with an isolating pool, `jsdom`/`happy-dom` are imported and set up once
686686+ * per file. When that repeated setup dominates the run, hint that a `vm`
687687+ * pool sets the environment up once per worker while keeping per-file
688688+ * isolation, and that `isolate: false` shares it across files.
689689+ */
690690+ private reportEnvironmentDiagnostic(files: File[]): boolean {
691691+ // merged blob reports replay durations of past runs: no environments were
692692+ // created by this process, and the per-project transform split needed for
693693+ // an accurate share is not part of the blob format
694694+ if (this.ctx.config.watch || this.ctx.state.blobs || !this.ctx.config.experimental.diagnostics.environment) {
695695+ return false
696696+ }
697697+698698+ const executionTime = this.end - this.start
699699+ const maxWorkers = this.getEffectiveMaxWorkers()
700700+ const transformTimes = this.ctx.state.transformTimes
701701+ const inputs = this.ctx.projects.map((project) => {
702702+ const projectFiles = files.filter(file => (file.projectName || '') === project.name)
703703+ let environmentTime = 0
704704+ let environmentCount = 0
705705+ let trackedTime = transformTimes.get(project.name) || 0
706706+ for (const file of projectFiles) {
707707+ if (file.environmentLoad) {
708708+ environmentTime += file.environmentLoad
709709+ environmentCount++
710710+ }
711711+ trackedTime
712712+ += (file.environmentLoad || 0)
713713+ + (file.setupDuration || 0)
714714+ + (file.collectDuration || 0)
715715+ + (file.result?.duration || 0)
716716+ }
717717+ return {
718718+ name: project.name,
719719+ environment: project.config.environment,
720720+ pool: project.config.pool,
721721+ isolate: project.config.isolate,
722722+ browser: project.config.browser.enabled,
723723+ poolProvided: project.config.providedOptions.pool,
724724+ isolateProvided: project.config.providedOptions.isolate,
725725+ environmentTime,
726726+ environmentCount,
727727+ trackedTime,
728728+ parallelism: Math.max(1, Math.min(environmentCount, maxWorkers)),
729729+ executionTime,
730730+ }
731731+ })
732732+733733+ const diagnostics = getEnvironmentDiagnostics(inputs)
734734+ if (!diagnostics.length) {
735735+ return false
736736+ }
737737+738738+ for (const diagnostic of diagnostics) {
739739+ const project = this.ctx.projects.find(p => p.name === diagnostic.name)
740740+ this.log()
741741+ this.log(
742742+ padSummaryTitle('Environment'),
743743+ formatProjectName(project)
744744+ + c.yellow(`${diagnostic.environment} was created ${diagnostic.environmentCount} times`)
745745+ + c.dim(` · ${formatTime(diagnostic.environmentTime)} total, ${Math.round(diagnostic.share * 100)}% of tracked time`),
746746+ )
747747+ const alternative = diagnostic.suggestIsolate
748748+ ? c.dim(' (keeps per-file isolation) or ') + c.yellow('isolate: false') + c.dim(' (shares it across files)')
749749+ : c.dim(' (keeps per-file isolation)')
750750+ this.log(
751751+ padSummaryTitle(''),
752752+ c.dim('create it once per worker with ')
753753+ + c.yellow(`pool: 'vmThreads'`)
754754+ + alternative,
755755+ )
756756+ this.log(
757757+ padSummaryTitle(''),
758758+ c.dim('learn more: https://vitest.dev/guide/improving-performance#test-environments'),
759759+ )
760760+ }
761761+762762+ return true
763763+ }
764764+765765+ /**
766766+ * Surfaces repeated evaluation of the same module graph: with `isolate: true`
767767+ * every test file re-imports its whole graph, so suites where files share
768768+ * most of their modules (typically through barrel files) pay the graph cost
769769+ * once per file. The duplication is measured from server-side fetch counts,
770770+ * so suites with disjoint per-file graphs stay quiet.
771771+ */
772772+ private reportImportDiagnostic(files: File[]): boolean {
773773+ if (this.ctx.config.watch || this.ctx.state.blobs || !this.ctx.config.experimental.diagnostics.import) {
774774+ return false
775775+ }
776776+777777+ const executionTime = this.end - this.start
778778+ const maxWorkers = this.getEffectiveMaxWorkers()
779779+ const transformTimes = this.ctx.state.transformTimes
780780+ const inputs = this.ctx.projects.map((project) => {
781781+ const projectFiles = files.filter(file => (file.projectName || '') === project.name)
782782+ let importTime = 0
783783+ let trackedTime = transformTimes.get(project.name) || 0
784784+ for (const file of projectFiles) {
785785+ importTime += file.collectDuration || 0
786786+ trackedTime
787787+ += (file.environmentLoad || 0)
788788+ + (file.setupDuration || 0)
789789+ + (file.collectDuration || 0)
790790+ + (file.result?.duration || 0)
791791+ }
792792+ const durations = this.ctx.state.metadata[project.name]?.duration
793793+ return {
794794+ name: project.name,
795795+ pool: project.config.pool,
796796+ isolate: project.config.isolate,
797797+ browser: project.config.browser.enabled,
798798+ isolateProvided: project.config.providedOptions.isolate,
799799+ importTime,
800800+ trackedTime,
801801+ fetchCounts: durations ? Object.values(durations).map(times => times.length) : [],
802802+ fileCount: projectFiles.length,
803803+ parallelism: Math.max(1, Math.min(projectFiles.length, maxWorkers)),
804804+ executionTime,
805805+ }
806806+ })
807807+808808+ const diagnostics = getImportDiagnostics(inputs)
809809+ if (!diagnostics.length) {
810810+ return false
811811+ }
812812+813813+ for (const diagnostic of diagnostics) {
814814+ const project = this.ctx.projects.find(p => p.name === diagnostic.name)
815815+ this.log()
816816+ this.log(
817817+ padSummaryTitle('Import'),
818818+ formatProjectName(project)
819819+ + c.yellow(`${diagnostic.uniqueModules} modules were evaluated ${diagnostic.totalFetches} times`)
820820+ + c.dim(` · ${formatTime(diagnostic.importTime)} total, ${Math.round(diagnostic.share * 100)}% of tracked time`),
821821+ )
822822+ this.log(
823823+ padSummaryTitle(''),
824824+ c.dim(`~${formatTime(diagnostic.estimatedSaving)} faster with `)
825825+ + c.yellow('isolate: false')
826826+ + c.dim(' — shared modules are evaluated once per worker instead of once per file'),
827827+ )
828828+ this.log(
829829+ padSummaryTitle(''),
830830+ c.dim('learn more: https://vitest.dev/guide/improving-performance#test-isolation'),
831831+ )
832832+ }
833833+834834+ return true
835835+ }
836836+837837+ /**
838838+ * Surfaces transform-dominated runs: without the fs module cache every
839839+ * `vitest run` transforms the whole module graph from scratch. Enabling
840840+ * `fsModuleCache` persists the results so the next run skips them.
841841+ */
842842+ private reportTransformDiagnostic(files: File[]): boolean {
843843+ if (this.ctx.config.watch || this.ctx.state.blobs || !this.ctx.config.experimental.diagnostics.transform) {
844844+ return false
845845+ }
846846+847847+ const executionTime = this.end - this.start
848848+ const transformTimes = this.ctx.state.transformTimes
849849+ const inputs = this.ctx.projects.map((project) => {
850850+ const projectFiles = files.filter(file => (file.projectName || '') === project.name)
851851+ const transformTime = transformTimes.get(project.name) || 0
852852+ let trackedTime = transformTime
853853+ for (const file of projectFiles) {
854854+ trackedTime
855855+ += (file.environmentLoad || 0)
856856+ + (file.setupDuration || 0)
857857+ + (file.collectDuration || 0)
858858+ + (file.result?.duration || 0)
859859+ }
860860+ return {
861861+ name: project.name,
862862+ transformTime,
863863+ trackedTime,
864864+ fsModuleCache: project.config.fsModuleCache === true,
865865+ fsModuleCacheProvided: project.config.providedOptions.fsModuleCache,
866866+ executionTime,
867867+ }
868868+ })
869869+870870+ const diagnostics = getTransformDiagnostics(inputs)
871871+ if (!diagnostics.length) {
872872+ return false
873873+ }
874874+875875+ for (const diagnostic of diagnostics) {
876876+ const project = this.ctx.projects.find(p => p.name === diagnostic.name)
877877+ this.log()
878878+ this.log(
879879+ padSummaryTitle('Transform'),
880880+ formatProjectName(project)
881881+ + c.yellow(`transforming modules took ${formatTime(diagnostic.transformTime)}`)
882882+ + c.dim(` · ${Math.round(diagnostic.share * 100)}% of tracked time, re-done on every run`),
883883+ )
884884+ this.log(
885885+ padSummaryTitle(''),
886886+ c.dim('persist transforms across runs with ')
887887+ + c.yellow('fsModuleCache: true'),
888888+ )
889889+ if (isCI) {
890890+ this.log(
891891+ padSummaryTitle(''),
892892+ c.dim('on CI this only helps when the cache directory is persisted between runs'),
893893+ )
894894+ }
895895+ this.log(
896896+ padSummaryTitle(''),
897897+ c.dim('learn more: https://vitest.dev/guide/improving-performance#caching-between-reruns'),
898898+ )
899899+ }
900900+901901+ return true
902902+ }
903903+904904+ /**
905905+ * Surfaces the cost of `isolate: true`: with isolation enabled Vitest spawns a
906906+ * fresh worker (and re-creates the test environment) for every test file. When
907907+ * that repeated startup cost is significant, hint that `isolate: false` would
908908+ * reuse workers across files.
909909+ */
910910+ private reportIsolateDiagnostic(files: File[]): void {
911911+ // opt-out via `experimental.diagnostics.isolate`; the timers it relies on
912912+ // are only shown for a full (non-watch) run
913913+ if (this.ctx.config.watch || !this.ctx.config.experimental.diagnostics.isolate) {
914914+ return
915915+ }
916916+917917+ const state = this.ctx.state
918918+ const numWorkers = state.workersSpawned
919919+ // only meaningful when at least one non-browser project isolates workers
920920+ // without the user having explicitly chosen isolation
921921+ const isolates = this.ctx.projects.some(
922922+ project => project.config.isolate
923923+ && !project.config.browser.enabled
924924+ && !project.config.providedOptions.isolate,
925925+ )
926926+ if (!numWorkers || !isolates) {
927927+ return
928928+ }
929929+930930+ const numFiles = files.length
931931+ // `startupTime` is the summed (across workers) time spent spawning the worker,
932932+ // loading its bundle and setting up the environment. The environment setup is
933933+ // already part of this window, so it is not added separately.
934934+ const startupTime = state.startupTime
935935+ const avgStartup = startupTime / numWorkers
936936+937937+ // with `isolate: false` the same files would run in ~`parallelism` reused
938938+ // workers instead of spawning a fresh worker for every file
939939+ const parallelism = Math.max(1, Math.min(numFiles, this.getEffectiveMaxWorkers()))
940940+941941+ // nothing was actually spawned per-file (e.g. a single worker handled everything)
942942+ if (numWorkers <= parallelism) {
943943+ return
944944+ }
945945+946946+ // Spawns are spread across ~`parallelism` lanes, so the wall-clock cost is the
947947+ // summed startup divided by parallelism. Reusing workers leaves ~1 spawn per
948948+ // lane, so the reducible wall-clock time is the rest.
949949+ const wallStartup = startupTime / parallelism
950950+951951+ // Reused workers also keep evaluated modules alive, so every module a later
952952+ // file would re-evaluate is saved as well. Per-module evaluation times are
953953+ // only collected when `experimental.importDurations` is enabled — without
954954+ // them the spawn saving is reported as a lower bound ("at least").
955955+ const measuresModules = this.ctx.config.experimental.importDurations.limit > 0
956956+ let moduleSavings = 0
957957+ if (measuresModules) {
958958+ // vm pools re-create the module graph per VM context regardless of
959959+ // `isolate`, so only files of `forks`/`threads` projects count
960960+ const eligibleProjects = new Set(this.ctx.projects
961961+ .filter(project => (project.config.pool === 'forks' || project.config.pool === 'threads')
962962+ && project.config.isolate
963963+ && !project.config.browser.enabled
964964+ && !project.config.providedOptions.isolate)
965965+ .map(project => project.name))
966966+ const moduleSelfTimes = new Map<string, number[]>()
967967+ for (const file of files) {
968968+ if (!eligibleProjects.has(file.projectName || '') || !file.importDurations) {
969969+ continue
970970+ }
971971+ for (const moduleId in file.importDurations) {
972972+ let times = moduleSelfTimes.get(moduleId)
973973+ if (!times) {
974974+ times = []
975975+ moduleSelfTimes.set(moduleId, times)
976976+ }
977977+ times.push(file.importDurations[moduleId].selfTime)
978978+ }
979979+ }
980980+ moduleSavings = estimateModuleEvaluationSaving(moduleSelfTimes.values(), parallelism)
981981+ }
982982+983983+ const estimatedSavings = wallStartup - avgStartup + moduleSavings
984984+985985+ if (!isSavingWorthHinting(estimatedSavings, this.end - this.start)) {
986986+ return
987987+ }
988988+989989+ this.log()
990990+ this.log(
991991+ padSummaryTitle('Isolate'),
992992+ c.yellow(`${numWorkers} workers spawned`)
993993+ + c.dim(` · ~${formatTime(avgStartup)} startup each (spawn + environment, per file)`),
994994+ )
995995+ this.log(
996996+ padSummaryTitle(''),
997997+ c.dim(`${measuresModules ? '' : 'at least '}~${formatTime(estimatedSavings)} faster with `)
998998+ + c.yellow('isolate: false')
999999+ + c.dim(measuresModules
10001000+ ? ' — reuses workers across files and evaluates shared modules once per worker'
10011001+ : ' — reuses workers across files instead of one per file'),
10021002+ )
6611003 }
66210046631005 private reportImportDurations() {
+288
packages/vitest/src/node/reporters/diagnostics.ts
···11+const DOM_ENVIRONMENTS = new Set(['jsdom', 'happy-dom'])
22+33+/** Minimum summed environment setup time before the hint is worth printing. */
44+const MIN_ENVIRONMENT_TIME = 2_000
55+/** Minimum share of the project's tracked time spent setting up environments. */
66+const MIN_ENVIRONMENT_SHARE = 0.25
77+88+/**
99+ * A hint has to be worth acting on: the estimated saving must be noticeable.
1010+ * Run-to-run noise of real suites is commonly a few percent, so anything below
1111+ * ~5% of the wall time cannot even be confirmed by trying the change - except
1212+ * on long runs, where 10 seconds is worth attention regardless of percentage.
1313+ */
1414+export function isSavingWorthHinting(saving: number, executionTime: number): boolean {
1515+ if (saving < 250) {
1616+ return false
1717+ }
1818+ return saving >= executionTime * 0.05 || saving >= 10_000
1919+}
2020+2121+export interface EnvironmentDiagnosticInput {
2222+ name: string
2323+ environment: string
2424+ pool: string
2525+ isolate: boolean
2626+ browser: boolean
2727+ /** The user explicitly configured the pool, so don't suggest changing it. */
2828+ poolProvided: boolean
2929+ /** The user explicitly configured isolation, so don't suggest disabling it. */
3030+ isolateProvided: boolean
3131+ /** Summed time spent creating the environment, across all files. */
3232+ environmentTime: number
3333+ /** Number of files that created an environment. */
3434+ environmentCount: number
3535+ /** Summed time of all tracked phases of this project. */
3636+ trackedTime: number
3737+ /** How many workers the environment setups were spread across. */
3838+ parallelism: number
3939+ /** Wall time of the whole run. */
4040+ executionTime: number
4141+}
4242+4343+export interface EnvironmentDiagnostic {
4444+ name: string
4545+ environment: string
4646+ environmentTime: number
4747+ environmentCount: number
4848+ /** Share of the project's tracked time, 0-1. */
4949+ share: number
5050+ /** Whether suggesting `isolate: false` is appropriate. */
5151+ suggestIsolate: boolean
5252+}
5353+5454+/** Minimum summed import time before the hint is worth printing. */
5555+const MIN_IMPORT_TIME = 2_000
5656+/** Minimum share of the project's tracked time spent importing modules. */
5757+const MIN_IMPORT_SHARE = 0.25
5858+/**
5959+ * Minimum fraction of module fetches that re-evaluate an already evaluated
6060+ * module. Below this the test files import mostly disjoint graphs and
6161+ * reusing workers would not meaningfully reduce the import work.
6262+ */
6363+const MIN_IMPORT_DUPLICATION = 0.2
6464+6565+export interface ImportDiagnosticInput {
6666+ name: string
6767+ pool: string
6868+ isolate: boolean
6969+ browser: boolean
7070+ /** The user explicitly configured isolation, so don't suggest disabling it. */
7171+ isolateProvided: boolean
7272+ /** Summed time spent importing test files and their module graphs. */
7373+ importTime: number
7474+ /** Summed time of all tracked phases of this project. */
7575+ trackedTime: number
7676+ /**
7777+ * How many times each module was served to a worker. With `isolate: true`
7878+ * every test file re-fetches (and re-evaluates) its whole module graph, so
7979+ * counts above the worker parallelism are repeated evaluations of the same
8080+ * module that `isolate: false` would avoid.
8181+ */
8282+ fetchCounts: number[]
8383+ fileCount: number
8484+ /** How many workers the imports were spread across. */
8585+ parallelism: number
8686+ /** Wall time of the whole run. */
8787+ executionTime: number
8888+}
8989+9090+export interface ImportDiagnostic {
9191+ name: string
9292+ importTime: number
9393+ /** Share of the project's tracked time, 0-1. */
9494+ share: number
9595+ totalFetches: number
9696+ uniqueModules: number
9797+ /** Fraction of fetches that re-evaluated an already evaluated module, 0-1. */
9898+ duplication: number
9999+ /** Estimated wall-clock saving of `isolate: false`. */
100100+ estimatedSaving: number
101101+}
102102+103103+/**
104104+ * Detects projects where test files repeatedly evaluate the same module
105105+ * graph. Typical for barrel-file imports: every test file pulls hundreds of
106106+ * shared modules to use a few of them, and `isolate: true` re-evaluates that
107107+ * graph for every file. The duplication is measured from the server-side
108108+ * fetch counts, so suites with disjoint per-file graphs - where reusing
109109+ * workers would not help - stay quiet.
110110+ */
111111+export function getImportDiagnostics(
112112+ projects: ImportDiagnosticInput[],
113113+): ImportDiagnostic[] {
114114+ const diagnostics: ImportDiagnostic[] = []
115115+ for (const project of projects) {
116116+ if (
117117+ // vm pools re-create the module graph per VM context regardless of
118118+ // `isolate`, so reusing workers would not reduce the import work
119119+ (project.pool !== 'forks' && project.pool !== 'threads')
120120+ || !project.isolate
121121+ || project.isolateProvided
122122+ || project.browser
123123+ || project.fileCount <= project.parallelism
124124+ || project.importTime < MIN_IMPORT_TIME
125125+ || project.trackedTime <= 0
126126+ || project.importTime / project.trackedTime < MIN_IMPORT_SHARE
127127+ ) {
128128+ continue
129129+ }
130130+ const parallelism = Math.max(1, project.parallelism)
131131+ let totalFetches = 0
132132+ let avoidableFetches = 0
133133+ for (const count of project.fetchCounts) {
134134+ totalFetches += count
135135+ // reused workers still fetch a module once per lane that needs it
136136+ avoidableFetches += Math.max(0, count - parallelism)
137137+ }
138138+ if (totalFetches === 0) {
139139+ continue
140140+ }
141141+ const duplication = avoidableFetches / totalFetches
142142+ if (duplication < MIN_IMPORT_DUPLICATION) {
143143+ continue
144144+ }
145145+ // imports are spread across the worker lanes, so the reducible wall time
146146+ // is the duplicated share of the summed import time divided by lanes
147147+ const estimatedSaving = (project.importTime * duplication) / parallelism
148148+ if (!isSavingWorthHinting(estimatedSaving, project.executionTime)) {
149149+ continue
150150+ }
151151+ diagnostics.push({
152152+ name: project.name,
153153+ importTime: project.importTime,
154154+ share: project.importTime / project.trackedTime,
155155+ totalFetches,
156156+ uniqueModules: project.fetchCounts.length,
157157+ duplication,
158158+ estimatedSaving,
159159+ })
160160+ }
161161+ return diagnostics
162162+}
163163+164164+/**
165165+ * Estimates the wall-clock time saved by evaluating shared modules once per
166166+ * worker instead of once per test file. `moduleSelfTimes` holds, per module,
167167+ * the module's own evaluation time in every test file that evaluated it.
168168+ * Reused workers keep ~`parallelism` evaluations of each module (one per
169169+ * lane); the rest of the summed time is avoidable and spread across the
170170+ * lanes.
171171+ */
172172+export function estimateModuleEvaluationSaving(
173173+ moduleSelfTimes: Iterable<number[]>,
174174+ parallelism: number,
175175+): number {
176176+ const lanes = Math.max(1, parallelism)
177177+ let avoidable = 0
178178+ for (const times of moduleSelfTimes) {
179179+ if (times.length <= lanes) {
180180+ continue
181181+ }
182182+ let sum = 0
183183+ for (const time of times) {
184184+ sum += time
185185+ }
186186+ avoidable += (sum * (times.length - lanes)) / times.length
187187+ }
188188+ return avoidable / lanes
189189+}
190190+191191+/** Minimum summed transform time before the hint is worth printing. */
192192+const MIN_TRANSFORM_TIME = 2_000
193193+/** Minimum share of the project's tracked time spent transforming modules. */
194194+const MIN_TRANSFORM_SHARE = 0.25
195195+196196+export interface TransformDiagnosticInput {
197197+ name: string
198198+ /** Summed time spent transforming and serving modules. */
199199+ transformTime: number
200200+ /** Summed time of all tracked phases of this project. */
201201+ trackedTime: number
202202+ /** The fs module cache is already enabled - transforms persist across runs. */
203203+ fsModuleCache: boolean
204204+ /** The user explicitly configured the cache, so don't suggest enabling it. */
205205+ fsModuleCacheProvided: boolean
206206+ /** Wall time of the whole run. */
207207+ executionTime: number
208208+}
209209+210210+export interface TransformDiagnostic {
211211+ name: string
212212+ transformTime: number
213213+ /** Share of the project's tracked time, 0-1. */
214214+ share: number
215215+}
216216+217217+/**
218218+ * Detects projects that spend the run transforming modules. Without the fs
219219+ * module cache every `vitest run` starts from scratch and transforms the
220220+ * whole module graph again; `fsModuleCache` persists the results on disk so
221221+ * repeated runs skip them.
222222+ */
223223+export function getTransformDiagnostics(
224224+ projects: TransformDiagnosticInput[],
225225+): TransformDiagnostic[] {
226226+ return projects
227227+ .filter((project) => {
228228+ if (
229229+ project.fsModuleCache
230230+ || project.fsModuleCacheProvided
231231+ || project.transformTime < MIN_TRANSFORM_TIME
232232+ || project.trackedTime <= 0
233233+ || project.transformTime / project.trackedTime < MIN_TRANSFORM_SHARE
234234+ ) {
235235+ return false
236236+ }
237237+ // the next run skips the persisted transforms, so the (mostly serial,
238238+ // main-thread) transform time itself bounds the saving
239239+ return isSavingWorthHinting(project.transformTime, project.executionTime)
240240+ })
241241+ .map(project => ({
242242+ name: project.name,
243243+ transformTime: project.transformTime,
244244+ share: project.transformTime / project.trackedTime,
245245+ }))
246246+}
247247+248248+/**
249249+ * Detects projects where re-creating a DOM environment for every test file
250250+ * dominates the run. With an isolating pool the environment is set up once
251251+ * per file; `vmThreads`/`vmForks` set it up once per worker while still
252252+ * giving every file a fresh VM context.
253253+ */
254254+export function getEnvironmentDiagnostics(
255255+ projects: EnvironmentDiagnosticInput[],
256256+): EnvironmentDiagnostic[] {
257257+ return projects
258258+ .filter((project) => {
259259+ if (
260260+ !DOM_ENVIRONMENTS.has(project.environment)
261261+ || (project.pool !== 'forks' && project.pool !== 'threads')
262262+ || !project.isolate
263263+ || project.browser
264264+ || project.poolProvided
265265+ || project.environmentCount <= 1
266266+ || project.environmentTime < MIN_ENVIRONMENT_TIME
267267+ || project.trackedTime <= 0
268268+ || project.environmentTime / project.trackedTime < MIN_ENVIRONMENT_SHARE
269269+ ) {
270270+ return false
271271+ }
272272+ // setups are spread across the worker lanes; a vm pool would still pay
273273+ // one setup per lane, so the reducible wall time is the rest
274274+ const parallelism = Math.max(1, project.parallelism)
275275+ const saving
276276+ = project.environmentTime / parallelism
277277+ - project.environmentTime / project.environmentCount
278278+ return isSavingWorthHinting(saving, project.executionTime)
279279+ })
280280+ .map(project => ({
281281+ name: project.name,
282282+ environment: project.environment,
283283+ environmentTime: project.environmentTime,
284284+ environmentCount: project.environmentCount,
285285+ share: project.environmentTime / project.trackedTime,
286286+ suggestIsolate: !project.isolateProvided,
287287+ }))
288288+}
+49-1
packages/vitest/src/node/types/config.ts
···10081008 * This will apply `.only` flag and test name pattern across all files without running them.
10091009 */
10101010 preParse?: boolean
10111011+10121012+ /**
10131013+ * Print performance hints after the run when the collected timings show
10141014+ * that a configuration change would make the run significantly faster.
10151015+ * Hints are never printed for options that were set explicitly.
10161016+ *
10171017+ * Set to `false` to disable all hints, or disable them individually:
10181018+ * - `isolate`: hint when `isolate: true` spends a significant amount of
10191019+ * time spawning a fresh worker (and re-creating the environment) for
10201020+ * every test file, estimating how much `isolate: false` could save.
10211021+ * - `environment`: hint when re-creating a DOM environment for every test
10221022+ * file dominates the run and a `vm` pool would set it up once per worker.
10231023+ * - `import`: hint when test files repeatedly evaluate the same module
10241024+ * graph (typical for barrel-file imports) and `isolate: false` would
10251025+ * evaluate it once per worker.
10261026+ * - `transform`: hint when transforming modules dominates the run and
10271027+ * `fsModuleCache` would persist the results across runs.
10281028+ * @default true
10291029+ */
10301030+ diagnostics?: boolean | {
10311031+ /** @default true */
10321032+ isolate?: boolean
10331033+ /** @default true */
10341034+ environment?: boolean
10351035+ /** @default true */
10361036+ import?: boolean
10371037+ /** @default true */
10381038+ transform?: boolean
10391039+ }
10111040 }
1012104110131042 /**
···12641293 tagsFilter?: string[]
12651294 mergeReportsLabel?: string
1266129512671267- experimental: Omit<Required<UserConfig>['experimental'], 'importDurations'> & {
12961296+ experimental: Omit<Required<UserConfig>['experimental'], 'importDurations' | 'diagnostics'> & {
12681297 importDurations: {
12691298 print: boolean | 'on-warn'
12701299 limit: number
···12741303 danger: number
12751304 }
12761305 }
13061306+ diagnostics: {
13071307+ isolate: boolean
13081308+ environment: boolean
13091309+ import: boolean
13101310+ transform: boolean
13111311+ }
13121312+ }
13131313+13141314+ /**
13151315+ * Options that were explicitly provided by the user, as opposed to resolved
13161316+ * defaults. Used by diagnostics to avoid suggesting changes to options the
13171317+ * user chose deliberately.
13181318+ * @internal
13191319+ */
13201320+ providedOptions: {
13211321+ pool: boolean
13221322+ isolate: boolean
13231323+ environment: boolean
13241324+ fsModuleCache: boolean
12771325 }
1278132612791327 cliOptions: CliOptions