--- 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 to vitest", "migrate tests off jest", or "run 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/`'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//`. ## 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-.md` before touching anything: ```bash ls packages//jest.config.cts packages//jest*.js 2>/dev/null cat packages//jest.config.cts cat packages//tsconfig.spec.json grep -rl "\.spec\.ts" -c packages//src | wc -l # rough spec count pnpm nx show project --json | head -40 ``` Capture: 1. **Spec count and current runtime.** Run `pnpm nx test --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=/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//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 `: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//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//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//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/` — **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(x)` | `await vi.importActual(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 '/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//**/__snapshots__/*.snap' cp -r tmp/snapshot-baseline/ # keep the jest originals pnpm nx test --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 --skip-nx-cache -- -u git diff --stat -- 'packages//**/__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 --skip-nx-cache # parallel-safety: repeat runs must be stable, not just green once pnpm nx test --skip-nx-cache pnpm nx test --skip-nx-cache # a single file still works (paths relative to the package root) pnpm nx run :test -- src/utils/some-file.spec.ts # nothing else broke - EVERY target the project has, not a set you picked pnpm nx show project --json | jq '.targets | keys' pnpm nx run-many -t test,build,lint,oxlint -p --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 --since 1d npx nx-cloud validate sandbox-violations \ .nx/workspace-data/sandbox-reports//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('/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//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 ` for targeting a single test. Add the package to the vitest note next to `packages/nx`. - Format: `npx oxfmt ` (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-.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(): add vitest config and setup for unit tests` 2. `chore(): codemod jest.* to vi.* in specs` 3. one commit per class of hand fix (CJS-channel mocks, frozen namespaces, constructor mocks, hook cleanup, …) 4. `chore(): regen snapshots for vitest` 5. `chore(): remove 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.