Skip to content

chore(core): run nx package unit tests with vitest - #36754

Merged
FrozenPandaz merged 34 commits into
masterfrom
worktree-vitest-nx-explore
Aug 26, 2026
Merged

FrozenPandaz merged 34 commits into
masterfrom
worktree-vitest-nx-explore

Conversation

@FrozenPandaz

Copy link
Copy Markdown
Contributor

Current Behavior

The nx package's ~6400 unit tests run with Jest, pinned to maxWorkers: 1 (a full nx test nx takes ~4 minutes), on top of a custom resolver, an SWC mut-cjs-exports plugin, and half a dozen CJS mock shims for ESM-only dependencies.

Expected Behavior

nx test nx runs the same 6423 tests with Vitest 4, inferred through the @nx/vitest plugin. The suite is parallel-safe (verified over repeated parallel and serial runs), which brings the wall time to ~50s (~4.5x faster). Other projects are unaffected and still infer Jest.

Highlights:

  • vitest.config.mts reproduces the Jest setup's special behavior: the @nx/nx-source resolve condition (replacing jest-resolver.js), a plugin routing the napi loader to the self-contained native-bindings.js, deep nx/src/* import aliases, and the CJS yargs entry.
  • vitest.setup.mts ports the unit-test-setup.js guards to vi.doMock, registers @swc-node/register so the codebase's lazy require() calls can load TS source (restoring Error.prepareStackTrace afterwards — the hook's source-map-support otherwise mis-maps vite-transformed frames, breaking error locations and inline-snapshot updates), and pins color detection off so snapshots are stable regardless of how the suite is invoked.
  • A new internal-testing-utils/cjs-mock.ts helper patches Module._load jest-style so specs can mock modules that the source loads with bare require() — the channel vi.mock cannot reach.
  • Spec migration was largely codemodded: jest.* → vi.* (with real hoisting), jest.requireActual → await vi.importActual, jest.isolateModules → vi.resetModules() + dynamic import(), done() callbacks → promises, plus per-file fixes for Vitest semantics (constructible class mocks, spy-mode module mocks for frozen ESM namespaces, hooks that must not return mocks, vi.resetAllMocks restoring real spy implementations).
  • Snapshots regenerated for Vitest's serializer; the ESM mock shims for @clack/prompts/ora/chalk/etc. are no longer needed for this suite.

The migration also surfaced and fixed two latent test bugs where assertions passed while the mock never applied — one of which let a spec overwrite the repository's real nx.json during test runs.

jest.config.cts and jest-resolver.js are removed; the nx-scoped branches in scripts/unit-test-setup.js are now dead code and can be cleaned up separately.

Related Issue(s)

N/A


View Polygraph session ↗

@netlify

netlify Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for nx-dev ready!

Name Link
🔨 Latest commit f960bd0
🔍 Latest deploy log https://app.netlify.com/projects/nx-dev/deploys/6a8f1f0514a51600075b74b4
😎 Deploy Preview https://deploy-preview-36754--nx-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for nx-docs ready!

Name Link
🔨 Latest commit f960bd0
🔍 Latest deploy log https://app.netlify.com/projects/nx-docs/deploys/6a8f1f0559239d000870a924
😎 Deploy Preview https://deploy-preview-36754--nx-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@FrozenPandaz
FrozenPandaz force-pushed the worktree-vitest-nx-explore branch from 03bd6c7 to 898b15c Compare August 21, 2026 19:38
@nx-cloud

nx-cloud Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit c40de2b

Command Status Duration Result
nx affected --targets=lint,oxlint,test,build,e2... ✅ Succeeded 7m 53s View ↗
nx run-many -t check-imports check-lock-files c... ✅ Succeeded 4s View ↗
nx-cloud record -- pnpm nx-cloud conformance:check ✅ Succeeded 56s View ↗
nx build workspace-plugin ✅ Succeeded <1s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 23s View ↗
nx-cloud record -- nx sync:check ✅ Succeeded 21s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-26 17:35:34 UTC

nx-cloud[bot]

This comment was marked as outdated.

@FrozenPandaz
FrozenPandaz force-pushed the worktree-vitest-nx-explore branch 2 times, most recently from c9b0bee to d96d9b6 Compare August 24, 2026 14:32
@FrozenPandaz
FrozenPandaz marked this pull request as ready for review August 24, 2026 19:35
@FrozenPandaz
FrozenPandaz requested a review from a team as a code owner August 24, 2026 19:35
@FrozenPandaz
FrozenPandaz force-pushed the worktree-vitest-nx-explore branch from 775decd to 762b6f6 Compare August 25, 2026 20:02
@FrozenPandaz
FrozenPandaz enabled auto-merge (squash) August 26, 2026 14:11
nx-cloud[bot]

This comment was marked as outdated.

AgentEnder added a commit that referenced this pull request Sep 2, 2026
packages/nx moved to vitest in #36754, which has no `jest` global.
AgentEnder added a commit that referenced this pull request Sep 2, 2026
packages/nx moved to vitest in #36754, which has no `jest` global.
AgentEnder added a commit that referenced this pull request Sep 2, 2026
packages/nx moved to vitest in #36754, which has no `jest` global.
AgentEnder added a commit that referenced this pull request Sep 2, 2026
packages/nx moved to vitest in #36754, which has no `jest` global.
AgentEnder added a commit that referenced this pull request Sep 3, 2026
The nx package moved to vitest in #36754; this spec was written against jest
and died at module evaluation with `ReferenceError: jest is not defined`.

`NxCache` is mocked with a plain function rather than an arrow, because vitest
calls the implementation with `new` and arrows are not constructable.

Claude-Session: https://claude.ai/code/session_01Sr9WDU54dksQvnDxB6csPZ
AgentEnder added a commit that referenced this pull request Sep 8, 2026
The nx package moved to vitest in #36754; both specs were written against jest
and died at module evaluation with `ReferenceError: jest is not defined`.

Claude-Session: https://claude.ai/code/session_01Sr9WDU54dksQvnDxB6csPZ
AgentEnder added a commit that referenced this pull request Sep 9, 2026
packages/nx moved to vitest in #36754, which has no `jest` global.

The AI-agent output-style tests looked up `isAiAgent` with `require()`, which
reached the real module rather than the mock: vitest intercepts imports, not
CJS requires. Bound to the hoisted import instead.
AgentEnder added a commit that referenced this pull request Sep 9, 2026
The nx package moved to vitest in #36754; this spec was written against jest
and died at module evaluation with `ReferenceError: jest is not defined`.

`NxCache` is mocked with a plain function rather than an arrow, because vitest
calls the implementation with `new` and arrows are not constructable.

Claude-Session: https://claude.ai/code/session_01Sr9WDU54dksQvnDxB6csPZ
AgentEnder added a commit that referenced this pull request Sep 9, 2026
packages/nx moved to vitest in #36754, which has no `jest` global.
AgentEnder added a commit that referenced this pull request Sep 9, 2026
The nx package moved to vitest in #36754; both specs were written against jest
and died at module evaluation with `ReferenceError: jest is not defined`.

Claude-Session: https://claude.ai/code/session_01Sr9WDU54dksQvnDxB6csPZ
FrozenPandaz added a commit that referenced this pull request Sep 9, 2026
Written from PR #36754 and corrected against the packages/workspace migration.
FrozenPandaz added a commit that referenced this pull request Sep 9, 2026
Written from PR #36754 and corrected against the packages/workspace migration.
AgentEnder added a commit that referenced this pull request Sep 10, 2026
…prefix the wire protocol (#36838)

## Current Behavior

The daemon mirrors the client's wire format when serializing a response,
with no fallback if that format cannot carry the payload. A large
`HASH_TASKS` response kills the daemon:

```
Serializing response for HASH_TASKS message in json mode
RangeError: Invalid string length
    at serializeUnserializedResult (daemon/server/server.js:514:21)
    at handleResult
```

The client side already had a JSON/v8 fallback, but it only covers
messages sent to the daemon. Nothing covered responses. The throw
escapes an un-awaited async callback, so it becomes an unhandled
rejection and terminates the process rather than failing one request.

Messages were also framed with a trailing `NX_MSG_END` delimiter and
carried as latin1 strings, so every message was capped at Node's max
string length of 536,870,888 characters in three places:
`.toString('binary')` on a v8 buffer, the response concatenation, and
the client's accumulating string.

## Expected Behavior

Daemon responses fall back to the other format instead of throwing, and
messages are no longer bounded by the max string length.

Two commits:

1. **Framing.** Messages carry an `NX_MSG_<byteLength>:` header and
travel as buffers end to end. Buffers sit outside the V8 heap, so a
large response stops counting against `--max-old-space-size`. Framing is
O(1) per message instead of a scan, so a payload containing the framing
marker can no longer desynchronize the stream, and a complete message
sharing a chunk with an incomplete one is delivered immediately rather
than held until a chunk ends on a boundary. An incomplete message waits
for its remaining bytes indefinitely — there is deliberately no idle
timer, since Node runs timer callbacks before the poll phase, so a timer
could discard bytes that had already arrived — while a runaway declared
length is bounded by `NX_MAX_MESSAGE_SIZE`, and a header that does not
parse fails the stream instead of hanging.

2. **Fallback.** `serializeWithFallback(data, preferred)` replaces
`serializeUnserializedResult` and backs both directions. This also fixes
a precedence bug it surfaced: `serialize()` branched on `force === 'v8'
|| isV8SerializerEnabled()`, so `force: 'json'` was ignored whenever
`NX_USE_V8_SERIALIZER` was set. `processInBackground` forces JSON
precisely because its payloads cannot be v8-cloned, so it paid a failed
serialization and a spurious warning on every call under that env var.

The format is detected per message after framing rather than per
connection, because a single chunk can carry a JSON streaming progress
message and a v8 response together. Responding in a format the client
did not send is already safe: streaming progress messages have always
been serialized from the daemon's own preference rather than the
requesting client's.

### Why both changes ship together

Measured on a `HASH_TASKS`-shaped payload, v8 output is 2.4% smaller
than JSON. With a string transport the fallback only widens the working
range from about 537MB to about 550MB, which stops the crash without
giving the reporting workspace headroom. Opting into
`NX_USE_V8_SERIALIZER` was not enough for them either.

### Compatibility

The wire format changes in both directions across the four channels that
share the framing: daemon client and server, plugin worker IPC, and
pseudo-IPC. Version skew is already covered by the `nxVersion` handshake
in `daemon/cache.ts`, which throws before any bytes are exchanged, so a
new client never talks to an old daemon.

### Testing

`consume-messages-from-socket.spec.ts` covers framing, header splits,
desync, the size ceiling, the absence of idle timers, a payload whose
bytes contain the framing marker, and three round-trips over a real unix
socket where the OS picks the chunk boundaries. The fallback tests avoid
allocating 512MB by using inputs each format rejects: `{value: 1n}`
fails JSON, `{fn(){}}` fails v8.

3178 tests pass across `daemon`, `utils`, `project-graph/plugins`, and
`tasks-runner`, with no new failures.

### Not affected

`serializeResult()` builds the `REQUEST_PROJECT_GRAPH` response by
string concatenation rather than going through this path. Its only other
caller passes `(error, null, null)`. The project graph does not carry
per-file data, so neither payload is anywhere near the limit that the
task hash details hit.

### Stacked on #36850

This PR is based on #36850, which raises the `packages/nx` vitest
timeout. `project-graph-incremental-recomputation.spec.ts` needs 42.4s
on CI against the previous 35s limit and fails on any branch that
executes it; this branch touches `packages/nx`, so it runs the spec
every push. Merge #36850 first, then retarget this PR to `master`. The
commit range here is only the nine daemon commits; the timeout change is
not part of this diff.

### CI note

Intermittent `Command timed out after 300s` failures on rollup builds in
`e2e-react` / `e2e-remix` / `e2e-rollup` are the pre-existing flake
tracked in #36794, not this change. That flake predates this branch,
hits about 4% of master runs, and the dump there showed the surviving
process is rollup's own CLI while the daemon round-trip finished in
milliseconds. This branch touches `packages/nx`, so every e2e task runs
rather than reading from cache, which is why it shows up more often
here.

## Related Issue(s)

Fixes NXC-4901

<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/daemon-serializer-920ef087">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end -->

---------

Co-authored-by: FrozenPandaz <jasonjean1993@gmail.com>

Backport notes: the NX_MAX_MESSAGE_SIZE daemon-env handling is dropped. 22.7.x
has no client-env reflection (daemon-environment.ts is 134 lines here against 411
on master), so the exclusion-list entry, the getDaemonSpawnEnv pin and
daemon-environment.spec.ts have nothing to attach to. The knob itself still works:
consume-messages-from-socket.ts reads process.env directly. Also dropped: the
WatcherFailedError class and its dispatch, another post-22.7 feature, keeping only
the framing-failure counter the new code needs; the astro-docs row, since the site
publishes from master; and applyDaemonEnvFromClient in the plugin worker's
setWorkerEnv, which keeps 22.7.x direct assignment loop. New specs converted from
vitest to jest, since #36754 is not on this branch.
AgentEnder added a commit that referenced this pull request Sep 10, 2026
…prefix the wire protocol (#36838)

## Current Behavior

The daemon mirrors the client's wire format when serializing a response,
with no fallback if that format cannot carry the payload. A large
`HASH_TASKS` response kills the daemon:

```
Serializing response for HASH_TASKS message in json mode
RangeError: Invalid string length
    at serializeUnserializedResult (daemon/server/server.js:514:21)
    at handleResult
```

The client side already had a JSON/v8 fallback, but it only covers
messages sent to the daemon. Nothing covered responses. The throw
escapes an un-awaited async callback, so it becomes an unhandled
rejection and terminates the process rather than failing one request.

Messages were also framed with a trailing `NX_MSG_END` delimiter and
carried as latin1 strings, so every message was capped at Node's max
string length of 536,870,888 characters in three places:
`.toString('binary')` on a v8 buffer, the response concatenation, and
the client's accumulating string.

## Expected Behavior

Daemon responses fall back to the other format instead of throwing, and
messages are no longer bounded by the max string length.

Two commits:

1. **Framing.** Messages carry an `NX_MSG_<byteLength>:` header and
travel as buffers end to end. Buffers sit outside the V8 heap, so a
large response stops counting against `--max-old-space-size`. Framing is
O(1) per message instead of a scan, so a payload containing the framing
marker can no longer desynchronize the stream, and a complete message
sharing a chunk with an incomplete one is delivered immediately rather
than held until a chunk ends on a boundary. An incomplete message waits
for its remaining bytes indefinitely — there is deliberately no idle
timer, since Node runs timer callbacks before the poll phase, so a timer
could discard bytes that had already arrived — while a runaway declared
length is bounded by `NX_MAX_MESSAGE_SIZE`, and a header that does not
parse fails the stream instead of hanging.

2. **Fallback.** `serializeWithFallback(data, preferred)` replaces
`serializeUnserializedResult` and backs both directions. This also fixes
a precedence bug it surfaced: `serialize()` branched on `force === 'v8'
|| isV8SerializerEnabled()`, so `force: 'json'` was ignored whenever
`NX_USE_V8_SERIALIZER` was set. `processInBackground` forces JSON
precisely because its payloads cannot be v8-cloned, so it paid a failed
serialization and a spurious warning on every call under that env var.

The format is detected per message after framing rather than per
connection, because a single chunk can carry a JSON streaming progress
message and a v8 response together. Responding in a format the client
did not send is already safe: streaming progress messages have always
been serialized from the daemon's own preference rather than the
requesting client's.

### Why both changes ship together

Measured on a `HASH_TASKS`-shaped payload, v8 output is 2.4% smaller
than JSON. With a string transport the fallback only widens the working
range from about 537MB to about 550MB, which stops the crash without
giving the reporting workspace headroom. Opting into
`NX_USE_V8_SERIALIZER` was not enough for them either.

### Compatibility

The wire format changes in both directions across the four channels that
share the framing: daemon client and server, plugin worker IPC, and
pseudo-IPC. Version skew is already covered by the `nxVersion` handshake
in `daemon/cache.ts`, which throws before any bytes are exchanged, so a
new client never talks to an old daemon.

### Testing

`consume-messages-from-socket.spec.ts` covers framing, header splits,
desync, the size ceiling, the absence of idle timers, a payload whose
bytes contain the framing marker, and three round-trips over a real unix
socket where the OS picks the chunk boundaries. The fallback tests avoid
allocating 512MB by using inputs each format rejects: `{value: 1n}`
fails JSON, `{fn(){}}` fails v8.

3178 tests pass across `daemon`, `utils`, `project-graph/plugins`, and
`tasks-runner`, with no new failures.

### Not affected

`serializeResult()` builds the `REQUEST_PROJECT_GRAPH` response by
string concatenation rather than going through this path. Its only other
caller passes `(error, null, null)`. The project graph does not carry
per-file data, so neither payload is anywhere near the limit that the
task hash details hit.

### Stacked on #36850

This PR is based on #36850, which raises the `packages/nx` vitest
timeout. `project-graph-incremental-recomputation.spec.ts` needs 42.4s
on CI against the previous 35s limit and fails on any branch that
executes it; this branch touches `packages/nx`, so it runs the spec
every push. Merge #36850 first, then retarget this PR to `master`. The
commit range here is only the nine daemon commits; the timeout change is
not part of this diff.

### CI note

Intermittent `Command timed out after 300s` failures on rollup builds in
`e2e-react` / `e2e-remix` / `e2e-rollup` are the pre-existing flake
tracked in #36794, not this change. That flake predates this branch,
hits about 4% of master runs, and the dump there showed the surviving
process is rollup's own CLI while the daemon round-trip finished in
milliseconds. This branch touches `packages/nx`, so every e2e task runs
rather than reading from cache, which is why it shows up more often
here.

## Related Issue(s)

Fixes NXC-4901

<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/daemon-serializer-920ef087">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end -->

---------

Co-authored-by: FrozenPandaz <jasonjean1993@gmail.com>

Backport notes: the NX_MAX_MESSAGE_SIZE daemon-env handling is dropped. 22.7.x
has no client-env reflection (daemon-environment.ts is 134 lines here against 411
on master), so the exclusion-list entry, the getDaemonSpawnEnv pin and
daemon-environment.spec.ts have nothing to attach to. The knob itself still works:
consume-messages-from-socket.ts reads process.env directly. Also dropped: the
WatcherFailedError class and its dispatch, another post-22.7 feature, keeping only
the framing-failure counter the new code needs; the astro-docs row, since the site
publishes from master; and applyDaemonEnvFromClient in the plugin worker's
setWorkerEnv, which keeps 22.7.x direct assignment loop. New specs converted from
vitest to jest, since #36754 is not on this branch.
FrozenPandaz pushed a commit that referenced this pull request Sep 10, 2026
…prefix the wire protocol (#36838)

## Current Behavior

The daemon mirrors the client's wire format when serializing a response,
with no fallback if that format cannot carry the payload. A large
`HASH_TASKS` response kills the daemon:

```
Serializing response for HASH_TASKS message in json mode
RangeError: Invalid string length
    at serializeUnserializedResult (daemon/server/server.js:514:21)
    at handleResult
```

The client side already had a JSON/v8 fallback, but it only covers
messages sent to the daemon. Nothing covered responses. The throw
escapes an un-awaited async callback, so it becomes an unhandled
rejection and terminates the process rather than failing one request.

Messages were also framed with a trailing `NX_MSG_END` delimiter and
carried as latin1 strings, so every message was capped at Node's max
string length of 536,870,888 characters in three places:
`.toString('binary')` on a v8 buffer, the response concatenation, and
the client's accumulating string.

## Expected Behavior

Daemon responses fall back to the other format instead of throwing, and
messages are no longer bounded by the max string length.

Two commits:

1. **Framing.** Messages carry an `NX_MSG_<byteLength>:` header and
travel as buffers end to end. Buffers sit outside the V8 heap, so a
large response stops counting against `--max-old-space-size`. Framing is
O(1) per message instead of a scan, so a payload containing the framing
marker can no longer desynchronize the stream, and a complete message
sharing a chunk with an incomplete one is delivered immediately rather
than held until a chunk ends on a boundary. An incomplete message waits
for its remaining bytes indefinitely — there is deliberately no idle
timer, since Node runs timer callbacks before the poll phase, so a timer
could discard bytes that had already arrived — while a runaway declared
length is bounded by `NX_MAX_MESSAGE_SIZE`, and a header that does not
parse fails the stream instead of hanging.

2. **Fallback.** `serializeWithFallback(data, preferred)` replaces
`serializeUnserializedResult` and backs both directions. This also fixes
a precedence bug it surfaced: `serialize()` branched on `force === 'v8'
|| isV8SerializerEnabled()`, so `force: 'json'` was ignored whenever
`NX_USE_V8_SERIALIZER` was set. `processInBackground` forces JSON
precisely because its payloads cannot be v8-cloned, so it paid a failed
serialization and a spurious warning on every call under that env var.

The format is detected per message after framing rather than per
connection, because a single chunk can carry a JSON streaming progress
message and a v8 response together. Responding in a format the client
did not send is already safe: streaming progress messages have always
been serialized from the daemon's own preference rather than the
requesting client's.

### Why both changes ship together

Measured on a `HASH_TASKS`-shaped payload, v8 output is 2.4% smaller
than JSON. With a string transport the fallback only widens the working
range from about 537MB to about 550MB, which stops the crash without
giving the reporting workspace headroom. Opting into
`NX_USE_V8_SERIALIZER` was not enough for them either.

### Compatibility

The wire format changes in both directions across the four channels that
share the framing: daemon client and server, plugin worker IPC, and
pseudo-IPC. Version skew is already covered by the `nxVersion` handshake
in `daemon/cache.ts`, which throws before any bytes are exchanged, so a
new client never talks to an old daemon.

### Testing

`consume-messages-from-socket.spec.ts` covers framing, header splits,
desync, the size ceiling, the absence of idle timers, a payload whose
bytes contain the framing marker, and three round-trips over a real unix
socket where the OS picks the chunk boundaries. The fallback tests avoid
allocating 512MB by using inputs each format rejects: `{value: 1n}`
fails JSON, `{fn(){}}` fails v8.

3178 tests pass across `daemon`, `utils`, `project-graph/plugins`, and
`tasks-runner`, with no new failures.

### Not affected

`serializeResult()` builds the `REQUEST_PROJECT_GRAPH` response by
string concatenation rather than going through this path. Its only other
caller passes `(error, null, null)`. The project graph does not carry
per-file data, so neither payload is anywhere near the limit that the
task hash details hit.

### Stacked on #36850

This PR is based on #36850, which raises the `packages/nx` vitest
timeout. `project-graph-incremental-recomputation.spec.ts` needs 42.4s
on CI against the previous 35s limit and fails on any branch that
executes it; this branch touches `packages/nx`, so it runs the spec
every push. Merge #36850 first, then retarget this PR to `master`. The
commit range here is only the nine daemon commits; the timeout change is
not part of this diff.

### CI note

Intermittent `Command timed out after 300s` failures on rollup builds in
`e2e-react` / `e2e-remix` / `e2e-rollup` are the pre-existing flake
tracked in #36794, not this change. That flake predates this branch,
hits about 4% of master runs, and the dump there showed the surviving
process is rollup's own CLI while the daemon round-trip finished in
milliseconds. This branch touches `packages/nx`, so every e2e task runs
rather than reading from cache, which is why it shows up more often
here.

## Related Issue(s)

Fixes NXC-4901

<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/daemon-serializer-920ef087">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end -->

---------

Co-authored-by: FrozenPandaz <jasonjean1993@gmail.com>

Backport notes: the NX_MAX_MESSAGE_SIZE daemon-env handling is dropped. 22.7.x
has no client-env reflection (daemon-environment.ts is 134 lines here against 411
on master), so the exclusion-list entry, the getDaemonSpawnEnv pin and
daemon-environment.spec.ts have nothing to attach to. The knob itself still works:
consume-messages-from-socket.ts reads process.env directly. Also dropped: the
WatcherFailedError class and its dispatch, another post-22.7 feature, keeping only
the framing-failure counter the new code needs; the astro-docs row, since the site
publishes from master; and applyDaemonEnvFromClient in the plugin worker's
setWorkerEnv, which keeps 22.7.x direct assignment loop. New specs converted from
vitest to jest, since #36754 is not on this branch.
AgentEnder added a commit that referenced this pull request Sep 11, 2026
packages/nx moved to vitest in #36754, which has no `jest` global.

The AI-agent output-style tests looked up `isAiAgent` with `require()`, which
reached the real module rather than the mock: vitest intercepts imports, not
CJS requires. Bound to the hoisted import instead.
AgentEnder added a commit that referenced this pull request Sep 11, 2026
packages/nx moved to vitest in #36754, which has no `jest` global.
FrozenPandaz added a commit that referenced this pull request Sep 14, 2026
…37039)

## Current Behavior

`nx test nx` intermittently loses a vitest fork on macOS (and, less
often, on the Linux CI agents) with `[vitest-pool]: Worker forks emitted
error ... Worker exited unexpectedly` while no test fails. macOS crash
reports show SIGSEGV at address 0 inside `nx.darwin-arm64.node`, in
jemalloc's `tcache_bin_flush_small` or `arena_ralloc_no_move`, under
`drop_in_place<TaskHasher>`, `Arc::drop_slow`, a `DashMap` drop, or a
`format!` inside `InstructionPool::intern`. The rate swings between
builds (2/10 to 5/6 runs of `native-task-hasher-impl.spec.ts`) and the
crashing tests never touch the code under review, which made it look
like a latent heap corruption in the Rust binding.

It is not. Every crash report's `usedImages` lists the binding **twice**
in the same worker process, same uuid, two paths:

- `packages/nx/src/native/nx.darwin-arm64.node`: loaded through
`native-bindings.js`, because the `nx-native-shim` vite plugin routed
every *import* of `src/native/index.js` there.
- `/tmp/.nx/<uid>/native-cache/0.0.1/0.0.1-<hash>-nx.darwin-arm64.node`:
loaded through `index.js`, because the lazy `require('../native')` calls
in nx source (`workspace-context.ts`, `file-hasher.ts`, `cache.ts`,
`analytics.ts`, ...) run through node's loader, never vite, and
`index.js`'s file cache copies the binary and loads the copy.

Two dylib images are two copies of the Rust statics and two independent
jemalloc heaps. `TaskHasher::new` (first image, from the vite import)
clones `Arc`s out of the `projectFileMap` / `allWorkspaceFiles`
externals that `WorkspaceContext.getFileMap()` created in the second
image (from the runtime require). Whichever image drops the last `Arc`
frees memory the other heap owns. jemalloc's sized free (`sdallocx`)
pushes the pointer into its thread cache without an ownership check, so
nothing fails at the free itself. The crash comes when that cache bin
flushes (`emap_edata_lookup` finds no extent, NULL `edata`, read of
address 0), or when `malloc` hands the recycled foreign block to a
`String` that is then grown with `rallocx`. That deferral is why the
crashing frame varies and why the rate depends on build layout.

This started with the vitest migration (#36754): under jest both paths
went through `index.js`.

Separately, `src/native/index.js` ends with `module.exports =
indexModule`. Node's CommonJS export lexer cannot follow an assignment
from a variable, so an ES module doing `import { hashArray } from
'nx/src/native/index.js'` fails with `Named export 'hashArray' not
found`. Only the default import works. That is also what stopped the
test setup from simply externalizing `index.js`: the setup's `vi.doMock`
of the native module spreads `vi.importActual(...)`, which came back
with no named exports.

## Expected Behavior

Two commits:

1. `index.js` ends with `module.exports =
require('./native-bindings.js')`. Same require, same order, same object,
but a form the lexer recognizes and follows to the named exports in
`native-bindings.js`. Verified with plain node against the published
package layout: the named import fails before and works after; default
import and CommonJS `require` are unchanged.

2. The vitest config externalizes `index.js` and drops the
`nx-native-shim` plugin. Every path to the binding, vite imports and
lazy requires alike, now runs the one loader under node, so a worker
holds a single image. A new case in `native-bindings.spec.ts` pins it:
the `TaskHasher` constructor from a vite import and from a runtime
`require('..')` must be the same function. It fails deterministically
when the binding loads twice, so the regression cannot come back as a
flake.

Verification on master's binary (uuid 4D093B75), loops of
`native-task-hasher-impl.spec.ts`:

| setup | crashes |
|---|---|
| master as is | 2/10 |
| `NX_SKIP_NATIVE_FILE_CACHE=true` (control: one image) | 0/20 |
| this branch | 0/20 |

A `Module._load` trace confirms the fixed worker loads the binding once
and all 15 runtime requires hit the cached `index.js`. Five runs of the
five native-context specs together: 0/5 crashes (114 tests each). Full
`packages/nx` suite: 338 files, 7670 passed, 0 worker crashes; the one
failure is the "blind window" case in `watch-before-scan.spec.ts`, which
fails the same way on untouched master on macOS (2/3 runs) and is
unrelated.

Considered and not taken: a node-side `Module._load` shim mirroring the
vite plugin (an earlier revision of this PR) also works, but leaves two
loaders and two shims in the test setup;
`NX_SKIP_NATIVE_FILE_CACHE=true` in `test.env` works only by coincidence
of node's require cache.

## Related Issue(s)

N/A. This is the pre-existing worker crash noted while reviewing #37017
and #37025.

<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/Stop-vitest-workers-loading-the-nx-native-binding-twice-jemalloc-SIGSEG-ecfbc350">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end -->

---------

Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com>
AgentEnder added a commit that referenced this pull request Sep 16, 2026
The nx package moved to vitest in #36754; both specs were written against jest
and died at module evaluation with `ReferenceError: jest is not defined`.

The `require` calls become `await import`. Under jest a require after
`resetModules` resolved to the mocked module; under vitest it bypasses the
module mocker, so the specs got the real `installPackageToTmpAsync` and a
second copy of `latest-nx` with its own cached install path.

Claude-Session: https://claude.ai/code/session_01Sr9WDU54dksQvnDxB6csPZ
AgentEnder added a commit that referenced this pull request Sep 16, 2026
packages/nx moved to vitest in #36754, which has no `jest` global.

The AI-agent output-style tests looked up `isAiAgent` with `require()`, which
reached the real module rather than the mock: vitest intercepts imports, not
CJS requires. Bound to the hoisted import instead.
AgentEnder added a commit that referenced this pull request Sep 16, 2026
packages/nx moved to vitest in #36754, which has no `jest` global.
FrozenPandaz added a commit that referenced this pull request Sep 23, 2026
Written from PR #36754 and corrected against the packages/workspace migration.
FrozenPandaz added a commit that referenced this pull request Sep 24, 2026
## Current Behavior

The `@nx/workspace` package's 420 unit tests run with Jest through the
shared `jest.preset.js`: pinned to `maxWorkers: 1`, on top of a custom
resolver (`scripts/patched-jest-resolver.js`), the workspace-wide guards
in `scripts/unit-test-setup.js`, and CJS shims for ESM-only
dependencies. A full `nx test workspace` takes 7m53s.

## Expected Behavior

`nx test workspace` runs the same 420 tests with Vitest 4, inferred
through the `@nx/vitest` plugin, in ~17.5s (~27x faster). Test count,
suite count, and every recorded snapshot value are unchanged; the suite
is stable over repeated runs. Other packages are unaffected and still
infer Jest.

Follows #36754, which moved `packages/nx`. Two pieces of that migration
turned out to be specific to `nx` — the one package that barely imports
its siblings — so this PR adds the shared machinery every other package
will need:

- `scripts/vitest-nx-source-resolver.mts` replaces
`scripts/patched-jest-resolver.js`. `resolve.conditions:
['@nx/nx-source']` is **not** sufficient on its own: `node_modules/nx`
and `node_modules/@nx/*` are the published tarballs (dist only), and
their exports maps advertise `@nx/nx-source` entries pointing at source
files the tarball does not ship. The plugin maps `nx` / `@nx/*` through
the local `packages/<pkg>/package.json` instead, with a file fallback
for deep imports no exports entry covers.
- `scripts/vitest-setup.mts` ports `scripts/unit-test-setup.js`
(jest-only, `jest.doMock`) and adds what vitest's execution model
requires:
- the same source mapping on `Module._resolveFilename`, so node's
`require` and vite's module graph agree — without it a lazy
`require('@nx/js')` fails outright under `--conditions=@nx/nx-source`;
- **the graph mocks repeated on the CJS channel** via `Module._load`.
Generators reach graph builders through lazy `require()`, which
`vi.mock` cannot see; unmocked, `createProjectGraphAsync` takes
`project-graph.lock` and deadlocks the worker with no output and no test
timeout;
- `NX_ISOLATE_PLUGINS=false`, so plugin isolation does not spawn worker
subprocesses that are never torn down (two `packages/nx` specs already
carry this note);
- `NX_WORKSPACE_ROOT_PATH` under `tmp/unit/<pid>`, per worker. The jest
resolver set a single `tmp/unit` as a side effect; with parallel workers
one shared root makes every worker queue on the same lock.
- The `@clack/prompts` shim is kept. It is not only ESM interop: the
real library drives a *synchronous* prompt, so a generator that asks a
question blocks the worker forever.

Spec changes are the usual codemod (`jest.*` → `vi.*`, async mock
factories with `vi.importActual`, `xdescribe` → `describe.skip`, vitest
type imports) plus `vi.mock('child_process', { spy: true })` where a
frozen ESM namespace was previously spied on directly.

Snapshots are rekeyed, not rewritten: vitest joins describe and test
names with `' > '` where jest used a space, so every key reads as new.
All 176 recorded values were diffed against the jest originals after
normalizing the separator and match exactly. The one shape change is
`toThrowErrorMatchingInlineSnapshot`, which vitest records as `[Error:
msg]` rather than `"msg"`.

### Verification

- `nx test workspace` — 420 tests (417 passed, 3 skipped), matching jest
exactly; stable across four consecutive runs.
- `nx run-many -t build,lint -p workspace` — green.
- `devkit` run as a canary — 563 tests, unchanged. A full `nx affected`
is not meaningful here: a new file under `scripts/` marks all 94
projects affected, and nothing jest reads was modified
(`jest.preset.js`, `scripts/unit-test-setup.js`, and
`scripts/patched-jest-resolver.js` are untouched).

### Follow-ups, not in this PR

- `packages/nx/src/internal-testing-utils/mock-project-graph.ts` logs a
vitest deprecation warning ("vi.mock call is not at the top level ...
will become an error in a future version") because of its dual-runner
`if (typeof vi !== 'undefined')` branch. Pre-existing; the `nx` suite
hits it too.
- `scripts/jest-mocks/` now serves vitest as well and wants renaming to
`scripts/test-mocks/` once a second package migrates.
- Each worker leaves a `tmp/unit/<pid>` directory behind (gitignored).
`packages/devkit/jest-setup-nx-workspace-data-dir.js` has the `mkdtemp`
+ `rmSync`-on-exit pattern if we want them cleaned up.

## Related Issue(s)

N/A

<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/Migrate-packages-workspace-unit-tests-to-vitest-a01a636a">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end -->
AgentEnder added a commit that referenced this pull request Oct 2, 2026
The nx package moved to vitest in #36754; this spec was written against jest
and died at module evaluation with `ReferenceError: jest is not defined`.

`NxCache` is mocked with a plain function rather than an arrow, because vitest
calls the implementation with `new` and arrows are not constructable.

Claude-Session: https://claude.ai/code/session_01Sr9WDU54dksQvnDxB6csPZ
AgentEnder added a commit that referenced this pull request Oct 5, 2026
The nx package moved to vitest in #36754; both specs were written against jest
and died at module evaluation with `ReferenceError: jest is not defined`.

The `require` calls become `await import`. Under jest a require after
`resetModules` resolved to the mocked module; under vitest it bypasses the
module mocker, so the specs got the real `installPackageToTmpAsync` and a
second copy of `latest-nx` with its own cached install path.

Claude-Session: https://claude.ai/code/session_01Sr9WDU54dksQvnDxB6csPZ
AgentEnder added a commit that referenced this pull request Oct 5, 2026
The nx package moved to vitest in #36754; this spec was written against jest
and died at module evaluation with `ReferenceError: jest is not defined`.

`NxCache` is mocked with a plain function rather than an arrow, because vitest
calls the implementation with `new` and arrows are not constructable.

Claude-Session: https://claude.ai/code/session_01Sr9WDU54dksQvnDxB6csPZ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants