diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh new file mode 100755 index 0000000000..0a0c607f5c --- /dev/null +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: .github/scripts/test-pkg-pr-new-migrate.sh [migrate-options...] + +Examples: + .github/scripts/test-pkg-pr-new-migrate.sh 1891 /path/to/npmx.dev + .github/scripts/test-pkg-pr-new-migrate.sh 4eb2104c /path/to/project --no-interactive + +Environment variables: + VP_PKG_PR_NEW_HOME Override the isolated global CLI installation directory. + ALLOW_DIRTY=1 Allow migration in a dirty Git worktree. +EOF +} + +if [ "$#" -lt 2 ]; then + usage >&2 + exit 2 +fi + +pr_ref="$1" +project_input="$2" +shift 2 + +case "$pr_ref" in + '' | *[![:alnum:]._-]*) + echo "error: PR or SHA contains unsupported characters: $pr_ref" >&2 + exit 2 + ;; +esac + +if [ ! -d "$project_input" ]; then + echo "error: project directory does not exist: $project_input" >&2 + exit 2 +fi + +project_dir="$(cd "$project_input" && pwd -P)" +if [ ! -f "$project_dir/package.json" ]; then + echo "error: package.json not found in project: $project_dir" >&2 + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "$script_dir/../.." && pwd -P)" +installer="$repo_root/packages/cli/install.sh" + +if [ ! -f "$installer" ]; then + echo "error: Vite+ installer not found: $installer" >&2 + exit 2 +fi + +is_git_repo=0 +if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + is_git_repo=1 + if [ "${ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git -C "$project_dir" status --porcelain)" ]; then + echo "error: project worktree is dirty: $project_dir" >&2 + echo "Commit or stash its changes, or rerun with ALLOW_DIRTY=1." >&2 + exit 2 + fi +fi + +original_home="$HOME" +cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" +pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" +installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" +cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" +vp_bin="$pr_home/bin/vp" +vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" +global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js" +commit_marker="$cached_version_dir/.pkg-pr-new-commit" +pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" +vite_plus_spec="$pkg_pr_new_base@$pr_ref" +vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$pr_ref" + +resolve_pkg_pr_new_commit() { + curl -fsSIL "$vite_plus_spec" | tr -d '\r' | awk -F ': ' ' + tolower($1) == "x-commit-key" { + count = split($2, parts, ":") + print parts[count] + exit + } + ' +} + +read_installed_commit() { + if [ -f "$commit_marker" ]; then + head -n 1 "$commit_marker" + return + fi + + if [ -f "$vite_plus_package_json" ]; then + awk -F '"' ' + $2 == "@voidzero-dev/vite-plus-core" { + value = $4 + sub(/^.*@/, "", value) + print value + exit + } + ' "$vite_plus_package_json" + fi +} + +available_commit="$(resolve_pkg_pr_new_commit || true)" +installed_commit="$(read_installed_commit || true)" +current_target="$(readlink "$pr_home/current" 2>/dev/null || true)" +reuse_install=0 + +if [ -n "$available_commit" ] && + [ "$installed_commit" = "$available_commit" ] && + [ "$current_target" = "pkg-pr-new-$pr_ref" ] && + [ -x "$vp_bin" ] && + [ -f "$vite_plus_package_json" ] && + [ -f "$global_cli_entry" ]; then + reuse_install=1 +fi + +cleanup() { + rm -rf "$installer_home" +} +trap cleanup EXIT + +if [ "$reuse_install" -eq 1 ]; then + printf '%s\n' "$available_commit" > "$commit_marker" + echo "Reusing installed Vite+ pkg.pr.new build $pr_ref ($available_commit) from $pr_home" +else + if [ -z "$available_commit" ]; then + echo "Could not verify the current pkg.pr.new commit; reinstalling $pr_ref." + elif [ -n "$installed_commit" ]; then + echo "pkg.pr.new build changed: $installed_commit -> $available_commit" + fi + + # Numeric pkg.pr.new references are mutable PR aliases. If the published + # commit changed, the reused lockfile can retain the checksum from the older + # tarball and fail with ERR_PNPM_TARBALL_INTEGRITY. Keep the downloaded + # runtime/package-manager cache, but force the wrapper dependency to resolve + # again. Commit SHA references are immutable and use their own cache path. + case "$pr_ref" in + *[!0-9]*) ;; + *) + rm -rf "$cached_version_dir/node_modules" + rm -f "$cached_version_dir/pnpm-lock.yaml" + ;; + esac + + echo "Installing Vite+ pkg.pr.new build $pr_ref into $pr_home" + HOME="$installer_home" \ + VP_HOME="$pr_home" \ + VP_PR_VERSION="$pr_ref" \ + VP_NODE_MANAGER=no \ + bash "$installer" + + if [ -n "$available_commit" ]; then + printf '%s\n' "$available_commit" > "$commit_marker" + fi +fi + +if [ ! -x "$vp_bin" ]; then + echo "error: installed vp executable not found: $vp_bin" >&2 + exit 1 +fi + +if [ ! -f "$vite_plus_package_json" ]; then + echo "error: installed vite-plus package not found: $vite_plus_package_json" >&2 + exit 1 +fi + +if [ ! -f "$global_cli_entry" ]; then + echo "error: installed Vite+ CLI entry not found: $global_cli_entry" >&2 + exit 1 +fi + +vitest_version="$(awk -F '"' '$2 == "vitest" { print $4; exit }' "$vite_plus_package_json")" +if [ -z "$vitest_version" ]; then + echo "error: could not determine the bundled Vitest version from $vite_plus_package_json" >&2 + exit 1 +fi + +export VP_HOME="$pr_home" +export PATH="$VP_HOME/bin:$PATH" +export VP_VERSION="$vite_plus_spec" +export VP_OVERRIDE_PACKAGES="$(printf \ + '{"vite":"%s","vitest":"%s"}' \ + "$vite_plus_core_spec" \ + "$vitest_version")" +export VP_FORCE_MIGRATE=1 +# pkg.pr.new packages depend on URL-resolved platform binaries. pnpm blocks +# those transitive URL dependencies when blockExoticSubdeps is enabled. The +# migration persists the corresponding workspace setting, while this temporary +# override also lets its pre-rewrite install recover a partially migrated tree. +export PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS=false +hash -r + +echo +echo "Using isolated global CLI:" +echo " executable: $vp_bin" +echo " installation: $(readlink "$pr_home/current" 2>/dev/null || echo unknown)" +echo " vite-plus spec: $VP_VERSION" +echo " vite spec: $vite_plus_core_spec" +"$vp_bin" --version + +echo +echo "Running vp migrate in $project_dir" +set +e +( + # Run the installed JS entry directly so a project-local vite-plus at the + # same semver cannot take precedence. Keep cwd at the project root because + # project config and plugins may resolve dependencies from process.cwd(). + cd "$project_dir" + "$vp_bin" node "$global_cli_entry" migrate "$project_dir" "$@" +) +migrate_status=$? +set -e + +if [ "$is_git_repo" -eq 1 ]; then + echo + echo "Migration worktree changes:" + git -C "$project_dir" status --short + git -C "$project_dir" diff --stat +fi + +exit "$migrate_status" diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e63f1a51f1..3af6cba3fc 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -318,6 +318,7 @@ jobs: # on vi.fn() calls — migration sets rule as "error" in config, --allow can't override vp run lint || true vp run test:types + vp test --project nuxt vp test --project unit - name: vite-plus-jest-dom-repro node-version: 24 diff --git a/crates/vite_global_cli/src/commands/migrate.rs b/crates/vite_global_cli/src/commands/migrate.rs index a458bbfad4..414b1e2e18 100644 --- a/crates/vite_global_cli/src/commands/migrate.rs +++ b/crates/vite_global_cli/src/commands/migrate.rs @@ -4,11 +4,18 @@ use std::process::ExitStatus; use vite_path::AbsolutePathBuf; -use crate::error::Error; +use crate::{error::Error, js_executor::JsExecutor}; /// Execute the `migrate` command by delegating to local or global vite-plus. +/// +/// Routes through [`JsExecutor::delegate_migrate`], which escalates to the +/// global CLI when the project's local `vite-plus` is older than this global +/// `vp` (the upgrade scenario). Otherwise it keeps local-first semantics. pub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result { - super::delegate::execute(cwd, "migrate", args).await + let mut executor = JsExecutor::new(None); + let mut full_args = vec!["migrate".to_string()]; + full_args.extend(args.iter().cloned()); + executor.delegate_migrate(&cwd, &full_args).await } #[cfg(test)] diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 585512d92e..bbf25fa9b6 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -247,6 +247,32 @@ impl JsExecutor { self.run_js_entry_output(project_path, &node_binary, &bin_prefix, args).await } + /// Delegate `migrate`, escalating to the global CLI when the project's local + /// `vite-plus` is older than this global `vp`. A stale local CLI predates the + /// upgrade logic and would otherwise run (and leave the project unmigrated), + /// so the newer global CLI must perform the upgrade; it re-pins `vite-plus`, + /// so the next invocation resolves the upgraded local CLI. When local == global + /// (or local is newer, or none is installed) keep local-first semantics + /// (`delegate_to_local_cli` already falls back to the global bin when no local + /// vite-plus is resolvable). + pub async fn delegate_migrate( + &mut self, + project_path: &AbsolutePath, + args: &[String], + ) -> Result { + let escalate = resolve_local_vite_plus_version(project_path) + .is_some_and(|local| local_vite_plus_is_older(&local, env!("CARGO_PKG_VERSION"))); + if escalate { + tracing::debug!( + "Local vite-plus is older than global vp {}; running migrate from the global CLI", + env!("CARGO_PKG_VERSION") + ); + self.delegate_to_global_cli(project_path, args).await + } else { + self.delegate_to_local_cli(project_path, args).await + } + } + /// Delegate to the global vite-plus CLI entrypoint directly. /// /// Unlike [`delegate_to_local_cli`], this bypasses project-local resolution and always runs @@ -364,6 +390,31 @@ impl JsExecutor { } } +/// Resolve the version of the project-local `vite-plus`, if one is installed. +fn resolve_local_vite_plus_version(project_path: &AbsolutePath) -> Option { + use oxc_resolver::{ResolveOptions, Resolver}; + + let resolver = Resolver::new(ResolveOptions { + condition_names: vec!["import".into(), "node".into()], + ..ResolveOptions::default() + }); + let resolved = resolver.resolve(project_path, "vite-plus/package.json").ok()?; + let content = std::fs::read_to_string(resolved.path()).ok()?; + let value: serde_json::Value = serde_json::from_str(&content).ok()?; + value.get("version")?.as_str().map(str::to_string) +} + +/// True when `local` is a parseable semver strictly older than `global`. +/// +/// Returns false if either version fails to parse (be conservative: never +/// escalate on a version we can't understand). +fn local_vite_plus_is_older(local: &str, global: &str) -> bool { + match (node_semver::Version::parse(local), node_semver::Version::parse(global)) { + (Ok(local_v), Ok(global_v)) => local_v < global_v, + _ => false, + } +} + /// Check whether a project directory has at least one valid version source. /// /// Uses `is_valid_version` (no warning side effects) to avoid duplicate @@ -427,6 +478,18 @@ mod tests { use super::*; + #[test] + fn test_local_vite_plus_is_older() { + // Older local should escalate. + assert!(local_vite_plus_is_older("0.1.24", "0.2.1")); + // Equal versions keep local-first semantics. + assert!(!local_vite_plus_is_older("0.2.1", "0.2.1")); + // Newer local keeps local-first semantics. + assert!(!local_vite_plus_is_older("0.3.0", "0.2.1")); + // Unparsable versions are conservative: never escalate. + assert!(!local_vite_plus_is_older("latest", "0.2.1")); + } + #[test] fn test_js_executor_new() { let executor = JsExecutor::new(None); diff --git a/crates/vite_migration/src/import_rewriter.rs b/crates/vite_migration/src/import_rewriter.rs index d0de5a0840..7b94ae88f7 100644 --- a/crates/vite_migration/src/import_rewriter.rs +++ b/crates/vite_migration/src/import_rewriter.rs @@ -1575,6 +1575,40 @@ static PARSED_VITEST_RULES: LazyLock>> = LazyLock::n ast_grep::load_rules(REWRITE_VITEST_RULES).expect("failed to parse vitest rewrite rules") }); +const BARE_VITEST_RULE_IDS: [&str; 4] = [ + "rewrite-vitest-import", + "rewrite-vitest-export", + "rewrite-vitest-require", + "rewrite-vitest-dynamic-import", +]; + +fn is_bare_vitest_rule(rule: &RuleConfig) -> bool { + BARE_VITEST_RULE_IDS.contains(&rule.id.as_str()) +} + +fn is_unscoped_vitest_rule(rule: &RuleConfig) -> bool { + is_bare_vitest_rule(rule) + || rule.id.starts_with("rewrite-vitest-config-") + || rule.id.starts_with("rewrite-vitest-subpath-") +} + +static PARSED_UNSCOPED_VITEST_RULES: LazyLock>> = LazyLock::new(|| { + ast_grep::load_rules(REWRITE_VITEST_RULES) + .expect("failed to parse vitest rewrite rules") + .into_iter() + .filter(is_unscoped_vitest_rule) + .collect() +}); + +static PARSED_VITEST_RULES_WITHOUT_UNSCOPED: LazyLock>> = + LazyLock::new(|| { + ast_grep::load_rules(REWRITE_VITEST_RULES) + .expect("failed to parse vitest rewrite rules") + .into_iter() + .filter(|rule| !is_unscoped_vitest_rule(rule)) + .collect() + }); + static PARSED_TSDOWN_RULES: LazyLock>> = LazyLock::new(|| { ast_grep::load_rules(REWRITE_TSDOWN_RULES).expect("failed to parse tsdown rewrite rules") }); @@ -1689,7 +1723,11 @@ fn apply_regex_replace(content: &mut String, re: &Regex, replacement: &str) -> b /// to match TypeScript semantics and avoid false positives inside string/template literals. /// Allocates only for preamble lines, leaving the file body untouched. /// Returns whether any changes were made. -fn rewrite_reference_types(content: &mut String, skip_packages: &SkipPackages) -> bool { +fn rewrite_reference_types( + content: &mut String, + skip_packages: &SkipPackages, + preserve_unscoped_vitest: bool, +) -> bool { // Fast path: skip files with no triple-slash reference directives. // Check for "///" which covers all spacing variants (/// bool { @@ -1937,15 +1995,15 @@ fn find_nearest_package_json(file_path: &Path, root: &Path) -> Option { /// Parse package.json and check which packages are in peerDependencies or dependencies. /// Returns default (no skipping) if package.json doesn't exist or can't be parsed. -fn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages { +fn get_package_rewrite_context(package_json_path: &Path) -> PackageRewriteContext { let content = match std::fs::read_to_string(package_json_path) { Ok(c) => c, - Err(_) => return SkipPackages::default(), + Err(_) => return PackageRewriteContext::default(), }; let pkg: serde_json::Value = match serde_json::from_str(&content) { Ok(p) => p, - Err(_) => return SkipPackages::default(), + Err(_) => return PackageRewriteContext::default(), }; // Helper to check if a package exists in a dependencies object @@ -1955,16 +2013,29 @@ fn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages .is_some_and(|deps| deps.contains_key(package_name)) }; - // Check both peerDependencies and dependencies - SkipPackages { - skip_vite: has_package("peerDependencies", "vite") || has_package("dependencies", "vite"), - skip_vitest: has_package("peerDependencies", "vitest") - || has_package("dependencies", "vitest"), - skip_tsdown: has_package("peerDependencies", "tsdown") - || has_package("dependencies", "tsdown"), + // Peer and runtime dependencies preserve the existing whole-package skip + // behavior. Nuxt compatibility is narrower and accepts the three install + // groups where @nuxt/test-utils is normally declared. + PackageRewriteContext { + skip_packages: SkipPackages { + skip_vite: has_package("peerDependencies", "vite") + || has_package("dependencies", "vite"), + skip_vitest: has_package("peerDependencies", "vitest") + || has_package("dependencies", "vitest"), + skip_tsdown: has_package("peerDependencies", "tsdown") + || has_package("dependencies", "tsdown"), + }, + uses_nuxt_test_utils: ["dependencies", "devDependencies", "optionalDependencies"] + .into_iter() + .any(|key| has_package(key, "@nuxt/test-utils")), } } +#[cfg(test)] +fn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages { + get_package_rewrite_context(package_json_path).skip_packages +} + /// Result of rewriting imports in a file #[derive(Debug)] struct RewriteResult { @@ -1972,6 +2043,8 @@ struct RewriteResult { pub content: String, /// Whether any changes were made pub updated: bool, + /// Whether an upstream `vitest` specifier was intentionally preserved. + pub preserved_vitest: bool, } /// Result of rewriting imports in multiple files @@ -1981,6 +2054,8 @@ pub struct BatchRewriteResult { pub modified_files: Vec, /// Files that had no changes pub unchanged_files: Vec, + /// Files in Nuxt test-utils packages where upstream `vitest` imports were preserved. + pub preserved_vitest_files: Vec, /// Files that had errors (path, error message) pub errors: Vec<(PathBuf, String)>, } @@ -2021,47 +2096,60 @@ enum FileResult { /// } /// ``` pub fn rewrite_imports_in_directory(root: &Path) -> Result { + rewrite_imports_in_directory_with_options(root, RewriteImportsOptions::default()) +} + +/// Rewrite imports with package-scoped compatibility options. +pub fn rewrite_imports_in_directory_with_options( + root: &Path, + options: RewriteImportsOptions, +) -> Result { let walk_result = file_walker::find_ts_files(root)?; - // Pre-compute skip_packages for each file (requires mutable cache, done sequentially) - let mut skip_packages_cache: HashMap = HashMap::new(); - let files_with_skip: Vec<(PathBuf, SkipPackages)> = walk_result + // Pre-compute package context for each file (requires mutable cache, done sequentially). + let mut package_context_cache: HashMap = HashMap::new(); + let files_with_context: Vec<(PathBuf, PackageRewriteContext)> = walk_result .files .into_iter() .map(|file_path| { - let skip_packages = + let package_context = if let Some(package_json_path) = find_nearest_package_json(&file_path, root) { - *skip_packages_cache + *package_context_cache .entry(package_json_path.clone()) - .or_insert_with(|| get_skip_packages_from_package_json(&package_json_path)) + .or_insert_with(|| get_package_rewrite_context(&package_json_path)) } else { - SkipPackages::default() + PackageRewriteContext::default() }; - (file_path, skip_packages) + (file_path, package_context) }) .collect(); // Process files in parallel using rayon - let results: Vec<(PathBuf, FileResult)> = files_with_skip + let results: Vec<(PathBuf, FileResult, bool)> = files_with_context .into_par_iter() - .map(|(file_path, skip_packages)| { + .map(|(file_path, package_context)| { + let skip_packages = package_context.skip_packages; if skip_packages.all_skipped() { - return (file_path, FileResult::Unchanged); + return (file_path, FileResult::Unchanged, false); } - match rewrite_import(&file_path, &skip_packages) { + match rewrite_import( + &file_path, + &skip_packages, + options.preserve_vitest_in_nuxt_packages && package_context.uses_nuxt_test_utils, + ) { Ok(rewrite_result) => { if rewrite_result.updated { if let Err(e) = std::fs::write(&file_path, &rewrite_result.content) { - (file_path, FileResult::Error(e.to_string())) + (file_path, FileResult::Error(e.to_string()), false) } else { - (file_path, FileResult::Modified) + (file_path, FileResult::Modified, rewrite_result.preserved_vitest) } } else { - (file_path, FileResult::Unchanged) + (file_path, FileResult::Unchanged, rewrite_result.preserved_vitest) } } - Err(e) => (file_path, FileResult::Error(e.to_string())), + Err(e) => (file_path, FileResult::Error(e.to_string()), false), } }) .collect(); @@ -2070,10 +2158,14 @@ pub fn rewrite_imports_in_directory(root: &Path) -> Result batch_result.modified_files.push(file_path), FileResult::Unchanged => batch_result.unchanged_files.push(file_path), @@ -2100,12 +2192,16 @@ pub fn rewrite_imports_in_directory(root: &Path) -> Result Result { +fn rewrite_import( + file_path: &Path, + skip_packages: &SkipPackages, + preserve_vitest_in_nuxt_package: bool, +) -> Result { // Read the file let content = std::fs::read_to_string(file_path)?; // Rewrite the imports - rewrite_import_content(&content, skip_packages) + rewrite_import_content_with_options(&content, skip_packages, preserve_vitest_in_nuxt_package) } /// Fast pre-filter to skip expensive AST parsing for files with no relevant imports. @@ -2128,17 +2224,31 @@ fn content_may_need_rewriting(content: &str, skip_packages: &SkipPackages) -> bo /// /// This is the internal function that performs the actual rewrite using ast-grep. /// Packages that are in peerDependencies or dependencies will be skipped. +#[cfg(test)] fn rewrite_import_content( content: &str, skip_packages: &SkipPackages, +) -> Result { + rewrite_import_content_with_options(content, skip_packages, false) +} + +fn rewrite_import_content_with_options( + content: &str, + skip_packages: &SkipPackages, + preserve_unscoped_vitest: bool, ) -> Result { // Fast path: skip AST parsing if the file doesn't contain any target strings if !content_may_need_rewriting(content, skip_packages) { - return Ok(RewriteResult { content: content.to_string(), updated: false }); + return Ok(RewriteResult { + content: content.to_string(), + updated: false, + preserved_vitest: false, + }); } let mut new_content = content.to_string(); let mut updated = false; + let mut preserved_vitest = false; // Apply vite rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vite { @@ -2151,7 +2261,15 @@ fn rewrite_import_content( // Apply vitest rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vitest { - let vitest_content = ast_grep::apply_loaded_rules(&new_content, &PARSED_VITEST_RULES); + let vitest_rules = if preserve_unscoped_vitest { + let upstream_rewrite = + ast_grep::apply_loaded_rules(&new_content, &PARSED_UNSCOPED_VITEST_RULES); + preserved_vitest = upstream_rewrite != new_content; + &*PARSED_VITEST_RULES_WITHOUT_UNSCOPED + } else { + &*PARSED_VITEST_RULES + }; + let vitest_content = ast_grep::apply_loaded_rules(&new_content, vitest_rules); if vitest_content != new_content { new_content = vitest_content; updated = true; @@ -2169,9 +2287,9 @@ fn rewrite_import_content( // Apply reference type rewriting (/// ) // These cannot be handled by ast-grep because they are parsed as comments. - updated |= rewrite_reference_types(&mut new_content, skip_packages); + updated |= rewrite_reference_types(&mut new_content, skip_packages, preserve_unscoped_vitest); - Ok(RewriteResult { content: new_content, updated }) + Ok(RewriteResult { content: new_content, updated, preserved_vitest }) } #[cfg(test)] @@ -2301,7 +2419,7 @@ export default defineConfig({{ .unwrap(); // Run the rewrite - let result = rewrite_import(&vite_config_path, &SkipPackages::default()).unwrap(); + let result = rewrite_import(&vite_config_path, &SkipPackages::default(), false).unwrap(); assert!(result.updated); assert_eq!( @@ -2778,6 +2896,85 @@ describe('test', () => {});"#, assert!(!utils_content.contains("vite-plus")); } + #[test] + fn test_preserves_unscoped_vitest_in_nuxt_test_utils_packages() { + use std::fs; + + let temp = tempdir().unwrap(); + fs::write( + temp.path().join("package.json"), + r#"{ + "devDependencies": { + "@nuxt/test-utils": "4.0.3", + "vitest": "4.1.9" + } +}"#, + ) + .unwrap(); + fs::write( + temp.path().join("nuxt.spec.ts"), + r#"import { vi } from 'vitest'; +export { expect } from 'vitest'; +const runtime = require('vitest'); +const dynamic = import('vitest'); +import { defineConfig } from 'vitest/config'; +import { startVitest } from 'vitest/node'; +import { page } from '@vitest/browser/context'; +import { mockNuxtImport } from '@nuxt/test-utils/runtime';"#, + ) + .unwrap(); + fs::write( + temp.path().join("ordinary.spec.ts"), + "/// \nimport { expect } from 'vitest';\n", + ) + .unwrap(); + + let result = rewrite_imports_in_directory_with_options( + temp.path(), + RewriteImportsOptions { preserve_vitest_in_nuxt_packages: true }, + ) + .unwrap(); + + assert_eq!(result.preserved_vitest_files.len(), 2); + assert!(result.preserved_vitest_files.contains(&temp.path().join("nuxt.spec.ts"))); + assert!(result.preserved_vitest_files.contains(&temp.path().join("ordinary.spec.ts"))); + let nuxt = fs::read_to_string(temp.path().join("nuxt.spec.ts")).unwrap(); + assert!(nuxt.contains("from 'vitest'")); + assert!(nuxt.contains("require('vitest')")); + assert!(nuxt.contains("import('vitest')")); + assert!(nuxt.contains("from 'vitest/config'")); + assert!(nuxt.contains("from 'vitest/node'")); + assert!(nuxt.contains("from 'vite-plus/test/browser/context'")); + + let ordinary = fs::read_to_string(temp.path().join("ordinary.spec.ts")).unwrap(); + assert!(ordinary.contains("from 'vitest'")); + assert!(ordinary.contains("types=\"vitest/globals\"")); + } + + #[test] + fn test_nuxt_preservation_requires_declared_test_utils_dependency() { + use std::fs; + + let temp = tempdir().unwrap(); + fs::write(temp.path().join("package.json"), r#"{"devDependencies":{"vitest":"4"}}"#) + .unwrap(); + fs::write( + temp.path().join("nuxt.spec.ts"), + "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", + ) + .unwrap(); + + let result = rewrite_imports_in_directory_with_options( + temp.path(), + RewriteImportsOptions { preserve_vitest_in_nuxt_packages: true }, + ) + .unwrap(); + + assert!(result.preserved_vitest_files.is_empty()); + let content = fs::read_to_string(temp.path().join("nuxt.spec.ts")).unwrap(); + assert!(content.contains("from 'vite-plus/test'")); + } + #[test] fn test_rewrite_imports_in_directory_empty() { let temp = tempdir().unwrap(); diff --git a/crates/vite_migration/src/lib.rs b/crates/vite_migration/src/lib.rs index 78ab12872f..855f23cd9b 100644 --- a/crates/vite_migration/src/lib.rs +++ b/crates/vite_migration/src/lib.rs @@ -16,7 +16,10 @@ mod script_rewrite; mod vite_config; pub use file_walker::{WalkResult, find_ts_files}; -pub use import_rewriter::{BatchRewriteResult, rewrite_imports_in_directory}; +pub use import_rewriter::{ + BatchRewriteResult, RewriteImportsOptions, rewrite_imports_in_directory, + rewrite_imports_in_directory_with_options, +}; pub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts}; pub use vite_config::{ MergeResult, has_config_key, merge_json_config, merge_tsdown_config, upsert_json_config, diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index 052fb48ee0..3a5940a04e 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -94,8 +94,9 @@ "npmx.dev": { "repository": "https://github.com/npmx-dev/npmx.dev.git", "branch": "main", - "hash": "230b7c7ddb6bb8551ce797144f0ce0f047ff8d7d", - "forceFreshMigration": true + "hash": "035776c96cf8f089c44e6011264b534b0bcde53c", + "forceFreshMigration": true, + "playwright": true }, "vite-plus-jest-dom-repro": { "repository": "https://github.com/why-reproductions-are-required/vite-plus-jest-dom-repro.git", diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 50de42f8fd..5d1ad7d870 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3288,6 +3288,8 @@ export interface BatchRewriteError { export interface BatchRewriteResult { /** Files that were modified */ modifiedFiles: Array; + /** Files in Nuxt test-utils packages where upstream `vitest` imports were preserved */ + preservedVitestFiles: Array; /** Files that had errors */ errors: Array; } @@ -3520,6 +3522,8 @@ export declare function rewriteEslint(scriptsJson: string): string | null; * # Arguments * * * `root` - The root directory to search for files + * * `preserve_vitest_in_nuxt_packages` - Preserve `vitest` and `vitest/*` + * specifiers throughout packages that declare `@nuxt/test-utils` * * # Returns * @@ -3537,7 +3541,10 @@ export declare function rewriteEslint(scriptsJson: string): string | null; * } * ``` */ -export declare function rewriteImportsInDirectory(root: string): BatchRewriteResult; +export declare function rewriteImportsInDirectory( + root: string, + preserveVitestInNuxtPackages?: boolean | undefined | null, +): BatchRewriteResult; /** * Rewrite Prettier scripts: rename `prettier` → `vp fmt` and strip Prettier-only flags. diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 059f8607ee..9306f7b79d 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -197,6 +197,8 @@ pub struct BatchRewriteError { pub struct BatchRewriteResult { /// Files that were modified pub modified_files: Vec, + /// Files in Nuxt test-utils packages where upstream `vitest` imports were preserved + pub preserved_vitest_files: Vec, /// Files that had errors pub errors: Vec, } @@ -266,6 +268,8 @@ pub fn wrap_lazy_plugins(vite_config_path: String) -> Result Result Result { - let result = vite_migration::rewrite_imports_in_directory(Path::new(&root)) - .map_err(anyhow::Error::from)?; +pub fn rewrite_imports_in_directory( + root: String, + preserve_vitest_in_nuxt_packages: Option, +) -> Result { + let result = vite_migration::rewrite_imports_in_directory_with_options( + Path::new(&root), + vite_migration::RewriteImportsOptions { + preserve_vitest_in_nuxt_packages: preserve_vitest_in_nuxt_packages.unwrap_or(false), + }, + ) + .map_err(anyhow::Error::from)?; Ok(BatchRewriteResult { modified_files: result @@ -293,6 +305,11 @@ pub fn rewrite_imports_in_directory(root: String) -> Result .iter() .map(|p| p.to_string_lossy().to_string()) .collect(), + preserved_vitest_files: result + .preserved_vitest_files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(), errors: result .errors .iter() diff --git a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt index 3ecc5a9256..6cc0357e2d 100644 --- a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt @@ -26,19 +26,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .vite-hooks/pre-commit # check pre-commit hook vp staged diff --git a/packages/cli/snap-tests-global/migration-agent-claude/snap.txt b/packages/cli/snap-tests-global/migration-agent-claude/snap.txt index 5e0ff8a6ac..13653cb4e3 100644 --- a/packages/cli/snap-tests-global/migration-agent-claude/snap.txt +++ b/packages/cli/snap-tests-global/migration-agent-claude/snap.txt @@ -1,7 +1,7 @@ > vp migrate --agent claude --no-interactive # migration with --agent claude should write CLAUDE.md ◇ Migrated . to Vite+ • Node pnpm -• 2 config updates applied +• 2 config updates applied, 1 file had imports rewritten > cat CLAUDE.md | head -3 # verify CLAUDE.md was created diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt index a497792e14..0d1216e4f3 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt @@ -14,8 +14,8 @@ "prepare": "vp config" }, "devDependencies": { - "vite": "^7.0.0", - "vite-plus": "latest" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt index 81dfa7d245..dacabcc34b 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt @@ -13,8 +13,8 @@ "prepare": "vp config" }, "devDependencies": { - "vite": "^7.0.0", - "vite-plus": "latest" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt index aa0b62ec9d..110719bbfb 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive # legacy wrapper-override project: rewrites the stale vitest wrapper override to bundled vitest and completes the missing @vitest/* family pins, no hooks/agent setup defaults +> vp migrate --no-interactive # common existing project removes the stale wrapper override, no hooks/agent setup defaults ◇ Migrated . to Vite+ • Node npm • Package manager settings configured @@ -12,11 +12,10 @@ { "name": "migration-already-vite-plus", "devDependencies": { - "vite-plus": "latest" + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json b/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json index 85bc820818..5a9a3fbc1a 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json +++ b/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp migrate --no-interactive # legacy wrapper-override project: rewrites the stale vitest wrapper override to bundled vitest and completes the missing @vitest/* family pins, no hooks/agent setup defaults", + "vp migrate --no-interactive # common existing project removes the stale wrapper override, no hooks/agent setup defaults", "vp migrate --no-interactive --hooks --agent agents # explicit setup should still update existing vite-plus project", "cat package.json # prepare script should be configured for vp config", "test -f AGENTS.md # explicit agent instructions should be written", diff --git a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt index 1192b20cdd..4884e5536a 100644 --- a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt @@ -56,16 +56,12 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt index 5d31e92cc2..5dbd5e2f13 100644 --- a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt +++ b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt @@ -59,16 +59,12 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt index 377a73d062..08ac7a6659 100644 --- a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt @@ -26,19 +26,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt index 060b656fab..6fd81f4a5a 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt @@ -26,19 +26,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .config/husky/pre-commit # pre-commit hook should be in custom dir vp staged diff --git a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt index 62670a4322..b1fec8a9b4 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt @@ -26,16 +26,12 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt index 1739bfda66..514f67d59b 100644 --- a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt @@ -26,19 +26,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt index 46060a3184..c43662a1f6 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt @@ -26,19 +26,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check oxlint config and staged config merged into vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt index dbeea0338d..6533b25c32 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt @@ -26,19 +26,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .lintstagedrc.json # check lintstagedrc.json is removed > cat vite.config.ts # check oxlint config merged into vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt index eae3f8790e..527ec413a8 100644 --- a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt @@ -31,18 +31,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f eslint.config.mjs # check eslint config is removed \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt index 0771255168..751dadc781 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt @@ -12,7 +12,7 @@ "lint": "vp lint ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt index dc0441dd50..0476a84c93 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt @@ -12,7 +12,7 @@ "lint": "vp lint ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt index fa4bf5b15c..60f25d1c4e 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt @@ -12,7 +12,7 @@ "lint": "vp lint ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-eslint/snap.txt b/packages/cli/snap-tests-global/migration-eslint/snap.txt index fea606b7f3..4c46bc311c 100644 --- a/packages/cli/snap-tests-global/migration-eslint/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint/snap.txt @@ -29,19 +29,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f eslint.config.mjs # check eslint config is removed > cat vite.config.ts # check oxlint config merged into vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt index d2c1c68a13..ad7c85413a 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt @@ -26,19 +26,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt index 5d26bc1549..95ddbf983b 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt @@ -29,19 +29,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .husky/pre-commit # hook file should be unchanged (still has bootstrap) . "$(dirname -- "$0")/_/husky.sh" diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt index d848a1259c..50ff3e3a31 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt @@ -29,19 +29,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .husky/pre-commit # hook file should be unchanged (still has bootstrap) . "$(dirname -- "$0")/_/husky.sh" diff --git a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt index cb5a7637e8..da674c99f3 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt @@ -26,19 +26,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .vite-hooks/pre-commit # check pre-commit hook rewritten to vp staged vp staged diff --git a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt index 940fa1c0aa..71d8b3ca22 100644 --- a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt @@ -26,19 +26,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .lintstagedrc.json # check lintstagedrc.json (should be deleted after inlining to vite.config.ts) > cat vite.config.ts # check staged config migrated to vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt index e6c009ca2b..b4d6dd5099 100644 --- a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt @@ -26,19 +26,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt index 5a898b0f28..2515239baa 100644 --- a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt @@ -27,19 +27,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .vite-hooks/pre-commit # check pre-commit hook vp staged diff --git a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt index 16087a6ade..cf858af8ea 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt @@ -51,19 +51,15 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > vp migrate --no-interactive # run migration again to check if it is idempotent This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt index 547d4c1772..85684a25b1 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt @@ -53,19 +53,15 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > vp migrate --no-interactive # run migration again to check if it is idempotent This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt index 9a6c718500..d1e01f3cd9 100644 --- a/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt @@ -59,9 +59,9 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: allowBuilds: edgedriver: true geckodriver: true diff --git a/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt b/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt index a72ade1a4e..c66403539c 100644 --- a/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt @@ -32,9 +32,9 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt index 3a30efa064..758f2bd08c 100644 --- a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt @@ -29,19 +29,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > git config --local core.hooksPath # should still be .custom-hooks .custom-hooks diff --git a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt index 72ce13481b..d898c5e5fd 100644 --- a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt @@ -34,19 +34,15 @@ packages: catalog: husky: ^9.1.7 lint-staged: ^16.2.6 - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt index efd29b79ed..395807de91 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt @@ -26,16 +26,12 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt index 40ab64b83a..9c7b70b6a5 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt @@ -28,16 +28,12 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt index 2e91e7579e..72d9aa6a38 100644 --- a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt @@ -26,16 +26,12 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt index 3b7b394d0c..8502bb4afe 100644 --- a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt @@ -26,16 +26,12 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt index 20e45a6e33..89cc040a7f 100644 --- a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt @@ -32,16 +32,12 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt b/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt index 95a4844c9a..84dc9c9e94 100644 --- a/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt +++ b/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt @@ -35,16 +35,12 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt index bfa1bb00f7..1c21e8be94 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt @@ -27,19 +27,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt index ecdad850c3..24d8e4eb9f 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt @@ -35,19 +35,15 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # vite config should be unchanged (merge failed) const config = { plugins: [] }; diff --git a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt index 287d37346a..3fab705ccf 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt @@ -30,19 +30,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat lint-staged.config.ts # check TS config is not modified export default { diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt index af8f1dd1f5..f19be992ec 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt @@ -99,19 +99,15 @@ Documentation: https://viteplus.dev/guide/migrate > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt index 9b0e74b5d9..ddc3c2228e 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt @@ -32,19 +32,15 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .lintstagedrc.json # config file should be preserved when merge fails { diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt index 5d9403d2b3..338e17c6f7 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt @@ -43,16 +43,12 @@ export default { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt index ba09d0e639..9f39a98e4a 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt @@ -27,19 +27,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -f .lintstagedrc.json && echo 'lintstagedrc.json still exists' || echo 'lintstagedrc.json was deleted' # should still exist lintstagedrc.json still exists diff --git a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt index 38385db3b2..c41684e788 100644 --- a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt +++ b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt @@ -57,16 +57,12 @@ export default { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt b/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt index d63ee649df..f3958fbf7c 100644 --- a/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt +++ b/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt @@ -91,9 +91,9 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt index 8a36eae8d9..e9aa907d74 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt @@ -44,9 +44,9 @@ export default defineConfig({ "packages/*" ], "catalog": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "", - "vite-plus": "latest" + "vite-plus": "" } }, "scripts": { diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt index b32ff43659..7f09fd6b80 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt @@ -38,23 +38,19 @@ packages: - packages/* catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: '@vitejs/plugin-react>vite': 'npm:vite@' 'supertest>superagent': vite: 'catalog:' - vitest: 'catalog:' react-click-away-listener>react: peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat packages/app/package.json # check app package.json { diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt index 4181032dc4..476de1b6be 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt @@ -82,9 +82,9 @@ packages: catalog: testnpm2: ^1.0.0 # test comment here to check if the comment is preserved - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: minimumReleaseAge: 1440 overrides: diff --git a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt index 6bd15e4900..2e8b54bad1 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt @@ -1,10 +1,15 @@ > vp migrate --no-interactive # migration should merge vite.config.ts and remove oxlintrc +⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP). + +✔ Switched Yarn to node-modules mode + ✔ Merged .oxlintrc.json into vite.config.ts ◇ Migrated . to Vite+ • Node yarn • 2 config updates applied, 1 file had imports rewritten • Inline Vite plugins wrapped with lazyPlugins for check/lint/fmt +• Package manager settings configured > cat vite.config.ts # check vite.config.ts import react from '@vitejs/plugin-react'; @@ -65,7 +70,7 @@ export default defineConfig({ }, "packageManager": "yarn@", "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" } } @@ -76,9 +81,9 @@ npmPreapprovedPackages: - vitest - '@vitest/*' catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: > cat packages/app/package.json # check app package.json { diff --git a/packages/cli/snap-tests-global/migration-no-agent/snap.txt b/packages/cli/snap-tests-global/migration-no-agent/snap.txt index ca1dc7f635..844536aae3 100644 --- a/packages/cli/snap-tests-global/migration-no-agent/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-agent/snap.txt @@ -1,7 +1,7 @@ > vp migrate --no-agent --no-interactive # migration with --no-agent should skip agent instructions ◇ Migrated . to Vite+ • Node pnpm -• 2 config updates applied +• 2 config updates applied, 1 file had imports rewritten > ls -la | grep -E '(AGENTS|CLAUDE)' || echo 'No agent file created' # verify no agent file was created No agent file created diff --git a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt index b7f357bd28..2d01e02028 100644 --- a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt @@ -24,19 +24,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -d .vite-hooks && echo 'hooks dir exists' || echo 'no hooks dir' hooks dir exists diff --git a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt index ec9d22ab50..cbdf2664ae 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt @@ -31,19 +31,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -d .husky && echo '.husky directory exists' || echo 'No .husky directory' # verify no .husky directory No .husky directory diff --git a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt index 99b7a3d9fb..d00770f2f9 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt @@ -22,19 +22,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -d .vite-hooks && echo '.vite-hooks directory exists' || echo 'No .vite-hooks directory' # verify no .vite-hooks directory No .vite-hooks directory diff --git a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt index f1fa202e1a..2a78d94c6e 100644 --- a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt +++ b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt @@ -34,16 +34,12 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt index d1718df00c..6ecb58bcb5 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt @@ -54,16 +54,12 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt index c96dc92a64..0fc7d99567 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt @@ -56,16 +56,12 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt index 59f7b0f5ed..83b5c2b3f1 100644 --- a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt @@ -27,8 +27,8 @@ "@vitejs/plugin-react": "^6.0.1", "globals": "^17.6.0", "typescript": "~6.0.2", - "vite": "^8.0.12", - "vite-plus": "^0.1.24" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { @@ -41,19 +41,15 @@ > cat pnpm-workspace.yaml # pnpm overrides and peerDependencyRules should be configured catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # vite imports should be rewritten import { defineConfig } from 'vite-plus' diff --git a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt index 3d367ba88e..4c73dac2ae 100644 --- a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt @@ -29,19 +29,15 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .husky/pre-commit # hook file should be unchanged (still has bootstrap) . "$(dirname -- "$0")/_/husky.sh" diff --git a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt index a52152e82e..f4dfa42963 100644 --- a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt @@ -33,19 +33,15 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f eslint.config.mjs # check eslint config is removed > test ! -f .prettierrc.json # check prettier config is removed diff --git a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt index efc29708b4..7baeaff783 100644 --- a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt @@ -31,18 +31,14 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .prettierrc.json # check prettier config is removed \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt index 3c99c1aaea..d14673abd6 100644 --- a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt @@ -28,19 +28,15 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check oxfmt config and staged config merged into vite.config.ts import { defineConfig } from "vite-plus"; diff --git a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt index 14e83cdefa..3353b0422d 100644 --- a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt @@ -29,19 +29,15 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check oxfmt config merged into vite.config.ts with semi/singleQuote settings import { defineConfig } from "vite-plus"; diff --git a/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt b/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt index 9bc156a317..df5a602fc3 100644 --- a/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt @@ -14,7 +14,7 @@ Prettier configuration detected. Auto-migrating to Oxfmt... "format": "vp fmt ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-prettier/snap.txt b/packages/cli/snap-tests-global/migration-prettier/snap.txt index ec5ada2f30..98486f0ebc 100644 --- a/packages/cli/snap-tests-global/migration-prettier/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier/snap.txt @@ -31,19 +31,15 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .prettierrc.json # check prettier config is removed > cat vite.config.ts # check oxfmt config merged into vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt index e816e2f39a..ff0de16915 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive # migration should rewrite imports to vite-plus +> vp migrate --no-interactive # retained vitest augmentations should keep a package-local vitest ◇ Migrated . to Vite+ • Node pnpm • 2 config updates applied, 1 file had imports rewritten @@ -38,6 +38,7 @@ declare module 'vitest/config' { { "name": "migration-rewrite-declare-module", "devDependencies": { + "vitest": "catalog:", "vite-plus": "catalog:" }, "devEngines": { @@ -54,9 +55,9 @@ declare module 'vitest/config' { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json b/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json index c55aec0263..52c732fd4d 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp migrate --no-interactive # migration should rewrite imports to vite-plus", + "vp migrate --no-interactive # retained vitest augmentations should keep a package-local vitest", "cat src/index.ts # check src/index.ts", "cat package.json # check package.json", "cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog" diff --git a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt index d74639391b..5ff6f9ab99 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt @@ -49,16 +49,12 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt index 29b077788e..2cacaef26b 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt @@ -49,16 +49,12 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt index 1bcde4d80e..680cd111de 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt @@ -9,15 +9,13 @@ "name": "migration-standalone-npm", "devDependencies": { "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "", "vite-plus": "latest" }, "packageManager": "npm@", "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" } } -[1]> node -e "const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && vite.resolved && vite.resolved.includes('@voidzero-dev/vite-plus-core')) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }" # verify lockfile updated with override -vite override not found in lockfile +> node -e "const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && (vite.name === '@voidzero-dev/vite-plus-core' || vite.resolved?.includes('/@voidzero-dev/vite-plus-core/'))) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }" # verify lockfile updated with override +lockfile has vite override diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/steps.json b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json index 41f180650f..42f66e7055 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json @@ -8,6 +8,6 @@ "commands": [ "vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile", "cat package.json # check package.json has overrides field (not pnpm.overrides)", - "node -e \"const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && vite.resolved && vite.resolved.includes('@voidzero-dev/vite-plus-core')) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }\" # verify lockfile updated with override" + "node -e \"const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && (vite.name === '@voidzero-dev/vite-plus-core' || vite.resolved?.includes('/@voidzero-dev/vite-plus-core/'))) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }\" # verify lockfile updated with override" ] } diff --git a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt index 53b010c9be..0c6b49172b 100644 --- a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt @@ -9,7 +9,6 @@ "name": "migration-standalone-pnpm", "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "pnpm@" @@ -17,16 +16,12 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides, peerDependencyRules, and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts new file mode 100644 index 0000000000..fbd3232594 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts @@ -0,0 +1,3 @@ +import { expect, it } from 'vitest'; + +it('works', () => expect(true).toBe(true)); diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json new file mode 100644 index 0000000000..08ef8b2b7d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json @@ -0,0 +1,11 @@ +{ + "name": "migration-standalone-yarn4-idempotent", + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + }, + "packageManager": "yarn@4.12.0" +} diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt new file mode 100644 index 0000000000..432e3c8c74 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -0,0 +1,43 @@ +> vp migrate --no-interactive # implicit Yarn Berry PnP converts before the first pass + +⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP). + +✔ Switched Yarn to node-modules mode +◇ Migrated . to Vite+ +• Node yarn +• 2 config updates applied, 1 file had imports rewritten +• Package manager settings configured + +> cat package.json # migrated dependency specs use the Yarn catalog immediately +{ + "name": "migration-standalone-yarn4-idempotent", + "scripts": { + "test": "vp test run", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "packageManager": "yarn@", + "resolutions": { + "vite": "npm:@voidzero-dev/vite-plus-core@" + } +} + +> cat .yarnrc.yml # managed catalog entries are available to those specs +nodeLinker: node-modules +npmPreapprovedPackages: + - vitest + - '@vitest/*' +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: + +> cat example.spec.ts # ordinary Vitest imports use the Vite+ public surface +import { expect, it } from 'vite-plus/test'; + +it('works', () => expect(true).toBe(true)); + +> vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json new file mode 100644 index 0000000000..09ea344a15 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + "vp migrate --no-interactive # implicit Yarn Berry PnP converts before the first pass", + "cat package.json # migrated dependency specs use the Yarn catalog immediately", + "cat .yarnrc.yml # managed catalog entries are available to those specs", + "cat example.spec.ts # ordinary Vitest imports use the Vite+ public surface", + "vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete" + ] +} diff --git a/packages/cli/snap-tests-global/migration-subpath/snap.txt b/packages/cli/snap-tests-global/migration-subpath/snap.txt index c327651108..fe0f5928bb 100644 --- a/packages/cli/snap-tests-global/migration-subpath/snap.txt +++ b/packages/cli/snap-tests-global/migration-subpath/snap.txt @@ -43,16 +43,12 @@ core.hooksPath is not set > cat foo/pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt index ae26c63e1e..5840a8b3a4 100644 --- a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt +++ b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt @@ -46,16 +46,12 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json new file mode 100644 index 0000000000..c1ec7ab36a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json @@ -0,0 +1,16 @@ +{ + "name": "migration-upgrade-browser-peer-only-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "peerDependencies": { + "@vitest/browser-playwright": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt new file mode 100644 index 0000000000..72a860833a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt @@ -0,0 +1,44 @@ +> vp migrate --no-interactive # peer-only browser provider is promoted with its required peers +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # provider, Playwright, and package-local Vitest are installed +{ + "name": "migration-upgrade-browser-peer-only-pnpm", + "devDependencies": { + "vite-plus": "catalog:", + "@vitest/browser-playwright": "", + "playwright": "*", + "vitest": "catalog:" + }, + "peerDependencies": { + "@vitest/browser-playwright": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # promoted provider keeps shared Vitest management +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' + +> vp migrate --no-interactive # repaired project should no longer be pending +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json new file mode 100644 index 0000000000..0487af5787 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # peer-only browser provider is promoted with its required peers", + "cat package.json # provider, Playwright, and package-local Vitest are installed", + "cat pnpm-workspace.yaml # promoted provider keeps shared Vitest management", + "vp migrate --no-interactive # repaired project should no longer be pending" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json new file mode 100644 index 0000000000..5798af5eaa --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-browser-source-only-pnpm", + "devDependencies": { + "@vitest/browser": "^4.1.8", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt new file mode 100644 index 0000000000..70b04086d7 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt @@ -0,0 +1,38 @@ +> vp migrate --no-interactive # source-only browser provider should be restored +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # provider, framework peer, and local vitest should be present +{ + "name": "migration-upgrade-browser-source-only-pnpm", + "devDependencies": { + "vite-plus": "catalog:", + "@vitest/browser-playwright": "", + "playwright": "*", + "vitest": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # shared vitest catalog and override should be present +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json new file mode 100644 index 0000000000..74dfa42763 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # source-only browser provider should be restored", + "cat package.json # provider, framework peer, and local vitest should be present", + "cat pnpm-workspace.yaml # shared vitest catalog and override should be present" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts new file mode 100644 index 0000000000..c8728c30c4 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite-plus'; +import { playwright } from 'vite-plus/test/browser-playwright'; + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright(), + }, + }, +}); diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json new file mode 100644 index 0000000000..c048b8c6a8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json @@ -0,0 +1,13 @@ +{ + "name": "migration-upgrade-browser-webdriverio-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt new file mode 100644 index 0000000000..b4da107e9f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt @@ -0,0 +1,41 @@ +> vp migrate --no-interactive # source-only WebdriverIO provider should be restored +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # provider, webdriverio, and local vitest should be present +{ + "name": "migration-upgrade-browser-webdriverio-pnpm", + "devDependencies": { + "vite-plus": "catalog:", + "@vitest/browser-webdriverio": "", + "webdriverio": "*", + "vitest": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # driver builds and shared vitest should be enabled +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' +allowBuilds: + edgedriver: true + geckodriver: true diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json new file mode 100644 index 0000000000..6ac329801a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # source-only WebdriverIO provider should be restored", + "cat package.json # provider, webdriverio, and local vitest should be present", + "cat pnpm-workspace.yaml # driver builds and shared vitest should be enabled" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts new file mode 100644 index 0000000000..36f9be16c6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite-plus'; +import { webdriverio } from 'vite-plus/test/browser-webdriverio'; + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: webdriverio(), + }, + }, +}); diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json new file mode 100644 index 0000000000..971be76cb9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-deprecated-coverage-c8-npm", + "devDependencies": { + "@vitest/coverage-c8": "^0.33.0", + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt new file mode 100644 index 0000000000..2f2872b2b8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt @@ -0,0 +1,25 @@ +> vp migrate --no-interactive # deprecated coverage-c8 has an independent version line +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # coverage-c8 must not be rewritten to a nonexistent Vitest 4 version +{ + "name": "migration-upgrade-deprecated-coverage-c8-npm", + "devDependencies": { + "@vitest/coverage-c8": "^0.33.0", + "vite-plus": "", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json new file mode 100644 index 0000000000..86c4696b8d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migrate --no-interactive # deprecated coverage-c8 has an independent version line", + "cat package.json # coverage-c8 must not be rewritten to a nonexistent Vitest 4 version" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json new file mode 100644 index 0000000000..bc93d7cada --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-monorepo-vitest-localized-pnpm", + "private": true, + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json new file mode 100644 index 0000000000..84fbcdd3c2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json @@ -0,0 +1,8 @@ +{ + "name": "app", + "devDependencies": { + "@vitest/ui": "^4.1.8", + "vite-plus": "^0.1.24", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..c809535178 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +packages: + - packages/* +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt new file mode 100644 index 0000000000..614252bdef --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt @@ -0,0 +1,48 @@ +> vp migrate --no-interactive # existing Vite+ workspace packages should be reconciled +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # root should not gain a direct vitest +{ + "name": "migration-upgrade-monorepo-vitest-localized-pnpm", + "private": true, + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat packages/app/package.json # only the peer consumer should gain local vitest +{ + "name": "app", + "devDependencies": { + "@vitest/ui": "", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} + +> cat pnpm-workspace.yaml # shared vitest config should exist for the consuming package +packages: + - packages/* +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json new file mode 100644 index 0000000000..30299fa416 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json @@ -0,0 +1,9 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # existing Vite+ workspace packages should be reconciled", + "cat package.json # root should not gain a direct vitest", + "cat packages/app/package.json # only the peer consumer should gain local vitest", + "cat pnpm-workspace.yaml # shared vitest config should exist for the consuming package" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json new file mode 100644 index 0000000000..7d344a220d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-nested-vitest-override-npm", + "devDependencies": { + "vite-plus": "latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": { + "@vitest/runner": "4.0.0" + } + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt new file mode 100644 index 0000000000..4454394ee9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt @@ -0,0 +1,27 @@ +> vp migrate --no-interactive # nested Vitest override is user-owned and not pending removal +This project is already using Vite+! Happy coding! + + +> cat package.json # object-valued override is preserved +{ + "name": "migration-upgrade-nested-vitest-override-npm", + "devDependencies": { + "vite-plus": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": { + "@vitest/runner": "" + } + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> vp migrate --no-interactive # nested override must not make migration permanently pending +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json new file mode 100644 index 0000000000..d97ed7f2e9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # nested Vitest override is user-owned and not pending removal", + "cat package.json # object-valued override is preserved", + "vp migrate --no-interactive # nested override must not make migration permanently pending" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json new file mode 100644 index 0000000000..578baa7ab6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nuxt/test-utils", + "version": "4.0.3", + "peerDependencies": { + "vitest": "^4.0.2" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json new file mode 100644 index 0000000000..66a6731860 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-nuxt-test-utils-monorepo", + "private": true, + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.2", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts new file mode 100644 index 0000000000..aad9acb752 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts @@ -0,0 +1,7 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vitest/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json new file mode 100644 index 0000000000..508cad9200 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json @@ -0,0 +1,8 @@ +{ + "name": "nuxt-tests", + "private": true, + "devDependencies": { + "@nuxt/test-utils": "file:../../.fixture/nuxt-test-utils", + "vitest": "catalog:" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts new file mode 100644 index 0000000000..3ea9392334 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts @@ -0,0 +1,5 @@ +import { expect } from 'vitest'; +import { startVitest } from 'vitest/node'; + +void expect; +void startVitest; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json new file mode 100644 index 0000000000..57a77b0b8e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json @@ -0,0 +1,7 @@ +{ + "name": "unit-tests", + "private": true, + "devDependencies": { + "vitest": "catalog:" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts new file mode 100644 index 0000000000..a5a3f5c5c2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts @@ -0,0 +1,3 @@ +import { expect } from 'vitest'; + +void expect; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml new file mode 100644 index 0000000000..912c35ad21 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - packages/* + +catalog: + vite-plus: latest + vitest: ^4.0.2 + +overrides: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt new file mode 100644 index 0000000000..b5b7ef9636 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt @@ -0,0 +1,67 @@ +> vp migrate --no-interactive # preserve upstream Vitest package-wide and localize it to the affected workspace +◇ Migrated . to Vite+ +• Node pnpm +• 1 file had imports rewritten +• Kept upstream `vitest` imports in 2 files for @nuxt/test-utils compatibility +• Package manager settings configured + +> cat packages/nuxt/package.json # affected workspace keeps direct Vitest +{ + "name": "nuxt-tests", + "private": true, + "devDependencies": { + "@nuxt/test-utils": "file:../../.fixture/nuxt-test-utils", + "vitest": "catalog:" + } +} + +> cat packages/unit/package.json # unrelated workspace drops direct Vitest +{ + "name": "unit-tests", + "private": true, + "devDependencies": {} +} + +> cat pnpm-workspace.yaml # shared Vitest pin remains because one workspace needs it +packages: + - packages/* + +catalog: + vite-plus: + vitest: + vite: npm:@voidzero-dev/vite-plus-core@ + +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' + +> cat packages/nuxt/nuxt.spec.ts # upstream Vitest and its subpath stay +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vitest/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; + +> cat packages/nuxt/unit.spec.ts # files without Nuxt imports still preserve Vitest in the affected package +import { expect } from 'vitest'; +import { startVitest } from 'vitest/node'; + +void expect; +void startVitest; + +> cat packages/unit/unit.spec.ts # an unrelated workspace still migrates Vitest +import { expect } from 'vite-plus/test'; + +void expect; + +> vp migrate --no-interactive # workspace result is idempotent +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json new file mode 100644 index 0000000000..b454ea054b --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json @@ -0,0 +1,18 @@ +{ + "commands": [ + "vp migrate --no-interactive # preserve upstream Vitest package-wide and localize it to the affected workspace", + "cat packages/nuxt/package.json # affected workspace keeps direct Vitest", + "cat packages/unit/package.json # unrelated workspace drops direct Vitest", + "cat pnpm-workspace.yaml # shared Vitest pin remains because one workspace needs it", + "cat packages/nuxt/nuxt.spec.ts # upstream Vitest and its subpath stay", + "cat packages/nuxt/unit.spec.ts # files without Nuxt imports still preserve Vitest in the affected package", + "cat packages/unit/unit.spec.ts # an unrelated workspace still migrates Vitest", + "vp migrate --no-interactive # workspace result is idempotent" + ], + "ignoredPlatforms": [ + { + "os": "linux", + "libc": "musl" + } + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json new file mode 100644 index 0000000000..578baa7ab6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nuxt/test-utils", + "version": "4.0.3", + "peerDependencies": { + "vitest": "^4.0.2" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts new file mode 100644 index 0000000000..a763129373 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts @@ -0,0 +1,8 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { page } from '@vitest/browser/context'; +import { vi } from 'vitest'; +import { defineConfig } from 'vitest/config'; + +mockNuxtImport('useExample', () => vi.fn()); +void page; +void defineConfig; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json new file mode 100644 index 0000000000..a1741d9a04 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-nuxt-test-utils", + "devDependencies": { + "@nuxt/test-utils": "file:.fixture/nuxt-test-utils", + "vite-plus": "latest", + "vitest": "^4.0.2" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt new file mode 100644 index 0000000000..c322569898 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt @@ -0,0 +1,45 @@ +> vp migrate --no-interactive # preserve upstream Vitest throughout packages that declare @nuxt/test-utils +◇ Migrated . to Vite+ +• Node npm +• 1 file had imports rewritten +• Kept upstream `vitest` imports in 2 files for @nuxt/test-utils compatibility +• Package manager settings configured + +> cat package.json # direct Vitest and its shared pin remain for the package-level exception +{ + "name": "migration-upgrade-nuxt-test-utils", + "devDependencies": { + "@nuxt/test-utils": "file:.fixture/nuxt-test-utils", + "vite-plus": "", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> cat nuxt.spec.ts # unscoped Vitest stays while the scoped browser package migrates +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { page } from 'vite-plus/test/browser/context'; +import { vi } from 'vitest'; +import { defineConfig } from 'vitest/config'; + +mockNuxtImport('useExample', () => vi.fn()); +void page; +void defineConfig; + +> cat unit.spec.ts # an unrelated test file in the same package also keeps upstream Vitest +import { expect } from 'vitest'; + +expect(true).toBe(true); + +> vp migrate --no-interactive # the package-level compatibility result is idempotent +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json new file mode 100644 index 0000000000..a3c9b5ae00 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json @@ -0,0 +1,15 @@ +{ + "commands": [ + "vp migrate --no-interactive # preserve upstream Vitest throughout packages that declare @nuxt/test-utils", + "cat package.json # direct Vitest and its shared pin remain for the package-level exception", + "cat nuxt.spec.ts # unscoped Vitest stays while the scoped browser package migrates", + "cat unit.spec.ts # an unrelated test file in the same package also keeps upstream Vitest", + "vp migrate --no-interactive # the package-level compatibility result is idempotent" + ], + "ignoredPlatforms": [ + { + "os": "linux", + "libc": "musl" + } + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts new file mode 100644 index 0000000000..593056d5d9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts @@ -0,0 +1,3 @@ +import { expect } from 'vitest'; + +expect(true).toBe(true); diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json new file mode 100644 index 0000000000..86d9d9cbcc --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json @@ -0,0 +1,16 @@ +{ + "name": "migration-upgrade-peer-vitest-catalog-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "peerDependencies": { + "vitest": "catalog:test" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..970868c122 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +catalogs: + test: + vitest: ^4.0.0 +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt new file mode 100644 index 0000000000..1102051ca6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt @@ -0,0 +1,39 @@ +> vp migrate --no-interactive # peer catalog must resolve before managed Vitest catalogs are pruned +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # peer uses its resolved public range without gaining direct Vitest +{ + "name": "migration-upgrade-peer-vitest-catalog-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "peerDependencies": { + "vitest": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # unreferenced managed Vitest catalog is removed +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: +catalogs: + test: {} +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' + +> vp migrate --no-interactive # repaired project should no longer be pending +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json new file mode 100644 index 0000000000..d51f6f4cfc --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # peer catalog must resolve before managed Vitest catalogs are pruned", + "cat package.json # peer uses its resolved public range without gaining direct Vitest", + "cat pnpm-workspace.yaml # unreferenced managed Vitest catalog is removed", + "vp migrate --no-interactive # repaired project should no longer be pending" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json new file mode 100644 index 0000000000..a104286713 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json @@ -0,0 +1,13 @@ +{ + "name": "migration-upgrade-pkg-pr-new-npm", + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", + "vite-plus": "^0.1.20", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" + }, + "packageManager": "npm@11.11.1" +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt new file mode 100644 index 0000000000..1bc76fd5f3 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt @@ -0,0 +1,28 @@ +> vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec +◇ Migrated . to Vite+ +• Node npm +• 2 config updates applied + +> cat package.json # direct dependencies and npm overrides use the same PR URLs +{ + "name": "migration-upgrade-pkg-pr-new-npm", + "devDependencies": { + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", + "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + }, + "overrides": { + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891" + }, + "packageManager": "npm@", + "scripts": { + "prepare": "vp config" + } +} + +> node -e "const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined) process.exit(1)" # pkg.pr.new specs use the minimal override shape +> node -e "require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')" # capture first migration result +> vp migrate --no-interactive # pkg.pr.new migration is idempotent +◇ Migrated . to Vite+ +• Node npm + +> node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)" # rerun leaves package.json unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json new file mode 100644 index 0000000000..5f2a8b74ab --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json @@ -0,0 +1,15 @@ +{ + "env": { + "VP_FORCE_MIGRATE": "1", + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + }, + "commands": [ + "vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec", + "cat package.json # direct dependencies and npm overrides use the same PR URLs", + "node -e \"const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined) process.exit(1)\" # pkg.pr.new specs use the minimal override shape", + "node -e \"require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')\" # capture first migration result", + "vp migrate --no-interactive # pkg.pr.new migration is idempotent", + "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)\" # rerun leaves package.json unchanged" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json new file mode 100644 index 0000000000..541f6d14f1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-upgrade-pkg-pr-new-pnpm", + "devDependencies": { + "@vitest/coverage-v8": "4.1.6", + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", + "vite-plus": "^0.1.20", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" + }, + "packageManager": "pnpm@10.33.2" +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..f7476db5c0 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml @@ -0,0 +1,21 @@ +packages: + - . + +blockExoticSubdeps: true + +catalog: + vite: npm:@voidzero-dev/vite-plus-core@^0.1.20 + vite-plus: ^0.1.20 + vitest: npm:@voidzero-dev/vite-plus-test@^0.1.20 + +overrides: + vite: 'catalog:' + vitest: 'catalog:' + +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt new file mode 100644 index 0000000000..2d08a0ae0d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt @@ -0,0 +1,57 @@ +> vp migrate --no-interactive # pkg.pr.new pnpm migration allows URL-resolved subdependencies +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied + +> cat package.json # direct dependencies use catalogs aligned to the pkg.pr.new build +{ + "name": "migration-upgrade-pkg-pr-new-pnpm", + "devDependencies": { + "@vitest/coverage-v8": "4.1.9", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" + }, + "packageManager": "pnpm@", + "pnpm": { + "overrides": { + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", + "vitest": "", + "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + } + }, + "scripts": { + "prepare": "vp config" + } +} + +> cat pnpm-workspace.yaml # pkg.pr.new URLs are pinned and exotic subdependencies are allowed +packages: + - . + +blockExoticSubdeps: false + +catalog: + vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891 + vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@1891 + vitest: + +overrides: + vite: 'catalog:' + vitest: 'catalog:' + +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' + +> node -e "const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@1891') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891')) process.exit(1)" # pnpm policy and PR targets are persisted +> node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result +> vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent +◇ Migrated . to Vite+ +• Node pnpm + +> node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)" # rerun leaves manifests unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json new file mode 100644 index 0000000000..38f0648435 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json @@ -0,0 +1,17 @@ +{ + "env": { + "PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false", + "VP_FORCE_MIGRATE": "1", + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + }, + "commands": [ + "vp migrate --no-interactive # pkg.pr.new pnpm migration allows URL-resolved subdependencies", + "cat package.json # direct dependencies use catalogs aligned to the pkg.pr.new build", + "cat pnpm-workspace.yaml # pkg.pr.new URLs are pinned and exotic subdependencies are allowed", + "node -e \"const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@1891') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891')) process.exit(1)\" # pnpm policy and PR targets are persisted", + "node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result", + "vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent", + "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves manifests unchanged" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js new file mode 100644 index 0000000000..f053ebf797 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json new file mode 100644 index 0000000000..53dde2cc8c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json @@ -0,0 +1,10 @@ +{ + "name": "vite-plugin-gherkin", + "version": "0.2.0", + "exports": { + ".": "./index.js" + }, + "peerDependencies": { + "vitest": "^4.1.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json new file mode 100644 index 0000000000..391a849187 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-required-vitest-peer-metadata-npm", + "devDependencies": { + "vite-plugin-gherkin": "0.2.0", + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt new file mode 100644 index 0000000000..bd06ee6529 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -0,0 +1,29 @@ +> vp migrate --no-interactive # clean checkout conservatively preserves existing Vitest +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # package-local Vitest and its shared override remain aligned +{ + "name": "migration-upgrade-required-vitest-peer-metadata-npm", + "devDependencies": { + "vite-plugin-gherkin": "0.2.0", + "vite-plus": "", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> node -e "const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })" # simulate installed dependency metadata +> vp migrate --no-interactive # metadata confirms the unnamed required Vitest peer +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json new file mode 100644 index 0000000000..46f3b70402 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # clean checkout conservatively preserves existing Vitest", + "cat package.json # package-local Vitest and its shared override remain aligned", + "node -e \"const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })\" # simulate installed dependency metadata", + "vp migrate --no-interactive # metadata confirms the unnamed required Vitest peer" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js new file mode 100644 index 0000000000..08e3fe0c42 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js @@ -0,0 +1,2 @@ +console.error('stale local vite-plus CLI was executed'); +process.exitCode = 42; diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json new file mode 100644 index 0000000000..c301f35a6f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-plus", + "version": "0.1.24" +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json new file mode 100644 index 0000000000..d65275d403 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json @@ -0,0 +1,16 @@ +{ + "name": "migration-upgrade-stale-local-pnpm", + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.24", + "vite-plus": "^0.1.24", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + }, + "pnpm": {} +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..a56e85d300 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +overrides: + vite: npm:@voidzero-dev/vite-plus-core@^0.1.24 + vitest: npm:@voidzero-dev/vite-plus-test@^0.1.24 +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs new file mode 100644 index 0000000000..6bbe95da83 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs @@ -0,0 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +fs.mkdirSync('node_modules', { recursive: true }); +fs.cpSync('local-vite-plus', path.join('node_modules', 'vite-plus'), { recursive: true }); diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt new file mode 100644 index 0000000000..a1d7a2f1d7 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt @@ -0,0 +1,34 @@ +> node setup-local.mjs +> vp migrate --no-interactive # newer global CLI must bypass the installed stale local CLI +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # stale wrapper deps and plain vite-plus range should be repaired +{ + "name": "migration-upgrade-stale-local-pnpm", + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, + "pnpm": {} +} + +> cat pnpm-workspace.yaml # empty pnpm field must not hide workspace overrides +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json new file mode 100644 index 0000000000..9e51e271ad --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json @@ -0,0 +1,9 @@ +{ + "env": {}, + "commands": [ + { "command": "node setup-local.mjs", "ignoreOutput": true }, + "vp migrate --no-interactive # newer global CLI must bypass the installed stale local CLI", + "cat package.json # stale wrapper deps and plain vite-plus range should be repaired", + "cat pnpm-workspace.yaml # empty pnpm field must not hide workspace overrides" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json new file mode 100644 index 0000000000..79eb0c6816 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json @@ -0,0 +1,18 @@ +{ + "name": "migration-upgrade-vite-plus-protocol-pin-npm", + "devDependencies": { + "vite-plus": "file:../custom-vite-plus", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt new file mode 100644 index 0000000000..2edd4a9266 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt @@ -0,0 +1,22 @@ +> vp migrate --no-interactive # deliberate vite-plus protocol pin must survive bootstrap +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # file pin should remain while stale vitest config is removed +{ + "name": "migration-upgrade-vite-plus-protocol-pin-npm", + "devDependencies": { + "vite-plus": "file:../custom-vite-plus" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json new file mode 100644 index 0000000000..4cf48ccb3d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json @@ -0,0 +1,7 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # deliberate vite-plus protocol pin must survive bootstrap", + "cat package.json # file pin should remain while stale vitest config is removed" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json new file mode 100644 index 0000000000..5b659d5fe8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json @@ -0,0 +1,23 @@ +{ + "name": "migration-upgrade-vitest-exact-peer-npm", + "devDependencies": { + "@vitest/coverage-v8": "^4.1.8", + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/ui": "^4.1.8", + "@vitest/utils": "^4.1.8", + "@vitest/web-worker": "^4.1.8", + "vite-plus": "latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt new file mode 100644 index 0000000000..86fcab4b69 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt @@ -0,0 +1,29 @@ +> vp migrate --no-interactive # exact @vitest peers require a package-local vitest +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # ecosystem packages and vitest should align to the bundled version +{ + "name": "migration-upgrade-vitest-exact-peer-npm", + "devDependencies": { + "@vitest/coverage-v8": "4.1.9", + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/ui": "", + "@vitest/utils": "", + "@vitest/web-worker": "", + "vite-plus": "", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json new file mode 100644 index 0000000000..792fcf8e77 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json @@ -0,0 +1,7 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # exact @vitest peers require a package-local vitest", + "cat package.json # ecosystem packages and vitest should align to the bundled version" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml new file mode 100644 index 0000000000..65d6ec1deb --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml @@ -0,0 +1,4 @@ +nodeLinker: pnp +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json new file mode 100644 index 0000000000..5f0242dfc9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json @@ -0,0 +1,17 @@ +{ + "name": "migration-upgrade-vitest-exact-peer-yarn4", + "devDependencies": { + "@vitest/ui": "^4.1.8", + "vite-plus": "catalog:" + }, + "resolutions": { + "vite": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "yarn", + "version": "4.12.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt new file mode 100644 index 0000000000..8a24282c5c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt @@ -0,0 +1,39 @@ +> vp migrate --no-interactive # Yarn PnP converts to node-modules before exact-peer migration + +⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP). + +✔ Switched Yarn to node-modules mode +◇ Migrated . to Vite+ +• Node yarn +• Package manager settings configured + +> cat package.json # direct deps and resolutions should use the managed catalog/version +{ + "name": "migration-upgrade-vitest-exact-peer-yarn4", + "devDependencies": { + "@vitest/ui": "", + "vite-plus": "catalog:", + "vitest": "catalog:" + }, + "resolutions": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "yarn", + "version": "", + "onFail": "download" + } + } +} + +> cat .yarnrc.yml # linker conversion and aligned Vitest catalog are persisted +nodeLinker: node-modules +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: + vitest: +npmPreapprovedPackages: + - vitest + - '@vitest/*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json new file mode 100644 index 0000000000..2c014edafb --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # Yarn PnP converts to node-modules before exact-peer migration", + "cat package.json # direct deps and resolutions should use the managed catalog/version", + "cat .yarnrc.yml # linker conversion and aligned Vitest catalog are persisted" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json new file mode 100644 index 0000000000..0dccb74e58 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json @@ -0,0 +1,21 @@ +{ + "name": "migration-upgrade-vitest-non-runtime-only-npm", + "devDependencies": { + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/utils": "^4.1.8", + "@vitest/ws-client": "^4.1.8", + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt new file mode 100644 index 0000000000..4698aff3e8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt @@ -0,0 +1,25 @@ +> vp migrate --no-interactive # non-runtime @vitest packages must not keep a vitest pin +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # internal packages align, eslint plugin stays independent, vitest is removed +{ + "name": "migration-upgrade-vitest-non-runtime-only-npm", + "devDependencies": { + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/utils": "", + "@vitest/ws-client": "", + "vite-plus": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json new file mode 100644 index 0000000000..06299da744 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json @@ -0,0 +1,7 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # non-runtime @vitest packages must not keep a vitest pin", + "cat package.json # internal packages align, eslint plugin stays independent, vitest is removed" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts new file mode 100644 index 0000000000..e4fafb12fe --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json new file mode 100644 index 0000000000..057c1fe203 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-vitest-reference-whitespace-pnpm", + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt new file mode 100644 index 0000000000..1b5bce1af1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -0,0 +1,41 @@ +> vp migrate --no-interactive # TypeScript whitespace in a Vitest type directive is valid +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied, 1 file had imports rewritten + +> cat package.json # rewritten directive does not retain a redundant Vitest dependency +{ + "name": "migration-upgrade-vitest-reference-whitespace-pnpm", + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, + "scripts": { + "prepare": "vp config" + } +} + +> cat env.d.ts # directive is rewritten to the Vite+ public type surface +/// + +> cat pnpm-workspace.yaml # rewritten directive does not retain shared Vitest management +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' + +> vp migrate --no-interactive # directive rewriting is stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json new file mode 100644 index 0000000000..188941dff5 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + "vp migrate --no-interactive # TypeScript whitespace in a Vitest type directive is valid", + "cat package.json # rewritten directive does not retain a redundant Vitest dependency", + "cat env.d.ts # directive is rewritten to the Vite+ public type surface", + "cat pnpm-workspace.yaml # rewritten directive does not retain shared Vitest management", + "vp migrate --no-interactive # directive rewriting is stable on rerun" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json new file mode 100644 index 0000000000..aa0a8c0310 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json new file mode 100644 index 0000000000..26701f311e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json @@ -0,0 +1,18 @@ +{ + "name": "migration-upgrade-vitest-retained-references-npm", + "devDependencies": { + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs new file mode 100644 index 0000000000..48997b4070 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs @@ -0,0 +1 @@ +module.exports = require.resolve('vitest'); diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt new file mode 100644 index 0000000000..3269c7bb16 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -0,0 +1,49 @@ +> vp migrate --no-interactive # retained upstream references require package-local Vitest +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # Vitest dependency and override stay aligned +{ + "name": "migration-upgrade-vitest-retained-references-npm", + "devDependencies": { + "vite-plus": "", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> cat tsconfig.json # compilerOptions.types remains an upstream Vitest reference +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} + +> cat config/tsconfig.test.json # nested compilerOptions.types is also retained +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} + +> cat resolve.cjs # require.resolve remains an upstream Vitest reference +module.exports = require.resolve('vitest'); + +> cat version.ts # vitest/package.json remains intentionally unre-written +import metadata from 'vitest/package.json'; + +console.log(metadata.version); + +> vp migrate --no-interactive # retained references remain stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json new file mode 100644 index 0000000000..0f3fbd9146 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json @@ -0,0 +1,11 @@ +{ + "commands": [ + "vp migrate --no-interactive # retained upstream references require package-local Vitest", + "cat package.json # Vitest dependency and override stay aligned", + "cat tsconfig.json # compilerOptions.types remains an upstream Vitest reference", + "cat config/tsconfig.test.json # nested compilerOptions.types is also retained", + "cat resolve.cjs # require.resolve remains an upstream Vitest reference", + "cat version.ts # vitest/package.json remains intentionally unre-written", + "vp migrate --no-interactive # retained references remain stable on rerun" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json new file mode 100644 index 0000000000..aa0a8c0310 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts new file mode 100644 index 0000000000..3b2e0f0e80 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts @@ -0,0 +1,3 @@ +import metadata from 'vitest/package.json'; + +console.log(metadata.version); diff --git a/packages/cli/snap-tests-global/migration-vite-version/snap.txt b/packages/cli/snap-tests-global/migration-vite-version/snap.txt index 9cdfbfdcad..c729e9012e 100644 --- a/packages/cli/snap-tests-global/migration-vite-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-vite-version/snap.txt @@ -26,16 +26,12 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts b/packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts new file mode 100644 index 0000000000..8305afb0b3 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts @@ -0,0 +1,5 @@ +import { expect, it } from 'vitest'; + +it('works', () => { + expect(true).toBe(true); +}); diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/package.json b/packages/cli/snap-tests-global/migration-vitest-import-only/package.json new file mode 100644 index 0000000000..00414adb22 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-vitest-import-only", + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt b/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt new file mode 100644 index 0000000000..ab2d6feba2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt @@ -0,0 +1,43 @@ +> vp migrate --no-interactive # ordinary vitest imports should migrate without retaining direct vitest +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied, 1 file had imports rewritten + +> cat package.json # direct dependency and shared pin should be removed +{ + "name": "migration-vitest-import-only", + "scripts": { + "test": "vp test", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat example.spec.ts # source import should use the Vite+ public surface +import { expect, it } from 'vite-plus/test'; + +it('works', () => { + expect(true).toBe(true); +}); + +> cat pnpm-workspace.yaml +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/steps.json b/packages/cli/snap-tests-global/migration-vitest-import-only/steps.json new file mode 100644 index 0000000000..5337542640 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/steps.json @@ -0,0 +1,9 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # ordinary vitest imports should migrate without retaining direct vitest", + "cat package.json # direct dependency and shared pin should be removed", + "cat example.spec.ts # source import should use the Vite+ public surface", + "cat pnpm-workspace.yaml" + ] +} diff --git a/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt b/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt index e6e3ef859a..930fcd96ff 100644 --- a/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt @@ -29,9 +29,9 @@ > cat pnpm-workspace.yaml catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json new file mode 100644 index 0000000000..184290e34f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json @@ -0,0 +1,11 @@ +{ + "name": "migration-vitest-unmanaged-override", + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "@vitest/ui": "4.0.13", + "vite": "^7.0.0", + "vitest": "4.0.13" + } +} diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt new file mode 100644 index 0000000000..052cde0151 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -0,0 +1,42 @@ +> vp migrate --no-interactive # vitest omitted from managed overrides must remain user-owned +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied + +> cat package.json # user's Vitest and exact-peer UI versions should both be preserved +{ + "name": "migration-vitest-unmanaged-override", + "scripts": { + "test": "vp test", + "prepare": "vp config" + }, + "devDependencies": { + "@vitest/ui": "", + "vite": "catalog:", + "vitest": "", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> node -e "const pkg = require('./package.json'); if (pkg.devDependencies.vitest !== '4.0.13' || pkg.devDependencies['@vitest/ui'] !== '4.0.13') process.exit(1)" # exact user-owned versions remain unchanged +> cat pnpm-workspace.yaml # no vitest catalog or override should be introduced +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' + +> vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json new file mode 100644 index 0000000000..767e603b45 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json @@ -0,0 +1,12 @@ +{ + "env": { + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"npm:@voidzero-dev/vite-plus-core@latest\"}" + }, + "commands": [ + "vp migrate --no-interactive # vitest omitted from managed overrides must remain user-owned", + "cat package.json # user's Vitest and exact-peer UI versions should both be preserved", + "node -e \"const pkg = require('./package.json'); if (pkg.devDependencies.vitest !== '4.0.13' || pkg.devDependencies['@vitest/ui'] !== '4.0.13') process.exit(1)\" # exact user-owned versions remain unchanged", + "cat pnpm-workspace.yaml # no vitest catalog or override should be introduced", + "vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun" + ] +} diff --git a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt index 931bc042ab..e5dff2a89c 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt @@ -30,8 +30,7 @@ vite.config.ts "vite-plus": "catalog:" }, "overrides": { - "vite": "catalog:", - "vitest": "catalog:" + "vite": "catalog:" }, "devEngines": { "packageManager": { @@ -44,9 +43,8 @@ vite.config.ts "node": ">=22.18.0" }, "catalog": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" } } diff --git a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt index 484912d7a9..a12fa92c4b 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt @@ -64,19 +64,15 @@ catalogMode: prefer catalog: "@types/node": ^24 typescript: ^5 - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > test -f vite-plus-monorepo/.gitignore && echo '.gitignore exists' || echo 'ERROR: .gitignore missing' # verify gitignore renamed from _gitignore .gitignore exists diff --git a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt index 1a2f29e76d..0a0534d8ea 100644 --- a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt @@ -16,12 +16,11 @@ "core-js": "3.39.0" }, "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -57,12 +56,11 @@ These dependencies may not work until built. Run vp pm approve-builds core-js in "core-js": "3.39.0" }, "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -93,12 +91,11 @@ bun pm trust v () "core-js": "3.39.0" }, "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt index ac8879bff3..49097fcdb8 100644 --- a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt @@ -10,19 +10,15 @@ Prettier detected in workspace packages but no root config found. Package-level allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved @@ -40,19 +36,15 @@ These dependencies may not work until built. Run vp pm approve-builds in the pro allowBuilds: core-js: set this to true or false catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build .../core-js@/node_modules/core-js postinstall$ node -e "try{require('./postinstall')}catch(e){}" @@ -62,16 +54,12 @@ peerDependencyRules: allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" diff --git a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt index ae6586d93e..0596ddd334 100644 --- a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt @@ -8,19 +8,15 @@ allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved @@ -36,19 +32,15 @@ These dependencies may not work until built. Run vp pm approve-builds in the pro allowBuilds: core-js: set this to true or false catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build .../core-js@/node_modules/core-js postinstall$ node -e "try{require('./postinstall')}catch(e){}" @@ -58,16 +50,12 @@ peerDependencyRules: allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" diff --git a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt index 831f12dea0..497b533e1c 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt @@ -16,7 +16,7 @@ "core-js": "3.39.0" }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "dependenciesMeta": { "core-js": { @@ -24,8 +24,7 @@ } }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -58,11 +57,10 @@ These dependencies may not work until built. Enable them in the workspace root p "core-js": "3.39.0" }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt index 3fe290bc75..b256a7ba2b 100644 --- a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt @@ -25,19 +25,15 @@ packages: - apps/* - packages/* catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > test -d my-mono/.git && echo 'Git initialized' # git-init prompt covers bundled monorepo path Git initialized diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json new file mode 100644 index 0000000000..66604e79b7 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json @@ -0,0 +1,8 @@ +{ + "name": "lint-vite-plus-imports-nuxt", + "version": "0.0.0", + "private": true, + "devDependencies": { + "@nuxt/test-utils": "^4.0.3" + } +} diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt new file mode 100644 index 0000000000..f98e748c25 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt @@ -0,0 +1,35 @@ +[1]> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # all upstream Vitest imports are exempt; the unrelated Vite import still fails + + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus' instead of 'vite' in Vite+ projects. + ╭─[src/unit.spec.ts:1:30] + 1 │ import { defineConfig } from 'vite'; + · ────── + 2 │ import { expect } from 'vitest'; + ╰──── + +Found 0 warnings and 1 error. +Finished in ms on 2 files with rules using threads. + +> vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # fix Vite without changing any upstream Vitest imports +Found 0 warnings and 0 errors. +Finished in ms on 2 files with rules using threads. + +> cat src/nuxt.spec.ts +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vitest/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; + +> cat src/unit.spec.ts +import { defineConfig } from 'vite-plus'; +import { expect } from 'vitest'; + +void defineConfig; +void expect; + +> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the package-level compatible result is clean +Found 0 warnings and 0 errors. +Finished in ms on 2 files with rules using threads. diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts new file mode 100644 index 0000000000..aad9acb752 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts @@ -0,0 +1,7 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vitest/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts new file mode 100644 index 0000000000..ec1d98893d --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite'; +import { expect } from 'vitest'; + +void defineConfig; +void expect; diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json new file mode 100644 index 0000000000..454491842b --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json @@ -0,0 +1,10 @@ +{ + "ignoredPlatforms": [{ "os": "linux", "libc": "musl" }], + "commands": [ + "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # all upstream Vitest imports are exempt; the unrelated Vite import still fails", + "vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # fix Vite without changing any upstream Vitest imports", + "cat src/nuxt.spec.ts", + "cat src/unit.spec.ts", + "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the package-level compatible result is clean" + ] +} diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts new file mode 100644 index 0000000000..ccf62c766b --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + lint: { + jsPlugins: [{ name: 'vite-plus', specifier: 'vite-plus/oxlint-plugin' }], + rules: { + 'vite-plus/prefer-vite-plus-imports': 'error', + }, + }, +}); diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/package.json b/packages/cli/snap-tests/migration-config-process-crash-isolated/package.json new file mode 100644 index 0000000000..5c6bd19cb9 --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/package.json @@ -0,0 +1,8 @@ +{ + "name": "migration-config-process-crash-isolated", + "version": "0.0.0", + "private": true, + "devDependencies": { + "vite": "^8.0.0" + } +} diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt b/packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt new file mode 100644 index 0000000000..60cdac381a --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt @@ -0,0 +1,23 @@ +> vp migrate --no-interactive --no-hooks 2>&1 # project config process handlers must not terminate migration +◇ Migrated . to Vite+ +• Node pnpm +• 1 file had imports rewritten + +> cat vite.config.ts # migration still rewrites the config after its compatibility probe crashes +import { defineConfig } from 'vite-plus'; + +// Models a project plugin that installs a process-level error backstop while +// its config is loaded. Re-throwing from this handler makes Node exit with code +// 7, which used to terminate `vp migrate` during its best-effort compatibility +// check instead of allowing migration to continue. +process.on('uncaughtException', (error) => { + throw error; +}); +queueMicrotask(() => { + throw new Error('simulated project config crash'); +}); + +export default defineConfig({ + fmt: {}, + lint: {"jsPlugins":[{"name":"vite-plus","specifier":"vite-plus/oxlint-plugin"}],"rules":{"vite-plus/prefer-vite-plus-imports":"error"},"options":{"typeAware":true,"typeCheck":true}}, +}); diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json b/packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json new file mode 100644 index 0000000000..e0cef40f52 --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migrate --no-interactive --no-hooks 2>&1 # project config process handlers must not terminate migration", + "cat vite.config.ts # migration still rewrites the config after its compatibility probe crashes" + ] +} diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts b/packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts new file mode 100644 index 0000000000..ac019508ed --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; + +// Models a project plugin that installs a process-level error backstop while +// its config is loaded. Re-throwing from this handler makes Node exit with code +// 7, which used to terminate `vp migrate` during its best-effort compatibility +// check instead of allowing migration to continue. +process.on('uncaughtException', (error) => { + throw error; +}); +queueMicrotask(() => { + throw new Error('simulated project config crash'); +}); + +export default defineConfig({}); diff --git a/packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json b/packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json new file mode 100644 index 0000000000..4749977f56 --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "devDependencies": { + "@nuxt/test-utils": "^4.0.3" + } +} diff --git a/packages/cli/src/__tests__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index 0e9f4c1b6b..0c66f92072 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { RuleTester } from 'oxlint/plugins-dev'; import { describe, expect, it } from 'vitest'; @@ -10,6 +12,15 @@ import { } from '../oxlint-plugin-config.js'; import { preferVitePlusImportsRule, rewriteVitePlusImportSpecifier } from '../oxlint-plugin.js'; +const nuxtTestFilename = path.join( + import.meta.dirname, + 'fixtures/nuxt-test-utils/component.spec.ts', +); +const nuxtUnitTestFilename = path.join( + import.meta.dirname, + 'fixtures/nuxt-test-utils/unit.spec.ts', +); + describe('oxlint plugin config defaults', () => { it('adds vite-plus js plugin and lint rule defaults', () => { expect( @@ -147,8 +158,22 @@ new RuleTester({ code: `declare module '@vitest/browser-playwright/context' {}`, filename: 'types.ts', }, + { + code: `import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + filename: nuxtTestFilename, + }, + { + code: `import { expect } from 'vitest';\nimport { startVitest } from 'vitest/node';\nimport { defineConfig } from 'vitest/config';`, + filename: nuxtUnitTestFilename, + }, ], invalid: [ + { + code: `import { page } from '@vitest/browser/context'`, + errors: 1, + filename: nuxtUnitTestFilename, + output: `import { page } from 'vite-plus/test/browser/context'`, + }, { // `declare module 'vite'` IS rewritten — the vite family doesn't // re-export upstream vite types so augmentation works against either id. @@ -211,5 +236,17 @@ new RuleTester({ errors: 2, output: `export * from 'vite-plus/test';\nimport { defineConfig } from 'vite-plus';`, }, + { + code: `import { vi } from 'vitest';\nimport { startVitest } from 'vitest/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + errors: 2, + filename: path.join(import.meta.dirname, 'ordinary.spec.ts'), + output: `import { vi } from 'vite-plus/test';\nimport { startVitest } from 'vite-plus/test/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + }, + { + code: `import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + errors: 1, + filename: path.join(import.meta.dirname, 'ordinary.spec.ts'), + output: `import { vi } from 'vite-plus/test';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + }, ], }); diff --git a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts index d04dbce46c..0594907345 100644 --- a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts +++ b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts @@ -195,7 +195,9 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { rewritePackageJson(pkg, PackageManager.pnpm, true); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - expect(pkg.peerDependencies.vitest).toBe('catalog:test'); + // With no catalog resolver available, use a public fallback rather than + // leaking either a dangling catalog reference or the managed file: path. + expect(pkg.peerDependencies.vitest).toBe('*'); expect(pkg.optionalDependencies.vite).toBe( 'file:/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz', ); @@ -203,4 +205,19 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { (pkg as { devDependencies?: Record }).devDependencies?.['vite-plus'], ).toBe('file:/tmp/tgz/vite-plus-0.0.0.tgz'); }); + + it('does not align Vitest ecosystem packages when Vitest is unmanaged', () => { + const pkg = { + devDependencies: { + vite: '^7.0.0', + vitest: '4.0.13', + '@vitest/ui': '4.0.13', + }, + }; + + rewritePackageJson(pkg, PackageManager.npm); + + expect(pkg.devDependencies.vitest).toBe('4.0.13'); + expect(pkg.devDependencies['@vitest/ui']).toBe('4.0.13'); + }); }); diff --git a/packages/cli/src/migration/__tests__/compat-runner.spec.ts b/packages/cli/src/migration/__tests__/compat-runner.spec.ts new file mode 100644 index 0000000000..5282ad105f --- /dev/null +++ b/packages/cli/src/migration/__tests__/compat-runner.spec.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../utils/command.ts', () => ({ + runCommandSilently: vi.fn(), +})); + +import { runCommandSilently } from '../../utils/command.ts'; +import { checkRolldownCompatibility, ROLLDOWN_COMPAT_RESULT_PREFIX } from '../compat-runner.ts'; +import { createMigrationReport } from '../report.ts'; + +const mockRunCommandSilently = vi.mocked(runCommandSilently); + +describe('checkRolldownCompatibility', () => { + beforeEach(() => { + mockRunCommandSilently.mockReset(); + }); + + it('merges warnings returned by the isolated config worker', async () => { + mockRunCommandSilently.mockResolvedValue({ + exitCode: 0, + stdout: Buffer.from( + `project config output\n${ROLLDOWN_COMPAT_RESULT_PREFIX}${JSON.stringify({ warnings: ['manualChunks warning'] })}\n`, + ), + stderr: Buffer.alloc(0), + }); + const report = createMigrationReport(); + + await checkRolldownCompatibility('/project', report); + + expect(report.warnings).toEqual(['manualChunks warning']); + expect(mockRunCommandSilently).toHaveBeenCalledWith({ + command: process.execPath, + args: [expect.stringMatching(/compat-worker\.js$/), '/project'], + cwd: '/project', + envs: process.env, + }); + }); + + it('skips compatibility checking when project config crashes the worker', async () => { + mockRunCommandSilently.mockResolvedValue({ + exitCode: 7, + stdout: Buffer.from( + `${ROLLDOWN_COMPAT_RESULT_PREFIX}${JSON.stringify({ warnings: ['incomplete result'] })}\n`, + ), + stderr: Buffer.from('project config crashed'), + }); + const report = createMigrationReport(); + + await expect(checkRolldownCompatibility('/project', report)).resolves.toBeUndefined(); + expect(report.warnings).toEqual([]); + }); + + it('skips compatibility checking when the worker cannot start', async () => { + mockRunCommandSilently.mockRejectedValue(new Error('spawn failed')); + const report = createMigrationReport(); + + await expect(checkRolldownCompatibility('/project', report)).resolves.toBeUndefined(); + expect(report.warnings).toEqual([]); + }); +}); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index d240d78559..baed18b04d 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -14,7 +14,14 @@ import { createMigrationReport } from '../report.js'; // which would cause snapshot mismatches. vi.mock('../../utils/constants.js', async (importOriginal) => { const mod = await importOriginal(); - return { ...mod, VITE_PLUS_VERSION: 'latest' }; + return { + ...mod, + VITE_PLUS_VERSION: 'latest', + VITE_PLUS_OVERRIDE_PACKAGES: { + ...mod.VITE_PLUS_OVERRIDE_PACKAGES, + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + }; }); const { @@ -23,6 +30,7 @@ const { rewriteMonorepo, rewriteMonorepoProject, detectPendingCoreMigration, + detectNuxtTestUtilsVitestImportFiles, detectVitePlusBootstrapPending, ensureVitePlusBootstrap, finalizeCoreMigrationForExistingVitePlus, @@ -39,9 +47,80 @@ const { detectIncompatibleEslintIntegration, preflightGitHooksSetup, detectLegacyGitHooksMigrationCandidate, + detectYarnPnpMode, + configureYarnNodeModulesMode, setPackageManager, } = await import('../migrator.js'); +describe('Yarn PnP migration preflight', () => { + let tmpDir: string; + const savedEnv: Record = {}; + const isolatedEnv = ['HOME', 'USERPROFILE', 'YARN_NODE_LINKER'] as const; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-yarn-pnp-')); + for (const key of isolatedEnv) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + const cleanHome = path.join(tmpDir, '.home'); + fs.mkdirSync(cleanHome); + process.env.HOME = cleanHome; + process.env.USERPROFILE = cleanHome; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + for (const key of isolatedEnv) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + it('detects explicit and implicit Yarn Berry PnP modes', () => { + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: pnp\n'); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toEqual({ source: 'configuration' }); + + fs.rmSync(path.join(tmpDir, '.yarnrc.yml')); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toEqual({ source: 'default' }); + expect(detectYarnPnpMode(tmpDir, 'latest')).toEqual({ source: 'default' }); + }); + + it('does not classify Yarn Classic or node-modules configuration as PnP', () => { + expect(detectYarnPnpMode(tmpDir, '1.22.22')).toBeUndefined(); + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toBeUndefined(); + }); + + it('honours YARN_NODE_LINKER over project configuration', () => { + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + process.env.YARN_NODE_LINKER = 'pnp'; + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toEqual({ source: 'environment' }); + + process.env.YARN_NODE_LINKER = 'node-modules'; + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: pnp\n'); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toBeUndefined(); + }); + + it('converts the project rc without discarding other settings and is idempotent', () => { + fs.writeFileSync( + path.join(tmpDir, '.yarnrc.yml'), + 'nodeLinker: pnp\nnmHoistingLimits: workspaces\ncatalog:\n react: ^19.0.0\n', + ); + + expect(configureYarnNodeModulesMode(tmpDir)).toBe(true); + expect(readYamlObject(path.join(tmpDir, '.yarnrc.yml'))).toEqual({ + nodeLinker: 'node-modules', + nmHoistingLimits: 'workspaces', + catalog: { react: '^19.0.0' }, + }); + expect(configureYarnNodeModulesMode(tmpDir)).toBe(false); + }); +}); + describe('rewritePackageJson', () => { it('should rewrite package.json scripts and extract staged config', async () => { const pkg = { @@ -1229,11 +1308,13 @@ describe('ensureVitePlusBootstrap', () => { devEngines: { packageManager: { name: string } }; }; expect(pkg.overrides.vite).toContain('@voidzero-dev/vite-plus-core'); - expect(pkg.overrides.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is NOT managed — + // it arrives transitively through vite-plus, so no override is written. + expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.devEngines.packageManager.name).toBe(PackageManager.npm); }); - it('rewrites the stale vitest wrapper override without pinning the @vitest/* family for npm projects', () => { + it('removes the stale vitest wrapper override for a non-vitest npm project', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -1251,22 +1332,62 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); - // The `vite` alias still points at the live `@voidzero-dev/vite-plus-core` - // package, so it satisfies the migration and is left untouched. The `vitest` - // alias points at the DELETED `@voidzero-dev/vite-plus-test` wrapper, so it is - // rewritten to the bundled vitest version. The `@vitest/*` family is NOT pinned: - // it resolves transitively from `vitest`'s own exact deps. + // Both managed aliases must match the active toolchain target. Keeping the + // old core alias while rewriting a direct `vite` dependency causes npm's + // EOVERRIDE error. The project does NOT use vitest directly (no @vitest/* + // dep, no vitest source), so the stale deleted wrapper override is removed. expect(result.changed).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { overrides: Record; }; - expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@0.1.0'); - expect(pkg.overrides.vitest).toBe('4.1.9'); + expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.overrides['@vitest/expect']).toBeUndefined(); expect(pkg.overrides['@vitest/coverage-v8']).toBeUndefined(); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); + it('replaces protocol-pinned migration targets in force-override mode', () => { + const savedForceMigrate = process.env.VP_FORCE_MIGRATE; + process.env.VP_FORCE_MIGRATE = '1'; + try { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'https://pkg.pr.new/voidzero-dev/vite-plus@old', + vite: 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@old', + }, + overrides: { + vite: 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@old', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.devDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + } finally { + if (savedForceMigrate === undefined) { + delete process.env.VP_FORCE_MIGRATE; + } else { + process.env.VP_FORCE_MIGRATE = savedForceMigrate; + } + } + }); + it('rewrites direct npm Vite dependencies before adding overrides', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), @@ -1294,7 +1415,9 @@ describe('ensureVitePlusBootstrap', () => { dependencies: Record; }; expect(pkg.devDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); - expect(pkg.dependencies.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): the direct `vitest` dep + // is removed — it arrives transitively through vite-plus. + expect(pkg.dependencies.vitest).toBeUndefined(); }); it('normalizes catalog vite-plus pins for npm projects', () => { @@ -1339,6 +1462,66 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); + it('allows pkg.pr.new transitive URLs in pnpm workspace config and is idempotent', () => { + const savedForceMigrate = process.env.VP_FORCE_MIGRATE; + const savedViteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; + const viteOverride = + 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; + process.env.VP_FORCE_MIGRATE = '1'; + VITE_PLUS_OVERRIDE_PACKAGES.vite = viteOverride; + try { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'blockExoticSubdeps: true', + 'catalog:', + ` vite: '${viteOverride}'`, + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny:', + ' - vite', + ' allowedVersions:', + " vite: '*'", + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + const first = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + expect(first.packageManagerConfig).toBe(true); + expect( + ( + readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + blockExoticSubdeps: boolean; + } + ).blockExoticSubdeps, + ).toBe(false); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + expect(ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)).changed).toBe( + false, + ); + } finally { + VITE_PLUS_OVERRIDE_PACKAGES.vite = savedViteOverride; + if (savedForceMigrate === undefined) { + delete process.env.VP_FORCE_MIGRATE; + } else { + process.env.VP_FORCE_MIGRATE = savedForceMigrate; + } + } + }); + it('detects missing pnpm workspace catalog entry for vite-plus', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), @@ -1380,172 +1563,1027 @@ describe('ensureVitePlusBootstrap', () => { expect(workspace.catalog['vite-plus']).toBe('latest'); }); - it('uses a concrete vite-plus version when pnpm config stays in package.json', () => { + it('reconciles stale pnpm-workspace.yaml overrides when package.json has an empty pnpm field (urllib shape)', () => { + // urllib 0.1.x shape: an empty `pnpm: {}` in package.json AND a committed + // pnpm-workspace.yaml whose overrides pin vite/vitest to the deleted + // @voidzero-dev/vite-plus-test wrapper. The empty `pnpm: {}` is truthy, so the + // bootstrap used to take the package.json path and IGNORE the workspace.yaml, + // leaving the dead wrapper override in place (and a second, conflicting + // override source in package.json). Because a pnpm-workspace.yaml exists, the + // workspace.yaml is the real config location and must be reconciled. fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ - name: 'test', - dependencies: { 'vite-plus': 'latest' }, + name: 'urllib', + devDependencies: { + '@vitest/coverage-v8': '^4.1.8', + vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24', + 'vite-plus': '^0.1.24', + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24', + }, pnpm: {}, + devEngines: { + packageManager: { name: 'pnpm', version: '11.7.0', onFail: 'download' }, + }, }), ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'overrides:', + " vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24'", + " vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24'", + 'peerDependencyRules:', + ' allowAny:', + ' - vite', + ' - vitest', + ].join('\n'), + ); - const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); - expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + // The deleted wrapper alias must no longer survive in the workspace.yaml. + const workspaceRaw = fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf-8'); + expect(workspaceRaw).not.toContain('@voidzero-dev/vite-plus-test'); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; - pnpm: { overrides: Record }; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); - expect(pkg.pnpm.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(JSON.stringify(pkg)).not.toContain('@voidzero-dev/vite-plus-test'); + + // And the project must not be left pending (no stale wrapper override anywhere). + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); - it('normalizes an existing catalog vite-plus pin when pnpm config stays in package.json', () => { + it('aligns coverage providers to the bundled vitest version (urllib coverage-v8 symptom)', () => { + // A coverage provider is a project-installed peer that Vitest pins to an + // exact runner version; a skewed copy makes Vitest run mixed versions. The + // upgrade must bump it to the bundled vitest version, not leave it behind. fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'test', - devDependencies: { 'vite-plus': 'catalog:' }, - devEngines: { - packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-v8': '^4.1.8', }, - pnpm: { - overrides: { - vite: 'npm:@voidzero-dev/vite-plus-core@latest', - vitest: 'npm:@voidzero-dev/vite-plus-test@latest', - }, - peerDependencyRules: { - allowAny: ['vite', 'vitest'], - allowedVersions: { vite: '*', vitest: '*' }, - }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, }, }), ); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); - const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); - expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); - it('normalizes catalog vite-plus pins outside devDependencies when pnpm config stays in package.json', () => { + it('aligns the full @vitest/* ecosystem (ui, web-worker) but leaves @vitest/eslint-plugin alone', () => { + // Every official @vitest/* package carries an exact `vitest` peer, so each + // must match the bundled vitest. @vitest/eslint-plugin versions on its own + // line (`vitest: *` peer) and must NOT be pinned to the vitest version. fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'test', - dependencies: { 'vite-plus': 'catalog:' }, - optionalDependencies: { 'vite-plus': 'catalog:' }, - devEngines: { - packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + devDependencies: { + 'vite-plus': 'latest', + '@vitest/ui': '^4.1.0', + '@vitest/web-worker': '^4.1.0', + '@vitest/eslint-plugin': '^1.0.0', }, - pnpm: { - overrides: { - vite: 'npm:@voidzero-dev/vite-plus-core@latest', - vitest: 'npm:@voidzero-dev/vite-plus-test@latest', - }, - peerDependencyRules: { - allowAny: ['vite', 'vitest'], - allowedVersions: { vite: '*', vitest: '*' }, - }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, }, }), ); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); - const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); - expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; - dependencies: Record; - optionalDependencies: Record; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); - expect(pkg.dependencies['vite-plus']).toBe('latest'); - expect(pkg.optionalDependencies['vite-plus']).toBe('latest'); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + expect(pkg.devDependencies['@vitest/ui']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/web-worker']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/eslint-plugin']).toBe('^1.0.0'); + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); }); - it('uses a concrete vite-plus version for pnpm monorepos that keep pnpm config in package.json', () => { + it('prefers existing catalogs for Vitest ecosystem packages and pins unsupported ones', () => { + const appDir = path.join(tmpDir, 'packages/app'); + fs.mkdirSync(appDir, { recursive: true }); fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ - name: 'test', - dependencies: { 'vite-plus': 'latest' }, - pnpm: {}, + name: 'root', + private: true, + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, }), ); - - const result = ensureVitePlusBootstrap({ + fs.writeFileSync( + path.join(appDir, 'package.json'), + JSON.stringify({ + name: 'app', + devDependencies: { + // Reproduce the output from the prior migration: the package was + // hard-pinned even though the default catalog already owned it. + '@vitest/coverage-istanbul': VITEST_VERSION, + '@vitest/ui': 'catalog:test', + '@vitest/web-worker': '^4.1.0', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + 'catalog:', + ' vite-plus: latest', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ` vitest: ${VITEST_VERSION}`, + " '@vitest/coverage-istanbul': 4.1.4", + 'catalogs:', + ' test:', + " '@vitest/ui': 4.1.4", + 'blockExoticSubdeps: false', + 'overrides:', + " vite: 'catalog:'", + " vitest: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite, vitest]', + ' allowedVersions:', + " vite: '*'", + " vitest: '*'", + '', + ].join('\n'), + ); + const workspaceInfo = { ...makeWorkspaceInfo(tmpDir, PackageManager.pnpm), isMonorepo: true, workspacePatterns: ['packages/*'], - }); + packages: [{ name: 'app', path: 'packages/app' }], + }; - expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); - const pkg = readJson(path.join(tmpDir, 'package.json')) as { + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(true); + ensureVitePlusBootstrap(workspaceInfo); + + const pkg = readJson(path.join(appDir, 'package.json')) as { devDependencies: Record; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.devDependencies['@vitest/coverage-istanbul']).toBe('catalog:'); + expect(pkg.devDependencies['@vitest/ui']).toBe('catalog:test'); + expect(pkg.devDependencies['@vitest/web-worker']).toBe(VITEST_VERSION); + + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + catalogs: Record>; + }; + expect(workspace.catalog['@vitest/coverage-istanbul']).toBe(VITEST_VERSION); + expect(workspace.catalogs.test['@vitest/ui']).toBe(VITEST_VERSION); + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(false); }); - it('keeps yarn monorepo bootstrap rewrites out of package dependency specs', () => { + it('does not align deprecated @vitest/coverage-c8 to a nonexistent Vitest 4 version', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'test', - devDependencies: { 'vite-plus': 'latest', vite: '^7.0.0' }, + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-c8': '^0.33.0', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, devEngines: { - packageManager: { name: 'yarn', version: '4.0.0', onFail: 'download' }, + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, }, }), ); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(true); - const result = ensureVitePlusBootstrap({ - ...makeWorkspaceInfo(tmpDir, PackageManager.yarn), - isMonorepo: true, - workspacePatterns: ['packages/*'], - }); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); - expect(result.changed).toBe(true); - expect(result.packageManagerConfig).toBe(true); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; - resolutions: Record; - }; - expect(pkg.devDependencies.vite).toBe('^7.0.0'); - expect(pkg.devDependencies['vite-plus']).toBe('latest'); - expect(pkg.resolutions.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); - const yarnrc = readYamlObject(path.join(tmpDir, '.yarnrc.yml')) as { - nodeLinker: string; - catalog: Record; }; - expect(yarnrc.nodeLinker).toBe('node-modules'); - expect(yarnrc.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); - expect(yarnrc.catalog.vitest).toBe('4.1.9'); - expect(yarnrc.catalog['vite-plus']).toBe('latest'); + expect(pkg.devDependencies['@vitest/coverage-c8']).toBe('^0.33.0'); + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); }); - it('completes missing pnpm workspace peer dependency rules', () => { + it('detects a required Vitest peer from Yarn PnP dependency metadata', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'test', - devDependencies: { 'vite-plus': 'catalog:' }, - devEngines: { - packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + devDependencies: { + 'vite-plus': 'latest', + 'vite-plugin-gherkin': '0.2.0', + }, + resolutions: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + devEngines: { + packageManager: { name: 'yarn', version: '4.12.0', onFail: 'download' }, + }, + }), + ); + const pluginDir = path.join(tmpDir, '.yarn/cache/vite-plugin-gherkin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'vite-plugin-gherkin', + version: '0.2.0', + exports: { '.': './index.js' }, + peerDependencies: { vitest: '^4.1.0' }, + }), + ); + fs.writeFileSync(path.join(pluginDir, 'index.js'), 'module.exports = {};\n'); + fs.writeFileSync( + path.join(tmpDir, '.pnp.cjs'), + [ + "const path = require('node:path');", + 'module.exports = {', + ' resolveToUnqualified(request) {', + " if (request !== 'vite-plugin-gherkin') throw new Error('not found');", + " return path.join(__dirname, '.yarn/cache/vite-plugin-gherkin');", + ' },', + '};', + '', + ].join('\n'), + ); + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: pnp\n'); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.yarn)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + resolutions: Record; + }; + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(pkg.resolutions.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); + }); + + it('preserves existing Vitest when dependency peer metadata is unavailable', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + 'vite-plugin-gherkin': '0.2.0', + vitest: '^4.1.0', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: '^4.1.0', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + + it.each([ + { + name: 'compilerOptions.types', + writeReference: (projectPath: string) => + fs.writeFileSync( + path.join(projectPath, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { types: ['vitest/globals'] } }), + ), + }, + { + name: 'nested compilerOptions.types', + writeReference: (projectPath: string) => { + const configDir = path.join(projectPath, 'config'); + fs.mkdirSync(configDir); + fs.writeFileSync( + path.join(configDir, 'tsconfig.test.json'), + JSON.stringify({ compilerOptions: { types: ['vitest/globals'] } }), + ); + }, + }, + { + name: 'vitest/package.json', + writeReference: (projectPath: string) => + fs.writeFileSync( + path.join(projectPath, 'version.ts'), + "import metadata from 'vitest/package.json';\nconsole.log(metadata.version);\n", + ), + }, + { + name: 'require.resolve', + writeReference: (projectPath: string) => + fs.writeFileSync( + path.join(projectPath, 'resolve.cjs'), + "module.exports = require.resolve('vitest');\n", + ), + }, + ])('keeps package-local Vitest for retained $name references', ({ writeReference }) => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest' }, + overrides: { vite: 'npm:@voidzero-dev/vite-plus-core@latest' }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + writeReference(tmpDir); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + + it('does not treat @vitest/eslint-plugin as runner usage', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/eslint-plugin': '^1.6.0', + '@vitest/utils': '^4.1.8', + vitest: '4.1.8', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: '4.1.8', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'eslint.config.js'), + "import vitest from '@vitest/eslint-plugin';\nimport { diff } from '@vitest/utils';\nexport default [vitest.configs.recommended, diff];\n", + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies['@vitest/eslint-plugin']).toBe('^1.6.0'); + expect(pkg.devDependencies['@vitest/utils']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(pkg.overrides.vitest).toBeUndefined(); + }); + + it('reconciles vitest and vite-plus in the workspace package that needs them', () => { + const appDir = path.join(tmpDir, 'packages/app'); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'root', + private: true, + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(appDir, 'package.json'), + JSON.stringify({ + name: 'app', + devDependencies: { + 'vite-plus': '^0.1.24', + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24', + '@vitest/ui': '^4.1.8', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + 'catalog:', + ' vite-plus: latest', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + const workspaceInfo = { + ...makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + isMonorepo: true, + workspacePatterns: ['packages/*'], + packages: [{ name: 'app', path: 'packages/app' }], + }; + + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(true); + ensureVitePlusBootstrap(workspaceInfo); + + const rootPkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + const appPkg = readJson(path.join(appDir, 'package.json')) as { + devDependencies: Record; + }; + expect(rootPkg.devDependencies.vitest).toBeUndefined(); + expect(appPkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(appPkg.devDependencies['@vitest/ui']).toBe(VITEST_VERSION); + expect(appPkg.devDependencies.vitest).toBe('catalog:'); + expect(JSON.stringify(appPkg)).not.toContain('@voidzero-dev/vite-plus-test'); + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(false); + }); + + it('restores an opt-in browser provider used only through a Vite+ shim', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'browser-app', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + [ + "import { defineConfig } from 'vite-plus';", + "import { playwright } from 'vite-plus/test/browser-playwright';", + 'export default defineConfig({ test: { browser: { enabled: true, provider: playwright() } } });', + ].join('\n'), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite-plus: latest', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/browser-playwright']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.playwright).toBe('*'); + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('resolves a Vitest peer catalog before removing its managed catalog entry', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'peer-library', + devDependencies: { 'vite-plus': 'catalog:' }, + peerDependencies: { vitest: 'catalog:test' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vite-plus: latest', + 'catalogs:', + ' test:', + ' vitest: ^4.0.0', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + peerDependencies: Record; + }; + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + expect(pkg.devDependencies.vitest).toBeUndefined(); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalogs: Record>; + }; + expect(workspace.catalogs.test.vitest).toBeUndefined(); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('keeps Vitest managed when promoting a peer-only browser provider', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'browser-library', + devDependencies: { 'vite-plus': 'catalog:' }, + peerDependencies: { '@vitest/browser-playwright': '^4.0.0' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + peerDependencies: Record; + }; + expect(pkg.peerDependencies['@vitest/browser-playwright']).toBe('^4.0.0'); + expect(pkg.devDependencies['@vitest/browser-playwright']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.playwright).toBe('*'); + expect(pkg.devDependencies.vitest).toBe('catalog:'); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + overrides: Record; + }; + expect(workspace.catalog.vitest).toBe(VITEST_VERSION); + expect(workspace.overrides.vitest).toBe('catalog:'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('rewrites whitespace-tolerant Vitest directives without leaving rerun mutations', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'typed-library', + devDependencies: { vite: '^7.0.0', vitest: '^4.0.0' }, + }), + ); + fs.writeFileSync(path.join(tmpDir, 'env.d.ts'), '/// \n'); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + + const firstPackageJson = fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'); + const firstWorkspace = fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf8'); + const firstDirective = fs.readFileSync(path.join(tmpDir, 'env.d.ts'), 'utf8'); + + expect(firstPackageJson).not.toContain('"vitest"'); + expect(firstWorkspace).not.toContain('vitest:'); + expect(firstDirective).toContain('types = "vite-plus/test"'); + + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + expect(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8')).toBe(firstPackageJson); + expect(fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf8')).toBe(firstWorkspace); + expect(fs.readFileSync(path.join(tmpDir, 'env.d.ts'), 'utf8')).toBe(firstDirective); + }); + + it('does not remain pending for an object-valued nested Vitest override', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nested-override', + devDependencies: { 'vite-plus': 'latest' }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: { '@vitest/runner': '4.0.0' }, + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + expect(result.changed).toBe(false); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + overrides: Record; + }; + expect(pkg.overrides.vitest).toEqual({ '@vitest/runner': '4.0.0' }); + }); + + it('removes a stale vitest wrapper override for a common-case npm project (no @vitest/* dep, no vitest source)', () => { + // v0.2.1 spec: vite-plus consumes upstream vitest directly, so a project that + // does NOT use vitest directly must NOT carry a managed `vitest` override — + // it arrives transitively through vite-plus. A pre-existing stale wrapper + // override (`npm:@voidzero-dev/vite-plus-test@*`) is REMOVED entirely while + // the `vite` alias stays. The bootstrap is idempotent: a second detect is + // false. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest' }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + overrides: Record; + }; + expect(pkg.overrides.vitest).toBeUndefined(); + expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + + it('keeps vitest managed for a direct-usage npm project (@vitest/coverage-v8) and aligns coverage', () => { + // The project lists `@vitest/coverage-v8`, so it USES vitest directly: the + // managed `vitest` override is kept (re-pinned to the bundled vitest version, + // off the stale wrapper) AND the coverage provider is aligned to that version. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-v8': '^4.1.8', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + // vitest stays managed (the stale wrapper is re-pinned to the bundled version). + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + // Coverage provider aligned to the same bundled vitest version. + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + + it('removes managed vitest catalog/override/peer entries from pnpm-workspace.yaml in the common case', () => { + // pnpm-workspace.yaml common-case removal: a project with no @vitest/* dep + // and no vitest source must have every managed `vitest` entry (catalog, + // override, peer rule) stripped from the workspace file so vitest resolves + // transitively through vite-plus. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vitest: npm:@voidzero-dev/vite-plus-test@latest', + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + " vitest: 'catalog:'", + 'peerDependencyRules:', + ' allowAny:', + ' - vite', + ' - vitest', + ' allowedVersions:', + " vite: '*'", + " vitest: '*'", + '', + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + overrides: Record; + peerDependencyRules: { allowAny: string[]; allowedVersions: Record }; + }; + // Managed `vitest` is gone from every sink; `vite` stays managed. + expect(workspace.catalog.vitest).toBeUndefined(); + expect(workspace.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(workspace.overrides.vitest).toBeUndefined(); + expect(workspace.overrides.vite).toBe('catalog:'); + expect(workspace.peerDependencyRules.allowAny).toEqual(['vite']); + expect(workspace.peerDependencyRules.allowedVersions).toEqual({ vite: '*' }); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('re-pins a behind vite-plus spec so the upgrade moves off the old version (urllib)', () => { + // urllib pinned vite-plus to a concrete 0.1.x range. A spec that stays at + // ^0.1.24 keeps the lockfile on the old resolution; the upgrade must re-pin + // it to the migrating toolchain target (here the mocked VITE_PLUS_VERSION + // 'latest', materialized as `catalog:` in a pnpm-workspace.yaml project) so + // the reinstall resolves the new version. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'urllib', + devDependencies: { + 'vite-plus': '^0.1.24', + vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24', + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24', + }, + pnpm: {}, + devEngines: { + packageManager: { name: 'pnpm', version: '11.7.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'overrides:', + " vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24'", + " vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24'", + ].join('\n'), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + // vite-plus must no longer be pinned to the old 0.1.x range. + expect(pkg.devDependencies['vite-plus']).not.toContain('0.1.24'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('uses a concrete vite-plus version when pnpm config stays in package.json', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + dependencies: { 'vite-plus': 'latest' }, + pnpm: {}, + }), + ); + + const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + expect(result.changed).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + pnpm: { overrides: Record }; + }; + expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.pnpm.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + }); + + it('normalizes an existing catalog vite-plus pin when pnpm config stays in package.json', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + pnpm: { + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + peerDependencyRules: { + allowAny: ['vite', 'vitest'], + allowedVersions: { vite: '*', vitest: '*' }, + }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + expect(result.changed).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('normalizes catalog vite-plus pins outside devDependencies when pnpm config stays in package.json', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + dependencies: { 'vite-plus': 'catalog:' }, + optionalDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + pnpm: { + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + peerDependencyRules: { + allowAny: ['vite', 'vitest'], + allowedVersions: { vite: '*', vitest: '*' }, + }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + expect(result.changed).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + dependencies: Record; + optionalDependencies: Record; + }; + expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.dependencies['vite-plus']).toBe('latest'); + expect(pkg.optionalDependencies['vite-plus']).toBe('latest'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('uses a concrete vite-plus version for pnpm monorepos that keep pnpm config in package.json', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + dependencies: { 'vite-plus': 'latest' }, + pnpm: {}, + }), + ); + + const result = ensureVitePlusBootstrap({ + ...makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + isMonorepo: true, + workspacePatterns: ['packages/*'], + }); + + expect(result.changed).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['vite-plus']).toBe('latest'); + }); + + it('normalizes yarn monorepo dependency specs through the shared catalog', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest', vite: '^7.0.0' }, + devEngines: { + packageManager: { name: 'yarn', version: '4.0.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(true); + const result = ensureVitePlusBootstrap({ + ...makeWorkspaceInfo(tmpDir, PackageManager.yarn), + isMonorepo: true, + workspacePatterns: ['packages/*'], + }); + + expect(result.changed).toBe(true); + expect(result.packageManagerConfig).toBe(true); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + resolutions: Record; + }; + expect(pkg.devDependencies.vite).toBe('catalog:'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.resolutions.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + const yarnrc = readYamlObject(path.join(tmpDir, '.yarnrc.yml')) as { + nodeLinker: string; + catalog: Record; + }; + expect(yarnrc.nodeLinker).toBe('node-modules'); + expect(yarnrc.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no catalog entry is written for it. + expect(yarnrc.catalog.vitest).toBeUndefined(); + expect(yarnrc.catalog['vite-plus']).toBe('latest'); + }); + + it('completes missing pnpm workspace peer dependency rules', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, }, }), ); @@ -1568,13 +2606,19 @@ describe('ensureVitePlusBootstrap', () => { expect(result.changed).toBe(true); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + // Common case (no @vitest/* dep, no vitest source): the pre-existing managed + // `vitest` catalog/override/peer entries are REMOVED — only `vite` stays + // managed. vitest arrives transitively through vite-plus. const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + overrides: Record; peerDependencyRules: { allowAny: string[]; allowedVersions: Record }; }; - expect(workspace.peerDependencyRules.allowAny).toEqual(['vite', 'vitest']); + expect(workspace.catalog.vitest).toBeUndefined(); + expect(workspace.overrides.vitest).toBeUndefined(); + expect(workspace.peerDependencyRules.allowAny).toEqual(['vite']); expect(workspace.peerDependencyRules.allowedVersions).toEqual({ vite: '*', - vitest: '*', }); }); @@ -1694,6 +2738,35 @@ describe('ensureVitePlusBootstrap', () => { }; expect(workspace.packages).toEqual(['packages/*']); }); + + it('writes catalog specs during the first standalone Yarn migration', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { vite: '^7.0.0', vitest: '^4.0.0' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'example.spec.ts'), + "import { expect, it } from 'vitest';\nit('works', () => expect(true).toBe(true));\n", + ); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.yarn); + + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + + const firstPackageJson = fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'); + const firstYarnrc = fs.readFileSync(path.join(tmpDir, '.yarnrc.yml'), 'utf8'); + const pkg = JSON.parse(firstPackageJson) as { devDependencies: Record }; + expect(pkg.devDependencies.vite).toBe('catalog:'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); + + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + expect(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8')).toBe(firstPackageJson); + expect(fs.readFileSync(path.join(tmpDir, '.yarnrc.yml'), 'utf8')).toBe(firstYarnrc); + }); }); describe('rewriteStandaloneProject pnpm workspace yaml', () => { @@ -1786,9 +2859,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { const overrides = pnpm.overrides as Record; expect(overrides['some-pkg']).toBe('1.0.0'); expect(overrides.vite).toBeDefined(); - // vitest is pinned via overrides so downstream projects resolve a single - // vitest copy (the one vp-cli ships). - expect(overrides.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no override is written — it arrives transitively through vite-plus. + expect(overrides.vitest).toBeUndefined(); // peerDependencyRules should be present expect(pnpm.peerDependencyRules).toBeDefined(); @@ -1834,9 +2907,10 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { const yaml = readYaml(path.join(tmpDir, 'pnpm-workspace.yaml')); expect(yaml).toContain("vite: 'catalog:'"); - // vitest is now a managed override key — it resolves through the catalog - // like vite does. - expect(yaml).toContain("vitest: 'catalog:'"); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no `vitest` override is written — it arrives transitively through + // vite-plus. + expect(yaml).not.toContain('vitest'); }); it('rewrites named catalogs in pnpm-workspace.yaml without adding new entries', () => { @@ -1879,16 +2953,16 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { catalogs: Record>; }; expect(yaml.overrides.vite).toBe('catalog:vite7'); - // vitest is now a managed override key — it is added to overrides as a - // `catalog:` reference, and its catalog entry is rewritten to the pinned - // vitest version vp-cli ships. - expect(yaml.overrides.vitest).toBe('catalog:'); - expect(yaml.catalog.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no override is added and the pre-existing managed `vitest` catalog + // entries (default + named) are REMOVED — it arrives transitively through + // vite-plus. + expect(yaml.overrides.vitest).toBeUndefined(); + expect(yaml.catalog?.vitest).toBeUndefined(); expect(yaml.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(yaml.catalogs.vite7.react).toBe('^18.0.0'); expect(yaml.catalogs.vite7['vite-plus']).toBe('latest'); - // Named catalog vitest entries are also pinned to the managed override version. - expect(yaml.catalogs.test.vitest).toBe('4.1.9'); + expect(yaml.catalogs.test.vitest).toBeUndefined(); expect(yaml.catalogs.test.tsdown).toBeUndefined(); expect(yaml.catalogs.test['vite-plus']).toBeUndefined(); @@ -1899,10 +2973,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(pkg.devDependencies.vite).toBe('catalog:vite7'); expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // vitest peer `catalog:` is resolved against the pre-rewrite catalog - // (which still holds the user's `^4.0.0`); only the catalog file itself - // is later rewritten to the pinned vp-cli version. The peer range stays - // as the user wrote it. + // Peer declarations do not keep the managed catalog alive. Resolve the + // catalog entry to its public range before pruning it so the peer cannot + // dangle after migration. expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); }); @@ -2029,11 +3102,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(overrides['some-pkg']['@vitest/browser-playwright']).toBe('4.0.0'); }); - it('leaves an already-declared coverage provider untouched (no pin, no override)', () => { - // Coverage providers are vitest PEER deps the project installs and versions - // ITSELF. vite-plus never pins or overrides them: the user owns the provider - // version. (The runtime guard in define-config.ts only fail-fasts on a skew - // at `vp test --coverage` time; it does not rewrite the project's deps.) + it('aligns already-declared coverage providers without adding provider overrides', () => { + // Coverage providers have an exact vitest peer and must match the runner. + // Align their dependency specs directly; no provider override is needed. fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2052,9 +3123,8 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { devDependencies: Record; overrides?: Record; }; - // Provider versions are preserved exactly as the user declared them. - expect(pkg.devDependencies['@vitest/coverage-v8']).toBe('^4.0.0'); - expect(pkg.devDependencies['@vitest/coverage-istanbul']).toBe('^4.0.0'); + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/coverage-istanbul']).toBe(VITEST_VERSION); // vitest itself is still pinned to the bundled version. expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); // …and coverage is never written into the override sink. @@ -2063,6 +3133,100 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(overrides['@vitest/coverage-istanbul']).toBeUndefined(); }); + it('removes direct vitest in the same pass that rewrites ordinary vitest imports', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { vite: '^7.0.0', vitest: '^4.0.0' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'example.spec.ts'), + "import { expect, it } from 'vitest';\nit('works', () => expect(true).toBe(true));\n", + ); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.npm), true, true); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(pkg.overrides.vitest).toBeUndefined(); + expect(fs.readFileSync(path.join(tmpDir, 'example.spec.ts'), 'utf8')).toContain( + "from 'vite-plus/test'", + ); + }); + + it.each(['dependencies', 'devDependencies', 'optionalDependencies'] as const)( + 'detects package-wide upstream Vitest imports from %s without installed metadata', + (dependencyGroup) => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nuxt-project', + [dependencyGroup]: { '@nuxt/test-utils': '^4.0.3' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'nuxt.spec.ts'), + "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", + ); + fs.writeFileSync(path.join(tmpDir, 'unit.spec.ts'), "import { expect } from 'vitest';\n"); + + expect(detectNuxtTestUtilsVitestImportFiles(tmpDir)).toEqual([ + path.join(tmpDir, 'nuxt.spec.ts'), + path.join(tmpDir, 'unit.spec.ts'), + ]); + }, + ); + + it('preserves all upstream Vitest imports in a Nuxt test-utils package', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nuxt-project', + devDependencies: { + vite: '^7.0.0', + vitest: '^4.0.0', + '@nuxt/test-utils': '^4.0.3', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'nuxt.spec.ts'), + [ + "import { vi } from 'vitest';", + "import { defineConfig } from 'vitest/config';", + "import { mockNuxtImport } from '@nuxt/test-utils/runtime';", + '', + ].join('\n'), + ); + fs.writeFileSync(path.join(tmpDir, 'unit.spec.ts'), "import { expect } from 'vitest';\n"); + const report = createMigrationReport(); + + rewriteStandaloneProject( + tmpDir, + makeWorkspaceInfo(tmpDir, PackageManager.npm), + true, + true, + report, + ); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + const nuxtTest = fs.readFileSync(path.join(tmpDir, 'nuxt.spec.ts'), 'utf8'); + expect(nuxtTest).toContain("from 'vitest'"); + expect(nuxtTest).toContain("from 'vitest/config'"); + expect(fs.readFileSync(path.join(tmpDir, 'unit.spec.ts'), 'utf8')).toContain("from 'vitest'"); + expect(report.preservedNuxtVitestImportFileCount).toBe(2); + }); + it('does not add a coverage provider the project never declared', () => { // A project that uses vitest WITHOUT a coverage provider must not have one // injected by the migration — the user installs it only if they need it. @@ -2489,6 +3653,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { // no catalog entry is written for it and it must self-resolve. expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', VITEST_VERSION); expect(devDeps.webdriverio).toBe('*'); + expect(devDeps.vitest).toBe('catalog:'); const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { allowBuilds: Record; @@ -2679,6 +3844,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { allowBuilds: Record; }; expect(yaml.catalog['@vitest/browser-webdriverio']).toBe('4.0.0'); + expect(yaml.catalog.vitest).toBe(VITEST_VERSION); expect(yaml.allowBuilds.edgedriver).toBe(true); expect(yaml.allowBuilds.geckodriver).toBe(true); }); @@ -2974,8 +4140,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { catalogs: Record>; }; expect(yaml.overrides.vite).toBe('catalog:vite7'); - // vitest is now injected into overrides as a managed override key. - expect(yaml.overrides.vitest).toBe('catalog:'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no `vitest` override is injected. + expect(yaml.overrides.vitest).toBeUndefined(); expect(yaml.overrides.react).toBe('^18.0.0'); expect(yaml.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); @@ -3019,8 +4186,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { overrides: Record; }; expect(yaml.overrides.vite).toBe('catalog:'); - // vitest is now a managed override key — added to overrides as catalog: ref. - expect(yaml.overrides.vitest).toBe('catalog:'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no `vitest` override is added. + expect(yaml.overrides.vitest).toBeUndefined(); }); it('does not resolve peer dependency catalog specs to migrated aliases', () => { @@ -3052,8 +4220,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { peerDependencies: Record; }; expect(pkg.peerDependencies.vite).toBe('*'); - // vitest is now a managed override key — peer dep catalog refs that - // resolve to the override target are coerced to '*'. + // Never expose the deleted wrapper alias as a public peer range. expect(pkg.peerDependencies.vitest).toBe('*'); }); @@ -4239,9 +5406,9 @@ describe('rewriteMonorepo yarn catalog', () => { expect(yarnrc.nodeLinker).toBe('node-modules'); expect(yarnrc.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(yarnrc.catalogs.vite7.react).toBe('^18.0.0'); - // vitest is now a managed override key — existing catalog entries are - // rewritten to the pinned vp-cli vitest version. - expect(yarnrc.catalogs.test.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing named-catalog `vitest` entry is REMOVED. + expect(yarnrc.catalogs.test.vitest).toBeUndefined(); expect(yarnrc.catalogs.test.oxlint).toBeUndefined(); const pkg = readJson(path.join(tmpDir, 'package.json')) as { @@ -4250,9 +5417,6 @@ describe('rewriteMonorepo yarn catalog', () => { }; expect(pkg.devDependencies.vite).toBe('catalog:vite7'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // vitest peer `catalog:test` is resolved against the pre-rewrite catalog - // (which still holds the user's `^4.0.0`). The peer range stays as the - // user wrote it; only the catalog file itself is later rewritten. expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); }); }); @@ -4346,9 +5510,9 @@ describe('rewriteMonorepo bun catalog', () => { expect(pkg.workspaces.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.workspaces.catalog['vite-plus']).toBe('latest'); expect(pkg.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); - // vitest is now a managed override key — pre-existing catalog entries are - // rewritten to the pinned vp-cli vitest version. - expect(pkg.catalog.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing catalog `vitest` entry is REMOVED. + expect(pkg.catalog.vitest).toBeUndefined(); expect(pkg.catalog.tsdown).toBeUndefined(); expect(pkg.catalog.react).toBe('^19.0.0'); expect(pkg.catalog['vite-plus']).toBeUndefined(); @@ -4408,16 +5572,14 @@ describe('rewriteMonorepo bun catalog', () => { expect(pkg.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.catalogs.build.react).toBe('^19.0.0'); expect(pkg.catalogs.build.tsdown).toBeUndefined(); - // vitest is now a managed override key — existing catalog entries are - // rewritten to the pinned version and `overrides.vitest` is injected - // as a `catalog:` ref so bun resolves it through the catalog. - expect(pkg.catalogs.test.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing named-catalog `vitest` entry is REMOVED and no + // `overrides.vitest` is injected. + expect(pkg.catalogs.test.vitest).toBeUndefined(); expect(pkg.overrides.vite).toBe('catalog:build'); - expect(pkg.overrides.vitest).toBe('catalog:'); + expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.devDependencies.vite).toBe('catalog:build'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // vitest peer `catalog:test` is resolved against the pre-rewrite catalog - // (which still holds the user's `^4.0.0`). Peer range stays as-is. expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); }); @@ -4453,9 +5615,9 @@ describe('rewriteMonorepo bun catalog', () => { expect(pkg.workspaces.catalog['vite-plus']).toBe('latest'); expect(pkg.workspaces.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.workspaces.catalogs.build.oxlint).toBeUndefined(); - // vitest is a managed override key — existing catalog entries are - // rewritten to the pinned vp-cli vitest version. - expect(pkg.workspaces.catalogs.test.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing named-catalog `vitest` entry is REMOVED. + expect(pkg.workspaces.catalogs.test.vitest).toBeUndefined(); expect(pkg.workspaces.catalogs.test.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.overrides.vite).toBe('catalog:'); }); diff --git a/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts b/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts new file mode 100644 index 0000000000..a25bc2dbb3 --- /dev/null +++ b/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { prepareNpmViteAliasReinstall } from '../npm-reinstall.ts'; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-plus-npm-reinstall-')); + tempDirs.push(tempDir); + return tempDir; +} + +function writePackage(packagePath: string, name: string): void { + fs.mkdirSync(packagePath, { recursive: true }); + fs.writeFileSync(path.join(packagePath, 'package.json'), JSON.stringify({ name })); +} + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('prepareNpmViteAliasReinstall', () => { + it('prunes stale real-Vite lock entries and installations while preserving the core alias', () => { + const rootDir = createTempDir(); + const staleRootVite = path.join(rootDir, 'node_modules', 'vite'); + const staleNestedVite = path.join(rootDir, 'node_modules', 'consumer', 'node_modules', 'vite'); + const coreVite = path.join(rootDir, 'packages', 'app', 'node_modules', 'vite'); + writePackage(staleRootVite, 'vite'); + writePackage(staleNestedVite, 'vite'); + writePackage(coreVite, '@voidzero-dev/vite-plus-core'); + fs.writeFileSync( + path.join(rootDir, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { name: 'test' }, + 'node_modules/vite': { + version: '7.3.5', + resolved: 'https://registry.npmjs.org/vite/-/vite-7.3.5.tgz', + }, + 'node_modules/consumer/node_modules/vite': { + version: '7.3.5', + resolved: 'https://registry.npmjs.org/vite/-/vite-7.3.5.tgz', + }, + 'packages/app/node_modules/vite': { + name: '@voidzero-dev/vite-plus-core', + version: '0.2.1', + }, + }, + }), + ); + + expect( + prepareNpmViteAliasReinstall(rootDir, [rootDir, path.join(rootDir, 'packages', 'app')]), + ).toBe(true); + + const lock = JSON.parse(fs.readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8')) as { + packages: Record; + }; + expect(lock.packages['node_modules/vite']).toBeUndefined(); + expect(lock.packages['node_modules/consumer/node_modules/vite']).toBeUndefined(); + expect(lock.packages['packages/app/node_modules/vite']).toBeDefined(); + expect(fs.existsSync(staleRootVite)).toBe(false); + expect(fs.existsSync(staleNestedVite)).toBe(false); + expect(fs.existsSync(coreVite)).toBe(true); + }); + + it('removes a stale workspace-local install when no package-lock exists', () => { + const rootDir = createTempDir(); + const workspaceDir = path.join(rootDir, 'packages', 'app'); + const staleVite = path.join(workspaceDir, 'node_modules', 'vite'); + writePackage(staleVite, 'vite'); + + expect(prepareNpmViteAliasReinstall(rootDir, [rootDir, workspaceDir])).toBe(true); + expect(fs.existsSync(staleVite)).toBe(false); + expect(prepareNpmViteAliasReinstall(rootDir, [rootDir, workspaceDir])).toBe(false); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index d9288bb2e8..da2203648a 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -45,6 +45,7 @@ import { } from '../utils/tsconfig.ts'; import type { PackageDependencies } from '../utils/types.ts'; import { detectWorkspace } from '../utils/workspace.ts'; +import { checkRolldownCompatibility } from './compat-runner.ts'; import { addFrameworkShim, checkVitestVersion, @@ -58,6 +59,7 @@ import { detectPendingCoreMigration, detectPrettierProject, detectVitePlusBootstrapPending, + detectYarnPnpMode, ensureVitePlusBootstrap, finalizeCoreMigrationForExistingVitePlus, hasFrameworkShim, @@ -68,6 +70,7 @@ import { migrateEslintToOxlint, migrateNodeVersionManagerFile, migratePrettierToOxfmt, + configureYarnNodeModulesMode, preflightGitHooksSetup, rewriteMonorepo, rewriteStandaloneProject, @@ -78,6 +81,7 @@ import { type Framework, type NodeVersionManagerDetection, } from './migrator.ts'; +import { prepareNpmViteAliasReinstall } from './npm-reinstall.ts'; import { addMigrationWarning, createMigrationReport, type MigrationReport } from './report.ts'; async function confirmNodeVersionFileMigration( @@ -124,6 +128,47 @@ async function confirmFrameworkShim(framework: Framework, interactive: boolean): return true; } +async function ensureYarnNodeModulesMode( + rootDir: string, + packageManager: PackageManager | undefined, + packageManagerVersion: string, + interactive: boolean, +): Promise { + if (packageManager !== PackageManager.yarn) { + return false; + } + + const pnp = detectYarnPnpMode(rootDir, packageManagerVersion); + if (!pnp) { + return false; + } + + prompts.log.warn(`⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP).`); + if (pnp.source === 'environment') { + cancelAndExit( + 'YARN_NODE_LINKER=pnp overrides project configuration. Set it to node-modules or unset it, then re-run `vp migrate`.', + 1, + ); + } + + if (interactive) { + const confirmed = await prompts.confirm({ + message: 'Switch this project to Yarn node-modules mode and continue?', + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + if (!confirmed) { + cancelAndExit('Migration cancelled. Vite+ requires Yarn node-modules mode.'); + } + } + + configureYarnNodeModulesMode(rootDir); + prompts.log.success('✔ Switched Yarn to node-modules mode'); + return true; +} + async function fixBaseUrlForWorkspace( workspaceInfo: { rootDir: string; packages?: WorkspacePackage[] }, fixBaseUrl: boolean, @@ -340,6 +385,7 @@ interface MigrationSetupPlan { interface MigrationPlan extends MigrationSetupPlan { packageManager: PackageManager; + yarnPnpConverted: boolean; migratePrettier: boolean; prettierConfigFile?: string; fixBaseUrl: boolean; @@ -628,12 +674,19 @@ function getExistingVitePlusSetupOptions( async function collectMigrationPlan( rootDir: string, detectedPackageManager: PackageManager | undefined, + detectedPackageManagerVersion: string, options: MigrationOptions, packages?: WorkspacePackage[], ): Promise { // 1. Package manager selection const packageManager = detectedPackageManager ?? (await selectPackageManager(options.interactive, true)); + const yarnPnpConverted = await ensureYarnNodeModulesMode( + rootDir, + packageManager, + detectedPackageManager ? detectedPackageManagerVersion : 'latest', + options.interactive, + ); // 2. Shared setup/tooling decisions const setupPlan = await collectMigrationSetupPlan(rootDir, packageManager, options, packages); @@ -667,6 +720,7 @@ async function collectMigrationPlan( const plan: MigrationPlan = { packageManager, + yarnPnpConverted, ...setupPlan, migratePrettier, prettierConfigFile: prettierProject.configFile, @@ -788,6 +842,13 @@ function showMigrationSummary(options: { } log(`${styleText('gray', '•')} ${parts.join(', ')}`); } + if (report.preservedNuxtVitestImportFileCount > 0) { + log( + `${styleText('gray', '•')} Kept upstream \`vitest\` imports in ${report.preservedNuxtVitestImportFileCount} ${ + report.preservedNuxtVitestImportFileCount === 1 ? 'file' : 'files' + } for @nuxt/test-utils compatibility`, + ); + } if (report.eslintMigrated) { log(`${styleText('gray', '•')} ESLint rules migrated to Oxlint`); } @@ -825,22 +886,6 @@ function showMigrationSummary(options: { } } -async function checkRolldownCompatibility(rootDir: string, report: MigrationReport): Promise { - try { - const { resolveConfig } = await import('../index.js'); - const { checkManualChunksCompat } = await import('./compat.js'); - // Use 'runner' configLoader to avoid Rolldown bundling the config file, - // which prints UNRESOLVED_IMPORT warnings that cannot be suppressed via logLevel. - const config = await resolveConfig( - { root: rootDir, logLevel: 'silent', configLoader: 'runner' }, - 'build', - ); - checkManualChunksCompat(config.build?.rollupOptions?.output, report); - } catch { - // Config resolution may fail — skip compatibility check silently - } -} - async function downloadSupportedPackageManager(options: { rootDir: string; packageManager: PackageManager; @@ -906,6 +951,7 @@ async function executeMigrationPlan( report: MigrationReport; }> { const report = createMigrationReport(); + report.packageManagerBootstrapConfigured = plan.yarnPnpConverted; const migrationProgress = interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; let migrationProgressStarted = false; const updateMigrationProgress = (message: string) => { @@ -1081,6 +1127,9 @@ async function executeMigrationPlan( plan.packageManager === PackageManager.npm || plan.packageManager === PackageManager.bun ? ['--force'] : ['--no-frozen-lockfile']; + if (plan.packageManager === PackageManager.npm) { + prepareNpmViteAliasReinstall(workspaceInfo.rootDir, getWorkspaceProjectPaths(workspaceInfo)); + } updateMigrationProgress('Installing dependencies'); const finalInstallSummary = await runViteInstall( workspaceInfo.rootDir, @@ -1137,10 +1186,17 @@ async function main() { workspaceInfoOptional.rootDir, ) as PackageDependencies | null; if (hasVitePlusDependency(rootPkg) && !isForceOverrideMode()) { - let didMigrate = false; + const yarnPnpConverted = await ensureYarnNodeModulesMode( + workspaceInfoOptional.rootDir, + workspaceInfoOptional.packageManager, + workspaceInfoOptional.packageManagerVersion, + options.interactive, + ); + let didMigrate = yarnPnpConverted; let installDurationMs = 0; let finalInstallOk = true; const report = createMigrationReport(); + report.packageManagerBootstrapConfigured = yarnPnpConverted; const migrationProgress = options.interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; @@ -1176,6 +1232,7 @@ async function main() { const vitePlusBootstrapPending = detectVitePlusBootstrapPending( workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, + workspaceInfoOptional.packages, ); let packageManager: PackageManager | undefined = vitePlusBootstrapPending ? (workspaceInfoOptional.packageManager ?? @@ -1254,7 +1311,7 @@ async function main() { workspaceInfoOptional.packages, ); - let needsInstall = false; + let needsInstall = yarnPnpConverted; if (vitePlusBootstrapPending) { const downloadResult = await ensureExistingPackageManager(); if (downloadResult && packageManager) { @@ -1392,6 +1449,12 @@ async function main() { const resolved = await ensureExistingPackageManager(); updateMigrationProgress('Installing dependencies'); const resolvedVersion = resolved?.version ?? packageManagerVersion; + if (packageManager === PackageManager.npm) { + prepareNpmViteAliasReinstall( + workspaceInfoOptional.rootDir, + getWorkspaceProjectPaths(workspaceInfoOptional), + ); + } const installSummary = await runViteInstall( workspaceInfoOptional.rootDir, options.interactive, @@ -1480,6 +1543,7 @@ async function main() { const plan = await collectMigrationPlan( workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, + workspaceInfoOptional.packageManagerVersion, options, workspaceInfoOptional.packages, ); diff --git a/packages/cli/src/migration/compat-protocol.ts b/packages/cli/src/migration/compat-protocol.ts new file mode 100644 index 0000000000..64f8459db4 --- /dev/null +++ b/packages/cli/src/migration/compat-protocol.ts @@ -0,0 +1 @@ +export const ROLLDOWN_COMPAT_RESULT_PREFIX = 'VITE_PLUS_ROLLDOWN_COMPAT_RESULT='; diff --git a/packages/cli/src/migration/compat-runner.ts b/packages/cli/src/migration/compat-runner.ts new file mode 100644 index 0000000000..62ad62c319 --- /dev/null +++ b/packages/cli/src/migration/compat-runner.ts @@ -0,0 +1,68 @@ +import { fileURLToPath } from 'node:url'; + +import { runCommandSilently } from '../utils/command.ts'; +import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './compat-protocol.ts'; +import { addMigrationWarning, type MigrationReport } from './report.ts'; + +export { ROLLDOWN_COMPAT_RESULT_PREFIX }; + +interface RolldownCompatibilityResult { + warnings: string[]; +} + +function parseRolldownCompatibilityResult(stdout: Buffer): RolldownCompatibilityResult | undefined { + const output = stdout.toString(); + const markerIndex = output.lastIndexOf(ROLLDOWN_COMPAT_RESULT_PREFIX); + if (markerIndex === -1) { + return undefined; + } + + const resultStart = markerIndex + ROLLDOWN_COMPAT_RESULT_PREFIX.length; + const resultEnd = output.indexOf('\n', resultStart); + const serialized = output.slice(resultStart, resultEnd === -1 ? undefined : resultEnd).trim(); + + try { + const result = JSON.parse(serialized) as Partial; + if ( + !Array.isArray(result.warnings) || + !result.warnings.every((item) => typeof item === 'string') + ) { + return undefined; + } + return { warnings: result.warnings }; + } catch { + return undefined; + } +} + +/** + * Resolve a project's Vite config in a child process before checking it for + * Rolldown-incompatible options. Config files execute arbitrary project code; + * isolating them prevents process-level handlers, explicit exits, and + * asynchronous crashes from terminating the migration itself. + */ +export async function checkRolldownCompatibility( + rootDir: string, + report: MigrationReport, +): Promise { + try { + const workerPath = fileURLToPath(new URL('./compat-worker.js', import.meta.url)); + const result = await runCommandSilently({ + command: process.execPath, + args: [workerPath, rootDir], + cwd: rootDir, + envs: process.env, + }); + + if (result.exitCode !== 0) { + return; + } + + const compatibilityResult = parseRolldownCompatibilityResult(result.stdout); + for (const warning of compatibilityResult?.warnings ?? []) { + addMigrationWarning(report, warning); + } + } catch { + // Config resolution is best-effort. Skip failures silently. + } +} diff --git a/packages/cli/src/migration/compat-worker.ts b/packages/cli/src/migration/compat-worker.ts new file mode 100644 index 0000000000..46c101e9a2 --- /dev/null +++ b/packages/cli/src/migration/compat-worker.ts @@ -0,0 +1,38 @@ +import { writeSync } from 'node:fs'; + +import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './compat-protocol.ts'; +import { checkManualChunksCompat } from './compat.ts'; +import { createMigrationReport } from './report.ts'; + +async function main(): Promise { + const rootDir = process.argv[2]; + if (!rootDir) { + return; + } + + try { + const { resolveConfig } = await import('../index.js'); + // Use 'runner' configLoader to avoid Rolldown bundling the config file, + // which prints UNRESOLVED_IMPORT warnings that cannot be suppressed via logLevel. + const config = await resolveConfig( + { root: rootDir, logLevel: 'silent', configLoader: 'runner' }, + 'build', + ); + const report = createMigrationReport(); + checkManualChunksCompat(config.build?.rollupOptions?.output, report); + writeSync( + process.stdout.fd, + `${ROLLDOWN_COMPAT_RESULT_PREFIX}${JSON.stringify({ warnings: report.warnings })}\n`, + ); + } catch { + // Config resolution may fail — skip compatibility checking silently. + } +} + +// Config plugins may leave active handles behind. Once the result has been +// written synchronously, terminate this disposable worker without waiting for +// project-owned cleanup. +main().then( + () => process.exit(0), + () => process.exit(0), +); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index ef6e6c94f1..7cab73984e 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -44,6 +44,7 @@ import { findTsconfigFiles, hasBaseUrlInTsconfig, hasTypesToRewriteInTsconfig, + hasVitestTypesInTsconfig, removeDeprecatedTsconfigFalseOption, rewriteTypesInTsconfig, } from '../utils/tsconfig.ts'; @@ -105,6 +106,42 @@ const PLAYWRIGHT_PROVIDER = '@vitest/browser-playwright'; // forcing pins dropped, while their catalog entries are PRESERVED. const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as const; +// Official `@vitest/*` packages are versioned in lockstep with vitest and carry +// an EXACT `vitest` peer (verified against the registry: `@vitest/coverage-v8`, +// `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, the browser +// family, and the runtime internals all pin `vitest: `), so any the +// project lists must match the bundled vitest or Vitest runs mixed copies (the +// `define-config.ts` coverage guard fail-fasts on exactly this skew). +// `@vitest/eslint-plugin` versions on its own line, and deprecated +// `@vitest/coverage-c8` never published on the Vitest 4 line, so neither may be +// pinned to the bundled Vitest version. +const VITEST_ALIGN_EXCLUDED = new Set([ + '@vitest/eslint-plugin', + // Deprecated at 0.33.0 and replaced by @vitest/coverage-v8. It does not + // publish versions on Vitest's current release line, so pinning it to the + // bundled Vitest version creates a dependency spec that does not exist. + '@vitest/coverage-c8', +]); + +// Official packages that do not declare a required `vitest` peer. Keep them +// aligned when a project lists them directly, but do not add a direct vitest +// merely because they are present. +const VITEST_DIRECT_USAGE_EXCLUDED = new Set([ + '@vitest/eslint-plugin', + '@vitest/expect', + '@vitest/mocker', + '@vitest/pretty-format', + '@vitest/runner', + '@vitest/snapshot', + '@vitest/spy', + '@vitest/utils', + '@vitest/ws-client', +]); + +function isAlignableVitestEcosystemPackage(name: string): boolean { + return name.startsWith('@vitest/') && !VITEST_ALIGN_EXCLUDED.has(name); +} + // Provider names whose stale pnpm overrides / resolutions are dropped during // migration: everything vite-plus owns (REMOVE_PACKAGES) plus the user-owned // opt-in providers. The provider DEP is preserved, but a leftover @@ -483,6 +520,207 @@ const PUBLIC_PEER_DEPENDENCY_FALLBACKS: Record = { vitest: '*', }; +// The managed override/catalog packages vite-plus writes and the detector +// requires. `vite` is ALWAYS managed (aliased to vite-plus-core). `vitest` is +// managed ONLY when the project uses vitest DIRECTLY — vite-plus consumes +// upstream vitest itself, so a non-vitest project gets it transitively through +// vite-plus and must NOT carry a managed `vitest` pin (which would drift on a +// future `vp update vite-plus`). When `usesVitest` is false the common-case +// removal logic ACTIVELY strips any lingering `vitest` entry. +function managedOverridePackages(usesVitest: boolean): Record { + if (usesVitest) { + return VITE_PLUS_OVERRIDE_PACKAGES; + } + // Drop only `vitest`; every other managed key (e.g. `vite`, and in + // force-override/CI mode the `@voidzero-dev/vite-plus-core` file: alias) stays. + return Object.fromEntries( + Object.entries(VITE_PLUS_OVERRIDE_PACKAGES).filter(([key]) => key !== 'vitest'), + ); +} + +// True iff a dependency field lists a vitest ecosystem package — any name that +// contains `vitest` other than bare `vitest` itself (e.g. `@vitest/coverage-v8`, +// `@vitest/browser-playwright`, `vitest-browser-svelte`). A bare `vitest` +// dependency alone is deliberately NOT a signal — a prior migration may have +// injected it transitively-redundantly, so it must not keep the project pinned +// to a managed `vitest`. This mirrors the `isVitestAdjacent` signal used later +// when deciding to inject a direct `vitest`, so the two stay consistent. +function projectListsVitestEcosystemDep(pkg: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +}): boolean { + // Peer declarations do not install the package in this project; its consumer + // is responsible for satisfying that package's peers. + const dependencyGroups = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]; + return dependencyGroups.some((deps) => + deps + ? Object.keys(deps).some( + (name) => + name !== 'vitest' && + name.includes('vitest') && + // Excluded official packages either have no vitest peer or (for the + // ESLint plugin) only an optional `vitest: *` peer. Neither needs a + // direct install or workspace-wide override. + !VITEST_DIRECT_USAGE_EXCLUDED.has(name), + ) + : false, + ); +} + +// Detect installed dependencies whose package metadata declares a required +// Vitest peer. Package names are not authoritative: integrations such as +// `vite-plugin-gherkin` require Vitest without containing "vitest" in their +// own name. Optional peers do not require package-local provisioning. +function projectListsRequiredVitestPeer( + projectPath: string, + pkg: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + }, +): boolean { + const installGroups = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]; + const hasExistingVitest = installGroups.some( + (dependencies) => dependencies?.vitest !== undefined, + ); + const dependencyNames = new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ]); + dependencyNames.delete('vitest'); + dependencyNames.delete('vite'); + dependencyNames.delete(VITE_PLUS_NAME); + for (const name of VITEST_DIRECT_USAGE_EXCLUDED) { + dependencyNames.delete(name); + } + let metadataUnavailable = false; + + for (const name of dependencyNames) { + const metadata = detectPackageMetadata(projectPath, name); + if (!metadata) { + metadataUnavailable = true; + continue; + } + try { + const installedPkg = readJsonFile(path.join(metadata.path, 'package.json')) as { + peerDependencies?: Record; + peerDependenciesMeta?: Record; + }; + if ( + typeof installedPkg.peerDependencies?.vitest === 'string' && + installedPkg.peerDependenciesMeta?.vitest?.optional !== true + ) { + return true; + } + } catch { + metadataUnavailable = true; + } + } + // A clean checkout may not have node_modules/.pnp metadata yet. If the user + // already carries a direct Vitest while any dependency's peer contract is + // unknown, preserve it rather than risk removing the provider for an + // arbitrary integration such as vite-plugin-gherkin. A later migration with + // complete metadata can safely remove a genuinely redundant pin. + return metadataUnavailable && hasExistingVitest; +} + +// True iff the project uses vitest DIRECTLY — via a dependency that is expected +// to have a required vitest peer (see `projectListsVitestEcosystemDep`), an +// upstream `vitest` module specifier, a package-level @nuxt/test-utils +// compatibility boundary, or vitest browser mode. Drives +// whether the migration keeps `vitest` managed or removes it entirely; the +// browser-mode arm keeps it aligned with the direct-`vitest` injection below so +// an injected `catalog:` spec never dangles against a vitest-less catalog. +function projectUsesVitestDirectly( + projectPath: string, + pkg: { + dependencies?: Record; + optionalDependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }, + requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg), + preserveNuxtVitestImports = true, +): boolean { + return ( + projectListsVitestEcosystemDep(pkg) || + requiredVitestPeer || + // Browser packages declared only as peers still become direct installs: + // rewritePackageJson/reconcileVitePlusBootstrapPackage promote opt-in + // providers into devDependencies and treat the bundled browser packages as + // browser-mode intent. Account for that promotion before shared + // catalog/override ownership is decided, otherwise the promoted provider's + // exact Vitest peer is left unsatisfied under strict pnpm/Yarn layouts. + VITEST_BROWSER_DEP_NAMES.some((name) => pkg.peerDependencies?.[name] !== undefined) || + sourceTreeReferencesRetainedVitestModule(projectPath) || + (preserveNuxtVitestImports && hasNuxtTestUtilsDependency(pkg)) || + usesVitestBrowserMode(projectPath) + ); +} + +// Common case (`!usesVitest`): vite-plus consumes upstream vitest itself, so a +// lingering `vitest` entry — a managed pin, a stale `npm:@voidzero-dev/vite-plus-test@*` +// wrapper alias, or a `catalog:` reference — must be REMOVED from every sink so +// it arrives transitively through vite-plus and a future `vp update vite-plus` +// keeps it correct with no pin to drift. The `@vitest/*` family is left +// untouched (those are direct-usage signals handled elsewhere). +// +// The removal only applies when `vitest` is a key vite-plus actually manages in +// the active override config. In force-override / CI mode (`VP_OVERRIDE_PACKAGES` +// with file: tgz aliases) `vitest` is NOT in the override set, so a `vitest` +// entry there is the user's own and must be left untouched. +const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGES; + +// Remove a managed `vitest` key from a flat string-valued record (dependency +// field, npm/bun overrides, yarn resolutions, pnpm.overrides, a catalog object). +// Only a STRING value is removed: a managed pin, `catalog:` reference, or wrapper +// alias is always a string, whereas a nested object value (npm/bun `overrides`) +// is a user override scoped under `vitest` and must be left intact. Returns true +// iff an entry was removed. +function removeManagedVitestEntry(record: Record | undefined): boolean { + if (VITEST_IS_MANAGED_OVERRIDE && typeof record?.vitest === 'string') { + delete record.vitest; + return true; + } + return false; +} + +// Remove a managed `vitest` scalar key from a YAMLMap (pnpm-workspace.yaml +// `overrides`, `catalog`, and each named `catalogs` entry). +function removeYamlMapVitestEntry(map: unknown): void { + if (!VITEST_IS_MANAGED_OVERRIDE || !(map instanceof YAMLMap)) { + return; + } + const target = map.items.find( + (item) => item.key instanceof Scalar && item.key.value === 'vitest', + )?.key; + if (target) { + map.delete(target); + } +} + +// Remove the managed `vitest` entry from pnpm peerDependencyRules (its +// `allowAny` array entry and `allowedVersions.vitest`), in place. Works on both +// the package.json `pnpm.peerDependencyRules` JSON shape and the same shape read +// back from pnpm-workspace.yaml. +function removeVitestPeerDependencyRule(peerDependencyRules: { + allowAny?: string[]; + allowedVersions?: Record; +}): void { + if (!VITEST_IS_MANAGED_OVERRIDE) { + return; + } + if (Array.isArray(peerDependencyRules.allowAny)) { + peerDependencyRules.allowAny = peerDependencyRules.allowAny.filter((key) => key !== 'vitest'); + } + if (peerDependencyRules.allowedVersions) { + delete peerDependencyRules.allowedVersions.vitest; + } +} + // Plugins Oxlint resolves natively (no JS import). Source: // `LintPluginOptionsSchema` in `node_modules/oxlint/dist/index.d.ts`. // Anything else in the merged `lint.plugins[]` after migration is a @@ -1468,12 +1706,17 @@ export function addFrameworkShim( * Rewrite standalone project to add vite-plus dependencies * @param projectPath - The path to the project */ +export interface VitestImportMigrationOptions { + preserveNuxtVitestImports?: boolean; +} + export function rewriteStandaloneProject( projectPath: string, workspaceInfo: WorkspaceInfo, skipStagedMigration?: boolean, silent = false, report?: MigrationReport, + importOptions?: VitestImportMigrationOptions, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -1482,12 +1725,17 @@ export function rewriteStandaloneProject( const packageManager = workspaceInfo.packageManager; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); let extractedStagedConfig: Record | null = null; let remainingPnpmOverrides: Record | undefined; let shouldRewritePnpmWorkspaceYaml = false; let shouldAddPnpmWorkspaceVitePlusOverride = false; let shouldAllowBrowserProviderBuilds = false; + // Whether the project uses vitest directly (a required-peer consumer, an + // upstream module reference, or browser mode). Computed inside the callback and + // hoisted so the post-callback pnpm-workspace.yaml writer sees it too. + let usesVitest = false; // Determined inside editJsonFile callback to avoid a redundant file read let usePnpmWorkspaceYaml = false; editJsonFile<{ @@ -1510,6 +1758,14 @@ export function rewriteStandaloneProject( }>(packageJsonPath, (pkg) => { shouldAllowBrowserProviderBuilds = hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); + const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); + usesVitest = projectUsesVitestDirectly( + projectPath, + pkg, + requiredVitestPeer, + importOptions?.preserveNuxtVitestImports !== false, + ); + const managed = managedOverridePackages(usesVitest); // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides // so the deleted wrapper doesn't survive migration in any sink. pruneLegacyWrapperAliases(pkg.resolutions); @@ -1524,15 +1780,21 @@ export function rewriteStandaloneProject( // the bundled-vitest-aligned 4.1.9. (The pnpm sinks are pruned below.) dropRemovePackageOverrideKeys(pkg.resolutions); dropRemovePackageOverrideKeys(pkg.overrides); + // Common case (no direct vitest): strip a lingering managed `vitest` from + // the npm/bun `overrides` and yarn `resolutions` sinks so it isn't re-pinned. + if (!usesVitest) { + removeManagedVitestEntry(pkg.resolutions); + removeManagedVitestEntry(pkg.overrides); + } if (packageManager === PackageManager.yarn) { pkg.resolutions = { ...pkg.resolutions, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; } else if (packageManager === PackageManager.npm || packageManager === PackageManager.bun) { pkg.overrides = { ...pkg.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; if (packageManager === PackageManager.bun) { // Bun walks transitive peer-deps before resolving overrides; vitest @@ -1548,25 +1810,34 @@ export function rewriteStandaloneProject( }; } } else if (packageManager === PackageManager.pnpm) { - // If package.json already has a "pnpm" field, keep using it; - // otherwise use pnpm-workspace.yaml. - usePnpmWorkspaceYaml = !pkg.pnpm; + // Keep overrides in package.json only when it actually owns override/peer + // configuration (or no workspace file exists). An empty/unrelated `pnpm` + // object must not hide stale overrides in pnpm-workspace.yaml. + usePnpmWorkspaceYaml = !pnpmConfigLivesInPackageJson(pkg, projectPath); if (usePnpmWorkspaceYaml) { shouldRewritePnpmWorkspaceYaml = true; shouldAddPnpmWorkspaceVitePlusOverride = isForceOverrideMode(); } - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); + const overrideKeys = Object.keys(managed); if (!usePnpmWorkspaceYaml) { // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) // whose target is a removed package, before re-merging the user's // overrides into the new pnpm config. dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); + // Common case: drop a lingering managed `vitest` override + its peer + // rules before re-merging. + if (!usesVitest) { + removeManagedVitestEntry(pkg.pnpm?.overrides); + if (pkg.pnpm?.peerDependencyRules) { + removeVitestPeerDependencyRule(pkg.pnpm.peerDependencyRules); + } + } // Project already has pnpm config in package.json -- keep using it. pkg.pnpm = { ...pkg.pnpm, overrides: { ...pkg.pnpm?.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, ...(isForceOverrideMode() ? { [VITE_PLUS_NAME]: VITE_PLUS_VERSION } : {}), }, peerDependencyRules: { @@ -1608,22 +1879,24 @@ export function rewriteStandaloneProject( } } + const supportCatalog = usePnpmWorkspaceYaml || packageManager === PackageManager.yarn; extractedStagedConfig = rewritePackageJson( pkg, packageManager, - usePnpmWorkspaceYaml, + supportCatalog, skipStagedMigration, catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), + usesVitest, + sourceTreeReferencesRetainedVitestModule(projectPath), + requiredVitestPeer, ); // ensure vite-plus is in devDependencies if (!pkg.devDependencies?.[VITE_PLUS_NAME] || isForceOverrideMode()) { const version = - usePnpmWorkspaceYaml && !VITE_PLUS_VERSION.startsWith('file:') - ? 'catalog:' - : VITE_PLUS_VERSION; + supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') ? 'catalog:' : VITE_PLUS_VERSION; pkg.devDependencies = { ...pkg.devDependencies, [VITE_PLUS_NAME]: version, @@ -1633,7 +1906,13 @@ export function rewriteStandaloneProject( }); if (shouldRewritePnpmWorkspaceYaml) { - rewritePnpmWorkspaceYaml(projectPath, pnpmMajorVersion, shouldAllowBrowserProviderBuilds); + rewritePnpmWorkspaceYaml( + projectPath, + pnpmMajorVersion, + shouldAllowBrowserProviderBuilds, + usesVitest, + vitestEcosystemPackages, + ); } // Move remaining non-Vite pnpm.overrides to pnpm-workspace.yaml @@ -1647,8 +1926,12 @@ export function rewriteStandaloneProject( }); } + if (packageManager === PackageManager.pnpm) { + ensurePnpmWorkspaceExoticSubdepsSetting(projectPath); + } + if (packageManager === PackageManager.yarn) { - rewriteYarnrcYml(projectPath); + rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages); } else if (packageManager === PackageManager.bun) { ensureBunfigPeerSuppression(projectPath); } @@ -1670,7 +1953,12 @@ export function rewriteStandaloneProject( injectFmtDefaults(projectPath, silent, report); mergeTsdownConfigFile(projectPath, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports(projectPath, silent, report); + rewriteAllImports( + projectPath, + silent, + report, + importOptions?.preserveNuxtVitestImports !== false, + ); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager setPackageManager(projectPath, workspaceInfo.downloadPackageManager); @@ -1685,6 +1973,7 @@ export function rewriteMonorepo( skipStagedMigration?: boolean, silent = false, report?: MigrationReport, + importOptions?: VitestImportMigrationOptions, ): void { const catalogDependencyResolver = createCatalogDependencyResolver( workspaceInfo.rootDir, @@ -1695,17 +1984,30 @@ export function rewriteMonorepo( workspaceInfo.rootDir, workspaceInfo.packages, ); + // The SHARED workspace sinks (catalog / overrides / peer rules) keep `vitest` + // managed iff ANY package in the workspace uses vitest directly. + const workspaceUsesVitest = workspaceUsesVitestDirectly( + workspaceInfo.rootDir, + workspaceInfo.packages, + importOptions?.preserveNuxtVitestImports !== false, + ); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); // rewrite root workspace if (workspaceInfo.packageManager === PackageManager.pnpm) { rewritePnpmWorkspaceYaml( workspaceInfo.rootDir, pnpmMajorVersion, workspaceShouldAllowBrowserBuilds, + workspaceUsesVitest, + vitestEcosystemPackages, ); } else if (workspaceInfo.packageManager === PackageManager.yarn) { - rewriteYarnrcYml(workspaceInfo.rootDir); + rewriteYarnrcYml(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); } else if (workspaceInfo.packageManager === PackageManager.bun) { - rewriteBunCatalog(workspaceInfo.rootDir); + rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); } rewriteRootWorkspacePackageJson( workspaceInfo.rootDir, @@ -1715,6 +2017,8 @@ export function rewriteMonorepo( workspaceInfo.packages, pnpmMajorVersion, workspaceShouldAllowBrowserBuilds, + workspaceUsesVitest, + importOptions, ); // (mergeViteConfigFiles below will sanitize the merged lint config // against this workspace's full package set.) @@ -1741,6 +2045,7 @@ export function rewriteMonorepo( catalogDependencyResolver, workspaceContext, true, + importOptions, ); } @@ -1754,7 +2059,12 @@ export function rewriteMonorepo( injectFmtDefaults(workspaceInfo.rootDir, silent, report); mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports(workspaceInfo.rootDir, silent, report); + rewriteAllImports( + workspaceInfo.rootDir, + silent, + report, + importOptions?.preserveNuxtVitestImports !== false, + ); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); for (const pkg of workspaceInfo.packages) { wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); @@ -1781,6 +2091,7 @@ export function rewriteMonorepoProject( catalogDependencyResolver?: CatalogDependencyResolver, workspaceContext?: { rootDir: string; packages: WorkspacePackage[] }, deferLazyPluginWrapping = false, + importOptions?: VitestImportMigrationOptions, ): void { cleanupDeprecatedTsconfigOptions(projectPath, silent, report); rewriteTsconfigTypes(projectPath, silent, report); @@ -1817,6 +2128,7 @@ export function rewriteMonorepoProject( scripts?: Record; installConfig?: { hoistingLimits?: string }; }>(packageJsonPath, (pkg) => { + const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); // rewrite scripts in package.json extractedStagedConfig = rewritePackageJson( pkg, @@ -1826,6 +2138,14 @@ export function rewriteMonorepoProject( catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), + projectUsesVitestDirectly( + projectPath, + pkg, + requiredVitestPeer, + importOptions?.preserveNuxtVitestImports !== false, + ), + sourceTreeReferencesRetainedVitestModule(projectPath), + requiredVitestPeer, ); // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its // hoisting (via the root `nmHoistingLimits` OR the workspace's own @@ -1870,15 +2190,20 @@ function rewritePnpmWorkspaceYaml( projectPath: string, pnpmMajorVersion: number | undefined, shouldAllowBrowserBuilds: boolean, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, ): void { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); if (!fs.existsSync(pnpmWorkspaceYamlPath)) { fs.writeFileSync(pnpmWorkspaceYamlPath, ''); } + const managed = managedOverridePackages(usesVitest); editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + ensurePnpmExoticSubdepsSetting(doc); + // catalog - rewriteCatalog(doc); + rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); if (pnpmMajorVersion !== undefined) { applyBuildAllowanceToWorkspaceYaml(doc, pnpmMajorVersion, shouldAllowBrowserBuilds); } @@ -1905,13 +2230,14 @@ function rewritePnpmWorkspaceYaml( } } } - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case (no direct vitest): actively strip any lingering managed + // `vitest` override so it arrives transitively through vite-plus. + if (!usesVitest) { + removeYamlMapVitestEntry(doc.getIn(['overrides'])); + } + for (const key of Object.keys(managed)) { const currentVersion = getYamlMapScalarStringValue(overrides, key); - const version = getCatalogDependencySpec( - currentVersion, - VITE_PLUS_OVERRIDE_PACKAGES[key], - true, - ); + const version = getCatalogDependencySpec(currentVersion, managed[key], true); doc.setIn(['overrides', scalarString(key)], scalarString(version)); } // remove dependency selector from vite, e.g. "vite-plugin-svgr>vite": "npm:vite@7.0.12" @@ -1930,8 +2256,12 @@ function rewritePnpmWorkspaceYaml( if (!allowAny) { allowAny = new YAMLSeq>(); } + // Common case: drop any lingering managed `vitest` allowAny entry. + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) { + allowAny.items = allowAny.items.filter((n) => n.value !== 'vitest'); + } const existing = new Set(allowAny.items.map((n) => n.value)); - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { + for (const key of Object.keys(managed)) { if (!existing.has(key)) { allowAny.add(scalarString(key)); } @@ -1946,7 +2276,11 @@ function rewritePnpmWorkspaceYaml( if (!allowedVersions) { allowedVersions = new YAMLMap, Scalar>(); } - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: drop any lingering managed `vitest` allowedVersions entry. + if (!usesVitest) { + removeYamlMapVitestEntry(allowedVersions); + } + for (const key of Object.keys(managed)) { // - vite: '*' allowedVersions.set(scalarString(key), scalarString('*')); } @@ -2004,10 +2338,17 @@ function cleanupPnpmOverridesForWorkspaceYaml( // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) // whose target is a removed package, before the exact-key sweep below. dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); - // Remove Vite-managed keys from pnpm.overrides + // Remove Vite-managed keys from pnpm.overrides. `vitest` is always swept so a + // lingering managed `vitest` override is dropped in the common case (when it + // is NOT in `overrideKeys` because the project does not use vitest directly) — + // it is deleted but NOT captured as a moved catalog override. + const sweepKeys = + overrideKeys.includes('vitest') || !VITEST_IS_MANAGED_OVERRIDE + ? overrideKeys + : [...overrideKeys, 'vitest']; const catalogOverrides: Record = {}; const overrides = pkg.pnpm?.overrides; - for (const key of [...overrideKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { + for (const key of [...sweepKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { const value = overrides?.[key]; if (value) { if (overrideKeys.includes(key) && value.startsWith('catalog:')) { @@ -2035,8 +2376,10 @@ function cleanupPnpmOverridesForWorkspaceYaml( remaining = { ...remaining, ...pkg.pnpm.overrides }; } delete pkg.pnpm?.overrides; - // Only remove Vite-managed peerDependencyRules entries, preserve custom ones - cleanupPeerDependencyRules(pkg.pnpm?.peerDependencyRules, overrideKeys); + // Only remove Vite-managed peerDependencyRules entries, preserve custom ones. + // `vitest` is always swept (common case: dropped even though it is not in the + // managed `overrideKeys`). + cleanupPeerDependencyRules(pkg.pnpm?.peerDependencyRules, sweepKeys); if (pkg.pnpm?.peerDependencyRules && Object.keys(pkg.pnpm.peerDependencyRules).length === 0) { delete pkg.pnpm.peerDependencyRules; } @@ -2117,6 +2460,33 @@ function workspaceUsesWebdriverio( return false; } +// Workspace-wide direct-vitest signal for the SHARED sinks a monorepo root +// owns (pnpm-workspace.yaml catalog/overrides/peer rules, .yarnrc.yml catalog, +// bun catalog): `vitest` stays managed there iff ANY package in the workspace — +// the root or any sub-package — uses vitest directly. See +// `projectUsesVitestDirectly`. +function workspaceUsesVitestDirectly( + rootDir: string, + packages: WorkspacePackage[] | undefined, + preserveNuxtVitestImports = true, +): boolean { + const rootPkg = readPackageJsonIfExists(path.join(rootDir, 'package.json')) ?? {}; + if (projectUsesVitestDirectly(rootDir, rootPkg, undefined, preserveNuxtVitestImports)) { + return true; + } + if (!packages) { + return false; + } + for (const pkg of packages) { + const packageDir = path.join(rootDir, pkg.path); + const subPkg = readPackageJsonIfExists(path.join(packageDir, 'package.json')) ?? {}; + if (projectUsesVitestDirectly(packageDir, subPkg, undefined, preserveNuxtVitestImports)) { + return true; + } + } + return false; +} + function readPackageJsonIfExists(packageJsonPath: string): DependencyBag | undefined { if (!fs.existsSync(packageJsonPath)) { return undefined; @@ -2360,6 +2730,51 @@ function resolveEffectiveYarnConfigValue( return home ? readYarnrcValue(home, key) : undefined; } +export interface YarnPnpDetection { + source: 'environment' | 'configuration' | 'default'; +} + +/** + * Detect Yarn Plug'n'Play using the same precedence Yarn applies to + * `nodeLinker`. Yarn 2+ defaults to PnP when no value is configured, while + * Yarn Classic defaults to node_modules. Unknown/`latest` Yarn versions are + * treated as modern because that is the version `vp` will provision. + */ +export function detectYarnPnpMode( + projectPath: string, + yarnVersion: string, +): YarnPnpDetection | undefined { + const environmentLinker = process.env.YARN_NODE_LINKER?.trim(); + if (environmentLinker) { + return environmentLinker.toLowerCase() === 'pnp' ? { source: 'environment' } : undefined; + } + + const configuredLinker = resolveEffectiveYarnConfigValue( + projectPath, + 'nodeLinker', + 'YARN_NODE_LINKER', + ); + if (configuredLinker) { + return configuredLinker.toLowerCase() === 'pnp' ? { source: 'configuration' } : undefined; + } + + const coercedVersion = semver.coerce(yarnVersion); + return coercedVersion?.major === 1 ? undefined : { source: 'default' }; +} + +/** Set the project-local Yarn linker while preserving every other rc setting. */ +export function configureYarnNodeModulesMode(projectPath: string): boolean { + const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); + const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf8') : undefined; + if (before === undefined) { + fs.writeFileSync(yarnrcYmlPath, ''); + } + editYamlFile(yarnrcYmlPath, (doc) => { + doc.set('nodeLinker', 'node-modules'); + }); + return before !== fs.readFileSync(yarnrcYmlPath, 'utf8'); +} + // True when `dir`'s package.json declares a `workspaces` field — i.e. `dir` is a // workspace (Yarn project) root. `workspaces` may be an array or an object // (`{ packages: [...] }`); both are truthy. @@ -2487,7 +2902,11 @@ function applyYarnWorkspaceHoistingFix( } } -function rewriteYarnrcYml(projectPath: string): void { +function rewriteYarnrcYml( + projectPath: string, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { fs.writeFileSync(yarnrcYmlPath, ''); @@ -2518,7 +2937,7 @@ function rewriteYarnrcYml(projectPath: string): void { } doc.setIn(['npmPreapprovedPackages'], npmPreapprovedPackages); // catalog - rewriteCatalog(doc); + rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); }); } @@ -2559,6 +2978,33 @@ function getCatalogDependencySpec( return currentValue?.startsWith('catalog:') ? currentValue : 'catalog:'; } +// A peer declaration does not install Vitest and therefore must not keep a +// workspace-wide managed Vitest catalog alive. Resolve its catalog reference to +// the public peer range before that catalog is pruned, so the surviving peer +// never points at a missing default/named catalog entry. +function normalizeVitestPeerCatalogSpec( + peerDependencies: Record | undefined, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!peerDependencies) { + return false; + } + const current = peerDependencies.vitest; + if (!current?.startsWith('catalog:')) { + return false; + } + const normalized = getCatalogDependencySpec(current, VITEST_VERSION, true, { + dependencyField: 'peerDependencies', + dependencyName: 'vitest', + catalogDependencyResolver, + }); + if (normalized === current) { + return false; + } + peerDependencies.vitest = normalized; + return true; +} + function isVitePlusOverrideSpec(value: string): boolean { return ( Object.values(VITE_PLUS_OVERRIDE_PACKAGES).includes(value) || @@ -2670,8 +3116,18 @@ function pruneYamlMapLegacyWrapperAliases(map: unknown): void { } } -function rewriteCatalog(doc: YamlDocument): void { - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { +function rewriteCatalog( + doc: YamlDocument, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { + const managed = managedOverridePackages(usesVitest); + // Common case (no direct vitest): remove any lingering managed `vitest` + // catalog entry so it resolves transitively through vite-plus. + if (!usesVitest) { + removeYamlMapVitestEntry(doc.getIn(['catalog'])); + } + for (const [key, value] of Object.entries(managed)) { // ERR_PNPM_CATALOG_IN_OVERRIDES  Could not resolve a catalog in the overrides: The entry for 'vite' in catalog 'default' declares a dependency using the 'file' protocol // ignore setting catalog if value starts with 'file:' if (value.startsWith('file:')) { @@ -2690,6 +3146,7 @@ function rewriteCatalog(doc: YamlDocument): void { } // Drop any entry still pointing at the deleted `vite-plus-test` wrapper. pruneYamlMapLegacyWrapperAliases(doc.getIn(['catalog'])); + rewriteVitestEcosystemYamlCatalog(doc.getIn(['catalog']), vitestEcosystemPackages); const catalogs = doc.getIn(['catalogs']); if (!(catalogs instanceof YAMLMap)) { @@ -2700,7 +3157,12 @@ function rewriteCatalog(doc: YamlDocument): void { if (typeof catalogName !== 'string' || !(item.value instanceof YAMLMap)) { continue; } - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: strip a lingering managed `vitest` entry from this named + // catalog (existing entries only — named catalogs are never grown here). + if (!usesVitest) { + removeYamlMapVitestEntry(item.value); + } + for (const [key, value] of Object.entries(managed)) { const catalogPath = ['catalogs', catalogName, key]; if (!value.startsWith('file:') && doc.hasIn(catalogPath)) { doc.setIn(catalogPath, scalarString(value)); @@ -2717,11 +3179,42 @@ function rewriteCatalog(doc: YamlDocument): void { } } pruneYamlMapLegacyWrapperAliases(item.value); + rewriteVitestEcosystemYamlCatalog(item.value, vitestEcosystemPackages); + } +} + +function rewriteVitestEcosystemYamlCatalog( + catalog: unknown, + vitestEcosystemPackages: ReadonlySet, +): void { + if (!VITEST_IS_MANAGED_OVERRIDE || !(catalog instanceof YAMLMap)) { + return; + } + for (const item of catalog.items) { + const name = item.key instanceof Scalar ? item.key.value : undefined; + if ( + typeof name === 'string' && + vitestEcosystemPackages.has(name) && + isAlignableVitestEcosystemPackage(name) + ) { + catalog.set(item.key, scalarString(VITEST_VERSION)); + } } } -function rewriteCatalogObject(catalog: Record, addMissing: boolean): void { - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { +function rewriteCatalogObject( + catalog: Record, + addMissing: boolean, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { + const managed = managedOverridePackages(usesVitest); + // Common case (no direct vitest): strip a lingering managed `vitest` catalog + // entry so it resolves transitively through vite-plus. + if (!usesVitest) { + removeManagedVitestEntry(catalog); + } + for (const [key, value] of Object.entries(managed)) { if (value.startsWith('file:') || (!addMissing && !(key in catalog))) { continue; } @@ -2733,11 +3226,22 @@ function rewriteCatalogObject(catalog: Record, addMissing: boole for (const name of REMOVE_PACKAGES) { delete catalog[name]; } + if (VITEST_IS_MANAGED_OVERRIDE) { + for (const name of Object.keys(catalog)) { + if (vitestEcosystemPackages.has(name) && isAlignableVitestEcosystemPackage(name)) { + catalog[name] = VITEST_VERSION; + } + } + } } -function rewriteCatalogsObject(catalogs: Record>): void { +function rewriteCatalogsObject( + catalogs: Record>, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { for (const catalog of Object.values(catalogs)) { - rewriteCatalogObject(catalog, false); + rewriteCatalogObject(catalog, false, usesVitest, vitestEcosystemPackages); } } @@ -2783,11 +3287,16 @@ function ensureBunfigPeerSuppression(projectPath: string): void { * unlike pnpm which uses pnpm-workspace.yaml. * @see https://bun.sh/docs/pm/catalogs */ -function rewriteBunCatalog(projectPath: string): void { +function rewriteBunCatalog( + projectPath: string, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; } + const managed = managedOverridePackages(usesVitest); editJsonFile<{ workspaces?: NpmWorkspaces; @@ -2805,30 +3314,30 @@ function rewriteBunCatalog(projectPath: string): void { ...(useWorkspacesCatalog ? workspacesObj?.catalog : pkg.catalog), }; - rewriteCatalogObject(catalog, true); + rewriteCatalogObject(catalog, true, usesVitest, vitestEcosystemPackages); pruneLegacyWrapperAliases(catalog); if (useWorkspacesCatalog) { workspacesObj.catalog = catalog; if (pkg.catalog) { - rewriteCatalogObject(pkg.catalog, false); + rewriteCatalogObject(pkg.catalog, false, usesVitest, vitestEcosystemPackages); pruneLegacyWrapperAliases(pkg.catalog); } } else { pkg.catalog = catalog; if (workspacesObj?.catalog) { - rewriteCatalogObject(workspacesObj.catalog, false); + rewriteCatalogObject(workspacesObj.catalog, false, usesVitest, vitestEcosystemPackages); pruneLegacyWrapperAliases(workspacesObj.catalog); } } if (workspacesObj?.catalogs) { - rewriteCatalogsObject(workspacesObj.catalogs); + rewriteCatalogsObject(workspacesObj.catalogs, usesVitest, vitestEcosystemPackages); for (const named of Object.values(workspacesObj.catalogs)) { pruneLegacyWrapperAliases(named); } } if (pkg.catalogs) { - rewriteCatalogsObject(pkg.catalogs); + rewriteCatalogsObject(pkg.catalogs, usesVitest, vitestEcosystemPackages); for (const named of Object.values(pkg.catalogs)) { pruneLegacyWrapperAliases(named); } @@ -2837,7 +3346,13 @@ function rewriteBunCatalog(projectPath: string): void { // bun overrides support catalog: references const overrides: Record = { ...pkg.overrides }; pruneLegacyWrapperAliases(overrides); - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case (no direct vitest): strip a lingering managed `vitest` + // override (string-valued only — a nested user override is left intact; + // removeManagedVitestEntry also no-ops when vitest is not a managed key). + if (!usesVitest && typeof overrides.vitest === 'string') { + removeManagedVitestEntry(overrides); + } + for (const [key, value] of Object.entries(managed)) { const current = overrides[key] as unknown; // A nested object value is a user override scoped under this managed key, // not a version pin — leave it intact (getCatalogDependencySpec expects a @@ -2870,11 +3385,17 @@ function rewriteRootWorkspacePackageJson( packages?: WorkspacePackage[], pnpmMajorVersion?: number, shouldAllowBrowserBuilds = false, + // Workspace-wide direct-vitest signal: the root resolution/override sinks are + // shared by every package, so `vitest` stays managed here iff ANY package uses + // vitest directly. + workspaceUsesVitest = true, + importOptions?: VitestImportMigrationOptions, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; } + const managed = managedOverridePackages(workspaceUsesVitest); let remainingPnpmOverrides: Record | undefined; editJsonFile<{ @@ -2908,17 +3429,23 @@ function rewriteRootWorkspacePackageJson( // the bundled-vitest-aligned 4.1.9. (The pnpm sinks are pruned below.) dropRemovePackageOverrideKeys(pkg.resolutions); dropRemovePackageOverrideKeys(pkg.overrides); + // Common case (no workspace-wide direct vitest): strip a lingering managed + // `vitest` from the shared root sinks so it isn't re-pinned. + if (!workspaceUsesVitest) { + removeManagedVitestEntry(pkg.resolutions); + removeManagedVitestEntry(pkg.overrides); + } if (packageManager === PackageManager.yarn) { pkg.resolutions = { ...pkg.resolutions, // FIXME: yarn don't support catalog on resolutions // https://github.com/yarnpkg/berry/issues/6979 - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; } else if (packageManager === PackageManager.npm) { pkg.overrides = { ...pkg.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; } else if (packageManager === PackageManager.bun) { // bun overrides are handled in rewriteBunCatalog() with catalog: references @@ -2936,12 +3463,16 @@ function rewriteRootWorkspacePackageJson( ), }; } else if (packageManager === PackageManager.pnpm) { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); + const overrideKeys = Object.keys(managed); if (isForceOverrideMode()) { // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) // whose target is a removed package, before re-merging the user's // overrides into the new pnpm config. dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); + // Common case: drop a lingering managed `vitest` override before merging. + if (!workspaceUsesVitest) { + removeManagedVitestEntry(pkg.pnpm?.overrides); + } // In force-override mode, keep overrides in package.json pnpm.overrides // because pnpm ignores pnpm-workspace.yaml overrides when pnpm.overrides // exists in package.json (even with unrelated entries like rollup). @@ -2949,7 +3480,7 @@ function rewriteRootWorkspacePackageJson( ...pkg.pnpm, overrides: { ...pkg.pnpm?.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, [VITE_PLUS_NAME]: VITE_PLUS_VERSION, }, }; @@ -3005,6 +3536,7 @@ function rewriteRootWorkspacePackageJson( catalogDependencyResolver, packages ? { rootDir: projectPath, packages } : undefined, true, + importOptions, ); } @@ -3107,6 +3639,7 @@ export function finalizeCoreMigrationForExistingVitePlus( silent = false, report?: MigrationReport, pending = detectPendingCoreMigration(workspaceInfo), + importOptions?: VitestImportMigrationOptions, ): CoreMigrationFinalizationResult { const projectPaths = getCoreMigrationProjectPaths(workspaceInfo); const result: CoreMigrationFinalizationResult = { @@ -3128,7 +3661,12 @@ export function finalizeCoreMigrationForExistingVitePlus( } } - result.imports = rewriteAllImports(workspaceInfo.rootDir, silent, report); + result.imports = rewriteAllImports( + workspaceInfo.rootDir, + silent, + report, + importOptions?.preserveNuxtVitestImports !== false, + ); return result; } @@ -3138,6 +3676,7 @@ type BootstrapPackageJson = { resolutions?: Record; devDependencies?: Record; dependencies?: Record; + peerDependencies?: Record; optionalDependencies?: Record; pnpm?: { overrides?: Record; @@ -3145,6 +3684,8 @@ type BootstrapPackageJson = { allowAny?: string[]; allowedVersions?: Record; }; + allowBuilds?: Record; + onlyBuiltDependencies?: string[]; }; packageManager?: string; devEngines?: { packageManager?: unknown; [key: string]: unknown }; @@ -3157,16 +3698,6 @@ export type VitePlusBootstrapResult = { packageManagerField: boolean; }; -function getVitePlusOverridePackageName(dependencyName: string): string | undefined { - if (dependencyName === 'vite') { - return '@voidzero-dev/vite-plus-core'; - } - if (dependencyName === 'vitest') { - return '@voidzero-dev/vite-plus-test'; - } - return undefined; -} - function isSemanticVitePlusOverrideSpec(dependencyName: string, spec: string | undefined): boolean { if (!spec) { return false; @@ -3182,8 +3713,7 @@ function isSemanticVitePlusOverrideSpec(dependencyName: string, spec: string | u if (spec === VITE_PLUS_OVERRIDE_PACKAGES[dependencyName]) { return true; } - const packageName = getVitePlusOverridePackageName(dependencyName); - return packageName !== undefined && spec.includes(packageName); + return false; } function overrideSpecSatisfiesVitePlus( @@ -3208,9 +3738,15 @@ function overrideSpecSatisfiesVitePlus( function overridesSatisfyVitePlus( overrides: Record | undefined, + usesVitest: boolean, catalogDependencyResolver?: CatalogDependencyResolver, ): boolean { - return Object.keys(VITE_PLUS_OVERRIDE_PACKAGES).every((dependencyName) => + // Common case: a lingering managed `vitest` override is NOT satisfied — it + // must be removed, so the bootstrap stays pending until it is. + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE && typeof overrides?.vitest === 'string') { + return false; + } + return Object.keys(managedOverridePackages(usesVitest)).every((dependencyName) => overrideSpecSatisfiesVitePlus( dependencyName, overrides?.[dependencyName], @@ -3248,16 +3784,36 @@ function pnpmPeerDependencyRulesSatisfyVitePlus( peerDependencyRules: | { allowAny?: string[]; allowedVersions?: Record } | undefined, + usesVitest: boolean, ): boolean { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); const allowAny = new Set(peerDependencyRules?.allowAny ?? []); const allowedVersions = peerDependencyRules?.allowedVersions ?? {}; + // Common case: a lingering managed `vitest` peer rule is NOT satisfied. + if ( + !usesVitest && + VITEST_IS_MANAGED_OVERRIDE && + (allowAny.has('vitest') || allowedVersions.vitest !== undefined) + ) { + return false; + } + const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); return overrideKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*'); } -function npmVitePlusManagedDependenciesPending(pkg: BootstrapPackageJson): boolean { +function npmVitePlusManagedDependenciesPending( + pkg: BootstrapPackageJson, + usesVitest: boolean, +): boolean { const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - return Object.keys(VITE_PLUS_OVERRIDE_PACKAGES).some((dependencyName) => + // Common case: a lingering managed `vitest` install dep is pending removal. + if ( + !usesVitest && + VITEST_IS_MANAGED_OVERRIDE && + dependencyGroups.some((dependencies) => dependencies?.vitest !== undefined) + ) { + return true; + } + return Object.keys(managedOverridePackages(usesVitest)).some((dependencyName) => dependencyGroups.some( (dependencies) => dependencies?.[dependencyName] !== undefined && @@ -3302,7 +3858,51 @@ function readPnpmWorkspacePeerDependencyRules( return doc?.peerDependencyRules; } -function yarnrcSatisfiesVitePlus(projectPath: string): boolean { +function forceOverrideUsesExoticPnpmSpec(): boolean { + if (!isForceOverrideMode()) { + return false; + } + return [VITE_PLUS_VERSION, ...Object.values(VITE_PLUS_OVERRIDE_PACKAGES)].some((spec) => + /^(?:file|https?):/.test(spec), + ); +} + +function pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath: string): boolean { + if (!forceOverrideUsesExoticPnpmSpec()) { + return true; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + return false; + } + const doc = readYamlFile(pnpmWorkspaceYamlPath) as { blockExoticSubdeps?: boolean } | null; + return doc?.blockExoticSubdeps === false; +} + +function ensurePnpmExoticSubdepsSetting(doc: YamlDocument): boolean { + if (!forceOverrideUsesExoticPnpmSpec() || doc.get('blockExoticSubdeps') === false) { + return false; + } + doc.set('blockExoticSubdeps', false); + return true; +} + +function ensurePnpmWorkspaceExoticSubdepsSetting(projectPath: string): boolean { + if (!forceOverrideUsesExoticPnpmSpec()) { + return false; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + fs.writeFileSync(pnpmWorkspaceYamlPath, ''); + } + let changed = false; + editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + changed = ensurePnpmExoticSubdepsSetting(doc); + }); + return changed; +} + +function yarnrcSatisfiesVitePlus(projectPath: string, usesVitest: boolean): boolean { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { return false; @@ -3314,7 +3914,7 @@ function yarnrcSatisfiesVitePlus(projectPath: string): boolean { return ( !!doc && Object.hasOwn(doc, 'nodeLinker') && - overridesSatisfyVitePlus(doc.catalog) && + overridesSatisfyVitePlus(doc.catalog, usesVitest) && (VITE_PLUS_VERSION.startsWith('file:') || doc.catalog?.[VITE_PLUS_NAME] === VITE_PLUS_VERSION) ); } @@ -3354,72 +3954,463 @@ function readBunCatalogDependencyResolver(pkg: { fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); } -export function detectVitePlusBootstrapPending( - projectPath: string, - packageManager: PackageManager | undefined, -): boolean { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { +// Decide where a pnpm project keeps its overrides / peer rules. A truthy +// `pkg.pnpm` is not enough: an empty `pnpm: {}` is truthy yet carries no +// override/peer config, and when a real `pnpm-workspace.yaml` exists that file +// is the actual source unless package.json explicitly defines one of those +// managed sections. Unrelated keys such as `onlyBuiltDependencies` do not move +// override ownership into package.json. +function pnpmConfigLivesInPackageJson(pkg: BootstrapPackageJson, projectPath: string): boolean { + if (pkg.pnpm == null) { return false; } - const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson & { - workspaces?: NpmWorkspaces; - catalog?: Record; - catalogs?: Record>; - }; - - if (!pkg.devDependencies?.[VITE_PLUS_NAME] || !hasPackageManagerPin(pkg)) { - return true; - } - - if (packageManager === undefined) { + if (!fs.existsSync(path.join(projectPath, 'pnpm-workspace.yaml'))) { return true; } + return Object.hasOwn(pkg.pnpm, 'overrides') || Object.hasOwn(pkg.pnpm, 'peerDependencyRules'); +} + +function getAlignedVitestEcosystemDependencySpec( + current: string, + dependencyName: string, + dependencyField: PackageJsonDependencyField, + packageManager: PackageManager, + supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): string { + const catalogSpec = current.startsWith('catalog:') ? current : 'catalog:'; + const catalogSupported = + supportCatalog && catalogDependencyResolver?.(catalogSpec, dependencyName) !== undefined; + return getCatalogDependencySpec(current, VITEST_VERSION, catalogSupported, { + dependencyField, + dependencyName, + packageManager, + catalogDependencyResolver, + }); +} + +// Align every declared official `@vitest/*` package with the bundled Vitest. +// Prefer an existing default or named catalog entry when the package manager +// supports catalogs; otherwise use the concrete bundled version. Returns true +// if any package.json spec changed. Catalog values are reconciled separately by +// the package-manager config writers above. +function alignVitestEcosystemPackages( + pkg: BootstrapPackageJson, + packageManager: PackageManager, + supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!VITEST_IS_MANAGED_OVERRIDE) { + return false; + } + const dependencyGroups: Array<{ + dependencyField: PackageJsonDependencyField; + dependencies: Record | undefined; + }> = [ + { dependencyField: 'devDependencies', dependencies: pkg.devDependencies }, + { dependencyField: 'dependencies', dependencies: pkg.dependencies }, + { dependencyField: 'optionalDependencies', dependencies: pkg.optionalDependencies }, + ]; + let changed = false; + for (const { dependencyField, dependencies } of dependencyGroups) { + if (!dependencies) { + continue; + } + for (const name of Object.keys(dependencies)) { + if (!isAlignableVitestEcosystemPackage(name)) { + continue; + } + const aligned = getAlignedVitestEcosystemDependencySpec( + dependencies[name], + name, + dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); + if (dependencies[name] !== aligned) { + dependencies[name] = aligned; + changed = true; + } + } + } + return changed; +} + +function vitestEcosystemCatalogReferencesPending( + pkg: BootstrapPackageJson, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!VITEST_IS_MANAGED_OVERRIDE || !catalogDependencyResolver) { + return false; + } + for (const dependencies of [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]) { + if (!dependencies) { + continue; + } + for (const [name, spec] of Object.entries(dependencies)) { + if ( + isAlignableVitestEcosystemPackage(name) && + spec.startsWith('catalog:') && + catalogDependencyResolver(spec, name) !== VITEST_VERSION + ) { + return true; + } + } + } + return false; +} + +/** + * Reconcile the install dependencies in one package during an existing-Vite+ + * bootstrap. Package-manager overrides are intentionally handled separately at + * the workspace root; this function owns only dependency fields so it can also + * be applied to every workspace package. + */ +function reconcileVitePlusBootstrapPackage( + projectPath: string, + pkg: BootstrapPackageJson, + vitePlusVersion: string, + packageManager: PackageManager, + supportCatalog: boolean, + ensureVitePlus: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, + importOptions?: VitestImportMigrationOptions, +): boolean { + const before = JSON.stringify(pkg); + const usesVitest = projectUsesVitestDirectly( + projectPath, + pkg, + undefined, + importOptions?.preserveNuxtVitestImports !== false, + ); + ensureVitePlusDependencySpecs(pkg, vitePlusVersion, ensureVitePlus); + + const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + const dependencyGroups = [...installGroups, pkg.peerDependencies]; + + // Remove every dependency alias to the deleted wrapper before deciding + // whether this package needs a direct upstream vitest peer provider. + for (const dependencies of dependencyGroups) { + pruneLegacyWrapperAliases(dependencies); + } + + // Normalize direct Vite install entries as well as the shared override. Keep + // named catalog references intact; plain/behind aliases move to the active + // default catalog or the current core alias. + for (const dependencies of installGroups) { + if (dependencies?.vite !== undefined) { + dependencies.vite = getCatalogDependencySpec( + dependencies.vite, + VITE_PLUS_OVERRIDE_PACKAGES.vite, + supportCatalog, + ); + } + } + + alignVitestEcosystemPackages(pkg, packageManager, supportCatalog, catalogDependencyResolver); + normalizeVitestPeerCatalogSpec(pkg.peerDependencies, catalogDependencyResolver); + + const providerSourceModes = collectProviderSourceModes(projectPath); + let usesAnyOptInProvider = false; + for (const provider of OPT_IN_BROWSER_PROVIDERS) { + const usesProvider = + providerSourceModes[provider] || + dependencyGroups.some((dependencies) => dependencies?.[provider] !== undefined); + if (!usesProvider) { + continue; + } + usesAnyOptInProvider = true; + const installGroupEntry = [ + { dependencyField: 'devDependencies' as const, dependencies: pkg.devDependencies }, + { dependencyField: 'dependencies' as const, dependencies: pkg.dependencies }, + { + dependencyField: 'optionalDependencies' as const, + dependencies: pkg.optionalDependencies, + }, + ].find(({ dependencies }) => dependencies?.[provider] !== undefined); + if (installGroupEntry?.dependencies) { + if (VITEST_IS_MANAGED_OVERRIDE) { + installGroupEntry.dependencies[provider] = getAlignedVitestEcosystemDependencySpec( + installGroupEntry.dependencies[provider], + provider, + installGroupEntry.dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); + } + } else { + pkg.devDependencies ??= {}; + pkg.devDependencies[provider] = VITEST_VERSION; + } + const frameworkPeer = BROWSER_PROVIDER_PEER_DEPS[provider]; + const frameworkPresent = dependencyGroups.some( + (dependencies) => dependencies?.[frameworkPeer] !== undefined, + ); + if (frameworkPeer && !frameworkPresent) { + pkg.devDependencies ??= {}; + pkg.devDependencies[frameworkPeer] = '*'; + } + } + + // The base browser runtime and preview provider are bundled by vite-plus; + // only the heavy framework-specific providers remain project-owned. + for (const bundledPackage of REMOVE_PACKAGES.filter((name) => name.startsWith('@vitest/'))) { + for (const dependencies of installGroups) { + if (dependencies?.[bundledPackage] !== undefined) { + delete dependencies[bundledPackage]; + } + } + } + + if (usesAnyOptInProvider && packageManager === PackageManager.npm) { + const viteAlreadyDirect = installGroups.some( + (dependencies) => dependencies?.vite !== undefined, + ); + if (!viteAlreadyDirect) { + pkg.devDependencies ??= {}; + pkg.devDependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; + } + } + + if (usesVitest) { + // A direct @vitest/*/integration dependency with a required vitest peer + // cannot use the copy nested under its sibling `vite-plus` dependency under + // Yarn PnP or strict pnpm. Provide the peer from this package and keep it on + // the same exact version as the Vite+ runner. + const existingGroup = installGroups.find((dependencies) => dependencies?.vitest !== undefined); + if (existingGroup) { + if (VITEST_IS_MANAGED_OVERRIDE) { + existingGroup.vitest = getCatalogDependencySpec( + existingGroup.vitest, + VITEST_VERSION, + supportCatalog, + ); + } + } else { + pkg.devDependencies ??= {}; + pkg.devDependencies.vitest = getCatalogDependencySpec( + undefined, + VITEST_VERSION, + supportCatalog, + ); + } + } else { + // Bare vitest is not itself a usage signal: older migrations injected it + // into every project. Remove that stale install pin when no remaining peer, + // source import, or browser-mode signal needs it. + for (const dependencies of installGroups) { + removeManagedVitestEntry(dependencies); + } + } + + return before !== JSON.stringify(pkg); +} + +function bootstrapProjectPaths( + rootDir: string, + packages: WorkspacePackage[] | undefined, +): string[] { + return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; +} + +function collectVitestEcosystemInstallDependencyNames( + rootDir: string, + packages?: WorkspacePackage[], +): Set { + const names = new Set(); + for (const packagePath of bootstrapProjectPaths(rootDir, packages)) { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; + for (const dependencies of [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]) { + for (const name of Object.keys(dependencies ?? {})) { + if (isAlignableVitestEcosystemPackage(name)) { + names.add(name); + } + } + } + } + return names; +} + +function workspaceVitestEcosystemCatalogReferencesPending( + rootDir: string, + packages: WorkspacePackage[] | undefined, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + return bootstrapProjectPaths(rootDir, packages).some((packagePath) => { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + return vitestEcosystemCatalogReferencesPending( + readJsonFile(packageJsonPath) as BootstrapPackageJson, + catalogDependencyResolver, + ); + }); +} + +export function detectVitePlusBootstrapPending( + projectPath: string, + packageManager: PackageManager | undefined, + packages?: WorkspacePackage[], + importOptions?: VitestImportMigrationOptions, +): boolean { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson & { + workspaces?: NpmWorkspaces; + catalog?: Record; + catalogs?: Record>; + }; + + if (!pkg.devDependencies?.[VITE_PLUS_NAME] || !hasPackageManagerPin(pkg)) { + return true; + } + + if (packageManager === undefined) { + return true; + } + + const usePnpmWorkspaceYaml = + packageManager === PackageManager.pnpm && !pnpmConfigLivesInPackageJson(pkg, projectPath); + const supportCatalog = + !VITE_PLUS_VERSION.startsWith('file:') && + (usePnpmWorkspaceYaml || + packageManager === PackageManager.yarn || + packageManager === PackageManager.bun); + const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; + const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); + if ( + workspaceVitestEcosystemCatalogReferencesPending( + projectPath, + packages, + catalogDependencyResolver, + ) + ) { + return true; + } + for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { + const childPackageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(childPackageJsonPath)) { + continue; + } + const childPkg = readJsonFile(childPackageJsonPath) as BootstrapPackageJson; + const candidate = JSON.parse(JSON.stringify(childPkg)) as BootstrapPackageJson; + if ( + reconcileVitePlusBootstrapPackage( + packagePath, + candidate, + canonicalVitePlusSpec, + packageManager, + supportCatalog, + index === 0, + catalogDependencyResolver, + importOptions, + ) + ) { + return true; + } + } + + // Shared override/catalog sinks must keep vitest managed when any package in + // the workspace needs it. The direct dependency itself is localized above. + const usesVitest = workspaceUsesVitestDirectly( + projectPath, + packages, + importOptions?.preserveNuxtVitestImports !== false, + ); if (packageManager === PackageManager.yarn) { - return !overridesSatisfyVitePlus(pkg.resolutions) || !yarnrcSatisfiesVitePlus(projectPath); + return ( + !overridesSatisfyVitePlus(pkg.resolutions, usesVitest) || + !yarnrcSatisfiesVitePlus(projectPath, usesVitest) + ); } if (packageManager === PackageManager.npm) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.overrides) || - npmVitePlusManagedDependenciesPending(pkg) + !overridesSatisfyVitePlus(pkg.overrides, usesVitest) || + npmVitePlusManagedDependenciesPending(pkg, usesVitest) ); } if (packageManager === PackageManager.bun) { - return !overridesSatisfyVitePlus(pkg.overrides, readBunCatalogDependencyResolver(pkg)); + return !overridesSatisfyVitePlus( + pkg.overrides, + usesVitest, + readBunCatalogDependencyResolver(pkg), + ); } if (packageManager === PackageManager.pnpm) { - if (pkg.pnpm) { + if (!pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath)) { + return true; + } + if (pnpmConfigLivesInPackageJson(pkg, projectPath)) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.pnpm.overrides) || - !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm.peerDependencyRules) + !overridesSatisfyVitePlus(pkg.pnpm?.overrides, usesVitest) || + !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules, usesVitest) ); } const resolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); return ( defaultCatalogVitePlusDependencyPending(pkg, resolver) || - !overridesSatisfyVitePlus(readPnpmWorkspaceOverrides(projectPath), resolver) || - !pnpmPeerDependencyRulesSatisfyVitePlus(readPnpmWorkspacePeerDependencyRules(projectPath)) + !overridesSatisfyVitePlus(readPnpmWorkspaceOverrides(projectPath), usesVitest, resolver) || + !pnpmPeerDependencyRulesSatisfyVitePlus( + readPnpmWorkspacePeerDependencyRules(projectPath), + usesVitest, + ) ); } return false; } -function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: string): boolean { +function ensureVitePlusDependencySpecs( + pkg: BootstrapPackageJson, + version: string, + ensurePresent = true, +): boolean { let changed = false; - if (version !== 'catalog:') { - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - for (const dependencies of dependencyGroups) { - if (dependencies?.[VITE_PLUS_NAME]?.startsWith('catalog:')) { - dependencies[VITE_PLUS_NAME] = version; - changed = true; - } + // Re-pin a pre-existing vite-plus spec to the migrating toolchain target so + // the lockfile moves off an old resolution (e.g. `^0.1.24`). Mirrors the + // full-migration rule at `shouldNormalizeExistingVitePlus`/`canonicalVitePlusSpec`: + // only vanilla version ranges are rewritten; deliberate protocol pins + // (workspace:, link:, file:, npm:, github:, git, http) are preserved. + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + for (const dependencies of dependencyGroups) { + if (dependencies === undefined) { + continue; + } + const spec = dependencies[VITE_PLUS_NAME]; + if (spec === undefined || spec === version) { + continue; + } + // Concrete target (e.g. `latest`): also rewrite an existing `catalog:` + // pin onto the concrete version — `isProtocolPinnedSpec` matches + // `catalog:`, so handle it explicitly before the generic plain-range case. + if (version !== 'catalog:' && spec.startsWith('catalog:')) { + dependencies[VITE_PLUS_NAME] = version; + changed = true; + continue; + } + // Plain (non-protocol-pinned) range like `^0.1.24` → rewrite to the target + // (`catalog:` for catalog-supporting projects, otherwise the concrete + // version). Already-`catalog:` / other protocol pins are left untouched, + // except in force-override mode where ecosystem/pkg.pr.new validation must + // replace every prior target with the requested artifact. + if (isForceOverrideMode() || !isProtocolPinnedSpec(spec)) { + dependencies[VITE_PLUS_NAME] = version; + changed = true; } } - if (pkg.devDependencies?.[VITE_PLUS_NAME]) { + if (pkg.devDependencies?.[VITE_PLUS_NAME] || !ensurePresent) { return changed; } pkg.devDependencies = { @@ -3431,11 +4422,18 @@ function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: strin function ensureOverrideEntries( overrides: Record | undefined, + usesVitest: boolean, catalogDependencyResolver?: CatalogDependencyResolver, ): { overrides: Record; changed: boolean } { const next = { ...overrides }; let changed = false; - for (const [dependencyName, overrideSpec] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: drop a lingering managed `vitest` override. + if (!usesVitest && removeManagedVitestEntry(next)) { + changed = true; + } + for (const [dependencyName, overrideSpec] of Object.entries( + managedOverridePackages(usesVitest), + )) { if ( !overrideSpecSatisfiesVitePlus( dependencyName, @@ -3450,31 +4448,29 @@ function ensureOverrideEntries( return { overrides: next, changed }; } -function ensureNpmVitePlusManagedDependencies(pkg: BootstrapPackageJson): boolean { - let changed = false; - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - for (const [dependencyName, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - for (const dependencies of dependencyGroups) { - if ( - dependencies?.[dependencyName] !== undefined && - !overrideSpecSatisfiesVitePlus(dependencyName, dependencies[dependencyName]) - ) { - dependencies[dependencyName] = version; - changed = true; - } +function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: boolean): boolean { + const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); + pkg.pnpm ??= {}; + // Common case: drop a lingering managed `vitest` peer rule from the source + // shape before re-deriving the managed rules. + const seed = { ...pkg.pnpm.peerDependencyRules } as { + allowAny?: string[]; + allowedVersions?: Record; + }; + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) { + if (Array.isArray(seed.allowAny)) { + seed.allowAny = seed.allowAny.filter((key) => key !== 'vitest'); + } + if (seed.allowedVersions) { + seed.allowedVersions = { ...seed.allowedVersions }; + delete seed.allowedVersions.vitest; } } - return changed; -} - -function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson): boolean { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); - pkg.pnpm ??= {}; const peerDependencyRules = { - ...pkg.pnpm.peerDependencyRules, - allowAny: [...new Set([...(pkg.pnpm.peerDependencyRules?.allowAny ?? []), ...overrideKeys])], + ...seed, + allowAny: [...new Set([...(seed.allowAny ?? []), ...overrideKeys])], allowedVersions: { - ...pkg.pnpm.peerDependencyRules?.allowedVersions, + ...seed.allowedVersions, ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])), }, }; @@ -3487,6 +4483,7 @@ function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson): boolean { export function ensureVitePlusBootstrap( workspaceInfo: WorkspaceInfo, report?: MigrationReport, + importOptions?: VitestImportMigrationOptions, ): VitePlusBootstrapResult { const projectPath = workspaceInfo.rootDir; const packageJsonPath = path.join(projectPath, 'package.json'); @@ -3500,6 +4497,40 @@ export function ensureVitePlusBootstrap( return result; } + const initialRootPkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; + // Shared override/catalog sinks are workspace-wide, so keep vitest managed + // when any package needs it. Each package's direct vitest dependency is + // reconciled independently below. + const usesVitest = workspaceUsesVitestDirectly( + projectPath, + workspaceInfo.packages, + importOptions?.preserveNuxtVitestImports !== false, + ); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); + const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); + const usePnpmWorkspaceYaml = + workspaceInfo.packageManager === PackageManager.pnpm && + !pnpmConfigLivesInPackageJson(initialRootPkg, projectPath); + const supportCatalog = + !VITE_PLUS_VERSION.startsWith('file:') && + (usePnpmWorkspaceYaml || + workspaceInfo.packageManager === PackageManager.yarn || + workspaceInfo.packageManager === PackageManager.bun); + const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; + const catalogDependencyResolver = createCatalogDependencyResolver( + projectPath, + workspaceInfo.packageManager, + ); + const ecosystemCatalogReferencesPending = workspaceVitestEcosystemCatalogReferencesPending( + projectPath, + workspaceInfo.packages, + catalogDependencyResolver, + ); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( + projectPath, + workspaceInfo.packages, + ); + editJsonFile< BootstrapPackageJson & { workspaces?: NpmWorkspaces; @@ -3507,71 +4538,118 @@ export function ensureVitePlusBootstrap( catalogs?: Record>; } >(packageJsonPath, (pkg) => { - const usePnpmWorkspaceYaml = workspaceInfo.packageManager === PackageManager.pnpm && !pkg.pnpm; - const supportCatalog = - !VITE_PLUS_VERSION.startsWith('file:') && - (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.bun); - let packageJsonChanged = ensureVitePlusDependencySpecs( + let packageJsonChanged = reconcileVitePlusBootstrapPackage( + projectPath, pkg, - supportCatalog ? 'catalog:' : VITE_PLUS_VERSION, + canonicalVitePlusSpec, + workspaceInfo.packageManager, + supportCatalog, + true, + catalogDependencyResolver, + importOptions, ); - if (workspaceInfo.packageManager === PackageManager.npm) { - packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg) || packageJsonChanged; - } if (workspaceInfo.packageManager === PackageManager.yarn) { - const ensured = ensureOverrideEntries(pkg.resolutions); + const ensured = ensureOverrideEntries(pkg.resolutions, usesVitest); if (ensured.changed) { pkg.resolutions = ensured.overrides; packageJsonChanged = true; } } else if (workspaceInfo.packageManager === PackageManager.npm) { - const ensured = ensureOverrideEntries(pkg.overrides); + const ensured = ensureOverrideEntries(pkg.overrides, usesVitest); if (ensured.changed) { pkg.overrides = ensured.overrides; packageJsonChanged = true; } } else if (workspaceInfo.packageManager === PackageManager.bun) { - const ensured = ensureOverrideEntries(pkg.overrides, readBunCatalogDependencyResolver(pkg)); + const ensured = ensureOverrideEntries( + pkg.overrides, + usesVitest, + readBunCatalogDependencyResolver(pkg), + ); if (ensured.changed) { pkg.overrides = ensured.overrides; packageJsonChanged = true; } - } else if (workspaceInfo.packageManager === PackageManager.pnpm && pkg.pnpm) { - const ensured = ensureOverrideEntries(pkg.pnpm.overrides); + } else if ( + workspaceInfo.packageManager === PackageManager.pnpm && + pnpmConfigLivesInPackageJson(pkg, projectPath) + ) { + // `pnpmConfigLivesInPackageJson` guarantees `pkg.pnpm` is present here, + // but it may be an empty object (no pnpm-workspace.yaml case), so seed it. + pkg.pnpm ??= {}; + const ensured = ensureOverrideEntries(pkg.pnpm.overrides, usesVitest); if (ensured.changed) { pkg.pnpm.overrides = ensured.overrides; packageJsonChanged = true; } - packageJsonChanged = ensurePnpmPeerDependencyRules(pkg) || packageJsonChanged; + packageJsonChanged = ensurePnpmPeerDependencyRules(pkg, usesVitest) || packageJsonChanged; + if (pnpmMajorVersion !== undefined && pkg.pnpm) { + const beforePnpm = JSON.stringify(pkg.pnpm); + applyBuildAllowanceToPackageJsonPnpm(pkg.pnpm, pnpmMajorVersion, shouldAllowBrowserBuilds); + packageJsonChanged = beforePnpm !== JSON.stringify(pkg.pnpm) || packageJsonChanged; + } } result.packageJson = packageJsonChanged; return pkg; }); + // Existing Vite+ monorepos take this bootstrap path instead of the full + // migration, so reconcile every workspace manifest as well as the root. + for (const workspacePackage of workspaceInfo.packages) { + const packagePath = path.join(projectPath, workspacePackage.path); + const childPackageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(childPackageJsonPath)) { + continue; + } + let childChanged = false; + editJsonFile(childPackageJsonPath, (pkg) => { + childChanged = reconcileVitePlusBootstrapPackage( + packagePath, + pkg, + canonicalVitePlusSpec, + workspaceInfo.packageManager, + supportCatalog, + false, + catalogDependencyResolver, + importOptions, + ); + return childChanged ? pkg : undefined; + }); + result.packageJson = result.packageJson || childChanged; + } + if (workspaceInfo.packageManager === PackageManager.pnpm) { const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; - if (!pkg.pnpm) { + if (!pnpmConfigLivesInPackageJson(pkg, projectPath)) { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); const before = fs.existsSync(pnpmWorkspaceYamlPath) ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') : undefined; const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); if ( + result.packageJson || + ecosystemCatalogReferencesPending || + !pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath) || defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus( readPnpmWorkspaceOverrides(projectPath), + usesVitest, catalogDependencyResolver, ) || - !pnpmPeerDependencyRulesSatisfyVitePlus(readPnpmWorkspacePeerDependencyRules(projectPath)) + !pnpmPeerDependencyRulesSatisfyVitePlus( + readPnpmWorkspacePeerDependencyRules(projectPath), + usesVitest, + ) ) { - // Bootstrap only completes the catalog / overrides / peer rules for a - // project that already uses Vite+. Build-script allowance stays owned - // by the full migration paths, so pass an undefined pnpm major to skip - // it (mirrors the single-arg call this path used before the signature - // grew the build-allowance parameters). - rewritePnpmWorkspaceYaml(projectPath, undefined, false); + rewritePnpmWorkspaceYaml( + projectPath, + pnpmMajorVersion, + shouldAllowBrowserBuilds, + usesVitest, + vitestEcosystemPackages, + ); } if (fs.existsSync(pnpmWorkspaceYamlPath)) { ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); @@ -3580,18 +4658,21 @@ export function ensureVitePlusBootstrap( ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') : undefined; result.packageManagerConfig = before !== after; + } else if (ensurePnpmWorkspaceExoticSubdepsSetting(projectPath)) { + ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); + result.packageManagerConfig = true; } } else if (workspaceInfo.packageManager === PackageManager.yarn) { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf-8') : undefined; - rewriteYarnrcYml(projectPath); + rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages); const after = fs.readFileSync(yarnrcYmlPath, 'utf-8'); result.packageManagerConfig = before !== after; } else if (workspaceInfo.packageManager === PackageManager.bun) { const before = fs.readFileSync(packageJsonPath, 'utf-8'); - rewriteBunCatalog(projectPath); + rewriteBunCatalog(projectPath, usesVitest, vitestEcosystemPackages); const after = fs.readFileSync(packageJsonPath, 'utf-8'); result.packageJson = result.packageJson || before !== after; } @@ -3765,9 +4846,12 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ * is a separate package that the migration scans on its own pass, so the root * package must not inherit a browser-mode signal from a sub-package. */ -function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { - const matchesHint = (content: string): boolean => hints.some((hint) => content.includes(hint)); - +function sourceTreeMatchingFiles( + projectPath: string, + matchesContent: (content: string) => boolean, + stopAfterFirst = false, +): string[] { + const matchingFiles: string[] = []; const scanDir = (dir: string, isRoot: boolean): boolean => { let entries: fs.Dirent[]; try { @@ -3791,8 +4875,11 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): } } else if (entry.isFile() && VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name))) { try { - if (matchesHint(fs.readFileSync(entryPath, 'utf8'))) { - return true; + if (matchesContent(fs.readFileSync(entryPath, 'utf8'))) { + matchingFiles.push(entryPath); + if (stopAfterFirst) { + return true; + } } } catch { // Unreadable file — ignore and keep scanning. @@ -3802,7 +4889,102 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): return false; }; - return scanDir(projectPath, true); + scanDir(projectPath, true); + return matchingFiles; +} + +function sourceTreeMatches( + projectPath: string, + matchesContent: (content: string) => boolean, +): boolean { + return sourceTreeMatchingFiles(projectPath, matchesContent, true).length > 0; +} + +function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { + return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); +} + +function findPackageTsconfigFiles(projectPath: string): string[] { + const files: string[] = []; + const scanDir = (dir: string, isRoot: boolean): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + if (!isRoot && entries.some((entry) => entry.isFile() && entry.name === 'package.json')) { + return; + } + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!VITEST_SCAN_SKIP_DIRS.has(entry.name)) { + scanDir(entryPath, false); + } + } else if (entry.isFile() && /^tsconfig(?:\.[\w-]+)?\.json$/i.test(entry.name)) { + files.push(entryPath); + } + } + }; + scanDir(projectPath, true); + return files; +} + +const UPSTREAM_VITEST_MODULE_REFERENCE = + /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]vitest(?:\/[^'"]+)?['"]/m; + +function hasNuxtTestUtilsDependency(pkg: DependencyBag): boolean { + return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( + (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, + ); +} + +/** + * Find files whose upstream Vitest imports are preserved by the + * @nuxt/test-utils package-level compatibility rule. + * Each package is scanned independently so a root dependency does not leak into + * unrelated workspace manifests. + */ +export function detectNuxtTestUtilsVitestImportFiles( + rootDir: string, + packages?: WorkspacePackage[], +): string[] { + const files: string[] = []; + for (const projectPath of [ + rootDir, + ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path)), + ]) { + const pkg = readPackageJsonIfExists(path.join(projectPath, 'package.json')); + if (!pkg || !hasNuxtTestUtilsDependency(pkg)) { + continue; + } + files.push( + ...sourceTreeMatchingFiles(projectPath, (content) => + UPSTREAM_VITEST_MODULE_REFERENCE.test(content), + ), + ); + } + return [...new Set(files)]; +} + +// Normal imports and triple-slash type directives from `vitest` are rewritten +// to `vite-plus/test` later in the same migration and therefore do not justify +// a lasting direct dependency. Module augmentations, `vitest/package.json`, and +// compilerOptions.types entries deliberately retain the upstream package +// identity, so keep Vitest package-local for those surfaces. +function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean { + return ( + findPackageTsconfigFiles(projectPath).some(hasVitestTypesInTsconfig) || + sourceTreeMatches(projectPath, (content) => { + return ( + /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || + content.includes('vitest/package.json') || + /\brequire\.resolve\s*\(\s*['"]vitest(?:\/[^'"]*)?['"]/.test(content) || + /\bimport\.meta\.resolve\s*\(\s*['"]vitest(?:\/[^'"]*)?['"]/.test(content) + ); + }) + ); } function usesVitestBrowserMode(projectPath: string): boolean { @@ -3851,6 +5033,19 @@ export function rewritePackageJson( // `@vitest/browser-webdriverio` → true). A provider with no dep declared but // imported in source still gets kept/injected. providerSourceModes?: Partial>, + // Whether the project uses vitest DIRECTLY (a required-peer consumer, an + // upstream module reference, or browser mode). `vitest` is managed only + // when true; in the common case (`false`) a lingering managed `vitest` entry + // is REMOVED so it arrives transitively through vite-plus. Defaults to true to + // preserve legacy behavior for callers that don't compute the signal. + usesVitestDirectly = true, + // Module augmentations, compilerOptions.types, and `vitest/package.json` + // intentionally retain the upstream package identity after import rewriting + // and therefore require a package-local provider under strict layouts. + retainedVitestModule = false, + // Installed dependency metadata can reveal required Vitest peers whose + // package names do not include "vitest". + requiredVitestPeer = false, ): Record | null { if (pkg.scripts) { const updated = rewriteScripts( @@ -3889,7 +5084,29 @@ export function rewritePackageJson( needVitePlus = true; } } - for (const [key, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + const managed = managedOverridePackages(usesVitestDirectly); + // Common case (no direct vitest): vite-plus consumes upstream vitest itself, + // so ACTIVELY REMOVE any lingering managed `vitest` dependency (a managed pin, + // a `catalog:` reference, or a stale wrapper alias already normalized above) — + // it arrives transitively through vite-plus and a future `vp update vite-plus` + // keeps it correct with no pin to drift. The `@vitest/*` family and unrelated + // keys are untouched. (Browser-mode / vitest-adjacent projects re-add a direct + // `vitest` below; those are direct-usage signals, so this never strips one a + // surviving consumer needs.) + if (!usesVitestDirectly) { + // Only the INSTALL groups — a `peerDependencies` `vitest` is a declaration + // about consumers, not an install pin, so it is not removed here. Catalog + // peer specs are resolved to their public range/fallback below. + for (const { dependencyField, dependencies } of dependencyGroups) { + if (dependencyField === 'peerDependencies') { + continue; + } + if (removeManagedVitestEntry(dependencies)) { + needVitePlus = true; + } + } + } + for (const [key, version] of Object.entries(managed)) { for (const { dependencyField, dependencies } of dependencyGroups) { if (dependencies?.[key]) { dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog, { @@ -3902,6 +5119,14 @@ export function rewritePackageJson( } } } + if (normalizeVitestPeerCatalogSpec(pkg.peerDependencies, catalogDependencyResolver)) { + needVitePlus = true; + } + // Optional Vitest packages are published in lockstep with the runner. Keep + // every declared official @vitest/* package on the bundled version during a + // fresh migration too; existing-Vite+ upgrades use the same rule in the + // bootstrap path. + alignVitestEcosystemPackages(pkg, packageManager, supportCatalog, catalogDependencyResolver); // Force-override mode (ecosystem CI / `vp create` E2E) must re-pin any // pre-existing `vite-plus` range to the local tgz. Otherwise pnpm reads the // published vite-plus metadata for transitive dep resolution (e.g. @@ -3957,12 +5182,11 @@ export function rewritePackageJson( // rewritten `vite-plus/test/browser-` import to resolve. Unlike the // rest of the `@vitest/*` family they are deliberately NOT in // VITE_PLUS_OVERRIDE_PACKAGES (so projects not using a provider stay - // untouched), which means the normalization loop above does not pin them. We - // pin each used provider here, to a CONCRETE version (no catalog entry is - // written for an opt-in provider) so it self-resolves and stays aligned with - // the bundled vitest, and we ensure its runtime framework peer - // (`webdriverio` / `playwright`). (`@vitest/browser`/preview stay bundled + - // stripped, handled in the REMOVE_PACKAGES loop above.) + // untouched), which means the normalization loop above does not add them. We + // align each installed provider here using its existing catalog when present, + // or the concrete bundled version otherwise, and ensure its runtime framework + // peer (`webdriverio` / `playwright`). (`@vitest/browser`/preview stay bundled + // + stripped, handled in the REMOVE_PACKAGES loop above.) let usesAnyOptInProvider = false; for (const provider of OPT_IN_BROWSER_PROVIDERS) { const usesProvider = @@ -3977,11 +5201,21 @@ export function rewritePackageJson( // resolve. Normalize an existing install-group declaration to the bundled // vitest version in place (the override loop above no longer pins it); // otherwise — a source-only or peer-only user — inject it into devDeps. - const installGroup = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].find( - (deps) => deps?.[provider] !== undefined, + const installGroupEntry = dependencyGroups.find( + ({ dependencyField, dependencies }) => + dependencyField !== 'peerDependencies' && dependencies?.[provider] !== undefined, ); - if (installGroup) { - installGroup[provider] = VITEST_VERSION; + if (installGroupEntry?.dependencies) { + if (VITEST_IS_MANAGED_OVERRIDE) { + installGroupEntry.dependencies[provider] = getAlignedVitestEcosystemDependencySpec( + installGroupEntry.dependencies[provider], + provider, + installGroupEntry.dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); + } } else { pkg.devDependencies ??= {}; pkg.devDependencies[provider] = VITEST_VERSION; @@ -4027,18 +5261,19 @@ export function rewritePackageJson( const effectiveBrowserMode = vitestBrowserMode || hasBrowserDepSignal; // Trigger vite-plus install when a project has a vitest-adjacent package // (e.g. `vitest-browser-svelte`) that declares vitest as a peer dep — even - // if the project has no vite/oxlint/tsdown dep to migrate. The peer dep is - // satisfied by the upstream vitest that vite-plus bundles as a direct dep. - // Note: peerDependencies count as "adjacent signal" but NOT as installed. + // if the project has no vite/oxlint/tsdown dep to migrate. Only installed + // dependency groups count; a peer declaration alone installs nothing here. const installableNames = [ ...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {}), ...Object.keys(pkg.optionalDependencies ?? {}), ]; - const adjacentSignals = [...installableNames, ...Object.keys(pkg.peerDependencies ?? {})]; const isVitestAdjacent = !installableNames.includes('vitest') && - adjacentSignals.some((name) => name !== 'vitest' && name.includes('vitest')); + installableNames.some( + (name) => + name !== 'vitest' && name.includes('vitest') && !VITEST_DIRECT_USAGE_EXCLUDED.has(name), + ); // Normalize a pre-existing pinned vite-plus so sub-packages don't drift // from siblings: in catalog-supporting monorepos that's `catalog:`, under // force-override (file:) it's the tgz path. Preserve protocol-prefixed @@ -4070,7 +5305,12 @@ export function rewritePackageJson( // `existingVitePlus` is already truthy here), or a re-migration of a project that // already owns it. The guard below still no-ops when a direct `vitest` already exists, // so a genuine normalize pass of an already-correct project mutates nothing. - const needDirectVitest = needVitePlus || effectiveBrowserMode || isVitestAdjacent; + const needDirectVitest = + needVitePlus || + effectiveBrowserMode || + isVitestAdjacent || + retainedVitestModule || + requiredVitestPeer; if (needVitePlus || shouldNormalizeExistingVitePlus) { pkg.devDependencies = { ...pkg.devDependencies, @@ -4096,7 +5336,10 @@ export function rewritePackageJson( }; if ( !installableDeps.vitest && - (effectiveBrowserMode || Object.keys(installableDeps).some((name) => name.includes('vitest'))) + (effectiveBrowserMode || + retainedVitestModule || + requiredVitestPeer || + Object.keys(installableDeps).some((name) => name.includes('vitest'))) ) { pkg.devDependencies ??= {}; pkg.devDependencies.vitest = getCatalogDependencySpec( @@ -4868,13 +6111,20 @@ function wrapLazyPluginsInViteConfig( * This rewrites vite/vitest imports to @voidzero-dev/vite-plus * @param projectPath - The root directory to search for files */ -function rewriteAllImports(projectPath: string, silent = false, report?: MigrationReport): boolean { - const result = rewriteImportsInDirectory(projectPath); +function rewriteAllImports( + projectPath: string, + silent = false, + report?: MigrationReport, + preserveNuxtVitestImports = true, +): boolean { + const result = rewriteImportsInDirectory(projectPath, preserveNuxtVitestImports); const modified = result.modifiedFiles.length; + const preserved = result.preservedVitestFiles.length; const errors = result.errors.length; if (report) { report.rewrittenImportFileCount += modified; + report.preservedNuxtVitestImportFileCount += preserved; report.rewrittenImportErrors.push( ...result.errors.map((error) => ({ path: displayRelative(error.path), diff --git a/packages/cli/src/migration/npm-reinstall.ts b/packages/cli/src/migration/npm-reinstall.ts new file mode 100644 index 0000000000..607fd7a652 --- /dev/null +++ b/packages/cli/src/migration/npm-reinstall.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readJsonFile, writeJsonFile } from '../utils/json.ts'; + +const VITE_PLUS_CORE_PACKAGE = '@voidzero-dev/vite-plus-core'; + +interface NpmLockPackage { + name?: string; + resolved?: string; +} + +interface NpmPackageLock { + packages?: Record; +} + +function isViteInstallPath(packagePath: string): boolean { + return packagePath === 'node_modules/vite' || packagePath.endsWith('/node_modules/vite'); +} + +function isVitePlusCorePackage(pkg: NpmLockPackage | undefined): boolean { + return ( + pkg?.name === VITE_PLUS_CORE_PACKAGE || + pkg?.resolved?.includes('/@voidzero-dev/vite-plus-core/') === true + ); +} + +function removeStaleInstalledVite(packagePath: string): boolean { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + try { + const pkg = readJsonFile(packageJsonPath) as { name?: string }; + if (pkg.name === VITE_PLUS_CORE_PACKAGE) { + return false; + } + } catch { + // A broken package directory also needs to be replaced by the reinstall. + } + + fs.rmSync(packagePath, { recursive: true, force: true }); + return true; +} + +/** + * npm does not replace an already-installed package when its dependency changes + * from `vite` to the `@voidzero-dev/vite-plus-core` npm alias. Even `npm + * install --force` can exit successfully while retaining the real Vite package + * and its stale package-lock entry. Remove only those stale Vite entries before + * the migration's final install so npm resolves the managed alias afresh. + */ +export function prepareNpmViteAliasReinstall( + rootDir: string, + projectPaths: string[] = [rootDir], +): boolean { + const packageLockPath = path.join(rootDir, 'package-lock.json'); + let changed = false; + + if (fs.existsSync(packageLockPath)) { + const packageLock = readJsonFile(packageLockPath) as NpmPackageLock; + let lockChanged = false; + + for (const [packagePath, pkg] of Object.entries(packageLock.packages ?? {})) { + if (!isViteInstallPath(packagePath)) { + continue; + } + + const installPath = path.resolve(rootDir, packagePath); + const relativeInstallPath = path.relative(rootDir, installPath); + if (relativeInstallPath.startsWith('..') || path.isAbsolute(relativeInstallPath)) { + continue; + } + + if (!isVitePlusCorePackage(pkg)) { + delete packageLock.packages?.[packagePath]; + lockChanged = true; + removeStaleInstalledVite(installPath); + } else { + changed = removeStaleInstalledVite(installPath) || changed; + } + } + + if (lockChanged) { + writeJsonFile(packageLockPath, packageLock as unknown as Record); + changed = true; + } + } + + // Also handle installs without a lockfile and workspace-local copies that do + // not have their own package-lock entry. + for (const projectPath of projectPaths) { + changed = removeStaleInstalledVite(path.join(projectPath, 'node_modules', 'vite')) || changed; + } + + return changed; +} diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index 63391ae03a..d2bfe2bfec 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -7,6 +7,7 @@ export interface MigrationReport { tsdownImportCount: number; wrappedPluginConfigCount: number; rewrittenImportFileCount: number; + preservedNuxtVitestImportFileCount: number; rewrittenImportErrors: Array<{ path: string; message: string }>; eslintMigrated: boolean; prettierMigrated: boolean; @@ -28,6 +29,7 @@ export function createMigrationReport(): MigrationReport { tsdownImportCount: 0, wrappedPluginConfigCount: 0, rewrittenImportFileCount: 0, + preservedNuxtVitestImportFileCount: 0, rewrittenImportErrors: [], eslintMigrated: false, prettierMigrated: false, diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 25ca9c2983..c763f9c235 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { definePlugin, defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; @@ -98,13 +101,59 @@ function quoteSpecifier(literal: ESTree.StringLiteral, replacement: string): str return `${quote}${replacement}${quote}`; } +const nuxtTestUtilsPackageCache = new Map(); + +function isUpstreamVitestSpecifier(specifier: string): boolean { + return specifier === 'vitest' || specifier.startsWith('vitest/'); +} + +function nearestPackageUsesNuxtTestUtils(filename: string): boolean { + if (!path.isAbsolute(filename)) { + return false; + } + let directory = path.dirname(filename); + while (true) { + const packageJsonPath = path.join(directory, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const cached = nuxtTestUtilsPackageCache.get(packageJsonPath); + if (cached !== undefined) { + return cached; + } + let usesNuxtTestUtils = false; + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + }; + usesNuxtTestUtils = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( + (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, + ); + } catch { + // Invalid or unreadable package metadata cannot opt into the exception. + } + nuxtTestUtilsPackageCache.set(packageJsonPath, usesNuxtTestUtils); + return usesNuxtTestUtils; + } + const parent = path.dirname(directory); + if (parent === directory) { + return false; + } + directory = parent; + } +} + function maybeReportLiteral( context: Context, literal: ESTree.Expression | ESTree.TSModuleDeclaration['id'] | null | undefined, + preserveUpstreamVitest = false, ) { if (!literal || literal.type !== 'Literal' || typeof literal.value !== 'string') { return; } + if (preserveUpstreamVitest && isUpstreamVitestSpecifier(literal.value)) { + return; + } const replacement = rewriteVitePlusImportSpecifier(literal.value); if (!replacement) { @@ -138,24 +187,28 @@ export const preferVitePlusImportsRule = defineRule({ }, }, createOnce(context: Context) { + let preserveUpstreamVitest = false; return { + Program() { + preserveUpstreamVitest = nearestPackageUsesNuxtTestUtils(context.filename); + }, ImportDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ExportAllDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ExportNamedDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ImportExpression(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, TSImportType(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, TSExternalModuleReference(node) { - maybeReportLiteral(context, node.expression); + maybeReportLiteral(context, node.expression, preserveUpstreamVitest); }, TSModuleDeclaration(node) { if (node.global) { @@ -169,7 +222,7 @@ export const preferVitePlusImportsRule = defineRule({ ) { return; } - maybeReportLiteral(context, id); + maybeReportLiteral(context, id, preserveUpstreamVitest); }, }; }, diff --git a/packages/cli/src/utils/__tests__/constants.spec.ts b/packages/cli/src/utils/__tests__/constants.spec.ts new file mode 100644 index 0000000000..5a2b514ef1 --- /dev/null +++ b/packages/cli/src/utils/__tests__/constants.spec.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import cliPkg from '../../../package.json' with { type: 'json' }; + +describe('Vite+ dependency versions', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('uses the concrete CLI version for vite-plus and vite-plus-core by default', async () => { + vi.stubEnv('VP_VERSION', ''); + vi.stubEnv('VP_OVERRIDE_PACKAGES', ''); + vi.resetModules(); + + const { VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } = await import('../constants.js'); + + expect(VITE_PLUS_VERSION).toBe(cliPkg.version); + expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe( + `npm:@voidzero-dev/vite-plus-core@${cliPkg.version}`, + ); + }); + + it('preserves explicit prerelease overrides', async () => { + const vitePlusUrl = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; + const viteCoreUrl = + 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; + vi.stubEnv('VP_VERSION', vitePlusUrl); + vi.stubEnv('VP_OVERRIDE_PACKAGES', JSON.stringify({ vite: viteCoreUrl, vitest: '4.1.9' })); + vi.resetModules(); + + const { VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } = await import('../constants.js'); + + expect(VITE_PLUS_VERSION).toBe(vitePlusUrl); + expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe(viteCoreUrl); + }); +}); diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 2ae6ba9c52..d7a3810a6f 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -1,14 +1,16 @@ import { createRequire } from 'node:module'; +import cliPkg from '../../package.json' with { type: 'json' }; + export const VITE_PLUS_NAME = 'vite-plus'; -export const VITE_PLUS_VERSION = process.env.VP_VERSION || 'latest'; +export const VITE_PLUS_VERSION = process.env.VP_VERSION || cliPkg.version; export const VITEST_VERSION = '4.1.9'; export const VITE_PLUS_OVERRIDE_PACKAGES: Record = process.env.VP_OVERRIDE_PACKAGES ? JSON.parse(process.env.VP_OVERRIDE_PACKAGES) : { - vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vite: `npm:@voidzero-dev/vite-plus-core@${VITE_PLUS_VERSION}`, // Pin `vitest` only. The `@vitest/*` family (expect, runner, snapshot, spy, // utils, mocker, pretty-format) are EXACT (`4.1.9`) dependencies of `vitest` // itself, so a single `vitest` override cascades one consistent version to diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts index ef3faccecf..14a8587766 100644 --- a/packages/cli/src/utils/package.ts +++ b/packages/cli/src/utils/package.ts @@ -19,15 +19,97 @@ interface PackageMetadata { path: string; } +function findOwningPackageJson(resolvedPath: string, packageName: string): string | undefined { + let currentDir: string; + try { + currentDir = fs.statSync(resolvedPath).isDirectory() + ? resolvedPath + : path.dirname(resolvedPath); + } catch { + return undefined; + } + while (currentDir !== path.dirname(currentDir)) { + const candidate = path.join(currentDir, 'package.json'); + if (fs.existsSync(candidate)) { + try { + const candidatePkg = JSON.parse(fs.readFileSync(candidate, 'utf8')); + if (candidatePkg.name === packageName) { + return candidate; + } + } catch { + // Keep walking: this may be an unrelated or malformed nested manifest. + } + } + currentDir = path.dirname(currentDir); + } + return undefined; +} + +function resolvePackageJsonWithNode( + require: ReturnType, + packageName: string, +): string | undefined { + try { + return require.resolve(`${packageName}/package.json`); + } catch { + // Packages with an exports map often do not expose `./package.json`. + } + try { + return findOwningPackageJson(require.resolve(packageName), packageName); + } catch { + return undefined; + } +} + +function findPnpApiPath(projectPath: string): string | undefined { + let currentDir = path.resolve(projectPath); + while (currentDir !== path.dirname(currentDir)) { + const candidate = path.join(currentDir, '.pnp.cjs'); + if (fs.existsSync(candidate)) { + return candidate; + } + currentDir = path.dirname(currentDir); + } + return undefined; +} + export function detectPackageMetadata( projectPath: string, packageName: string, ): PackageMetadata | void { + // Create require from the project path so resolution only searches the + // project's dependencies, not the global installation's. + const require = createRequire(path.join(projectPath, 'noop.js')); + let pkgFilePath = resolvePackageJsonWithNode(require, packageName); + if (!pkgFilePath) { + const pnpApiPath = findPnpApiPath(projectPath); + if (!pnpApiPath) { + return; + } + try { + const pnpApi = createRequire(pnpApiPath)(pnpApiPath) as { + resolveToUnqualified: (request: string, issuer: string) => string; + setup?: () => void; + }; + // Activating the generated API makes archive-backed Yarn cache paths + // readable through Node's fs implementation as well. + pnpApi.setup?.(); + const unqualified = pnpApi.resolveToUnqualified( + packageName, + path.join(projectPath, 'noop.js'), + ); + pkgFilePath = findOwningPackageJson(unqualified, packageName); + if (!pkgFilePath) { + pkgFilePath = resolvePackageJsonWithNode(require, packageName); + } + } catch { + return; + } + } + if (!pkgFilePath) { + return; + } try { - // Create require from the project path so resolution only searches - // the project's node_modules, not the global installation's - const require = createRequire(path.join(projectPath, 'noop.js')); - const pkgFilePath = require.resolve(`${packageName}/package.json`); const pkg = JSON.parse(fs.readFileSync(pkgFilePath, 'utf8')); return { name: pkg.name, @@ -35,7 +117,6 @@ export function detectPackageMetadata( path: path.dirname(pkgFilePath), }; } catch { - // ignore MODULE_NOT_FOUND error return; } } diff --git a/packages/cli/src/utils/tsconfig.ts b/packages/cli/src/utils/tsconfig.ts index f421dae252..a842e1360f 100644 --- a/packages/cli/src/utils/tsconfig.ts +++ b/packages/cli/src/utils/tsconfig.ts @@ -192,6 +192,27 @@ export function hasTypesToRewriteInTsconfig(filePath: string): boolean { ); } +export function hasVitestTypesInTsconfig(filePath: string): boolean { + let text: string; + try { + text = fs.readFileSync(filePath, 'utf-8'); + } catch { + return false; + } + + const parsed = parseJsonc(text) as { + compilerOptions?: { types?: unknown[] }; + } | null; + + const types = parsed?.compilerOptions?.types; + return ( + Array.isArray(types) && + types.some((type) => + typeof type === 'string' ? type === 'vitest' || type.startsWith('vitest/') : false, + ) + ); +} + export function rewriteTypesInTsconfig(filePath: string): boolean { let text: string; try { diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 9b1f2e8bff..9724dfe744 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -36,6 +36,7 @@ export default defineConfig([ // Without these, tsdown inlines them into bin.js, breaking on-demand loading. 'create/bin': './src/create/bin.ts', 'migration/bin': './src/migration/bin.ts', + 'migration/compat-worker': './src/migration/compat-worker.ts', version: './src/version.ts', 'config/bin': './src/config/bin.ts', 'staged/bin': './src/staged/bin.ts', diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 597b47aaf7..d99bb96ef6 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -73,6 +73,14 @@ export function replaceUnstableOutput(output: string, cwd?: string) { /("(?:vitest|@vitest\/(?!coverage-)[\w-]+)": ")(?:[4-9]|[1-9]\d+)\.\d+\.\d+(?:-[\w.]+)?(")/g, '$1$2', ) + // Vite+ and its core package are written as exact lockstep versions by + // create/migrate. Mask JSON dependency values so release bumps do not + // create unrelated snapshot churn (YAML values and npm aliases are + // already covered by the generic semver normalization above). + .replaceAll( + /("(?:vite-plus|@voidzero-dev\/vite-plus-core)": ")\d+\.\d+\.\d+(?:-[\w.]+)?(")/g, + '$1$2', + ) // devEngines.packageManager auto-pin writes the exact resolved version // e.g.: `"name": "pnpm",\n "version": "11.5.1"` -> `"version": ""` // (the optional suffix covers prerelease and build metadata: -rc-1, +sha.abc) diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md new file mode 100644 index 0000000000..2d32d616ab --- /dev/null +++ b/rfcs/migrate-existing-projects.md @@ -0,0 +1,135 @@ +# RFC: Migrating Existing Vite+ Projects to a New Version + +- Status: Implemented on `rfc/migrate-upgrade-path`; end-to-end browser-mode verification remains (see Follow-ups) +- Depends on: [#1588 replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) +- Related: `docs/guide/upgrade.md`, [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md) + +## Goal: upgrade in two commands + +Any later Vite+ upgrade is two commands: upgrade the global CLI, then migrate the project. + +```bash +vp upgrade # update the global `vp` binary +vp migrate # bring the project up to the new toolchain +``` + +Both are needed, and the order matters. `vp migrate` normally runs the project's **local** `vite-plus`, which on an old project predates the new upgrade logic (and would even rewrite config that pins the project to the old version). So `vp upgrade` first makes a new-enough CLI available, and `vp migrate` then escalates to it (see Routing) and applies the rules below. `vp update vite-plus` alone is not enough: it bumps the dependency but does not reconcile the override/catalog config. + +`vp migrate` is idempotent: on an already-current project it reports "already using Vite+" and changes nothing. + +## Migrate rules + +Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so ordinary node-mode projects using only `vite-plus/test*` do not need their own `vitest`. A direct package with a required `vitest` peer is different: under strict dependency layouts, the copy nested below the sibling `vite-plus` dependency cannot satisfy that peer. Such a package needs a package-local direct `vitest`, plus a shared override when the package manager supports one. This applies whether the peer range is exact or broad. + +Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. + +### Yarn Plug'n'Play preflight + +Vite+ does not currently support Yarn Plug'n'Play. Before collecting the other migration decisions or installing dependencies, `vp migrate` resolves the effective Yarn linker from `YARN_NODE_LINKER`, project/ancestor/home `.yarnrc.yml` files, and Yarn's version-dependent default. Explicit `nodeLinker: pnp` and the implicit Yarn 2+ default are both PnP mode. + +When PnP is active, interactive migration prints the incompatibility and asks whether to switch the project to `nodeLinker: node-modules` and continue. Accepting writes the project-root `.yarnrc.yml` without discarding its other settings; declining cancels before the remaining migration mutates the project. `--no-interactive` uses the affirmative default, reports the conversion, and continues. The conversion happens before the initial install so a clean checkout gets physical dependency metadata for required-peer detection. A process-level `YARN_NODE_LINKER=pnp` cannot be persistently repaired in project files, so migration stops with instructions to unset it or change it to `node-modules`. + +| Area | Rule | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| Yarn linker | Vite+ does not currently support Yarn PnP. Detect explicit and implicit PnP before migration, ask to switch to `nodeLinker: node-modules`, and continue only after conversion. Non-interactive migration accepts this conversion by default. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to the concrete `@voidzero-dev/vite-plus-core` version matching the migrating `vite-plus` release in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or declares `@nuxt/test-utils`. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. When that metadata is unavailable in a clean checkout, preserve an existing direct Vitest conservatively. Other retained references include module augmentations, nested or root `compilerOptions.types`, `require.resolve` / `import.meta.resolve`, and the intentionally unre-written `vitest/package.json` export. In a Nuxt test-utils package, all `vitest` and `vitest/*` specifiers remain upstream consistently; in other packages, rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | + +Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. + +## `@nuxt/test-utils` compatibility + +`@nuxt/test-utils`'s transform detects an existing `vi` import only when its module specifier is exactly `vitest`. When a test uses `mockNuxtImport` or `mockComponent`, changing that import to `vite-plus/test` makes the transform inject a second `vi` import and can fail compilation with a duplicate identifier. Requiring users to know which individual files exercise that transform is brittle, so the migration uses one package-level rule instead. + +Detection and scope: + +1. A package is eligible when its `dependencies`, `devDependencies`, or `optionalDependencies` contains `@nuxt/test-utils`. +2. Every `vitest` and `vitest/*` module specifier in that package is preserved, regardless of whether the individual file imports `@nuxt/test-utils`. This includes unit tests and shared test helpers, eliminating mixed import identities within one test suite. +3. Scoped `@vitest/browser*` specifiers keep their existing Vite+ rewrites and provider provisioning because they are separate packages, not the upstream `vitest` package identity protected by this rule. +4. An eligible package keeps its package-local `vitest`, and the workspace keeps the matching shared pin/catalog entry. +5. Workspace scope follows the nearest `package.json`: one Nuxt package does not suppress rewrites in unrelated workspace packages. +6. `prefer-vite-plus-imports` uses the same package-level exception for `vitest` and `vitest/*`. Lint and autofix must not undo the migration result. + +This rule is automatic in interactive and non-interactive migrations; there is no per-file prompt. A migration reports: + +```text +• Kept upstream `vitest` imports in 135 files for @nuxt/test-utils compatibility +``` + +The count is the number of files, not import declarations. + +**Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. + +## Vitest ecosystem packages + +How each package the `vitest` ecosystem rule covers is handled, verified against the registry at `4.1.9`. The code rule: align any `@vitest/*` the project lists to `VITEST_VERSION`, except `@vitest/eslint-plugin`; the browser packages additionally follow their bundled/opt-in handling. + +| Package | `vitest` peer | Handling | +| ---------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- | +| `@vitest/coverage-v8` | `4.1.9` (exact) | align; provide direct `vitest` in the same package | +| `@vitest/coverage-istanbul` | `4.1.9` | align; provide direct `vitest` in the same package | +| `@vitest/ui` | `4.1.9` | align; provide direct `vitest` in the same package | +| `@vitest/web-worker` | `4.1.9` | align; provide direct `vitest` in the same package | +| `@vitest/browser` | `4.1.9` | removed (bundled by `vite-plus`); browser package keeps direct `vitest` | +| `@vitest/browser-preview` | `4.1.9` | removed (bundled by `vite-plus`); browser package keeps direct `vitest` | +| `@vitest/browser-playwright` | `4.1.9` + `playwright` | opt-in: pin to `VITEST_VERSION`, keep `playwright` and direct `vitest` | +| `@vitest/browser-webdriverio` | `4.1.9` + `webdriverio` | opt-in: pin to `VITEST_VERSION`, keep `webdriverio` and direct `vitest` | +| `@vitest/expect` `/runner` `/snapshot` `/spy` `/utils` `/mocker` `/pretty-format` `/ws-client` | none | transitive runtime packages; align if listed, but do not add `vitest` for them alone | +| `@vitest/eslint-plugin` | `*` | left as-is (own version line, e.g. `1.6.x`) | +| `@vitest/coverage-c8` | `>=0.30.0 <1` | left as-is (deprecated at `0.33.0`; there is no package version matching Vitest 4) | +| `vitest-browser-react` `/-vue` `/-svelte`, ... | `^4` (range) | third-party, own versioning; left at a compatible release, with a package-local `vitest` plus shared override | + +## Implementation + +| Area | Change | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `crates/vite_migration` (`import_rewriter.rs`) | Support a package-scoped Nuxt compatibility mode that preserves `vitest` and `vitest/*` specifiers throughout packages that declare `@nuxt/test-utils`, while continuing scoped `@vitest/browser*` rewrites; return the preserved-file count for the migration summary. | +| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Yarn PnP preflight and `node-modules` conversion; usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; package-level Nuxt dependency detection and retained Vitest provisioning. | +| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt package-level `vitest` / `vitest/*` exception so diagnostics and autofix preserve the migration's compatible result. | + +Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. + +## Snapshot coverage + +| Scenario | Global snap fixture | +| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Stale local CLI escalation, plain-range re-pin, stale wrapper removal, empty `pnpm` routing | `migration-upgrade-stale-local-pnpm` | +| Default direct-`vitest` removal and ordinary import rewrite | `migration-already-vite-plus`, `migration-vitest-import-only` | +| Official exact peers under npm and Yarn after PnP-to-node-modules conversion | `migration-upgrade-vitest-exact-peer-npm`, `migration-upgrade-vitest-exact-peer-yarn4` | +| Third-party range peer | `migration-vitest-peer-dep` | +| Internal `@vitest/*` packages and `@vitest/eslint-plugin` exclusions | `migration-upgrade-vitest-non-runtime-only-npm` | +| Playwright and WebdriverIO browser restoration, including pnpm driver approvals | `migration-upgrade-browser-source-only-pnpm`, `migration-upgrade-browser-webdriverio-pnpm` | +| Package-local Vitest in an existing monorepo with shared root overrides | `migration-upgrade-monorepo-vitest-localized-pnpm` | +| Retained upstream module augmentations | `migration-rewrite-declare-module` | +| Unmanaged/CI override mode preserves user-owned Vitest | `migration-vitest-unmanaged-override` | +| Deliberate protocol-pinned `vite-plus` spec | `migration-upgrade-vite-plus-protocol-pin-npm` | +| Idempotent rerun on an already-current project | `migration-from-tsdown`, `migration-from-tsdown-json-config` | +| Reinstall and lockfile refresh after the alias rewrite | `migration-standalone-npm` | +| Peer `vitest` catalog references resolve before managed catalog pruning | `migration-upgrade-peer-vitest-catalog-pnpm` | +| Peer-only browser providers are promoted with direct and shared Vitest | `migration-upgrade-browser-peer-only-pnpm` | +| Whitespace-tolerant Vitest directives rewrite without leaving transient pins | `migration-upgrade-vitest-reference-whitespace-pnpm` | +| Object-valued nested Vitest overrides remain user-owned and idempotent | `migration-upgrade-nested-vitest-override-npm` | +| Retained tsconfig, resolver, and `vitest/package.json` references keep direct Vitest | `migration-upgrade-vitest-retained-references-npm` | +| Required Vitest peers discovered from installed dependency metadata | `migration-upgrade-required-vitest-peer-metadata-npm` | +| Deprecated `@vitest/coverage-c8` is not assigned a nonexistent Vitest 4 version | `migration-upgrade-deprecated-coverage-c8-npm` | +| Standalone Yarn writes catalog specs in one pass and is idempotent | `migration-standalone-yarn4-idempotent` | +| Unmanaged exact-peer Vitest ecosystem versions remain aligned with user-owned Vitest | `migration-vitest-unmanaged-override` | +| Nuxt packages preserve all upstream `vitest` imports without affecting sibling packages | `migration-upgrade-nuxt-test-utils`, `migration-upgrade-nuxt-test-utils-monorepo` | + +The matching Oxlint/autofix behavior is covered by the local `lint-vite-plus-imports-nuxt` snapshot: all `vitest` imports in the Nuxt package remain exempt, while the rule continues rewriting Vite and scoped browser-package imports. + +## Follow-ups (not in this change) + +- Verify the browser-mode upgrade across pnpm/npm/yarn; simplify package-local provisioning only if strict peer and optimizer resolution remain correct. +- Add an end-to-end check on a real `0.1.x` project. +- Update `docs/guide/upgrade.md` / the release-notes prompt to the `vp upgrade && vp migrate` flow once shipped, and `npm deprecate @voidzero-dev/vite-plus-test`. +- Optional `vp migrate --check` (detection-only, exit code signals an available upgrade) for CI.