Skip to content

@nx/vitest atomizer: every atomized target declares the same coverage output, so atoms clobber each other's coverage #36503

Description

@bonetunes

Current Behavior

Every atomized target created by the @nx/vitest atomizer declares the project's configured coverage reportsDirectory as its output:

https://github.com/nrwl/nx/blob/master/packages/vitest/src/plugins/plugin.ts#L344-L352

const targetName = `${options.ciTargetName}--${relativePath}`;
targets[targetName] = {
  command: `vitest run ${relativePath}`,
  cache: targets[options.testTargetName].cache,
  inputs: targets[options.testTargetName].inputs,
  outputs: targets[options.testTargetName].outputs, // <- same reportsDirectory for every atom
  ...
};

Each atom runs the same Vitest config, so with coverage enabled every atom writes its report to the same directory under the same file name (coverage-final.json for the json reporter). The reports overwrite one another:

  • On a single machine, each atom's run clobbers the previous atom's report.
  • Under distributed execution, every agent produces its own copy of the identical path, and collecting them onto the main job leaves only one.

Cache restore doesn't help either — copyFilesFromCache expands and restores per file, so atoms whose declared outputs resolve to byte-identical relative paths simply overwrite each other on restore.

The result is that an atomized Vitest run reports the coverage of a single test file instead of the union of all of them. It fails silently: coverage is produced, it's just wrong, so any downstream quality gate consuming it (Sonar, Codecov, a threshold check) silently reads a near-empty report.

@nx/jest's atomizer has the same shape (packages/jest/src/plugins/plugin.ts), so this likely isn't Vitest-specific.

Notably, @nx/playwright already solves exactly this problem for its atomized reporter outputs — getAtomizedTaskOutputs / getAtomizedTaskEnvVars derive a per-spec subfolder, redirect the tool's output into it, and declare only that subfolder as the atom's output (added in #31615). @nx/vitest just never got the equivalent treatment.

Expected Behavior

Each atomized target should write to, and declare, its own coverage subdirectory, so the reports stay disjoint and every atom's coverage survives — matching what @nx/playwright already does.

Vitest exposes the necessary lever as a first-class CLI flag, --coverage.reportsDirectory=<path>, so the atomized command can be redirected per atom:

vitest run src/a.test.ts --coverage.reportsDirectory=coverage/src-a-test-ts

Worth noting that this can be added unconditionally without turning coverage on for anyone: Vitest's --coverage option only sets coverage.enabled when given a boolean (transformNestedBoolean), while a nested option such as --coverage.reportsDirectory sets only that value, and coverage.enabled defaults to false.

Today the only workaround is an out-of-tree inference plugin that re-derives the atomizer's target names, overrides each atom's outputs, and injects an env var so the Vitest config can name each report file uniquely — which is fragile, since the override silently no-ops if the recomputed atom names ever drift from the atomizer's.

Steps to Reproduce

  1. Create a workspace with @nx/vitest and enable the atomizer (ciTargetName), with coverage enabled in vitest.config.ts (e.g. test.coverage.enabled: true, reportsDirectory: 'coverage').
  2. Add two or more test files to the project so the atomizer produces more than one test-ci--<file> target.
  3. Inspect the inferred outputs — every atom declares the same directory:
    nx show project <project> --json | jq '.targets | to_entries[] | select(.key | startswith("test-ci--")) | {key, outputs: .value.outputs}'
    
  4. Run the atomized targets: nx run-many -t test-ci.
  5. Inspect the merged coverage in coverage/ — it reflects only one test file rather than all of them.

Nx Report

Node           : 22.16.0
OS             : darwin-arm64
Native Target  : aarch64-macos
pnpm           : 11.2.2

nx                     : 23.2.0-beta.2
@nx/devkit             : 23.2.0-beta.2
@nx/vite               : 23.2.0-beta.2
@nx/vitest             : 23.2.0-beta.2
@nx/jest               : 23.2.0-beta.2
@nx/playwright         : 23.2.0-beta.2
typescript             : 6.0.3

(Identified by inspection of first-party source on master in the nrwl/nx repo itself, so the report above is from that checkout rather than a reproduction workspace.)

Failure Logs

N/A — no error is raised; the coverage report is silently incomplete.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions