diff --git a/.changeset/migration-cli.md b/.changeset/migration-cli.md
new file mode 100644
index 0000000..095e8ec
--- /dev/null
+++ b/.changeset/migration-cli.md
@@ -0,0 +1,11 @@
+---
+"vitest-native": minor
+---
+
+New CLI: `npx vitest-native init | doctor | migrate`.
+
+- **`init`** writes a ready-to-run Vitest config (`--jest-compat` for the exact jest-compat block the migration guide documents; refuses to overwrite without `--force`).
+- **`doctor`** diagnoses the environment read-only: Node floor (including the RNTL 14 ⇄ Node 22.13 interaction, which previously surfaced only as a raw runtime failure), required peers against supported ranges, which engine `auto` resolves to and why, every auto-detected preset, Expo presence with known-limits pointer, and config presence. Exits non-zero on blocking problems.
+- **`migrate`** analyzes the project's Jest configuration (`package.json#jest` or `jest.config.{js,cjs,json}`) and reports key-by-key what maps automatically (setup files, path aliases, `transformIgnorePatterns` allowlists → `transform: [...]`, timeouts, mock hygiene flags), what the auto-detected presets already cover (deletable manual `__mocks__` and setup lines), what needs a human, and what drops — ending with a complete suggested config. Dry-run by default; `--write` saves it. Test files are never edited (`jestMockTransform()` handles top-level `jest.mock` at runtime).
+
+The packed-tarball consumer suite exercises the bin end-to-end (`npx vitest-native doctor|migrate`).
diff --git a/packages/vitest-native/package.json b/packages/vitest-native/package.json
index ac5455e..56a075e 100644
--- a/packages/vitest-native/package.json
+++ b/packages/vitest-native/package.json
@@ -204,5 +204,8 @@
"dependencies": {
"flow-remove-types": "^2.318.0",
"magic-string": "^0.30.21"
+ },
+ "bin": {
+ "vitest-native": "./dist/cli.mjs"
}
}
diff --git a/packages/vitest-native/scripts/test-consumers.mjs b/packages/vitest-native/scripts/test-consumers.mjs
index 3c408c0..ffc61d5 100644
--- a/packages/vitest-native/scripts/test-consumers.mjs
+++ b/packages/vitest-native/scripts/test-consumers.mjs
@@ -52,6 +52,18 @@ try {
run("npm", ["test"], fixtureRoot);
}
+ // The CLI ships as the package bin — prove it dispatches from the packed
+ // tarball the way `npx vitest-native` would (doctor exercises peer probing,
+ // engine detection, and preset scanning against the fixture's real installs;
+ // migrate analyzes a minimal Jest config planted for the smoke).
+ const cliFixture = path.join(tempRoot, "bare");
+ run("npx", ["--no-install", "vitest-native", "doctor"], cliFixture);
+ fs.writeFileSync(
+ path.join(cliFixture, "jest.config.json"),
+ `${JSON.stringify({ preset: "react-native", testTimeout: 10000 }, null, 2)}\n`,
+ );
+ run("npx", ["--no-install", "vitest-native", "migrate"], cliFixture);
+
console.log("\nAll packed consumer fixtures passed.");
} finally {
fs.rmSync(tempRoot, { force: true, recursive: true });
diff --git a/packages/vitest-native/src/cli/doctor.ts b/packages/vitest-native/src/cli/doctor.ts
new file mode 100644
index 0000000..33ee075
--- /dev/null
+++ b/packages/vitest-native/src/cli/doctor.ts
@@ -0,0 +1,157 @@
+/**
+ * `vitest-native doctor` — diagnose the project's environment: peer versions,
+ * engine resolution (and why), detected presets, RNTL/Node compatibility, and
+ * config presence. Read-only; never mutates the project.
+ */
+import { createRequire } from "node:module";
+import fs from "node:fs";
+import path from "node:path";
+import { validatePeerDependency } from "../validate.js";
+import { detectEngine } from "../native/detect.js";
+import { AUTO_DETECT_PRESETS } from "../preset-map.js";
+import { PEER_REQUIREMENTS } from "../peer-requirements.js";
+
+export interface DoctorResult {
+ lines: string[];
+ ok: boolean;
+}
+
+function packageVersion(root: string, name: string): string | null {
+ try {
+ const req = createRequire(path.join(root, "package.json"));
+ return (req(`${name}/package.json`) as { version?: string }).version ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function findConfigFile(root: string): string | null {
+ for (const name of [
+ "vitest.config.ts",
+ "vitest.config.mts",
+ "vitest.config.js",
+ "vitest.config.mjs",
+ "vitest.config.cts",
+ "vitest.config.cjs",
+ ]) {
+ if (fs.existsSync(path.join(root, name))) return name;
+ }
+ return null;
+}
+
+export function runDoctor(root: string, nodeVersion: string = process.versions.node): DoctorResult {
+ const lines: string[] = [];
+ let ok = true;
+ const pass = (s: string) => lines.push(` ✓ ${s}`);
+ const warn = (s: string) => lines.push(` ⚠ ${s}`);
+ const fail = (s: string) => {
+ ok = false;
+ lines.push(` ✗ ${s}`);
+ };
+
+ lines.push(`vitest-native doctor — ${root}`);
+
+ // --- Runtime ---
+ lines.push("", "Runtime");
+ const nodeMajorMinor = nodeVersion.split(".").slice(0, 2).map(Number);
+ if (nodeMajorMinor[0] >= 20) pass(`Node ${nodeVersion} (floor: 20)`);
+ else fail(`Node ${nodeVersion} — vitest-native requires Node >= 20.`);
+
+ // --- Required peers ---
+ lines.push("", "Peer dependencies");
+ for (const { name, minimum, maximumMajor, minimumByMajor } of PEER_REQUIREMENTS) {
+ const error = validatePeerDependency(name, minimum, root, maximumMajor, minimumByMajor);
+ if (error) fail(error);
+ else pass(`${name} ${packageVersion(root, name)}`);
+ }
+
+ // --- Engine ---
+ lines.push("", "Engine");
+ const rnVersion = packageVersion(root, "react-native");
+ const decision = detectEngine("auto", root);
+ if (decision.engine === "native") {
+ pass(
+ `engine 'auto' resolves to NATIVE — real React Native${rnVersion ? ` ${rnVersion}` : ""} with @react-native/babel-preset + @babel/core present.`,
+ );
+ } else if (rnVersion) {
+ warn(
+ `engine 'auto' resolves to MOCK: react-native ${rnVersion} is installed but ` +
+ `@react-native/babel-preset and/or @babel/core do not resolve. Install both ` +
+ `as devDependencies to run the real-RN native engine.`,
+ );
+ } else {
+ warn(
+ `engine 'auto' resolves to MOCK: react-native is not installed. ` +
+ `That is fine for pure-logic suites; install react-native (+ its babel preset) for the native engine.`,
+ );
+ }
+
+ // --- RNTL ---
+ lines.push("", "Testing library");
+ const rntl = packageVersion(root, "@testing-library/react-native");
+ if (!rntl) {
+ warn(
+ "@testing-library/react-native not found — optional, but required for render()/screen queries.",
+ );
+ } else {
+ const rntlMajor = Number(rntl.split(".")[0]);
+ if (rntlMajor < 12 || rntlMajor >= 15) {
+ fail(`@testing-library/react-native ${rntl} — supported range is >=12 <15.`);
+ } else if (
+ rntlMajor >= 14 &&
+ (nodeMajorMinor[0] < 22 || (nodeMajorMinor[0] === 22 && nodeMajorMinor[1] < 13))
+ ) {
+ fail(
+ `@testing-library/react-native ${rntl} requires Node >= 22.13, but this is Node ${nodeVersion}. ` +
+ `Upgrade Node or pin @testing-library/react-native@13.`,
+ );
+ } else {
+ pass(
+ `@testing-library/react-native ${rntl}${rntlMajor >= 14 ? " (14 is async: await render/fireEvent)" : ""}`,
+ );
+ }
+ }
+
+ // --- Presets ---
+ lines.push("", "Auto-detected presets");
+ const req = createRequire(path.join(root, "package.json"));
+ const detected: string[] = [];
+ for (const [pkg, preset] of Object.entries(AUTO_DETECT_PRESETS)) {
+ try {
+ req.resolve(pkg);
+ detected.push(`${pkg} → ${preset}`);
+ } catch {
+ // not installed
+ }
+ }
+ if (detected.length) for (const d of detected) pass(d);
+ else lines.push(" (none — no preset-covered packages installed)");
+
+ // --- Expo ---
+ const expo = packageVersion(root, "expo");
+ if (expo) {
+ lines.push("", "Expo");
+ warn(
+ `expo ${expo} detected. Expo-module components work via the auto-detected preset; ` +
+ `suites that import Expo CORE internals (expo-router setups, dev-client wiring) can hit ` +
+ `known limits — see the Jest migration guide's Expo notes.`,
+ );
+ }
+
+ // --- Config ---
+ lines.push("", "Config");
+ const configFile = findConfigFile(root);
+ if (!configFile) {
+ warn("no vitest.config.* found — run `vitest-native init` to create one.");
+ } else {
+ const content = fs.readFileSync(path.join(root, configFile), "utf8");
+ if (content.includes("vitest-native") || content.includes("reactNative(")) {
+ pass(`${configFile} uses vitest-native.`);
+ } else {
+ warn(`${configFile} exists but does not reference vitest-native.`);
+ }
+ }
+
+ lines.push("", ok ? "✓ No blocking problems found." : "✗ Blocking problems found (see ✗ above).");
+ return { lines, ok };
+}
diff --git a/packages/vitest-native/src/cli/index.ts b/packages/vitest-native/src/cli/index.ts
new file mode 100644
index 0000000..9a0c8d3
--- /dev/null
+++ b/packages/vitest-native/src/cli/index.ts
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+/**
+ * vitest-native CLI: `init` (write a ready config), `doctor` (diagnose the
+ * environment), `migrate` (analyze a Jest config and report the mapping).
+ * Deliberately vitest-free at module load — it runs via npx outside any test
+ * process.
+ */
+import path from "node:path";
+import fs from "node:fs";
+import { runDoctor } from "./doctor.js";
+import { runInit } from "./init.js";
+import { analyzeJestConfig, renderMigrationReport } from "./migrate.js";
+
+const USAGE = `vitest-native — test React Native with Vitest
+
+Usage:
+ vitest-native init [--jest-compat] [--force] write a ready-to-run vitest config
+ vitest-native doctor diagnose peers, engine, presets, RNTL/Node
+ vitest-native migrate [--write] [--force] analyze jest config → migration report
+ (--write also saves the suggested config)
+Options:
+ --root
project root (default: cwd)
+ --help this text
+
+Docs: https://danfry1.github.io/vitest-native/`;
+
+export function main(argv: string[], log: (line: string) => void = console.log): number {
+ const args = argv.filter((a) => a !== "--");
+ // The value following --root is an argument, not the command.
+ const command = args.find((a, i) => !a.startsWith("-") && args[i - 1] !== "--root");
+ const has = (flag: string) => args.includes(flag);
+ const rootIdx = args.indexOf("--root");
+ const root = path.resolve(rootIdx !== -1 ? (args[rootIdx + 1] ?? ".") : ".");
+
+ if (has("--help") || has("-h")) {
+ log(USAGE);
+ return 0;
+ }
+ if (!command) {
+ log(USAGE);
+ return 1;
+ }
+ if (!fs.existsSync(path.join(root, "package.json"))) {
+ log(`✗ no package.json at ${root} — run from a project root or pass --root.`);
+ return 1;
+ }
+
+ switch (command) {
+ case "doctor": {
+ const result = runDoctor(root);
+ for (const line of result.lines) log(line);
+ return result.ok ? 0 : 1;
+ }
+ case "init": {
+ const result = runInit(root, { jestCompat: has("--jest-compat"), force: has("--force") });
+ for (const line of result.lines) log(line);
+ return result.ok ? 0 : 1;
+ }
+ case "migrate": {
+ const report = analyzeJestConfig(root);
+ for (const line of renderMigrationReport(report)) log(line);
+ if (report.ok && has("--write")) {
+ const init = runInit(root, { force: has("--force") });
+ if (!init.ok) {
+ for (const line of init.lines) log(line);
+ return 1;
+ }
+ const target = path.join(root, init.wrote as string);
+ fs.writeFileSync(target, report.suggestedConfig);
+ log(`✓ wrote ${init.wrote} from the analysis above.`);
+ }
+ return report.ok ? 0 : 1;
+ }
+ default:
+ log(`✗ unknown command '${command}'.`);
+ log(USAGE);
+ return 1;
+ }
+}
+
+// Invoked as a bin (npx vitest-native / node dist/cli.mjs) — dispatch and
+// exit. Tests import main() directly, where argv[1] is the test runner.
+const entry = process.argv[1] ? path.basename(process.argv[1]) : "";
+if (entry === "cli.mjs" || entry === "cli.cjs" || entry === "vitest-native") {
+ process.exit(main(process.argv.slice(2)));
+}
diff --git a/packages/vitest-native/src/cli/init.ts b/packages/vitest-native/src/cli/init.ts
new file mode 100644
index 0000000..94a8584
--- /dev/null
+++ b/packages/vitest-native/src/cli/init.ts
@@ -0,0 +1,108 @@
+/**
+ * `vitest-native init` — write a ready-to-run Vitest config. Two shapes:
+ * the zero-config default, and the jest-compat variant (the exact block the
+ * migration guide documents, so docs and CLI can never drift apart by shape).
+ */
+import fs from "node:fs";
+import path from "node:path";
+
+export interface InitOptions {
+ jestCompat?: boolean;
+ force?: boolean;
+}
+
+export interface InitResult {
+ lines: string[];
+ ok: boolean;
+ wrote?: string;
+}
+
+const PLAIN_CONFIG = `import { defineConfig } from 'vitest/config'
+import { reactNative } from 'vitest-native'
+
+export default defineConfig({
+ // Zero config: engine 'auto' runs REAL React Native (mocking only the native
+ // boundary) when @react-native/babel-preset + @babel/core are present, and
+ // falls back to the pure-JS mock engine otherwise.
+ plugins: [reactNative()],
+ test: {
+ environment: 'node',
+ },
+})
+`;
+
+const JEST_COMPAT_CONFIG = `import { defineConfig } from 'vitest/config'
+import { reactNative } from 'vitest-native'
+import { jestCompatAliases, jestCompatSetup, jestMockTransform } from 'vitest-native/jest-compat'
+
+export default defineConfig({
+ // jestMockTransform() must come AFTER reactNative() and stay normal-order:
+ // it rewrites top-level jest.mock(...) into hoisted vi.mock(...).
+ plugins: [reactNative(), jestMockTransform()],
+ resolve: {
+ dedupe: ['react', 'react-test-renderer', 'react-is'],
+ alias: { ...jestCompatAliases() },
+ },
+ test: {
+ globals: true,
+ environment: 'node',
+ setupFiles: [jestCompatSetup],
+ },
+})
+`;
+
+export function renderInitConfig(jestCompat: boolean): string {
+ return jestCompat ? JEST_COMPAT_CONFIG : PLAIN_CONFIG;
+}
+
+function existingConfig(root: string): string | null {
+ for (const name of [
+ "vitest.config.ts",
+ "vitest.config.mts",
+ "vitest.config.js",
+ "vitest.config.mjs",
+ "vitest.config.cts",
+ "vitest.config.cjs",
+ ]) {
+ if (fs.existsSync(path.join(root, name))) return name;
+ }
+ return null;
+}
+
+export function runInit(root: string, opts: InitOptions = {}): InitResult {
+ const lines: string[] = [];
+ const existing = existingConfig(root);
+ if (existing && !opts.force) {
+ return {
+ lines: [
+ `✗ ${existing} already exists — not overwriting. Re-run with --force to replace it,`,
+ ` or run \`vitest-native migrate\` to analyze an existing Jest setup first.`,
+ ],
+ ok: false,
+ };
+ }
+
+ // TS projects get a .mts config (types flow through defineConfig); everything
+ // else gets .mjs — both are ESM regardless of the package's "type".
+ const file = fs.existsSync(path.join(root, "tsconfig.json"))
+ ? "vitest.config.mts"
+ : "vitest.config.mjs";
+ const target = path.join(root, file);
+ fs.writeFileSync(target, renderInitConfig(opts.jestCompat ?? false));
+
+ lines.push(`✓ wrote ${file}${opts.jestCompat ? " (jest-compat variant)" : ""}`);
+ if (existing && opts.force) lines.push(` (replaced ${existing === file ? "it" : existing})`);
+ lines.push(
+ "",
+ "Next steps:",
+ ` 1. add a script: "test": "vitest"`,
+ ` 2. run: npx vitest`,
+ ` 3. check the environment: npx vitest-native doctor`,
+ );
+ if (opts.jestCompat) {
+ lines.push(
+ ` 4. migrating a Jest suite? \`vitest-native migrate\` maps your jest.config onto this file.`,
+ );
+ }
+ return { lines, ok: true, wrote: file };
+}
diff --git a/packages/vitest-native/src/cli/migrate.ts b/packages/vitest-native/src/cli/migrate.ts
new file mode 100644
index 0000000..3262173
--- /dev/null
+++ b/packages/vitest-native/src/cli/migrate.ts
@@ -0,0 +1,420 @@
+/**
+ * `vitest-native migrate` — analyze a project's Jest configuration and report,
+ * key by key, what maps automatically, what the presets already cover (delete
+ * it), and what needs a human. Dry-run by default; --write emits the suggested
+ * Vitest config. It never edits test files: `jestMockTransform()` handles
+ * top-level `jest.mock` at runtime, so file codemods aren't required to start.
+ */
+import { createRequire } from "node:module";
+import fs from "node:fs";
+import path from "node:path";
+import { AUTO_DETECT_PRESETS } from "../preset-map.js";
+
+export interface MigrationReport {
+ /** Where the Jest config was found, or null. */
+ source: string | null;
+ /** Keys mapped automatically into the suggested config. */
+ automatic: string[];
+ /** Findings that need a human decision. */
+ attention: string[];
+ /** Things the presets already cover — deletable. */
+ presetCovered: string[];
+ /** Jest keys with no vitest-native relevance (dropped). */
+ dropped: string[];
+ /** The generated config text. */
+ suggestedConfig: string;
+ ok: boolean;
+}
+
+/**
+ * Extract package names from Jest's classic transformIgnorePatterns allowlist
+ * (`node_modules/(?!(?:pkg1|@scope/pkg2|...)/)`). Best-effort by design: the
+ * raw pattern is always surfaced in the report alongside the extraction, and
+ * entries the extraction cannot be confident about (capturing groups like
+ * `(jest-)?react-native`, whose strip would fabricate a package name) are
+ * returned separately instead of guessed at.
+ */
+export function extractAllowlistPackages(pattern: string): {
+ packages: string[];
+ unparseable: string[];
+} {
+ // The lookahead body is a sequence of non-paren CHARACTERS and complete
+ // (one-level) paren groups. Single-character alternatives keep the repetition
+ // unambiguous — `[^()]+` inside the star is the classic nested-quantifier
+ // ReDoS shape (exponential backtracking on unclosed input) — while still not
+ // closing early on a LEADING nested group the way a greedy [^)]* would.
+ const m = /\(\?!((?:[^()]|\([^()]*\))*)\)/.exec(pattern);
+ if (!m) return { packages: [], unparseable: [] };
+ const packages: string[] = [];
+ const unparseable: string[] = [];
+ for (const raw of m[1].split("|")) {
+ const entry = raw.trim();
+ if (!entry) continue;
+ // A capturing group means alternation/optionality inside one entry —
+ // stripping would fabricate names (`(jest-)?react-native` → "jest-react-native").
+ if (/\((?!\?)/.test(entry)) {
+ unparseable.push(entry);
+ continue;
+ }
+ const cleaned = entry
+ .replace(/\(\?:/g, "")
+ .replace(/[()?*^$]/g, "")
+ .replace(/\\\//g, "/")
+ .replace(/\/\.$/, "")
+ .replace(/\/$/, "")
+ .replace(/\.$/, "")
+ .trim();
+ if (cleaned.length > 0 && !cleaned.includes("\\")) packages.push(cleaned);
+ else if (entry.length > 0) unparseable.push(entry);
+ }
+ return { packages, unparseable };
+}
+
+interface JestConfig {
+ [key: string]: unknown;
+}
+
+function loadJestConfig(root: string): { source: string | null; config: JestConfig | null } {
+ const req = createRequire(path.join(root, "package.json"));
+ // Jest's own precedence: a jest.config.* file wins over package.json#jest.
+ for (const name of ["jest.config.js", "jest.config.cjs", "jest.config.json"]) {
+ const file = path.join(root, name);
+ if (!fs.existsSync(file)) continue;
+ try {
+ const loaded = name.endsWith(".json") ? JSON.parse(fs.readFileSync(file, "utf8")) : req(file);
+ const config = (
+ loaded && typeof loaded === "object" && "default" in loaded
+ ? (loaded as { default: unknown }).default
+ : loaded
+ ) as JestConfig;
+ if (typeof config === "function") {
+ return { source: name, config: null };
+ }
+ return { source: name, config };
+ } catch {
+ return { source: name, config: null };
+ }
+ }
+ for (const name of ["jest.config.mjs", "jest.config.ts", "jest.config.mts"]) {
+ if (fs.existsSync(path.join(root, name))) return { source: name, config: null };
+ }
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
+ if (pkg.jest && typeof pkg.jest === "object") {
+ return { source: "package.json#jest", config: pkg.jest as JestConfig };
+ }
+ } catch {
+ // no/invalid package.json
+ }
+ return { source: null, config: null };
+}
+
+export function analyzeJestConfig(root: string): MigrationReport {
+ const { source, config } = loadJestConfig(root);
+ const automatic: string[] = [];
+ const attention: string[] = [];
+ const presetCovered: string[] = [];
+ const dropped: string[] = [];
+
+ // Suggested-config fragments assembled at the end.
+ const testEntries: string[] = [`globals: true`, `environment: 'node'`];
+ const setupFiles: string[] = ["jestCompatSetup"];
+ const aliasEntries: string[] = ["...jestCompatAliases()"];
+ const transformPkgs: string[] = [];
+ let needsUrlImport = false;
+
+ const presetPkgs = new Set(Object.keys(AUTO_DETECT_PRESETS));
+
+ if (config) {
+ const handled = new Set();
+ const take = (key: string): T | undefined => {
+ handled.add(key);
+ return config[key] as T | undefined;
+ };
+
+ // preset
+ const preset = take("preset");
+ if (preset === "react-native" || preset === "@react-native/jest-preset") {
+ automatic.push(`preset: '${preset}' → replaced by the reactNative() plugin.`);
+ } else if (preset === "jest-expo") {
+ attention.push(
+ `preset: 'jest-expo' → the reactNative() plugin + auto-detected expo preset replace most of it, ` +
+ `but suites importing Expo CORE internals can hit known limits (see the migration guide's Expo notes).`,
+ );
+ } else if (preset) {
+ attention.push(`preset: '${preset}' — unknown preset; review what it configured.`);
+ }
+
+ // setup files
+ for (const key of ["setupFiles", "setupFilesAfterEnv"]) {
+ const files = take(key);
+ if (!files?.length) continue;
+ for (const f of files) {
+ if (/react-native\/jest\/setup|jest-expo/.test(f)) {
+ presetCovered.push(`${key}: '${f}' — the plugin injects its own setup; delete.`);
+ } else {
+ setupFiles.push(JSON.stringify(f));
+ automatic.push(
+ `${key}: '${f}' → test.setupFiles (its jest.* calls run under the jest-compat shim).`,
+ );
+ }
+ }
+ }
+
+ // moduleNameMapper
+ const mapper = take>("moduleNameMapper");
+ if (mapper) {
+ for (const [pattern, target] of Object.entries(mapper)) {
+ // An escaped-dot + asset-extension pattern is Jest's classic asset
+ // mapper (`\.(png|jpg|...)$`) in any grouping variant.
+ if (
+ /\\\./.test(pattern) &&
+ /\b(png|jpe?g|gif|webp|svg|ttf|otf|woff2?|mp4|mp3)\b/i.test(pattern)
+ ) {
+ presetCovered.push(`moduleNameMapper '${pattern}' — asset stubbing is built in; delete.`);
+ } else if (/^\^?@\/|\^~\/|\^src\//.test(pattern)) {
+ const aliasKey = pattern
+ .replace(/[\^$]/g, "")
+ .replace(/\((\.\*|\.\+)\)\$?$/, "")
+ .replace(/\/$/, "");
+ const aliasTarget = String(target)
+ .replace(/^\/?/, "./")
+ .replace(/\/\$1$/, "")
+ .replace(/\/$/, "");
+ // Residual regex syntax after the strip means this mapper is more
+ // than a plain prefix alias — don't emit something half-right.
+ if (/[(){}?+*\\[\]]/.test(aliasKey) || /[(){}?+*\\[\]$]/.test(aliasTarget)) {
+ attention.push(
+ `moduleNameMapper '${pattern}' → '${String(target)}' — map manually to resolve.alias (regex mappers need rewriting).`,
+ );
+ } else {
+ // Vite resolves string-substituted aliases relative to the IMPORTER,
+ // so filesystem aliases must be emitted as absolute paths.
+ aliasEntries.push(
+ `${JSON.stringify(aliasKey)}: fileURLToPath(new URL(${JSON.stringify(aliasTarget)}, import.meta.url))`,
+ );
+ needsUrlImport = true;
+ automatic.push(`moduleNameMapper '${pattern}' → resolve.alias (absolute path).`);
+ }
+ } else {
+ attention.push(
+ `moduleNameMapper '${pattern}' → '${String(target)}' — map manually to resolve.alias (regex mappers need rewriting).`,
+ );
+ }
+ }
+ }
+
+ // transformIgnorePatterns → transform allowlist
+ const tip = take("transformIgnorePatterns");
+ if (tip?.length) {
+ const results = tip.map(extractAllowlistPackages);
+ const extracted = results.flatMap((r) => r.packages);
+ const unparseable = results.flatMap((r) => r.unparseable);
+ // The raw pattern is always surfaced so a mis-extraction can't hide.
+ automatic.push(`transformIgnorePatterns (raw): ${JSON.stringify(tip)}`);
+ for (const entry of unparseable) {
+ attention.push(
+ `transformIgnorePatterns entry '${entry}' — could not extract package names confidently; ` +
+ `expand it by hand into reactNative({ transform: [...] }) if those packages ship untranspiled source.`,
+ );
+ }
+ if (extracted.length === 0 && unparseable.length === 0) {
+ attention.push(
+ `transformIgnorePatterns: ${JSON.stringify(tip)} — could not extract an allowlist automatically; ` +
+ `packages shipping untranspiled source go in reactNative({ transform: [...] }).`,
+ );
+ } else {
+ for (const pkg of extracted) {
+ if (pkg === "react-native" || pkg.startsWith("@react-native")) {
+ automatic.push(
+ `transformIgnorePatterns allows '${pkg}' — the native engine transforms RN itself; nothing to do.`,
+ );
+ } else if (presetPkgs.has(pkg)) {
+ presetCovered.push(
+ `transformIgnorePatterns allows '${pkg}' — shadowed by the auto-detected preset; nothing to do.`,
+ );
+ } else {
+ transformPkgs.push(pkg);
+ automatic.push(
+ `transformIgnorePatterns allows '${pkg}' → reactNative({ transform: [...] }).`,
+ );
+ }
+ }
+ }
+ }
+
+ // timeouts + environment
+ const timeout = take("testTimeout");
+ if (typeof timeout === "number") {
+ testEntries.push(`testTimeout: ${timeout}`);
+ automatic.push(`testTimeout: ${timeout} → test.testTimeout.`);
+ }
+ const env = take("testEnvironment");
+ if (env && env !== "node") {
+ attention.push(`testEnvironment: '${env}' — vitest-native suites run under 'node'; review.`);
+ } else {
+ handled.add("testEnvironment");
+ }
+ if (take("fakeTimers") !== undefined) {
+ attention.push(
+ `fakeTimers — configure per-suite with vi.useFakeTimers() (the jest-compat shim covers jest.useFakeTimers()).`,
+ );
+ }
+
+ // includes/excludes
+ const testMatch = take("testMatch");
+ if (testMatch?.length) {
+ testEntries.push(
+ `include: [${testMatch.map((g) => JSON.stringify(g.replace("/", ""))).join(", ")}]`,
+ );
+ automatic.push(`testMatch → test.include.`);
+ }
+ const ignore = take("testPathIgnorePatterns");
+ if (ignore?.length) {
+ attention.push(
+ `testPathIgnorePatterns: ${JSON.stringify(ignore)} — move to test.exclude (glob syntax, not regex).`,
+ );
+ }
+
+ // coverage
+ const coverageFrom = take("collectCoverageFrom");
+ if (coverageFrom?.length) {
+ testEntries.push(
+ `coverage: { include: [${coverageFrom
+ .map((g) => JSON.stringify(g.replace(/^\//, "")))
+ .join(", ")}] }`,
+ );
+ automatic.push(`collectCoverageFrom → test.coverage.include (install @vitest/coverage-v8).`);
+ }
+ take("coveragePathIgnorePatterns");
+ take("coverageThreshold");
+ if (config.coveragePathIgnorePatterns || config.coverageThreshold) {
+ attention.push(`coverage settings — map onto test.coverage.{exclude,thresholds}.`);
+ }
+
+ // Known no-ops under vitest-native.
+ for (const key of [
+ "moduleFileExtensions",
+ "maxWorkers",
+ "verbose",
+ "clearMocks",
+ "resetMocks",
+ "restoreMocks",
+ "collectCoverage",
+ "coverageDirectory",
+ "coverageReporters",
+ "cacheDirectory",
+ "haste",
+ "watchPlugins",
+ "transform",
+ "globals",
+ "roots",
+ "testRegex",
+ "snapshotSerializers",
+ "reporters",
+ "modulePathIgnorePatterns",
+ ]) {
+ if (config[key] !== undefined) {
+ handled.add(key);
+ if (key === "transform") {
+ dropped.push(`transform — Babel/esbuild transforms are the plugin's job; dropped.`);
+ } else if (key === "clearMocks" || key === "resetMocks" || key === "restoreMocks") {
+ automatic.push(`${key} → test.${key} (same name in Vitest).`);
+ testEntries.push(`${key}: ${JSON.stringify(config[key])}`);
+ } else if (key === "snapshotSerializers") {
+ attention.push(
+ `snapshotSerializers — Vitest uses expect.addSnapshotSerializer; vitest-native ships one for RN trees.`,
+ );
+ } else {
+ dropped.push(`${key} — no vitest-native equivalent needed; dropped.`);
+ }
+ }
+ }
+
+ for (const key of Object.keys(config)) {
+ if (!handled.has(key)) {
+ attention.push(`'${key}' — unrecognized Jest key; review manually.`);
+ }
+ }
+ }
+
+ // Manual __mocks__ that presets replace.
+ const mocksDir = path.join(root, "__mocks__");
+ if (fs.existsSync(mocksDir)) {
+ for (const entry of fs.readdirSync(mocksDir)) {
+ const name = entry.replace(/\.(js|cjs|mjs|ts|tsx)$/, "");
+ const scoped = fs.statSync(path.join(mocksDir, entry)).isDirectory();
+ const candidates = scoped
+ ? fs
+ .readdirSync(path.join(mocksDir, entry))
+ .map((f) => `${name}/${f.replace(/\.(js|cjs|mjs|ts|tsx)$/, "")}`)
+ : [name];
+ for (const candidate of candidates) {
+ if (presetPkgs.has(candidate)) {
+ const preset = (AUTO_DETECT_PRESETS as Record)[candidate];
+ presetCovered.push(
+ `__mocks__/${candidate} — the ${preset} preset covers this; delete the manual mock.`,
+ );
+ }
+ }
+ }
+ }
+
+ const transformLine = transformPkgs.length
+ ? `reactNative({ transform: [${transformPkgs.map((p) => JSON.stringify(p)).join(", ")}] })`
+ : `reactNative()`;
+ const suggestedConfig = `import { defineConfig } from 'vitest/config'
+import { reactNative } from 'vitest-native'
+import { jestCompatAliases, jestCompatSetup, jestMockTransform } from 'vitest-native/jest-compat'
+${needsUrlImport ? "import { fileURLToPath } from 'node:url'\n" : ""}
+export default defineConfig({
+ plugins: [${transformLine}, jestMockTransform()],
+ resolve: {
+ dedupe: ['react', 'react-test-renderer', 'react-is'],
+ alias: { ${aliasEntries.join(", ")} },
+ },
+ test: {
+ ${testEntries.join(",\n ")},
+ setupFiles: [${setupFiles.join(", ")}],
+ },
+})
+`;
+
+ return {
+ source,
+ automatic,
+ attention,
+ presetCovered,
+ dropped,
+ suggestedConfig,
+ ok: source !== null,
+ };
+}
+
+export function renderMigrationReport(report: MigrationReport): string[] {
+ const lines: string[] = [];
+ if (!report.source) {
+ lines.push(
+ "✗ no Jest configuration found (package.json#jest or jest.config.{js,cjs,json}).",
+ " Starting fresh? Run `vitest-native init` instead.",
+ );
+ return lines;
+ }
+ lines.push(`Analyzed ${report.source}`, "");
+ const section = (title: string, items: string[]) => {
+ if (!items.length) return;
+ lines.push(`${title}:`);
+ for (const i of items) lines.push(` • ${i}`);
+ lines.push("");
+ };
+ section("Mapped automatically", report.automatic);
+ section("Covered by presets — delete", report.presetCovered);
+ section("Needs your attention", report.attention);
+ section("Dropped (no equivalent needed)", report.dropped);
+ lines.push("Suggested vitest.config:", "");
+ lines.push(...report.suggestedConfig.split("\n").map((l) => ` ${l}`));
+ lines.push(
+ "Re-run with --write to save this config, then: npx vitest-native doctor && npx vitest",
+ );
+ return lines;
+}
diff --git a/packages/vitest-native/src/peer-requirements.ts b/packages/vitest-native/src/peer-requirements.ts
new file mode 100644
index 0000000..77325a5
--- /dev/null
+++ b/packages/vitest-native/src/peer-requirements.ts
@@ -0,0 +1,25 @@
+/**
+ * The supported peer-dependency ranges — single source consumed by the plugin's
+ * startup validation (plugin.ts configResolved) and the CLI's doctor command,
+ * so the two can never drift apart.
+ *
+ * `minimumByMajor` entries are security floors: the minimum for that major is
+ * the release carrying a security fix, not just the earliest compatible one.
+ */
+export interface PeerRequirement {
+ name: string;
+ minimum: string;
+ maximumMajor?: number;
+ minimumByMajor?: Record;
+}
+
+export const PEER_REQUIREMENTS: PeerRequirement[] = [
+ { name: "vitest", minimum: "4.0.0", maximumMajor: 5 },
+ {
+ name: "vite",
+ minimum: "6.4.2",
+ maximumMajor: 9,
+ minimumByMajor: { 6: "6.4.2", 7: "7.3.2", 8: "8.0.5" },
+ },
+ { name: "react", minimum: "18.0.0" },
+];
diff --git a/packages/vitest-native/src/plugin.ts b/packages/vitest-native/src/plugin.ts
index f82c8d8..742bf67 100644
--- a/packages/vitest-native/src/plugin.ts
+++ b/packages/vitest-native/src/plugin.ts
@@ -8,6 +8,7 @@ import fs from "node:fs";
import { createRequire } from "node:module";
import flowRemoveTypes from "flow-remove-types";
import { validateOptions, validatePeerDependency, warnUnknownOptions } from "./validate.js";
+import { PEER_REQUIREMENTS } from "./peer-requirements.js";
import { nativeEngineConfig, type JsxTransformConfig } from "./native/apply.js";
import { detectEngine } from "./native/detect.js";
@@ -546,19 +547,9 @@ export function reactNative(options?: VitestNativeOptions): Plugin {
},
async configResolved(config) {
- // Validate peer dependencies
- const peers = [
- { name: "vitest", minimum: "4.0.0", maximumMajor: 5 },
- {
- name: "vite",
- minimum: "6.4.2",
- maximumMajor: 9,
- minimumByMajor: { 6: "6.4.2", 7: "7.3.2", 8: "8.0.5" },
- },
- { name: "react", minimum: "18.0.0" },
- ];
+ // Validate peer dependencies (table shared with the CLI's doctor command).
const peerErrors: string[] = [];
- for (const { name, minimum, maximumMajor, minimumByMajor } of peers) {
+ for (const { name, minimum, maximumMajor, minimumByMajor } of PEER_REQUIREMENTS) {
const error = validatePeerDependency(
name,
minimum,
diff --git a/packages/vitest-native/tests/cli.test.ts b/packages/vitest-native/tests/cli.test.ts
new file mode 100644
index 0000000..72d55af
--- /dev/null
+++ b/packages/vitest-native/tests/cli.test.ts
@@ -0,0 +1,219 @@
+/**
+ * The vitest-native CLI: init / doctor / migrate against fixture projects.
+ * Tests import main() and the per-command functions directly (the bin guard
+ * only fires when argv[1] is the CLI itself).
+ */
+import { describe, it, expect } from "vitest";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { main } from "../src/cli/index.js";
+import { runInit, renderInitConfig } from "../src/cli/init.js";
+import { runDoctor } from "../src/cli/doctor.js";
+import {
+ analyzeJestConfig,
+ extractAllowlistPackages,
+ renderMigrationReport,
+} from "../src/cli/migrate.js";
+
+function fixture(files: Record): string {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "vn-cli-"));
+ for (const [rel, content] of Object.entries(files)) {
+ const p = path.join(root, rel);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, typeof content === "string" ? content : JSON.stringify(content, null, 2));
+ }
+ return root;
+}
+
+function capture(): { log: (l: string) => void; text: () => string } {
+ const lines: string[] = [];
+ return { log: (l) => lines.push(l), text: () => lines.join("\n") };
+}
+
+describe("cli dispatch", () => {
+ it("prints usage and exits non-zero with no command", () => {
+ const io = capture();
+ expect(main([], io.log)).toBe(1);
+ expect(io.text()).toContain("Usage:");
+ });
+
+ it("rejects an unknown command", () => {
+ const root = fixture({ "package.json": { name: "x" } });
+ const io = capture();
+ expect(main(["frobnicate", "--root", root], io.log)).toBe(1);
+ expect(io.text()).toContain("unknown command");
+ });
+
+ it("accepts --root before the command and exits 0 on explicit --help", () => {
+ const root = fixture({ "package.json": { name: "x" } });
+ const io = capture();
+ // The --root VALUE must not be mistaken for the command.
+ expect(main(["--root", root, "migrate"], io.log)).toBe(1); // no jest config → 1, but dispatched
+ expect(io.text()).toContain("no Jest configuration found");
+ expect(main(["--help"], capture().log)).toBe(0);
+ });
+
+ it("refuses to run outside a package root", () => {
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), "vn-cli-empty-"));
+ const io = capture();
+ expect(main(["doctor", "--root", empty], io.log)).toBe(1);
+ expect(io.text()).toContain("no package.json");
+ });
+});
+
+describe("init", () => {
+ it("writes a TS config when tsconfig.json exists", () => {
+ const root = fixture({ "package.json": { name: "x" }, "tsconfig.json": {} });
+ const result = runInit(root);
+ expect(result.ok).toBe(true);
+ expect(result.wrote).toBe("vitest.config.mts");
+ const written = fs.readFileSync(path.join(root, "vitest.config.mts"), "utf8");
+ expect(written).toContain("reactNative()");
+ expect(written).not.toContain("jestMockTransform");
+ });
+
+ it("writes the jest-compat variant matching the migration guide's shape", () => {
+ const root = fixture({ "package.json": { name: "x" } });
+ const result = runInit(root, { jestCompat: true });
+ expect(result.wrote).toBe("vitest.config.mjs");
+ const written = fs.readFileSync(path.join(root, "vitest.config.mjs"), "utf8");
+ expect(written).toContain("jestMockTransform()");
+ expect(written).toContain("setupFiles: [jestCompatSetup]");
+ expect(written).toContain("globals: true");
+ // jestMockTransform must come after reactNative (normal plugin order).
+ expect(written).toContain("plugins: [reactNative(), jestMockTransform()]");
+ });
+
+ it("refuses to overwrite an existing config without --force", () => {
+ const root = fixture({
+ "package.json": { name: "x" },
+ "vitest.config.ts": "export default {}",
+ });
+ expect(runInit(root).ok).toBe(false);
+ expect(runInit(root, { force: true }).ok).toBe(true);
+ });
+
+ it("renderInitConfig variants are syntactically importable shapes", () => {
+ for (const variant of [renderInitConfig(false), renderInitConfig(true)]) {
+ expect(variant).toContain("export default defineConfig({");
+ expect(variant).toContain("from 'vitest-native'");
+ }
+ });
+});
+
+describe("doctor", () => {
+ it("fails on missing peers in an empty project", () => {
+ const root = fixture({ "package.json": { name: "x" } });
+ const result = runDoctor(root);
+ expect(result.ok).toBe(false);
+ expect(result.lines.join("\n")).toContain("vitest");
+ });
+
+ it("flags RNTL 14 on a Node below 22.13", () => {
+ const root = fixture({
+ "package.json": { name: "x" },
+ "node_modules/@testing-library/react-native/package.json": {
+ name: "@testing-library/react-native",
+ version: "14.0.0",
+ main: "index.js",
+ },
+ "node_modules/@testing-library/react-native/index.js": "module.exports = {};",
+ });
+ const result = runDoctor(root, "22.12.0");
+ expect(result.lines.join("\n")).toContain("requires Node >= 22.13");
+ expect(result.ok).toBe(false);
+ });
+
+ it("passes cleanly against this package's own environment", () => {
+ // fileURLToPath, not URL.pathname: the latter yields "/C:/…" on Windows.
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
+ const pkgRoot = path.resolve(HERE, "..");
+ const result = runDoctor(pkgRoot);
+ expect(result.ok).toBe(true);
+ expect(result.lines.join("\n")).toContain("resolves to NATIVE");
+ });
+});
+
+describe("migrate", () => {
+ it("extracts packages from the classic transformIgnorePatterns allowlist", () => {
+ expect(
+ extractAllowlistPackages("node_modules/(?!(?:react-native|@react-native|moti|uniwind)/)"),
+ ).toEqual({
+ packages: ["react-native", "@react-native", "moti", "uniwind"],
+ unparseable: [],
+ });
+ expect(extractAllowlistPackages("node_modules")).toEqual({ packages: [], unparseable: [] });
+ // Capturing groups would fabricate names if stripped ("jest-react-native") —
+ // they must be surfaced as unparseable instead.
+ expect(extractAllowlistPackages("node_modules/(?!(jest-)?react-native|expo)")).toEqual({
+ packages: ["expo"],
+ unparseable: ["(jest-)?react-native"],
+ });
+ });
+
+ it("produces the full report for a representative jest config", () => {
+ const root = fixture({
+ "package.json": { name: "x" },
+ "jest.config.json": {
+ preset: "react-native",
+ setupFilesAfterEnv: ["./jest.setup.js", "react-native/jest/setup"],
+ moduleNameMapper: {
+ "^@/(.*)$": "/src/$1",
+ "\\.(png|jpg)$": "/__mocks__/fileMock.js",
+ },
+ transformIgnorePatterns: [
+ "node_modules/(?!(?:react-native|react-native-reanimated|moti)/)",
+ ],
+ testTimeout: 15000,
+ moduleFileExtensions: ["ts", "js"],
+ someCustomKey: 1,
+ },
+ "__mocks__/react-native-gesture-handler.js": "module.exports = {};",
+ });
+ const report = analyzeJestConfig(root);
+ expect(report.ok).toBe(true);
+ expect(report.source).toBe("jest.config.json");
+ const text = renderMigrationReport(report).join("\n");
+ // transform allowlist: moti extracted, RN handled, reanimated preset-covered.
+ expect(report.suggestedConfig).toContain(`transform: ["moti"]`);
+ expect(text).toContain("'react-native-reanimated' — shadowed by the auto-detected preset");
+ // asset mapper recognized as built-in; alias mapped ABSOLUTE (Vite resolves
+ // string-substituted aliases relative to the importer); setup preserved.
+ expect(text).toContain("asset stubbing is built in");
+ expect(report.suggestedConfig).toContain(
+ `"@": fileURLToPath(new URL("./src", import.meta.url))`,
+ );
+ expect(report.suggestedConfig).toContain(`import { fileURLToPath } from 'node:url'`);
+ expect(report.suggestedConfig).toContain(`"./jest.setup.js"`);
+ expect(report.suggestedConfig).toContain("testTimeout: 15000");
+ // manual mock covered by preset; unknown key surfaced.
+ expect(text).toContain("__mocks__/react-native-gesture-handler");
+ expect(text).toContain("'someCustomKey' — unrecognized Jest key");
+ });
+
+ it("reads package.json#jest and reports a missing config honestly", () => {
+ const withEmbedded = fixture({
+ "package.json": { name: "x", jest: { preset: "react-native" } },
+ });
+ expect(analyzeJestConfig(withEmbedded).source).toBe("package.json#jest");
+
+ const without = fixture({ "package.json": { name: "x" } });
+ const report = analyzeJestConfig(without);
+ expect(report.ok).toBe(false);
+ expect(renderMigrationReport(report).join("\n")).toContain("no Jest configuration found");
+ });
+
+ it("--write via main() saves the suggested config", () => {
+ const root = fixture({
+ "package.json": { name: "x" },
+ "jest.config.json": { preset: "react-native", testTimeout: 9000 },
+ });
+ const io = capture();
+ expect(main(["migrate", "--write", "--root", root], io.log)).toBe(0);
+ const written = fs.readFileSync(path.join(root, "vitest.config.mjs"), "utf8");
+ expect(written).toContain("testTimeout: 9000");
+ expect(written).toContain("jestMockTransform()");
+ });
+});
diff --git a/packages/vitest-native/tsdown.config.ts b/packages/vitest-native/tsdown.config.ts
index eeddaf4..be13f17 100644
--- a/packages/vitest-native/tsdown.config.ts
+++ b/packages/vitest-native/tsdown.config.ts
@@ -11,6 +11,7 @@ export default defineConfig({
presets: 'src/presets/index.ts',
matchers: 'src/matchers/animated.ts',
'jest-compat': 'src/jest-compat/index.ts',
+ cli: 'src/cli/index.ts',
},
format: ['esm', 'cjs'],
dts: true,
diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts
index 936af98..4e002f1 100644
--- a/website/.vitepress/config.ts
+++ b/website/.vitepress/config.ts
@@ -66,6 +66,7 @@ export default defineConfig({
{ text: 'Plugin Options', link: '/guide/plugin-options' },
{ text: 'Third-Party Presets', link: '/guide/presets' },
{ text: 'Test Helpers', link: '/guide/helpers' },
+ { text: 'CLI', link: '/guide/cli' },
],
},
{
diff --git a/website/guide/cli.md b/website/guide/cli.md
new file mode 100644
index 0000000..d8ee04b
--- /dev/null
+++ b/website/guide/cli.md
@@ -0,0 +1,66 @@
+# CLI
+
+vitest-native ships a small CLI for the three moments where a config file and
+docs page aren't enough: starting out, checking an environment, and migrating
+from Jest.
+
+```bash
+npx vitest-native init # write a ready-to-run vitest config
+npx vitest-native doctor # diagnose peers, engine, presets, RNTL/Node
+npx vitest-native migrate # analyze your jest config → migration report
+```
+
+All commands accept `--root ` (default: the current directory).
+
+## `init`
+
+Writes `vitest.config.mts` (or `.mjs` in JS-only projects) with the
+zero-config plugin. Refuses to overwrite an existing config unless `--force`.
+
+```bash
+npx vitest-native init # plain config
+npx vitest-native init --jest-compat # + jest-compat layer, for migrated suites
+```
+
+The `--jest-compat` variant is byte-for-byte the config block the
+[Jest migration guide](/migration/from-jest) documents: `jestMockTransform()`
+after `reactNative()`, `globals: true`, the `@jest/globals` alias, and the
+jest-compat setup file.
+
+## `doctor`
+
+Read-only environment diagnosis:
+
+- Node version against the floor (and the RNTL 14 ⇄ Node 22.13 interaction —
+ the one incompatibility that otherwise surfaces as a raw runtime failure).
+- Required peers (vitest, vite, react) against the supported ranges, including
+ the per-major Vite security floors.
+- Which engine `auto` resolves to for this project, and why.
+- Every auto-detected preset (installed package → preset).
+- Expo presence, with a pointer to the known Expo-core limits.
+- Whether a vitest config exists and uses vitest-native.
+
+Exits non-zero when it finds a blocking problem, so it can gate CI setup jobs.
+
+## `migrate`
+
+Reads your Jest configuration (`package.json#jest` or
+`jest.config.{js,cjs,json}`) and reports, key by key:
+
+- **Mapped automatically** — keys the suggested config absorbs
+ (`setupFilesAfterEnv` → `setupFiles`, path aliases → `resolve.alias`,
+ `transformIgnorePatterns` allowlists → `reactNative({ transform: [...] })`,
+ `testTimeout`, `clearMocks`, …).
+- **Covered by presets — delete** — manual `__mocks__/` files and setup lines
+ that the auto-detected presets replace.
+- **Needs your attention** — regex module mappers, fake-timer config, unknown
+ keys: things a human should decide.
+- **Dropped** — Jest keys with no vitest-native equivalent needed.
+
+It ends with a complete suggested config. Dry-run by default; `--write` saves
+it (guarded like `init`). It never edits test files — top-level `jest.mock`
+calls are handled at runtime by
+[`jestMockTransform()`](/guide/jest-compat).
+
+`jest.config.ts` / `.mjs` and function-form configs can't be loaded
+automatically; the report says so instead of guessing.