From 2cd37ee1ac5d38fa43022b2015925fa3ba1e54ea Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Sun, 5 Jul 2026 21:17:56 +0100 Subject: [PATCH 1/3] feat(cli): npx vitest-native init | doctor | migrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs previously asked migrating users to be the codemod: the jest-compat config block was copy-pasted across four doc pages, environment problems (missing babel preset, RNTL 14 on Node < 22.13, absent peers) surfaced as raw runtime failures, and the project's own migration-friction audit found ~70% of Jest-migration friction to be mechanically mappable configuration. - init: writes a ready-to-run config; --jest-compat emits the migration guide's canonical block (single source of shape); refuses to overwrite without --force; .mts for TS projects, .mjs otherwise. - doctor: read-only diagnosis — Node floor, RNTL 14 ⇄ Node 22.13, required peers against supported ranges (per-major Vite floors included), the engine 'auto' resolution and its reason, every auto-detected preset, Expo presence with known-limits pointer, config presence. Non-zero exit on blocking problems so it can gate CI setup. - migrate: analyzes package.json#jest / jest.config.{js,cjs,json} and reports key-by-key: mapped automatically (setup files, path aliases, transformIgnorePatterns allowlist extraction → transform: [...], timeouts, clear/reset/restoreMocks), covered-by-presets (deletable __mocks__ and setup lines), needs-attention (regex mappers, fake timers, unknown keys), and dropped keys — ending with a complete suggested config. Dry-run by default; --write saves it. ts/mjs/function configs are reported as unloadable rather than guessed at. Implementation reuses the plugin's own building blocks (detectEngine, validatePeerDependency, AUTO_DETECT_PRESETS) and is vitest-free at module load (runs via npx). Ships as dist/cli.mjs via the package bin; the packed-tarball consumer suite runs doctor and migrate end-to-end. --- .changeset/migration-cli.md | 11 + packages/vitest-native/package.json | 3 + .../vitest-native/scripts/test-consumers.mjs | 12 + packages/vitest-native/src/cli/doctor.ts | 171 ++++++++ packages/vitest-native/src/cli/index.ts | 81 ++++ packages/vitest-native/src/cli/init.ts | 108 +++++ packages/vitest-native/src/cli/migrate.ts | 370 ++++++++++++++++++ packages/vitest-native/tests/cli.test.ts | 195 +++++++++ packages/vitest-native/tsdown.config.ts | 1 + website/.vitepress/config.ts | 1 + website/guide/cli.md | 66 ++++ 11 files changed, 1019 insertions(+) create mode 100644 .changeset/migration-cli.md create mode 100644 packages/vitest-native/src/cli/doctor.ts create mode 100644 packages/vitest-native/src/cli/index.ts create mode 100644 packages/vitest-native/src/cli/init.ts create mode 100644 packages/vitest-native/src/cli/migrate.ts create mode 100644 packages/vitest-native/tests/cli.test.ts create mode 100644 website/guide/cli.md 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..7c2a674 --- /dev/null +++ b/packages/vitest-native/src/cli/doctor.ts @@ -0,0 +1,171 @@ +/** + * `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"; + +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"); + const peers: Array<{ + name: string; + minimum: string; + maximumMajor?: number; + minimumByMajor?: Record; + }> = [ + { 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" }, + ]; + for (const { name, minimum, maximumMajor, minimumByMajor } of peers) { + 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..2bf0329 --- /dev/null +++ b/packages/vitest-native/src/cli/index.ts @@ -0,0 +1,81 @@ +#!/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] 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 !== "--"); + const command = args.find((a) => !a.startsWith("-")); + const has = (flag: string) => args.includes(flag); + const rootIdx = args.indexOf("--root"); + const root = path.resolve(rootIdx !== -1 ? (args[rootIdx + 1] ?? ".") : "."); + + if (!command || has("--help") || has("-h")) { + log(USAGE); + return command ? 0 : 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..854e94e --- /dev/null +++ b/packages/vitest-native/src/cli/migrate.ts @@ -0,0 +1,370 @@ +/** + * `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 alongside the extraction so nothing is + * silently lost on exotic regexes. + */ +export function extractAllowlistPackages(pattern: string): string[] { + const m = /\(\?!([^)]*(?:\([^)]*\)[^)]*)*)\)/.exec(pattern); + if (!m) return []; + return m[1] + .split("|") + .map((entry) => + entry + .replace(/\(\?:/g, "") + .replace(/[()?*^$]/g, "") + .replace(/\\\//g, "/") + .replace(/\/\.$/, "") + .replace(/\/$/, "") + .replace(/\.$/, "") + .trim(), + ) + .filter((entry) => entry.length > 0 && !entry.includes("\\")); +} + +interface JestConfig { + [key: string]: unknown; +} + +function loadJestConfig(root: string): { source: string | null; config: JestConfig | null } { + const req = createRequire(path.join(root, "package.json")); + // package.json#jest + 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 — fall through to config files + } + 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 }; + } + 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[] = []; + + 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", "setupFilesAfterEach", "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 alias = pattern.replace(/[\^$]/g, "").replace(/\(\.\*\)\$?/, ""); + aliasEntries.push( + `${JSON.stringify(alias.replace(/\/$/, ""))}: ${JSON.stringify( + String(target) + .replace("", ".") + .replace(/\/\$1$/, ""), + )}`, + ); + automatic.push(`moduleNameMapper '${pattern}' → resolve.alias.`); + } 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 extracted = tip.flatMap(extractAllowlistPackages); + if (extracted.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) { + 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' + +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/tests/cli.test.ts b/packages/vitest-native/tests/cli.test.ts new file mode 100644 index 0000000..4f141d1 --- /dev/null +++ b/packages/vitest-native/tests/cli.test.ts @@ -0,0 +1,195 @@ +/** + * 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 { 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("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.text ?? 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", () => { + const HERE = path.dirname(new URL(import.meta.url).pathname); + 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(["react-native", "@react-native", "moti", "uniwind"]); + expect(extractAllowlistPackages("node_modules")).toEqual([]); + }); + + 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; setup preserved. + expect(text).toContain("asset stubbing is built in"); + expect(report.suggestedConfig).toContain(`"@": "./src"`); + 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. From 24a9d0810fb7507d40283fa9c8ce0fcc6cb1eb01 Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Sun, 5 Jul 2026 21:30:52 +0100 Subject: [PATCH 2/3] review: absolute alias emission, Windows-safe test paths, dispatch and extraction fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-PR review findings, two of them blockers: - migrate's alias emission produced relative Vite aliases ('@' → './src'), which Vite resolves relative to the IMPORTER — broken for any test not at the project root. Aliases now emit absolute paths via fileURLToPath(new URL(..., import.meta.url)) with the import added to the suggested config; mappers that still carry regex syntax after the strip go to needs-attention instead of emitting something half-right. - A test derived a filesystem path from URL.pathname, which yields /C:/… on Windows and would have failed the Windows CI leg; fileURLToPath now. - transformIgnorePatterns extraction: entries with capturing groups ((jest-)?react-native) previously stripped into fabricated package names; they are now surfaced as needs-attention, the raw pattern is always echoed in the report, and the lookahead-body regex no longer closes early on a leading nested group. - Jest config precedence now matches Jest (config file wins over package.json#jest); collectCoverageFrom is actually emitted as test.coverage.include; --root before the command dispatches correctly; explicit --help exits 0; the dead setupFilesAfterEach branch is gone. - The peer-requirements table is extracted to src/peer-requirements.ts and shared by plugin startup validation and doctor, so they cannot drift. --- packages/vitest-native/src/cli/doctor.ts | 18 +-- packages/vitest-native/src/cli/index.ts | 13 +- packages/vitest-native/src/cli/migrate.ts | 128 ++++++++++++------ .../vitest-native/src/peer-requirements.ts | 25 ++++ packages/vitest-native/src/plugin.ts | 15 +- packages/vitest-native/tests/cli.test.ts | 36 ++++- 6 files changed, 157 insertions(+), 78 deletions(-) create mode 100644 packages/vitest-native/src/peer-requirements.ts diff --git a/packages/vitest-native/src/cli/doctor.ts b/packages/vitest-native/src/cli/doctor.ts index 7c2a674..33ee075 100644 --- a/packages/vitest-native/src/cli/doctor.ts +++ b/packages/vitest-native/src/cli/doctor.ts @@ -9,6 +9,7 @@ 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[]; @@ -58,22 +59,7 @@ export function runDoctor(root: string, nodeVersion: string = process.versions.n // --- Required peers --- lines.push("", "Peer dependencies"); - const peers: Array<{ - name: string; - minimum: string; - maximumMajor?: number; - minimumByMajor?: Record; - }> = [ - { 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" }, - ]; - for (const { name, minimum, maximumMajor, minimumByMajor } of peers) { + 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)}`); diff --git a/packages/vitest-native/src/cli/index.ts b/packages/vitest-native/src/cli/index.ts index 2bf0329..9a0c8d3 100644 --- a/packages/vitest-native/src/cli/index.ts +++ b/packages/vitest-native/src/cli/index.ts @@ -16,7 +16,7 @@ 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] analyze jest config → migration report + vitest-native migrate [--write] [--force] analyze jest config → migration report (--write also saves the suggested config) Options: --root project root (default: cwd) @@ -26,14 +26,19 @@ 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 !== "--"); - const command = args.find((a) => !a.startsWith("-")); + // 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 (!command || has("--help") || has("-h")) { + if (has("--help") || has("-h")) { log(USAGE); - return command ? 0 : 1; + 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.`); diff --git a/packages/vitest-native/src/cli/migrate.ts b/packages/vitest-native/src/cli/migrate.ts index 854e94e..7107989 100644 --- a/packages/vitest-native/src/cli/migrate.ts +++ b/packages/vitest-native/src/cli/migrate.ts @@ -29,25 +29,43 @@ export interface MigrationReport { /** * Extract package names from Jest's classic transformIgnorePatterns allowlist * (`node_modules/(?!(?:pkg1|@scope/pkg2|...)/)`). Best-effort by design: the - * raw pattern is always surfaced alongside the extraction so nothing is - * silently lost on exotic regexes. + * 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): string[] { - const m = /\(\?!([^)]*(?:\([^)]*\)[^)]*)*)\)/.exec(pattern); - if (!m) return []; - return m[1] - .split("|") - .map((entry) => - entry - .replace(/\(\?:/g, "") - .replace(/[()?*^$]/g, "") - .replace(/\\\//g, "/") - .replace(/\/\.$/, "") - .replace(/\/$/, "") - .replace(/\.$/, "") - .trim(), - ) - .filter((entry) => entry.length > 0 && !entry.includes("\\")); +export function extractAllowlistPackages(pattern: string): { + packages: string[]; + unparseable: string[]; +} { + // The lookahead body is a sequence of non-paren runs and complete (one-level) + // paren groups — this shape can't close 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 { @@ -56,15 +74,7 @@ interface JestConfig { function loadJestConfig(root: string): { source: string | null; config: JestConfig | null } { const req = createRequire(path.join(root, "package.json")); - // package.json#jest - 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 — fall through to config files - } + // 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; @@ -86,6 +96,14 @@ function loadJestConfig(root: string): { source: string | null; config: JestConf 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 }; } @@ -101,6 +119,7 @@ export function analyzeJestConfig(root: string): MigrationReport { const setupFiles: string[] = ["jestCompatSetup"]; const aliasEntries: string[] = ["...jestCompatAliases()"]; const transformPkgs: string[] = []; + let needsUrlImport = false; const presetPkgs = new Set(Object.keys(AUTO_DETECT_PRESETS)); @@ -125,7 +144,7 @@ export function analyzeJestConfig(root: string): MigrationReport { } // setup files - for (const key of ["setupFiles", "setupFilesAfterEach", "setupFilesAfterEnv"]) { + for (const key of ["setupFiles", "setupFilesAfterEnv"]) { const files = take(key); if (!files?.length) continue; for (const f of files) { @@ -152,15 +171,29 @@ export function analyzeJestConfig(root: string): MigrationReport { ) { presetCovered.push(`moduleNameMapper '${pattern}' — asset stubbing is built in; delete.`); } else if (/^\^?@\/|\^~\/|\^src\//.test(pattern)) { - const alias = pattern.replace(/[\^$]/g, "").replace(/\(\.\*\)\$?/, ""); - aliasEntries.push( - `${JSON.stringify(alias.replace(/\/$/, ""))}: ${JSON.stringify( - String(target) - .replace("", ".") - .replace(/\/\$1$/, ""), - )}`, - ); - automatic.push(`moduleNameMapper '${pattern}' → resolve.alias.`); + 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).`, @@ -172,8 +205,18 @@ export function analyzeJestConfig(root: string): MigrationReport { // transformIgnorePatterns → transform allowlist const tip = take("transformIgnorePatterns"); if (tip?.length) { - const extracted = tip.flatMap(extractAllowlistPackages); - if (extracted.length === 0) { + 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: [...] }).`, @@ -234,6 +277,11 @@ export function analyzeJestConfig(root: string): MigrationReport { // 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"); @@ -316,7 +364,7 @@ export function analyzeJestConfig(root: string): MigrationReport { 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: { 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 index 4f141d1..72d55af 100644 --- a/packages/vitest-native/tests/cli.test.ts +++ b/packages/vitest-native/tests/cli.test.ts @@ -7,6 +7,7 @@ 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"; @@ -45,6 +46,15 @@ describe("cli dispatch", () => { 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(); @@ -98,7 +108,7 @@ describe("doctor", () => { const root = fixture({ "package.json": { name: "x" } }); const result = runDoctor(root); expect(result.ok).toBe(false); - expect(result.text ?? result.lines.join("\n")).toContain("vitest"); + expect(result.lines.join("\n")).toContain("vitest"); }); it("flags RNTL 14 on a Node below 22.13", () => { @@ -117,7 +127,8 @@ describe("doctor", () => { }); it("passes cleanly against this package's own environment", () => { - const HERE = path.dirname(new URL(import.meta.url).pathname); + // 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); @@ -129,8 +140,17 @@ describe("migrate", () => { it("extracts packages from the classic transformIgnorePatterns allowlist", () => { expect( extractAllowlistPackages("node_modules/(?!(?:react-native|@react-native|moti|uniwind)/)"), - ).toEqual(["react-native", "@react-native", "moti", "uniwind"]); - expect(extractAllowlistPackages("node_modules")).toEqual([]); + ).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", () => { @@ -159,9 +179,13 @@ describe("migrate", () => { // 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; setup preserved. + // 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(`"@": "./src"`); + 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. From a50108f35d5611a798b827750aae7ec6bd9e8df1 Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Sun, 5 Jul 2026 21:43:47 +0100 Subject: [PATCH 3/3] security: linear-time allowlist extraction regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL (js/redos): the lookahead-body regex used [^()]+ inside a star — the classic nested-quantifier shape with exponential backtracking on unclosed input like '(?!aaaa…'. Single-character alternatives keep the repetition unambiguous and linear while preserving the no-early-close property for leading nested groups. Verified: 50k-char unclosed input resolves in milliseconds. --- packages/vitest-native/src/cli/migrate.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/vitest-native/src/cli/migrate.ts b/packages/vitest-native/src/cli/migrate.ts index 7107989..3262173 100644 --- a/packages/vitest-native/src/cli/migrate.ts +++ b/packages/vitest-native/src/cli/migrate.ts @@ -38,10 +38,12 @@ export function extractAllowlistPackages(pattern: string): { packages: string[]; unparseable: string[]; } { - // The lookahead body is a sequence of non-paren runs and complete (one-level) - // paren groups — this shape can't close early on a LEADING nested group the - // way a greedy [^)]* would. - const m = /\(\?!((?:[^()]+|\([^()]*\))*)\)/.exec(pattern); + // 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[] = [];