All jobs

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 RushIn 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 with this body:

{
  "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.

The use case this job follows, with its automation and trigger: Hand off from your terminal.

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.