Skip to content

Coverage chunk writes should ensure reportsDirectory/.tmp exists before writing coverage-*.json #10111

@rbcb-dev

Description

@rbcb-dev

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:

  1. We observed coverage failures on the .tmp/coverage-*.json path family and on missing .tmp directories in real workspace runs.
  2. The unpatched vitest@4.1.3 code path still performs a bare writeFile() into that temp directory.
  3. We added a local defensive patch to that writeFile() path.

The scenario where that patch is necessary is:

  1. Run Vitest with coverage enabled and a concrete coverage.reportsDirectory.
  2. Let Vitest create its temp coverage directory during clean().
  3. Later, when onAfterSuiteRun() tries to write coverage-*.json, the temp directory is no longer present.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions