vitest-migration
nrwl/nx/.claude/skills/vitest-migration/SKILL.md
Migrate an Nx repo package's unit tests from Jest to Vitest, reusing the shared setup that packages/workspace established. Use when asked to "move <pkg> to vitest", "migrate <pkg> tests off jest", or "run <pkg> unit tests with vitest".
Skill29k starsChanged 3 days ago
What's in it
- Migrate a package's unit tests to Vitest
- Argument
- Why this is not a find-and-replace
- Step 0 — Survey the package
- Step 1 — Target inference
- Step 2 — Write packages/<name>/vitest.config.mts
- Step 3 — Wire up the shared setup
- Step 4 — tsconfig.spec.json
- Step 5 — Codemod the specs
- Step 6 — Hand-fix the semantic gaps
- Step 7 — Snapshots
- Step 8 — Verify
- Step 8b — Sandbox violations
- Step 9 — Clean up and document
- Commit shape
Tools it asks for
- Read
- Glob
- Grep
- Agent
- Edit(*)
- Write(*)
- Bash(pnpm nx *)
- Bash(npx nx *)
- Bash(nx *)
- Bash(git *)
- Bash(ls *)
- Bash(cat *)
- Bash(head *)
- Bash(tail *)
- Bash(sed *)
- Bash(grep *)
- Bash(rg *)
- Bash(find *)
- Bash(wc *)
- Bash(echo *)
- Bash(mkdir *)
- Bash(rm *)
- Bash(mv *)
- Bash(node *)
- Bash(npx oxfmt *)
- Bash(gh pr view *)
- Bash(gh pr diff *)
---
name: vitest-migration
description: Migrate an Nx repo package's unit tests from Jest to Vitest, reusing the shared setup that packages/workspace established. Use when asked to "move <pkg> to vitest", "migrate <pkg> tests off jest", or "run <pkg> unit tests with vitest".
allowed-tools: Read, Glob, Grep, Agent, Edit(*), Write(*), Bash(pnpm nx *), Bash(npx nx *), Bash(nx *), Bash(git *), Bash(ls *), Bash(cat *), Bash(head *), Bash(tail *), Bash(sed *), Bash(grep *), Bash(rg *), Bash(find *), Bash(wc *), Bash(echo *), Bash(mkdir *), Bash(rm *), Bash(mv *), Bash(node *), Bash(npx oxfmt *), Bash(gh pr view *), Bash(gh pr diff *)
---
# Migrate a package's unit tests to Vitest
Move `packages/<name>`'s unit tests from Jest to Vitest 4, inferred through the
`@nx/vitest` plugin.
**Start from `packages/workspace`, not `packages/nx`.** The shared machinery a
sibling package needs already exists — read these first and reuse them as-is:
- `tools/vitest/setup.mts` — the port of `scripts/unit-test-setup.js`; every
migrated package loads it as its `setupFiles`
- `tools/vitest/nx-source-resolver.mts` — resolves `nx` / `@nx/*` to this
repo's source, for both vite and node
- `tools/vitest/tsconfig.json` — a leaf tsconfig whose only job is to stop
vite's tsconfig lookup. **Do not move these files to the workspace root.**
With no tsconfig beside them, the nearest one is the root solution file, and
vite walks its `references` — reading all ~114 project tsconfigs on every
run, which lands as a sandbox violation. `tools/vitest` is deliberately a
plain directory, not an Nx project: adding `project.json` makes
`@nx/js:typescript-sync` demand a project reference to a test-only tool from
each consuming package's _published_ `tsconfig.lib.json` (`composite: false`
does not suppress it)
- `packages/workspace/vitest.config.mts` — the config those two plug into
- `packages/workspace/project.json` — `test.inputs` naming the shared scripts
`packages/nx` (PR #36754, commit `32dd3fb533`) is the _original_ migration but a
poor template: it is the one package that imports almost no siblings, so it
needs neither the source resolver nor the CJS-channel mocks. Consult it only for
`vitest-write-guard.cjs` and `src/internal-testing-utils/cjs-mock.ts`.
`packages/angular-rspack/vitest.config.mts` is the simple end of the spectrum
(no nx source at all).
## Argument
The package name (e.g. `js`, `devkit`, `workspace`). The package lives at
`packages/<name>/`.
## Why this is not a find-and-replace
Jest and Vitest disagree on module semantics, not just API names. The
mechanical `jest.*` → `vi.*` rename is maybe 80% of the diff and 20% of the
work. The rest is: which _channel_ a mock reaches (ESM graph vs CJS
`require()`), whether a namespace is frozen, and what `resetAllMocks` does to a
spy. Budget for hand-fixing specs after the codemod.
---
## Step 0 — Survey the package
Run these and write the answers into `tmp/notes/vitest-migration-<name>.md`
before touching anything:
```bash
ls packages/<name>/jest.config.cts packages/<name>/jest*.js 2>/dev/null
cat packages/<name>/jest.config.cts
cat packages/<name>/tsconfig.spec.json
grep -rl "\.spec\.ts" -c packages/<name>/src | wc -l # rough spec count
pnpm nx show project <name> --json | head -40
```
Capture:
1. **Spec count and current runtime.** Run `pnpm nx test <name> --skip-nx-cache`
once and record the reported test count and wall time. That number is the
parity target in Step 6 — you cannot verify the migration without it.
2. **Jest config specials** — anything beyond `displayName`/`preset`/
`moduleFileExtensions` is behavior you must reproduce:
- `setupFiles` (e.g. `packages/devkit/jest-setup-nx-workspace-data-dir.js`)
- `moduleNameMapper` (path shims; also `identity-obj-proxy` for CSS)
- `testEnvironment: 'jsdom'` → needs `environment: 'jsdom'` and the
`jsdom` dep
- `modulePathIgnorePatterns` / `testPathIgnorePatterns` → `exclude`
- `resolver` → `resolve.conditions` (see Step 2)
3. **Inherited preset behavior** (`jest.preset.js`) that Vitest does _not_ get
for free:
- `setupFiles: ['../../scripts/unit-test-setup.js']` — the workspace-wide
project-graph / workspace-context / native guards. **This must be ported**
(Step 3).
- `resolver: '../../scripts/patched-jest-resolver.js'` — maps `@nx/*` and
`nx/*` onto `packages/*` source, **and** sets
`NX_WORKSPACE_ROOT_PATH=<repo>/tmp/unit` as a side effect. Both are
reproduced by the shared scripts (Steps 2 and 3).
- `moduleNameMapper` ESM shims (`@clack/prompts`, `ora`, `chalk`,
`yargs-parser`, `prettier`, `magic-string`, `oxfmt`). Most are pure ESM
interop Vitest does not need — but check each for _behavior_ before
dropping it. `@clack/prompts` is load-bearing: the stub answers `undefined`
where the real library drives a **synchronous** prompt, and a generator
that asks a question blocks the worker forever with no test timeout.
`tools/vitest/setup.mts` already keeps that one. `prettier`'s stub also
pins `resolveConfig: () => null`, which matters if the package snapshots
formatted output.
- `maxWorkers: 1` — Vitest runs files in parallel. Any spec relying on
cross-file ordering or a shared mutable temp dir will now fail. This is
the main source of "it passed under jest" flakes.
4. **Native bindings** — does the package load `nx/src/native` or a `.node`
file? If yes you need `pool: 'forks'` and the native shim plugin from
`packages/nx/vitest.config.mts`.
5. **Lazy `require()` of TS source** — `grep -rn "require(" packages/<name>/src
--include=*.ts | grep -v "^.*spec"`. Every bare `require()` of a local `.ts`
file needs `@swc-node/register` (Step 2) and can only be mocked through
`mockCjsModule` (Step 4).
---
## Step 1 — Target inference
`@nx/vitest` is already registered in `nx.json` for `packages/**/*`, so a
`vitest.config.mts` at the package root is enough to infer `<name>:test`.
Verify the plugin block still reads:
```json
{
"plugin": "@nx/vitest",
"options": { "testTargetName": "test" },
"include": ["packages/**/*"],
"exclude": ["**/out-tsc/**"]
}
```
`@nx/jest` infers `test` from `jest.config.*` presence. **Both plugins would
claim `test`**, so `jest.config.cts` must be deleted in the same change, not
left behind "just in case". Also delete any `jest-resolver.js` and drop
`project.json` target overrides that reference jest inputs (see the
`packages/nx` diff — a `"test": { "inputs": [..., "patched-jest-resolver.js"] }`
block was removed).
---
## Step 2 — Write `packages/<name>/vitest.config.mts`
Start from `packages/nx/vitest.config.mts` and keep only what the survey
justified. The load-bearing pieces and why:
```ts
export default defineConfig({
root: import.meta.dirname,
cacheDir: '../../node_modules/.vite/<name>/unit',
test: {
watch: false,
globals: true, // specs use bare describe/it/expect/vi
environment: 'node', // or 'jsdom' if the jest config said so
include: ['**/*.spec.ts'],
exclude: ['**/node_modules/**'],
setupFiles: ['./vitest.setup.mts'],
testTimeout: 35000, // matches jest.preset.js
pool: 'forks', // ONLY if native .node bindings are loaded;
// they are not thread-safe across workers
teardownTimeout: 60_000, // specs holding native contexts exit slowly;
// the jest setup hid this behind --forceExit
execArgv: ['--conditions=@nx/nx-source'],
server: { deps: { external: [/\.node$/] } },
},
resolve: {
conditions: ['@nx/nx-source'],
},
plugins: [nxSourceResolver()], // tools/vitest/nx-source-resolver.mts
});
```
Rules for resolution — the part that most looks solved and isn't:
- **`conditions: ['@nx/nx-source']` does NOT replace the jest resolver.**
`node_modules/nx` and `node_modules/@nx/*` are the _published_ tarballs (dist
only, no source), and their exports maps advertise `@nx/nx-source` entries
pointing at `./src/index.ts` files the tarball does not ship — so the
condition resolves to a file that isn't there. Use `nxSourceResolver()` from
`tools/vitest/nx-source-resolver.mts`, which maps `nx` / `@nx/*` through
the _local_ `packages/<pkg>/package.json`, with a file fallback for deep
imports no exports entry covers (`@nx/workspace/src/...`).
- **`execArgv: ['--conditions=@nx/nx-source']` on its own actively breaks node
resolution**, for the same reason: a lazy `require('@nx/js')` dies with
`Cannot find module '.../node_modules/@nx/js/src/index.ts'`. Keep the flag,
but `tools/vitest/setup.mts` must also patch `Module._resolveFilename`
with the same mapping so both channels agree.
- **Aliases use regex, not strings.** Vite string aliases do prefix matching,
so `'@nx/devkit'` would rewrite `@nx/devkit/internal` too. Use
`{ find: /^@nx\/devkit$/, replacement: ... }`.
- `packages/nx` predates the shared resolver and hard-codes `nx/src/*` and
`nx/bin/*` aliases instead. Don't copy that — the resolver covers it.
- If the package imports `yargs` with CJS-namespace style
(`yargs.terminalWidth()`), alias it to `node_modules/yargs/index.cjs`.
- If the package loads `nx/src/native`, copy the `nx-native-shim` plugin
verbatim — `src/native/index.js` requires TS files and cannot run outside a
transform, so it must be routed to the generated `native-bindings.js` and
externalized.
---
## Step 3 — Wire up the shared setup
Point the config at the shared file; do not write a per-package copy:
```ts
setupFiles: ['../../tools/vitest/setup.mts'],
```
`tools/vitest/setup.mts` is the port of `scripts/unit-test-setup.js` (which
is jest-only — `jest.doMock` — so it can never be imported from vitest). Read
it before assuming anything is missing; it already does all of the following,
and each line is there because its absence broke `packages/workspace`:
- `NX_DAEMON=false`, `npm_config_user_agent` deleted, `FORCE_COLOR` deleted and
`NO_COLOR=1` (snapshots are recorded colorless).
- `NX_WORKSPACE_ROOT_PATH` under `tmp/unit/<pid>` — **per worker process**,
unlike jest. The jest resolver set a single `tmp/unit` as a side effect;
with vitest's parallel workers one shared root makes every worker queue on
the same lock ("Waiting for graph construction in another process to
complete", 35s timeouts).
- `NX_ISOLATE_PLUGINS=false`. Otherwise plugin isolation spawns a worker
subprocess per plugin that is never torn down, and the spec file stalls to
its timeout. Two `packages/nx` specs already carry this same note.
- `@swc-node/register`, with `Error.prepareStackTrace` **restored immediately
after**: the hook installs source-map-support, which mis-maps
vite-transformed frames and breaks error locations _and_ inline-snapshot
updates.
- `Module._resolveFilename` patched with the source mapping (Step 2), plus
`@clack/prompts` → `scripts/jest-mocks/clack-prompts.js`.
- `vi.doMock` graph/workspace-context/native guards, keyed by **absolute
physical path** — mocking the `nx/src/...` specifier routes through the pnpm
symlink and keys as a different module, so the mock silently never applies.
- **The same graph mocks again, on the CJS channel**, via a `Module._load`
patch. This is the one most easily missed and the most expensive to debug:
generators reach graph builders through lazy `require()`, which `vi.mock`
cannot see, and the unmocked `createProjectGraphAsync` takes
`project-graph.lock` and **deadlocks the worker** — no output, and no test
timeout fires, because the main thread is blocked in a futex.
- Pass-through helpers are plain functions, not `vi.fn()`, so a suite's
`vi.resetAllMocks()` cannot wipe them into `() => undefined`.
Add to the shared file (not a package-local one) if the package needs a guard
nothing else does, and say so in the PR — every migrated package loads it.
Two more rules:
1. **Do not alias a `jest` global in the setup.** A stray `jest.mock` would not
be hoisted by vitest's transform and would silently fail to intercept. Let
it throw.
2. If the package's specs can write repo files, copy
`packages/nx/vitest-write-guard.cjs` and load it through
`execArgv: ['--require', ...]`. It must be `execArgv`, not `setupFiles`:
node snapshots a module's ESM named exports on first import, so a patch
applied from a setup file is invisible to `import { writeFile } from 'fs'`.
(The `packages/nx` migration found a spec that had been overwriting the
repo's real `nx.json`.)
Finally, name the shared files in the package's `project.json` so the cache
sees them — they live outside `{projectRoot}`, so nothing else invalidates on
an edit:
```json
"test": {
"inputs": [
"...",
"{workspaceRoot}/tools/vitest/**/*",
"{workspaceRoot}/scripts/jest-mocks/clack-prompts.js"
]
}
```
These are not optional bookkeeping. `@nx/vitest` infers `setup.mts` and its
tsconfig (nx#36920), but nothing infers the resolver the config imports or the
clack mock the setup loads by path — `default` is project-scoped — so leaving
them off does not fail loudly; it serves a **stale cache hit** the next time
someone edits them. Keep the whole `tools/vitest/**/*` glob rather than naming
the resolver alone, so a helper added there later is covered too.
---
## Step 4 — `tsconfig.spec.json`
```jsonc
{
"compilerOptions": {
"types": ["vitest/globals", "node"], // was ["jest", "node"]
},
"include": [
// ...
"vitest.config.mts", // replaces "jest.config.ts"
"vitest.setup.mts",
],
}
```
Drop `@types/jest` from the package's `devDependencies` only if no other
project in the repo still needs it there.
---
## Step 5 — Codemod the specs
Apply mechanically, then hand-fix. Prefer one script over 200 manual edits, and
commit the codemod pass separately from the hand fixes so review can follow.
Two rules before you run anything:
- **Never codemod the whole package blindly.** Some files contain `jest.*` in
_strings_, not calls — a spec for a codemod that rewrites `jest.mock(...)`
text, generator specs asserting on `jest.config.cts` contents, or
`'@nx/jest:jest'` executor names. Build the file list from a grep for real
API usage (`grep -l 'jest\.[a-z]' | grep -v` the string-only ones) and
eyeball it.
- **Match across newlines.** `jest\n .fn()` and `jest\n .spyOn(...)` are
common in this repo and a line-based `s/jest\.fn(/vi.fn(/` silently misses
them, leaving `ReferenceError: jest is not defined` at collection. Use
`perl -0p` (or equivalent) and re-grep for a bare `\bjest\b` afterwards.
| Jest | Vitest | Note |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| `jest.fn` / `jest.spyOn` / `jest.mock` / `jest.doMock` / `jest.unmock` / `jest.clearAllMocks` / `jest.restoreAllMocks` / `jest.resetModules` / `jest.mocked` | same with `vi.` | pure rename |
| `jest.requireActual<T>(x)` | `await vi.importActual<T>(x)` | factory must become `async` |
| `jest.requireMock(x)` | `await vi.importMock(x)` | factory must become `async` |
| `jest.isolateModules(() => { require(x) })` | `vi.resetModules()` + `await import(x)` | for CJS-loaded modules use `delete cjsRequire.cache[cjsRequire.resolve(x)]; cjsRequire(x)` |
| `jest.isolateModulesAsync(async () => …)` | `vi.resetModules()` then fresh `await import()`s | |
| `jest.Mock` | `import type { Mock } from 'vitest'` | type-only import |
| `jest.SpyInstance` | `import type { MockInstance } from 'vitest'` | type-only import |
| `jest.MockedFunction` | `import type { MockedFunction } from 'vitest'` | type-only import |
| `it('x', (done) => …)` | return a promise | Vitest has no `done` callback |
| `xdescribe` / `xit` | `describe.skip` / `it.skip` | not defined in Vitest |
| `import { jest } from '@jest/globals'` | delete | `vi` is global with `globals: true` |
Hoisting is real in Vitest: `vi.mock` calls are lifted to the top of the file,
above imports _and_ above any `const` the factory closes over. Anything a
factory needs must go through `vi.hoisted(() => …)`.
---
## Step 6 — Hand-fix the semantic gaps
This is where the time goes. The catalogue, from the `packages/nx` migration:
**Frozen ESM namespaces.** `vi.spyOn(semverNamespace, 'gt')` throws on a
node builtin or an external ESM package — the namespace object is frozen.
Mock at the module level in spy mode, which keeps the real implementations
until a test overrides one:
```ts
vi.mock('semver', { spy: true });
vi.mock('child_process', { spy: true });
```
**Modules the source loads with bare `require()`.** `vi.mock` never sees that
channel. Use the helper (add it if the package does not have one — it lives in
`packages/nx/src/internal-testing-utils/cjs-mock.ts` and patches
`Module._load`):
```ts
import { mockCjsModule } from '<path>/internal-testing-utils/cjs-mock';
mockCjsModule(import.meta.url, './run', { runCommand: vi.fn() });
```
Registrations are per-file (Vitest forks per file), but a swap made for a
single test must be undone with `unmockCjsModule` / `resetCjsMocks` or it leaks
into later tests in the same file.
**Class mocks must be constructible.** `vi.fn()` returning an object is not
`new`-able the way jest's auto-mock was. Return a real function with a
`prototype`, as `GuardedWorkspaceContext` does in `vitest.setup.mts`.
**`vi.resetAllMocks()` restores a spy's real implementation** rather than
leaving `() => undefined` like jest. Specs that relied on the jest behavior
(expecting `undefined` after a reset) need explicit `mockReturnValue(undefined)`.
**Setup-file mocks must not use `vi.fn()` for pass-through helpers.** A spec
calling `vi.resetAllMocks()` would wipe the implementation and surface as
"is not iterable" downstream. Use plain functions, as the workspace-context
mock does.
**Hooks that must not return a mock.** `beforeEach(() => vi.fn())` — Vitest
treats a returned function as a teardown callback. Make the body a block.
**Parallelism.** With `maxWorkers: 1` gone, two spec files sharing a temp dir,
a `process.chdir`, or a module-level singleton will now collide. Fix by giving
each file its own `TempFs` root; reach for `test.sequential`/`isolate: false`
only after proving the collision is not the spec's own bug.
**A spec that hangs with no output and no timeout.** The test timeout cannot
fire, because the worker's main thread is blocked in a futex — so the usual
"which test is slow" reflexes give you nothing. Diagnose it from the outside:
```bash
p=$(pgrep -f "workers/forks" | head -1)
cat /proc/$p/wchan # futex_do_wait == blocked, not busy
ps -o pcpu= -p $p # ~0% confirms it is not just slow
ls -l /proc/$p/fd | grep -v socket # the lock file it is stuck on
pgrep -aP $p # a spawned worker/install it waits for
```
In `packages/workspace` this was `project-graph.lock`: real graph construction
running on the CJS channel. The three causes seen so far are all handled by
`tools/vitest/setup.mts` — CJS graph mocks, `NX_ISOLATE_PLUGINS=false`, and
the `@clack/prompts` stub — so first check the setup is actually loaded before
hunting further.
**Watch for latent test bugs.** Both spec bugs the `packages/nx` migration
uncovered were assertions that passed while the mock never applied. If a spec
starts failing after the mock finally lands, the _test_ was wrong — fix the
expectation, do not paper over it by restoring the broken mock.
---
## Step 7 — Snapshots
Vitest joins describe and test names with `>` where jest used a space, so
**every** existing key reads as new: a plain run appends a full second copy of
the file and leaves the jest entries orphaned. Regenerate from a pristine tree
so `-u` also drops the old keys:
```bash
git checkout -- 'packages/<name>/**/__snapshots__/*.snap'
cp -r <snapshots> tmp/snapshot-baseline/ # keep the jest originals
pnpm nx test <name> --skip-nx-cache -- -u
```
Then prove the _values_ did not move: parse both sides into `{key: value}`,
normalize the separator (`' > '` → `' '`), and diff. Key counts and every value
must match — that is the real parity check, not the pass/fail.
`toThrowErrorMatchingInlineSnapshot` is the known exception: vitest records
`[Error: msg]` where jest recorded `"msg"`. Same message, different serializer.
Vitest's serializer differs from Jest's elsewhere too. Regenerate, then **read the diff**:
```bash
pnpm nx test <name> --skip-nx-cache -- -u
git diff --stat -- 'packages/<name>/**/__snapshots__/*'
```
Snapshot churn should be formatting only (quoting, indentation, `Object {` →
`{`). Any change in _content_ is a real behavior difference — investigate it
before accepting. Colorless output is guaranteed by the `NO_COLOR` pin in the
setup file; if you see ANSI codes land in a snapshot, that pin is missing.
---
## Step 8 — Verify
```bash
# same test count as Step 0, and it should be dramatically faster
pnpm nx test <name> --skip-nx-cache
# parallel-safety: repeat runs must be stable, not just green once
pnpm nx test <name> --skip-nx-cache
pnpm nx test <name> --skip-nx-cache
# a single file still works (paths relative to the package root)
pnpm nx run <name>:test -- src/utils/some-file.spec.ts
# nothing else broke - EVERY target the project has, not a set you picked
pnpm nx show project <name> --json | jq '.targets | keys'
pnpm nx run-many -t test,build,lint,oxlint -p <name> --skip-nx-cache
pnpm nx sync:check
```
**`oxlint` is a separate target from `lint`.** Running `test,build,lint` and
calling it green is how a restricted-import error reaches CI: this repo bans
`nx/src/...` imports in favour of `@nx/devkit/internal*`, and only `oxlint`
catches it. Read the target list rather than assuming the usual three.
Parity is **test count**, not just a green run. A dropped `include` pattern or
a silently-skipped directory shows up as a lower count, and a green suite hides
it. If the count differs, find every missing file before proceeding.
Note the caching caveat: after a mechanical sweep, `nx affected` can replay a
stale cached pass. Always validate with `--skip-nx-cache`.
Do **not** run a full `nx affected`: a new or moved file under a workspace-root
directory marks all ~90 projects affected, which is hours of jest for changes
that touch nothing jest reads. Run one still-on-jest package as a canary
instead — `devkit` is the most entangled.
---
## Step 8b — Sandbox violations
The migration is not done when CI is green. Nx Cloud reports the task's file
reads against its declared inputs, and a vitest suite reads things the jest one
did not. Fetch them once the PR has run:
```bash
npx nx-cloud get sandbox-reports --branch <PR-number> --since 1d
npx nx-cloud validate sandbox-violations \
.nx/workspace-data/sandbox-reports/<PR-number>/index.json --json
```
`nx reset` deletes `.nx/workspace-data`, and the downloaded reports with it —
re-download after one.
The per-task JSON carries `processTree` plus a `pid` on every read, which is
how you attribute a violation instead of guessing. Map them:
```python
tree = {p['pid']: p for p in report['processTree']}
for r in report['unexpectedReads']:
print(r['path'], tree.get(r['pid'], {}).get('cmd'))
```
The two this migration produced, both worth checking for:
- **Every project's `tsconfig.json`, read by the vitest main process.** The
timestamps show the root solution tsconfig read milliseconds after a
workspace-root source file. Cause and fix are in the Step 3 note about where
the shared setup lives. Confirm with the resolver vite itself uses rather
than a filesystem tracer — `tsconfck`'s `parse()` reports what it consulted,
and a tracer on `fs` misses it because the ESM `node:fs/promises` bindings
are snapshotted before a `--require` preload can patch them:
```js
const { parse } =
await import('<repo>/node_modules/.pnpm/tsconfck@*/node_modules/tsconfck/src/index.js');
console.log((await parse('tools/vitest/setup.mts')).referenced?.length); // 0 == leaf, 114 == solution root
```
- **The repo's `.editorconfig`, read by a worker.** `formatFiles` resolves
prettier config from disk at `tree.root`, and a spec that points
`tree.root` into the repo (e.g. `process.cwd()`) picks it up; the jest
prettier shim pinned `resolveConfig: () => null` and hid it. Give that spec a
`TempFs` root instead of declaring the file as an input.
Prefer declaring an input over excluding a path: an over-broad input costs
cache misses, an over-broad exclusion buys wrong cache hits. But a violation
that only exists because a file sits in the wrong place is a **layout** bug —
fix the layout. Declaring 114 tsconfigs as inputs would have been "correct"
and would have quietly wrecked the cache for every vitest suite in the repo.
---
## Step 9 — Clean up and document
- Delete `packages/<name>/jest.config.cts` and any package-local jest resolver
or setup file whose behavior you ported.
- If this was the last jest project touching a `scripts/jest-mocks/*` shim or a
branch of `scripts/unit-test-setup.js`, delete it. If not, leave it and say
so in the PR body — the `packages/nx` PR explicitly deferred the dead
`scripts/unit-test-setup.js` branches to a follow-up rather than mixing them
in.
- Update `CONTRIBUTING.md` — it documents `npx jest <path>` for targeting a
single test. Add the package to the vitest note next to `packages/nx`.
- Format: `npx oxfmt <changed files>` (check the branch's own `check` target
first — a feature branch may still run pretty-quick).
- Do **not** run a full `nx affected`: a new file under `scripts/` marks all 90+
projects affected, which is hours of jest. Nothing jest reads has changed
(`jest.preset.js`, `scripts/unit-test-setup.js`,
`scripts/patched-jest-resolver.js` are untouched), so run one still-on-jest
package as a canary instead — `devkit` is the most entangled.
- Write `tmp/notes/vitest-migration-<name>.md`: before/after test count and
wall time, the list of hand-fixed specs and why, and anything deferred.
## Commit shape
Follow the reference PR — small, reviewable, conventional-commit slices:
1. `chore(<scope>): add vitest config and setup for <name> unit tests`
2. `chore(<scope>): codemod jest.* to vi.* in <name> specs`
3. one commit per class of hand fix (CJS-channel mocks, frozen namespaces,
constructor mocks, hook cleanup, …)
4. `chore(<scope>): regen <name> snapshots for vitest`
5. `chore(<scope>): remove <name> jest config`
PR body: fill the template, state the before/after test count and wall time,
list what the vitest config reproduces from the jest setup, and call out any
latent test bug the migration exposed.
More agent context in nrwl/nx
38 other files this repository gives its agents.
AGENTS.md
CLAUDE.md
Skill
- link-workspace-packages.agents/skills/link-workspace-packages/SKILL.md
- monitor-ci.agents/skills/monitor-ci/SKILL.md
- nx-generate.agents/skills/nx-generate/SKILL.md
- nx-import.agents/skills/nx-import/SKILL.md
- nx-plugins.agents/skills/nx-plugins/SKILL.md
- nx-run-tasks.agents/skills/nx-run-tasks/SKILL.md
- nx-workspace.agents/skills/nx-workspace/SKILL.md
- review-pr.agents/skills/review-pr/SKILL.md
- author-migration.claude/skills/author-migration/SKILL.md
- check-docs-style.claude/skills/check-docs-style/SKILL.md
- dist-build-migration.claude/skills/dist-build-migration/SKILL.md
- docs-website-update.claude/skills/docs-website-update/SKILL.md
- grill-me.claude/skills/grill-me/SKILL.md
- multi-version-compliance.claude/skills/multi-version-compliance/SKILL.md
- nx-gradle-plugin-version-bump.claude/skills/nx-gradle-plugin-version-bump/SKILL.md
- nx-multi-repo-migrate.claude/skills/nx-multi-repo-migrate/SKILL.md
- reproduce-issue.claude/skills/reproduce-issue/SKILL.md
- review-local-branch.claude/skills/review-local-branch/SKILL.md
- review-pending-pr-reviews.claude/skills/review-pending-pr-reviews/SKILL.md
- review-pr.claude/skills/review-pr/SKILL.md
- run-nx-generator.claude/skills/run-nx-generator/SKILL.md
- setup-review-sandbox.claude/skills/setup-review-sandbox/SKILL.md
- update-cnw-templates.claude/skills/update-cnw-templates/SKILL.md
- ci-monitor.cursor/skills/ci-monitor/SKILL.md
- nx-generate.cursor/skills/nx-generate/SKILL.md
- nx-plugins.cursor/skills/nx-plugins/SKILL.md
- nx-run-tasks.cursor/skills/nx-run-tasks/SKILL.md
- nx-workspace.cursor/skills/nx-workspace/SKILL.md
- ci-monitor.github/skills/ci-monitor/SKILL.md
- link-workspace-packages.github/skills/link-workspace-packages/SKILL.md
- monitor-ci.github/skills/monitor-ci/SKILL.md
- nx-generate.github/skills/nx-generate/SKILL.md
- nx-import.github/skills/nx-import/SKILL.md
- nx-plugins.github/skills/nx-plugins/SKILL.md
- nx-run-tasks.github/skills/nx-run-tasks/SKILL.md
- nx-workspace.github/skills/nx-workspace/SKILL.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
Reports can't be read right now.
Posts are public. Sign in to say whether it worked for you.Sign in to post
Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.

