Describe the bug
When coverage is enabled, Vitest writes per-suite coverage JSON files into a temp directory under coverage.reportsDirectory.
In the unpatched vitest@4.1.3 coverage code path, onAfterSuiteRun() writes a coverage chunk without first ensuring that reportsDirectory/.tmp still exists:
const filename = resolve(this.coverageFilesDirectory, `coverage-${uniqueId++}.json`);
const promise = promises.writeFile(filename, JSON.stringify(coverage), 'utf-8');
We have previously observed this class of failure in coverage-enabled workspace runs, where the temp directory was missing at the point a coverage file was opened or stat'ed, with errors like:
Error: ENOENT: no such file or directory, open '/.../coverage/<project>/.tmp/coverage-0.json'
or:
Error: ENOENT: no such file or directory, lstat '/.../coverage/<project>/.tmp'
Because vitest@4.1.3 still has the same write-without-mkdir assumption in this code path, we are currently carrying a local defensive patch that recreates the temp directory immediately before each coverage chunk write:
const promise = promises
.mkdir(this.coverageFilesDirectory, { recursive: true })
.then(() => promises.writeFile(filename, JSON.stringify(coverage), 'utf-8'));
This is a no-op when the directory already exists, but it makes the write robust if the temp directory is missing at write time.
The practical impact is that a test run that otherwise completed successfully can still fail at the coverage persistence step. From a user perspective, that is a high-friction failure mode because:
- tests may already be green
- the failure happens late, during coverage result flushing
- the error points at Vitest's internal
.tmp/coverage-*.json files rather than at user test code
- it can make CI or task-runner integrations look flaky even when the tests themselves are stable
Reproduction
I do not have a public minimal reproduction repo yet because the original workspace where this was first observed is private.
What I can state directly:
- We observed coverage failures on the
.tmp/coverage-*.json path family and on missing .tmp directories in real workspace runs.
- The unpatched
vitest@4.1.3 code path still performs a bare writeFile() into that temp directory.
- We added a local defensive patch to that
writeFile() path.
The scenario where that patch is necessary is:
- Run Vitest with coverage enabled and a concrete
coverage.reportsDirectory.
- Let Vitest create its temp coverage directory during
clean().
- Later, when
onAfterSuiteRun() tries to write coverage-*.json, the temp directory is no longer present.
- The run fails with
ENOENT on reportsDirectory/.tmp/coverage-*.json or on the missing .tmp directory itself.
From Vitest's perspective, the key condition is simply this: onAfterSuiteRun() currently assumes the temp directory created earlier still exists at write time.
I suspect this can show up more easily when Vitest is run through another task runner / wrapper that manages per-project coverage directories, cleans outputs between runs, or coordinates repeated isolated test runs, but I have not reduced that to a public minimal reproduction yet.
If needed, I can work on a standalone public reproduction. Right now the strongest directly-supported claim is that the write path is not defensive against a missing temp directory.
Why this is worth fixing upstream
Even if the missing-directory condition is more likely to surface in orchestrated or wrapped executions, this still looks worth hardening in Vitest itself.
Reasons:
- the failure mode is expensive for users because it turns successful test execution into a failing overall command late in the run
- the error surface is confusing because it looks like an internal Vitest filesystem problem rather than an actionable user mistake
- coverage temp-file handling is Vitest-owned internal state, so making that write path defensive improves robustness at the correct boundary
- downstream users should not need to carry a patch for an idempotent directory-creation guard around an internal temporary file write
Why this patch is needed
The current flow creates coverageFilesDirectory in clean(), but later writes in onAfterSuiteRun() do not re-check that the directory still exists.
That means the write path is only correct if the directory lifetime is guaranteed from the initial clean() call until every later coverage chunk write happens.
We have evidence that this guarantee did not hold in at least one real coverage-enabled execution, because the failing paths were exactly the temp coverage file locations under .tmp.
Recreating the directory directly before writeFile() makes the write idempotent and removes that assumption.
Why the change is low risk
The proposed change is intentionally very small and local.
- it only touches the coverage temp-file write path
- it uses
mkdir(..., { recursive: true }), which is already safe when the directory exists
- it does not change coverage file names, file contents, report formats, or aggregation logic
- it preserves the existing behavior in the normal case and only adds resilience in the missing-directory case
So the fix is effectively a defensive precondition check directly at the point of file output.
Current workaround
We are currently working around this downstream by patching vitest@4.1.3 locally.
Patch shape:
- const promise = promises.writeFile(filename, JSON.stringify(coverage), 'utf-8')
+ const promise = promises.mkdir(this.coverageFilesDirectory, { recursive: true }).then(
+ () => promises.writeFile(filename, JSON.stringify(coverage), 'utf-8')
+ )
In our environment, this is applied via the package manager's patch mechanism rather than by forking Vitest.
Evidence from the workaround
This is not yet a public minimal reproduction, so I do not want to overstate the proof.
What I can say accurately is:
- without this defensive guard, we have observed coverage failures on Vitest temp paths such as
coverage/<project>/.tmp/coverage-*.json
- with this patch applied, our current coverage-enabled workspace verification completes successfully on
vitest@4.1.3 / @vitest/coverage-v8@4.1.3
- the patch has not caused any observable change in coverage output shape or normal successful runs; it only hardens the temp-file write path
So while this is not a substitute for a standalone public reproduction, it is practical evidence that the change is safe and useful in a real coverage-enabled setup.
Suggested solution
Make onAfterSuiteRun() ensure coverageFilesDirectory exists before writing the JSON file.
This looks like a good upstream tradeoff because the implementation cost is minimal, the change is narrowly scoped, and the upside is improved reliability for coverage-enabled runs across more execution environments.
Conceptually:
const filename = resolve(this.coverageFilesDirectory, `coverage-${uniqueId++}.json`);
const promise = promises
.mkdir(this.coverageFilesDirectory, { recursive: true })
.then(() => promises.writeFile(filename, JSON.stringify(coverage), 'utf-8'));
this.pendingPromises.push(promise);
System Info
System:
OS: Linux 6.6 Ubuntu 22.04.5 LTS 22.04.5 LTS (Jammy Jellyfish)
CPU: (16) x64 11th Gen Intel(R) Core(TM) i9-11900H @ 2.50GHz
Memory: 18.01 GB / 31.20 GB
Container: Yes
Shell: 5.8.1 - /usr/bin/zsh
Binaries:
Node: 22.21.1 - /home/dev/.local/share/mise/installs/node/22.21.1/bin/node
npm: 10.9.4 - /home/dev/.local/share/mise/installs/node/22.21.1/bin/npm
pnpm: 10.27.0 - /home/dev/.local/share/mise/installs/node/22.21.1/bin/pnpm
bun: 1.3.8 - /home/dev/.local/share/mise/installs/bun/1.3.8/bin/bun
Browsers:
Chrome: 146.0.7680.164
npmPackages:
@vitest/coverage-v8: catalog: => 4.1.3
@vitest/eslint-plugin: catalog: => 1.6.15
@vitest/ui: catalog: => 4.1.3
vite: catalog: => 7.3.2
vitest: catalog: => 4.1.3
Used Package Manager
pnpm
Validations
Describe the bug
When coverage is enabled, Vitest writes per-suite coverage JSON files into a temp directory under
coverage.reportsDirectory.In the unpatched
vitest@4.1.3coverage code path,onAfterSuiteRun()writes a coverage chunk without first ensuring thatreportsDirectory/.tmpstill exists:We have previously observed this class of failure in coverage-enabled workspace runs, where the temp directory was missing at the point a coverage file was opened or stat'ed, with errors like:
or:
Because
vitest@4.1.3still has the same write-without-mkdirassumption in this code path, we are currently carrying a local defensive patch that recreates the temp directory immediately before each coverage chunk write:This is a no-op when the directory already exists, but it makes the write robust if the temp directory is missing at write time.
The practical impact is that a test run that otherwise completed successfully can still fail at the coverage persistence step. From a user perspective, that is a high-friction failure mode because:
.tmp/coverage-*.jsonfiles rather than at user test codeReproduction
I do not have a public minimal reproduction repo yet because the original workspace where this was first observed is private.
What I can state directly:
.tmp/coverage-*.jsonpath family and on missing.tmpdirectories in real workspace runs.vitest@4.1.3code path still performs a barewriteFile()into that temp directory.writeFile()path.The scenario where that patch is necessary is:
coverage.reportsDirectory.clean().onAfterSuiteRun()tries to writecoverage-*.json, the temp directory is no longer present.ENOENTonreportsDirectory/.tmp/coverage-*.jsonor on the missing.tmpdirectory itself.From Vitest's perspective, the key condition is simply this:
onAfterSuiteRun()currently assumes the temp directory created earlier still exists at write time.I suspect this can show up more easily when Vitest is run through another task runner / wrapper that manages per-project coverage directories, cleans outputs between runs, or coordinates repeated isolated test runs, but I have not reduced that to a public minimal reproduction yet.
If needed, I can work on a standalone public reproduction. Right now the strongest directly-supported claim is that the write path is not defensive against a missing temp directory.
Why this is worth fixing upstream
Even if the missing-directory condition is more likely to surface in orchestrated or wrapped executions, this still looks worth hardening in Vitest itself.
Reasons:
Why this patch is needed
The current flow creates
coverageFilesDirectoryinclean(), but later writes inonAfterSuiteRun()do not re-check that the directory still exists.That means the write path is only correct if the directory lifetime is guaranteed from the initial
clean()call until every later coverage chunk write happens.We have evidence that this guarantee did not hold in at least one real coverage-enabled execution, because the failing paths were exactly the temp coverage file locations under
.tmp.Recreating the directory directly before
writeFile()makes the write idempotent and removes that assumption.Why the change is low risk
The proposed change is intentionally very small and local.
mkdir(..., { recursive: true }), which is already safe when the directory existsSo the fix is effectively a defensive precondition check directly at the point of file output.
Current workaround
We are currently working around this downstream by patching
vitest@4.1.3locally.Patch shape:
In our environment, this is applied via the package manager's patch mechanism rather than by forking Vitest.
Evidence from the workaround
This is not yet a public minimal reproduction, so I do not want to overstate the proof.
What I can say accurately is:
coverage/<project>/.tmp/coverage-*.jsonvitest@4.1.3/@vitest/coverage-v8@4.1.3So while this is not a substitute for a standalone public reproduction, it is practical evidence that the change is safe and useful in a real coverage-enabled setup.
Suggested solution
Make
onAfterSuiteRun()ensurecoverageFilesDirectoryexists before writing the JSON file.This looks like a good upstream tradeoff because the implementation cost is minimal, the change is narrowly scoped, and the upside is improved reliability for coverage-enabled runs across more execution environments.
Conceptually:
System Info
System: OS: Linux 6.6 Ubuntu 22.04.5 LTS 22.04.5 LTS (Jammy Jellyfish) CPU: (16) x64 11th Gen Intel(R) Core(TM) i9-11900H @ 2.50GHz Memory: 18.01 GB / 31.20 GB Container: Yes Shell: 5.8.1 - /usr/bin/zsh Binaries: Node: 22.21.1 - /home/dev/.local/share/mise/installs/node/22.21.1/bin/node npm: 10.9.4 - /home/dev/.local/share/mise/installs/node/22.21.1/bin/npm pnpm: 10.27.0 - /home/dev/.local/share/mise/installs/node/22.21.1/bin/pnpm bun: 1.3.8 - /home/dev/.local/share/mise/installs/bun/1.3.8/bin/bun Browsers: Chrome: 146.0.7680.164 npmPackages: @vitest/coverage-v8: catalog: => 4.1.3 @vitest/eslint-plugin: catalog: => 1.6.15 @vitest/ui: catalog: => 4.1.3 vite: catalog: => 7.3.2 vitest: catalog: => 4.1.3Used Package Manager
pnpm
Validations