Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/migration-cli.md
Original file line number Diff line number Diff line change
@@ -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`).
3 changes: 3 additions & 0 deletions packages/vitest-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,5 +204,8 @@
"dependencies": {
"flow-remove-types": "^2.318.0",
"magic-string": "^0.30.21"
},
"bin": {
"vitest-native": "./dist/cli.mjs"
}
}
12 changes: 12 additions & 0 deletions packages/vitest-native/scripts/test-consumers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
157 changes: 157 additions & 0 deletions packages/vitest-native/src/cli/doctor.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
86 changes: 86 additions & 0 deletions packages/vitest-native/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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 <dir> 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)));
}
108 changes: 108 additions & 0 deletions packages/vitest-native/src/cli/init.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading