# Migrate a GitHub Action from Jest to Vitest

Yes. An agent moves a TypeScript action's ESM tests from Jest to Vitest, keeps the coverage badge and the dist check working, and opens a pull request.

[Run this on Rush](https://cloud.getrush.ai/run/migrate-jest-to-vitest/github-actions/), in a Project that has your repository.

## What the agent does

The agent clones the action into its own cloud sandbox and reads how the tests are wired before it changes a line. In an action built from actions/typescript-action, `package.json` is `"type": "module"`, wants Node `>=24.0.0`, and runs both `test` and `ci-test` as `NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest`, the flags Jest needs for native ESM. `jest.config.js` uses the `ts-jest` preset with `useESM: true`, `extensionsToTreatAsEsm: ['.ts']`, the `ts-jest-resolver`, `testEnvironment: 'node'`, `testMatch: ['**/*.test.ts']`, `clearMocks: true`, and `collectCoverage: true` over `./src/**` with the `json-summary`, `text` and `lcov` reporters. That last detail matters: the `coverage` script runs `make-coverage-badge --output-path ./badges/coverage.svg`, which reads `coverage/coverage-summary.json`, and `npm run all` chains format, lint, test, coverage and package.

The two tests show what has to be translated. `__tests__/main.test.ts` mocks ESM modules with `jest.unstable_mockModule('@actions/core', () => core)` and the same for `../src/wait.js`, then imports the module under test dynamically with `await import('../src/main.js')` so the mocks are in place first; the mocks live in `__fixtures__/core.ts` and `__fixtures__/wait.ts` as `jest.fn<typeof core.debug>()` and friends, imported from `@jest/globals`. `__tests__/wait.test.ts` needs no mocks and checks that `wait` rejects a non-number and takes at least 450 ms for 500. The agent rewrites the fixtures with `vi.fn<typeof core.debug>()` from `vitest`, replaces each `unstable_mockModule` with a hoisted `vi.mock('@actions/core', () => core)`, keeps the dynamic import where it documents the ordering, swaps `jest.resetAllMocks()` for `vi.resetAllMocks()`, and leaves the assertions, including `expect.stringMatching(/^\d{2}:\d{2}:\d{2}/)` on the `time` output, as they are.

Configuration moves next. A `vitest.config.ts` sets `include: ['**/*.test.ts']`, `environment: 'node'`, `clearMocks: true` and coverage with the `v8` provider, `include: ['src/**']` and the same three reporters, so `coverage/coverage-summary.json` keeps the name the badge script expects. `jest.config.js` goes; `jest`, `ts-jest`, `ts-jest-resolver`, `@jest/globals`, `@types/jest` and `eslint-plugin-jest` leave `devDependencies`, and `vitest` and `@vitest/coverage-v8` arrive. `eslint.config.mjs` extends `plugin:jest/recommended`, lists `jest.config.js` under `allowDefaultProject` and spreads `globals.jest`; the agent removes those three, adds `vitest.config.ts` to the default-project list, and since Vitest is imported explicitly it adds no globals. The `test` and `ci-test` scripts become `vitest run` and `vitest run --coverage`, with the `NODE_OPTIONS` prefix gone. `tsconfig.json` already excludes `__tests__` and `__fixtures__`, so the Rollup bundle in `rollup.config.ts` and the committed `dist/index.js` are untouched.

Verification is the workflow. `.github/workflows/ci.yml` runs `npm ci`, `npm run format:check`, `npm run lint` and `npm run ci-test` on the Node in `.node-version` (24.4.0), then runs the action itself with `milliseconds: 2000` and prints its `time` output; `check-dist.yml` deletes `dist/`, runs `npm run bundle` and fails if the result differs from what is committed. The agent runs the same four npm scripts, then `npm run coverage` to regenerate `badges/coverage.svg`, then `npm run bundle` followed by `git status --porcelain dist/` to show the bundle did not move, and reads `package-lock.json` to confirm only the test packages changed.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "Migrate this GitHub Action's tests from Jest to Vitest. Read package.json, jest.config.js, eslint.config.mjs, tsconfig.json, rollup.config.ts, __tests__/*.ts, __fixtures__/*.ts and .github/workflows/ci.yml and check-dist.yml. Add vitest and @vitest/coverage-v8, write vitest.config.ts with the same test match, node environment, clearMocks and coverage over src/ with the json-summary, text and lcov reporters so make-coverage-badge still finds coverage/coverage-summary.json. Rewrite the fixtures with vi.fn and the tests with vi.mock in place of jest.unstable_mockModule, keeping the assertions. Remove jest, ts-jest, ts-jest-resolver, @jest/globals, @types/jest and eslint-plugin-jest, update eslint.config.mjs accordingly, and change the test and ci-test scripts to vitest run and vitest run --coverage without NODE_OPTIONS. Run npm run format:check, npm run lint, npm run ci-test, npm run coverage and npm run bundle, confirm git reports no change under dist/, and open a pull request with the command output in its description.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    }
  ]
}
```

1. **Get an API key.** In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
2. **Send the request.** POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
3. **Read what came back.** Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

## What comes back

What comes back is a pull request on its own branch that adds `vitest.config.ts`, removes `jest.config.js`, rewrites `__tests__/main.test.ts`, `__tests__/wait.test.ts` and both files in `__fixtures__/`, changes the scripts and dev dependencies in `package.json` and `package-lock.json`, trims the Jest plugin and globals from `eslint.config.mjs`, and regenerates `badges/coverage.svg`. The description lists the packages removed and added, the tail of the `vitest run --coverage` output, and the `git status` line showing `dist/` unchanged.

The session read names the repository and the pull request, and the transcript at `GET /o/{org}/sessions/{id}/messages` keeps every command: the install, each npm script with its output, and the bundle check. The workflow runs on the pull request give the second opinion; a check that fails ends the run `needs_review` rather than as a green claim.

Before merging, a reviewer compares the coverage summary with the one on the default branch, confirms `check-dist.yml` passed, and reads the mock rewrite in `main.test.ts` to see that `@actions/core` is still mocked before `src/main.js` loads. A follow-up on the same session can add the coverage thresholds `jest.config.js` left commented out.

## Run it on every action repository still on Jest

An organisation with many actions built from the same template runs this once per repository. A script on your server lists them, sends one `POST /o/{org}/sessions` per repository with this body and that repository in `resources`, and uses the repository name as the `request_id` so a rerun returns the run already started instead of opening a second pull request. Each run has its own sandbox, so twenty migrations run at once without sharing a `node_modules`.

Give the key a webhook URL and Rush POSTs `run.completed` with `data.session_url` when each turn ends. A handler reads the session, takes the pull request from it and posts the list of opened pull requests to the team, with any run that ended `needs_review` called out for a person to read.

## Why run it on Rush?

A Jest-to-Vitest move on an ESM action fails in details that only running shows: a mock that is no longer hoisted, a coverage file with a new name, a bundle that changed because a dependency moved. In its own sandbox the agent runs the exact scripts `ci.yml` and `check-dist.yml` run, on the Node version in `.node-version`, and keeps the output, so the pull request carries evidence rather than a claim that the tests pass.

You start it from a terminal and walk away. The sandbox parks when the turn ends and wakes for a follow-up, so adding a coverage threshold or a second test file is one more message on the same session, with the same checkout and the same installed packages.

## Frequently asked questions

### Will the coverage badge keep working?

Yes. `make-coverage-badge` reads `coverage/coverage-summary.json`, and the Vitest config keeps the `json-summary` reporter with coverage limited to `src/**`, so the file name and shape stay what the `coverage` script expects. The agent runs `npm run coverage` and commits the regenerated `badges/coverage.svg`.

### Does the bundled dist/ change?

It should not, and the agent proves it: `rollup.config.ts` bundles `src/index.ts` only, `tsconfig.json` excludes `__tests__` and `__fixtures__`, and after `npm run bundle` the agent runs `git status --porcelain dist/` and puts the empty result in the pull request. `check-dist.yml` repeats that check on the pull request.

### How are the ESM mocks translated?

`jest.unstable_mockModule(path, factory)` becomes `vi.mock(path, factory)`, which Vitest hoists above the imports, and the fixtures switch from `jest.fn<typeof core.debug>()` to `vi.fn<typeof core.debug>()`. The dynamic `await import('../src/main.js')` can stay; it still documents that mocks are registered before the module under test loads.

### What about eslint-plugin-jest?

It is removed with the `plugin:jest/recommended` extend and the `globals.jest` spread in `eslint.config.mjs`, since the tests import `vi`, `describe`, `it` and `expect` from `vitest` and need no globals. `vitest.config.ts` is added to the `allowDefaultProject` list so the typed linting still covers it, and `npm run lint` runs clean before the pull request opens.
