diff --git a/crates/vite_global_cli/src/commands/env/config.rs b/crates/vite_global_cli/src/commands/env/config.rs index e9b9b4b685..eabf328c47 100644 --- a/crates/vite_global_cli/src/commands/env/config.rs +++ b/crates/vite_global_cli/src/commands/env/config.rs @@ -258,29 +258,34 @@ pub async fn resolve_version(cwd: &AbsolutePath) -> Result Result<&JsRuntime, Error> { if self.project_runtime.is_none() { tracing::debug!("Resolving project runtime from {:?}", project_path); - let runtime = download_runtime_for_project(project_path).await?; + + // 1–2. Session overrides: env var (from `vp env use`), then file + let session_version = vite_shared::EnvConfig::get() + .node_version + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + let session_version = if session_version.is_some() { + session_version + } else { + config::read_session_version().await + }; + if let Some(version) = session_version { + let runtime = download_runtime(JsRuntimeType::Node, &version).await?; + return Ok(self.project_runtime.insert(runtime)); + } + + // 3. Check if project has any *valid* version source. + // resolve_node_version returns Some for any non-empty value, + // even invalid ones. We must validate before routing to + // download_runtime_for_project, which falls to LTS on all-invalid + // and would skip the user's configured default. + let has_valid_project_source = has_valid_version_source(project_path).await?; + + let runtime = if has_valid_project_source { + // At least one valid project source exists — delegate to + // download_runtime_for_project for cache-aware range resolution + // and intra-project fallback chain + download_runtime_for_project(project_path).await? + } else { + // No valid project source — check user default from config, then LTS + let resolution = config::resolve_version(project_path).await?; + download_runtime(JsRuntimeType::Node, &resolution.version).await? + }; self.project_runtime = Some(runtime); } Ok(self.project_runtime.as_ref().unwrap()) @@ -163,8 +203,7 @@ impl JsExecutor { /// If found, runs the local `dist/bin.js` directly. Otherwise, falls back /// to the global installation's `dist/bin.js`. /// - /// Uses the project's runtime (from its `devEngines.runtime` configuration). - /// This may write a `.node-version` file if the project has no version source. + /// Uses the project's runtime resolved via `config::resolve_version()`. /// For side-effect-free commands like `--version`, use [`delegate_with_cli_runtime`] instead. /// /// # Arguments @@ -252,6 +291,48 @@ impl JsExecutor { } } +/// Check whether a project directory has at least one valid version source. +/// +/// Uses `is_valid_version` (no warning side effects) to avoid duplicate +/// warnings when `download_runtime_for_project` or `config::resolve_version` +/// later call `normalize_version` on the same values. +/// +/// Returns `false` when all sources are missing or invalid, so the caller +/// can fall through to the user's configured default instead of LTS. +async fn has_valid_version_source( + project_path: &AbsolutePath, +) -> Result { + let resolution = resolve_node_version(project_path, true).await?; + let Some(ref r) = resolution else { + return Ok(false); + }; + + // Primary source is a valid version? + if is_valid_version(&r.version) { + return Ok(true); + } + + // Primary source invalid — check package.json for valid fallbacks + let pkg_path = project_path.join("package.json"); + let Ok(Some(pkg)) = read_package_json(&pkg_path).await else { + return Ok(false); + }; + + let engines_valid = + pkg.engines.as_ref().and_then(|e| e.node.as_ref()).is_some_and(|v| is_valid_version(v)); + + let dev_engines_valid = !engines_valid + && pkg + .dev_engines + .as_ref() + .and_then(|de| de.runtime.as_ref()) + .and_then(|rt| rt.find_by_name("node")) + .filter(|r| !r.version.is_empty()) + .is_some_and(|r| is_valid_version(&r.version)); + + Ok(engines_valid || dev_engines_valid) +} + #[cfg(test)] mod tests { use serial_test::serial; diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs index 4460080f49..71b7a488ec 100644 --- a/crates/vite_js_runtime/src/lib.rs +++ b/crates/vite_js_runtime/src/lib.rs @@ -51,6 +51,6 @@ pub use provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvi pub use providers::{LtsInfo, NodeProvider, NodeVersionEntry}; pub use runtime::{ JsRuntime, JsRuntimeType, VersionResolution, VersionSource, download_runtime, - download_runtime_for_project, download_runtime_with_provider, normalize_version, - read_package_json, resolve_node_version, + download_runtime_for_project, download_runtime_with_provider, is_valid_version, + normalize_version, read_package_json, resolve_node_version, }; diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index b6853693c6..443d14bf89 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -5,7 +5,7 @@ use vite_str::Str; use crate::{ Error, Platform, - dev_engines::{PackageJson, read_node_version_file, write_node_version_file}, + dev_engines::{PackageJson, read_node_version_file}, download::{download_file, download_text, extract_archive, move_to_cache, verify_file_hash}, provider::{HashVerification, JsRuntimeProvider}, providers::NodeProvider, @@ -384,8 +384,7 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result tracing::debug!("Selected version source: {source:?}, version_req: {version_req:?}"); // Resolve version (if range/partial → exact) - let (version, should_write_back) = - resolve_version_for_project(&version_req, source, &provider, &cache_dir).await?; + let version = resolve_version_for_project(&version_req, &provider, &cache_dir).await?; // Check compatibility with lower priority sources check_version_compatibility(&version, source, &engines_node, &dev_engines_runtime); @@ -393,71 +392,51 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result tracing::info!("Resolved Node.js version: {version}"); let runtime = download_runtime(JsRuntimeType::Node, &version).await?; - // Write resolved version to .node-version (if resolution occurred) - if should_write_back { - if let Err(e) = write_node_version_file(project_path, &version).await { - tracing::warn!("Failed to write .node-version: {e}"); - } else { - tracing::info!("Using Node {version} - saved version to .node-version"); - } - } - Ok(runtime) } /// Resolve version requirement to an exact version. /// -/// Returns (resolved_version, should_write_back). +/// Returns the resolved exact version string. async fn resolve_version_for_project( version_req: &str, - _source: Option, provider: &NodeProvider, cache_dir: &AbsolutePath, -) -> Result<(Str, bool), Error> { +) -> Result { if version_req.is_empty() { // No source specified - fetch latest LTS from network tracing::debug!("No version source specified, fetching latest LTS from network"); - let version = provider.resolve_latest_version().await?; - return Ok((version, true)); + return provider.resolve_latest_version().await; } // Handle LTS aliases (lts/*, lts/iron, lts/-1) if NodeProvider::is_lts_alias(version_req) { tracing::debug!("Resolving LTS alias: {version_req}"); - let version = provider.resolve_lts_alias(version_req).await?; - // Don't write back - user explicitly specified an LTS alias - return Ok((version, false)); + return provider.resolve_lts_alias(version_req).await; } // Handle "latest" alias - resolves to absolute latest version (including non-LTS) if NodeProvider::is_latest_alias(version_req) { tracing::debug!("Resolving 'latest' alias"); - let version = provider.resolve_version("*").await?; - // Don't write back - user explicitly specified "latest" - return Ok((version, false)); + return provider.resolve_version("*").await; } // Check if it's an exact version if NodeProvider::is_exact_version(version_req) { let normalized = version_req.strip_prefix('v').unwrap_or(version_req); tracing::debug!("Using exact version: {normalized}"); - // Never write back exact versions - user explicitly specified the version - return Ok((normalized.into(), false)); + return Ok(normalized.into()); } // Check local cache first if let Some(cached) = provider.find_cached_version(version_req, cache_dir).await? { tracing::debug!("Found cached version {cached} satisfying {version_req}"); - // Don't write back - user specified a version requirement - return Ok((cached, false)); + return Ok(cached); } // Resolve from network tracing::debug!("Resolving version requirement from network: {version_req}"); - let version = provider.resolve_version(version_req).await?; - - // Don't write back - user specified a version requirement - Ok((version, false)) + provider.resolve_version(version_req).await } /// Check if the resolved version is compatible with lower priority sources. @@ -512,34 +491,44 @@ fn check_constraint( } } -/// Normalize and validate a version string as semver (exact version or range) or LTS alias. -/// Trims whitespace and returns the normalized version, or None with a warning if invalid. -pub fn normalize_version(version: &Str, source: &str) -> Option { - // Trim leading/trailing whitespace - let trimmed: Str = version.trim().into(); +/// Check if a version string is valid (exact version, range, or LTS alias). +/// Trims whitespace before checking. Does not print warnings. +#[must_use] +pub fn is_valid_version(version: &str) -> bool { + let trimmed = version.trim(); if trimmed.is_empty() { - return None; + return false; } // Accept version aliases (lts/*, lts/iron, lts/-1, latest) - if NodeProvider::is_version_alias(&trimmed) { - return Some(trimmed); + if NodeProvider::is_version_alias(trimmed) { + return true; } // Try parsing as exact version (strip 'v' prefix for exact version check) - let without_v = trimmed.strip_prefix('v').unwrap_or(&trimmed); + let without_v = trimmed.strip_prefix('v').unwrap_or(trimmed); if Version::parse(without_v).is_ok() { - return Some(trimmed); + return true; } // Try parsing as range - if Range::parse(&trimmed).is_ok() { + Range::parse(trimmed).is_ok() +} + +/// Normalize and validate a version string as semver (exact version or range) or LTS alias. +/// Trims whitespace and returns the normalized version, or None with a warning if invalid. +pub fn normalize_version(version: &Str, source: &str) -> Option { + let trimmed: Str = version.trim().into(); + + if is_valid_version(&trimmed) { return Some(trimmed); } - // Invalid version - println!("warning: invalid version '{version}' in {source}, ignoring"); + // Invalid version — print warning (only if non-empty, empty is just "not specified") + if !trimmed.is_empty() { + println!("warning: invalid version '{version}' in {source}, ignoring"); + } None } @@ -684,7 +673,7 @@ mod tests { } #[tokio::test] - async fn test_download_runtime_for_project_writes_back_when_no_version() { + async fn test_download_runtime_for_project_does_not_write_back_when_no_version() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -700,16 +689,13 @@ mod tests { "#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - let version = runtime.version(); + let _runtime = download_runtime_for_project(&temp_path).await.unwrap(); - // .node-version is written only if no ancestor has one (write-back is - // suppressed when an ancestor .node-version exists, e.g. in a monorepo) - if tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap() { - let node_version_content = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version_content, format!("{version}\n")); - } + // .node-version should NOT be written (auto-write was removed) + assert!( + !tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap(), + ".node-version should not be auto-created" + ); // package.json should remain unchanged let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); @@ -777,6 +763,12 @@ mod tests { // Should download latest Node.js assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should NOT write .node-version + assert!( + !tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap(), + ".node-version should not be auto-created" + ); } #[tokio::test] diff --git a/packages/cli/snap-tests-global/command-pack-npm10-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-pack-npm10-with-workspace/snap.txt index dcd99a5eff..461f800722 100644 --- a/packages/cli/snap-tests-global/command-pack-npm10-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-npm10-with-workspace/snap.txt @@ -10,11 +10,6 @@ "integrity": "sha512-", "filename": "command-pack-npm10-with-workspace-1.0.0.tgz", "files": [ - { - "path": ".node-version", - "size": , - "mode": 420 - }, { "path": "output.log", "size": , @@ -46,7 +41,7 @@ "mode": 420 } ], - "entryCount": 7, + "entryCount": 6, "bundled": [] } ] @@ -170,11 +165,6 @@ "integrity": "sha512-", "filename": "command-pack-npm10-with-workspace-1.0.0.tgz", "files": [ - { - "path": ".node-version", - "size": , - "mode": 420 - }, { "path": "app-1.0.0.tgz", "size": , @@ -221,7 +211,7 @@ "mode": 420 } ], - "entryCount": 10, + "entryCount": 9, "bundled": [] } ] diff --git a/packages/cli/snap-tests-global/command-pack-npm10/snap.txt b/packages/cli/snap-tests-global/command-pack-npm10/snap.txt index d4efce7401..1835d19aae 100644 --- a/packages/cli/snap-tests-global/command-pack-npm10/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-npm10/snap.txt @@ -10,11 +10,6 @@ "integrity": "sha512-", "filename": "command-pack-npm10-1.0.0.tgz", "files": [ - { - "path": ".node-version", - "size": , - "mode": 420 - }, { "path": "output.log", "size": , @@ -36,7 +31,7 @@ "mode": 420 } ], - "entryCount": 5, + "entryCount": 4, "bundled": [] } ] @@ -53,11 +48,6 @@ "integrity": "sha512-", "filename": "command-pack-npm10-1.0.0.tgz", "files": [ - { - "path": ".node-version", - "size": , - "mode": 420 - }, { "path": "command-pack-npm10-1.0.0.tgz", "size": , @@ -84,7 +74,7 @@ "mode": 420 } ], - "entryCount": 6, + "entryCount": 5, "bundled": [] } ] @@ -101,11 +91,6 @@ "integrity": "sha512-", "filename": "command-pack-npm10-1.0.0.tgz", "files": [ - { - "path": ".node-version", - "size": , - "mode": 420 - }, { "path": "command-pack-npm10-1.0.0.tgz", "size": , @@ -132,7 +117,7 @@ "mode": 420 } ], - "entryCount": 6, + "entryCount": 5, "bundled": [] } ] diff --git a/packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/snap.txt index cb858c8d43..c7817703ac 100644 --- a/packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/snap.txt @@ -1,7 +1,6 @@ > vp pm pack && rm -rf *.tgz # should pack current workspace root 📦 command-pack-pnpm10-with-workspace@ Tarball Contents -.node-version output.log package.json packages/app/package.json @@ -39,9 +38,6 @@ command-pack-pnpm10-with-workspace-1.0.0.tgz "version": "1.0.0", "filename": "command-pack-pnpm10-with-workspace-1.0.0.tgz", "files": [ - { - "path": ".node-version" - }, { "path": "command-pack-pnpm10-with-workspace-1.0.0.tgz" }, @@ -107,7 +103,6 @@ Tarball Details > vp pm pack --out ./dist/package.tgz && rm -rf ./dist # should pack with output file 📦 command-pack-pnpm10-with-workspace@ Tarball Contents -.node-version app-1.0.0.tgz command-pack-pnpm10-with-workspace-1.0.0.tgz out.json @@ -125,7 +120,6 @@ Tarball Details > vp pm pack --pack-destination ./dist && rm -rf ./dist # should pack with destination 📦 command-pack-pnpm10-with-workspace@ Tarball Contents -.node-version app-1.0.0.tgz command-pack-pnpm10-with-workspace-1.0.0.tgz out.json @@ -143,7 +137,6 @@ Tarball Details > vp pm pack --pack-gzip-level 9 && rm -rf *.tgz # should pack with gzip compression level 📦 command-pack-pnpm10-with-workspace@ Tarball Contents -.node-version app-1.0.0.tgz command-pack-pnpm10-with-workspace-1.0.0.tgz out.json @@ -164,9 +157,6 @@ command-pack-pnpm10-with-workspace-1.0.0.tgz "version": "1.0.0", "filename": "foo-command-pack-pnpm10-with-workspace-1.0.0.tgz", "files": [ - { - "path": ".node-version" - }, { "path": "app-1.0.0.tgz" }, diff --git a/packages/cli/snap-tests-global/command-pack-pnpm10/snap.txt b/packages/cli/snap-tests-global/command-pack-pnpm10/snap.txt index ccdfcc03d9..ecfb9b7e5f 100644 --- a/packages/cli/snap-tests-global/command-pack-pnpm10/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-pnpm10/snap.txt @@ -18,7 +18,6 @@ Options: > vp pm pack && rm -rf *.tgz # should pack current package 📦 command-pack-pnpm10@ Tarball Contents -.node-version output.log package.json snap.txt @@ -29,7 +28,6 @@ command-pack-pnpm10-1.0.0.tgz > vp pm pack --out ./dist/package.tgz && rm -rf ./dist # should pack with output file 📦 command-pack-pnpm10@ Tarball Contents -.node-version command-pack-pnpm10-1.0.0.tgz output.log package.json @@ -41,7 +39,6 @@ Tarball Details > vp pm pack --pack-destination ./dist && rm -rf ./dist # should pack with destination 📦 command-pack-pnpm10@ Tarball Contents -.node-version command-pack-pnpm10-1.0.0.tgz output.log package.json @@ -56,9 +53,6 @@ Tarball Details "version": "1.0.0", "filename": "command-pack-pnpm10-1.0.0.tgz", "files": [ - { - "path": ".node-version" - }, { "path": "command-pack-pnpm10-1.0.0.tgz" }, @@ -83,9 +77,6 @@ Tarball Details "version": "1.0.0", "filename": "command-pack-pnpm10-1.0.0.tgz", "files": [ - { - "path": ".node-version" - }, { "path": "command-pack-pnpm10-1.0.0.tgz" }, @@ -107,7 +98,6 @@ Tarball Details > vp pm pack -- --loglevel=warn && rm -rf *.tgz # should support pass through arguments 📦 command-pack-pnpm10@ Tarball Contents -.node-version command-pack-pnpm10-1.0.0.tgz output.log package.json diff --git a/packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/snap.txt index aa7e4e1ea2..49e144a7fd 100644 --- a/packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/snap.txt @@ -10,7 +10,6 @@ ➤ YN0000: · Done with warnings in ms ms > vp pm pack # should pack current workspace root -➤ YN0000: .node-version ➤ YN0000: output.log ➤ YN0000: package.json ➤ YN0000: snap.txt @@ -19,7 +18,6 @@ ➤ YN0000: Done in ms ms > vp pm pack --recursive # should pack all packages in workspace (uses workspaces foreach --all pack) -➤ YN0000: .node-version ➤ YN0000: output.log ➤ YN0000: package.json ➤ YN0000: snap.txt @@ -50,7 +48,6 @@ Done in ms ms Done in ms ms > vp pm pack --out ./dist/package.tgz # should pack with output file -➤ YN0000: .node-version ➤ YN0000: output.log ➤ YN0000: package.json ➤ YN0000: snap.txt @@ -60,7 +57,6 @@ Done in ms ms > vp pm pack --json # should pack with json output {"base":""} -{"location":".node-version"} {"location":"dist/package.tgz"} {"location":"output.log"} {"location":"package.json"} diff --git a/packages/cli/snap-tests-global/command-pack-yarn4/snap.txt b/packages/cli/snap-tests-global/command-pack-yarn4/snap.txt index 4c022f9291..ba931513ab 100644 --- a/packages/cli/snap-tests-global/command-pack-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-yarn4/snap.txt @@ -1,5 +1,4 @@ > vp pm pack # should pack current package -➤ YN0000: .node-version ➤ YN0000: output.log ➤ YN0000: package.json ➤ YN0000: snap.txt @@ -8,7 +7,6 @@ ➤ YN0000: Done in ms ms > vp pm pack --out ./dist/package.tgz # should pack with output file -➤ YN0000: .node-version ➤ YN0000: output.log ➤ YN0000: package.json ➤ YN0000: snap.txt @@ -18,7 +16,6 @@ > vp pm pack --json # should pack with json output {"base":""} -{"location":".node-version"} {"location":"dist/package.tgz"} {"location":"output.log"} {"location":"package.json"} @@ -27,7 +24,6 @@ {"output":"/package.tgz"} > vp pm pack -- --dry-run # should support pass through arguments -➤ YN0000: .node-version ➤ YN0000: dist/package.tgz ➤ YN0000: output.log ➤ YN0000: package.json diff --git a/packages/cli/snap-tests-global/delegate-respects-default-node-version/package.json b/packages/cli/snap-tests-global/delegate-respects-default-node-version/package.json new file mode 100644 index 0000000000..545d0359ca --- /dev/null +++ b/packages/cli/snap-tests-global/delegate-respects-default-node-version/package.json @@ -0,0 +1,8 @@ +{ + "name": "delegate-respects-default-node-version", + "version": "1.0.0", + "private": true, + "scripts": { + "check-node": "node -e \"console.log(process.version)\"" + } +} diff --git a/packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt b/packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt new file mode 100644 index 0000000000..5d3658f839 --- /dev/null +++ b/packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt @@ -0,0 +1,17 @@ +> vp env default 22.12.0 # Set global default to 22.12.0 +✓ Default Node.js version set to + +> vp run check-node # Should also use 22.12.0 + +> delegate-respects-default-node-version@ check-node +> node -e "console.log(process.version)" + +v + +> vp exec node -e "console.log(process.version)" # Should also use 22.12.0 +v22.12.0 + +> vp env which node # Should show 22.12.0 from 'default' source +/js_runtime/node//bin/node + Version:  22.12.0 + Source:  /config.json diff --git a/packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json b/packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json new file mode 100644 index 0000000000..19db8e8903 --- /dev/null +++ b/packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json @@ -0,0 +1,11 @@ +{ + "serial": true, + "ignoredPlatforms": ["win32"], + "commands": [ + "vp env default 22.12.0 # Set global default to 22.12.0", + "vp run check-node # Should also use 22.12.0", + "vp exec node -e \"console.log(process.version)\" # Should also use 22.12.0", + "vp env which node # Should show 22.12.0 from 'default' source" + ], + "after": ["vp env default lts # Restore default to LTS"] +} diff --git a/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/package.json b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/package.json new file mode 100644 index 0000000000..e77e0c4fe8 --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/package.json @@ -0,0 +1,8 @@ +{ + "name": "fallback-all-invalid-to-user-default", + "version": "1.0.0", + "private": true, + "engines": { + "node": "invalid" + } +} diff --git a/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/snap.txt b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/snap.txt new file mode 100644 index 0000000000..3b9822ebcd --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/snap.txt @@ -0,0 +1,6 @@ +> vp env default 22.12.0 # Set user default +✓ Default Node.js version set to + +> vp exec node -e "console.log(process.version)" # Should use default 22.12.0, not LTS +warning: invalid version 'invalid' in engines.node, ignoring +v diff --git a/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json new file mode 100644 index 0000000000..5401c467a5 --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json @@ -0,0 +1,9 @@ +{ + "serial": true, + "ignoredPlatforms": ["win32"], + "commands": [ + "vp env default 22.12.0 # Set user default", + "vp exec node -e \"console.log(process.version)\" # Should use default 22.12.0, not LTS" + ], + "after": ["vp env default lts # Restore default"] +} diff --git a/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/package.json b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/package.json new file mode 100644 index 0000000000..dc1f13f7f9 --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/package.json @@ -0,0 +1,14 @@ +{ + "name": "fallback-invalid-engines-to-dev-engines", + "version": "1.0.0", + "private": true, + "devEngines": { + "runtime": { + "name": "node", + "version": "22.12.0" + } + }, + "engines": { + "node": "invalid" + } +} diff --git a/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt new file mode 100644 index 0000000000..6ac44ac490 --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt @@ -0,0 +1,10 @@ +> vp exec node -e "console.log(process.version)" # Should use devEngines.runtime 22.12.0, not LTS +warning: invalid version 'invalid' in engines.node, ignoring +warning: invalid version 'invalid' in engines.node, ignoring +v + +> vp env which node # Should show devEngines.runtime source +warning: invalid version 'invalid' in engines.node, ignoring +/js_runtime/node//bin/node + Version:  22.12.0 + Source:  /package.json diff --git a/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json new file mode 100644 index 0000000000..e9d33e4e04 --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json @@ -0,0 +1,7 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": [ + "vp exec node -e \"console.log(process.version)\" # Should use devEngines.runtime 22.12.0, not LTS", + "vp env which node # Should show devEngines.runtime source" + ] +} diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index e581d0bbe2..2d02eecd75 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -125,7 +125,8 @@ export async function snapTest() { const casesDir = path.resolve(values.dir || 'snap-tests'); - const taskFunctions: (() => Promise)[] = []; + const serialTasks: (() => Promise)[] = []; + const parallelTasks: (() => Promise)[] = []; const missingStepsJson: string[] = []; for (const caseName of fs.readdirSync(casesDir)) { if (caseName.startsWith('.')) { @@ -135,12 +136,19 @@ export async function snapTest() { if (!fs.statSync(caseDir).isDirectory()) { continue; } - if (!fs.existsSync(path.join(caseDir, 'steps.json'))) { + const stepsPath = path.join(caseDir, 'steps.json'); + if (!fs.existsSync(stepsPath)) { missingStepsJson.push(caseName); continue; } if (caseName.includes(filter)) { - taskFunctions.push(() => runTestCase(caseName, tempTmpDir, casesDir, values['bin-dir'])); + const steps: Steps = JSON.parse(readFileSync(stepsPath, 'utf-8')); + const task = () => runTestCase(caseName, tempTmpDir, casesDir, values['bin-dir']); + if (steps.serial) { + serialTasks.push(task); + } else { + parallelTasks.push(task); + } } } @@ -150,14 +158,18 @@ export async function snapTest() { ); } - if (taskFunctions.length > 0) { + const totalCount = serialTasks.length + parallelTasks.length; + if (totalCount > 0) { const cpuCount = cpus().length; console.log( - 'Running %d test cases with concurrency limit of %d (CPU count)', - taskFunctions.length, + 'Running %d test cases (%d serial + %d parallel, concurrency limit %d)', + totalCount, + serialTasks.length, + parallelTasks.length, cpuCount, ); - await runWithConcurrencyLimit(taskFunctions, cpuCount); + await runWithConcurrencyLimit(serialTasks, 1); + await runWithConcurrencyLimit(parallelTasks, cpuCount); } process.exit(0); // Ensure exit even if there are pending timed-out steps } @@ -186,6 +198,11 @@ interface Steps { * These commands are not included in the snap output. */ after?: string[]; + /** + * If true, this test case will run serially before parallel tests. + * Use for tests that modify global shared state (e.g., `vp env default`). + */ + serial?: boolean; } async function runTestCase(name: string, tempTmpDir: string, casesDir: string, binDir?: string) { diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index e30ec95921..08629511a2 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -157,7 +157,6 @@ pub async fn download_runtime( /// Download runtime based on project's version configuration /// Reads from .node-version, engines.node, or devEngines.runtime (in priority order) /// Resolves semver ranges, downloads the matching version -/// Writes resolved version to .node-version for future use pub async fn download_runtime_for_project( project_path: &AbsolutePath, ) -> Result; @@ -209,7 +208,6 @@ use vite_path::AbsolutePathBuf; let project_path = AbsolutePathBuf::new("/path/to/project".into()).unwrap(); let runtime = download_runtime_for_project(&project_path).await?; // Version is resolved from .node-version > engines.node > devEngines.runtime -// Resolved version is saved to .node-version for future use ``` ## Cache Directory Structure @@ -407,52 +405,13 @@ When no version source exists: 1. Check local cache for installed Node.js versions 2. Use the **latest installed version** (if any exist) 3. If no cached versions exist, fetch and use latest LTS from network -4. Write the used version to `.node-version` -5. Print: `Using Node {version} - saved version to .node-version` This optimizes for: - Avoiding unnecessary network requests - Using what the user already has installed -- Establishing `.node-version` as the version source going forward -### Version Write-Back - -When `download_runtime_for_project` resolves a version and **no version source exists**, it writes the resolved version to `.node-version`. This establishes a version source for future use. - -**Write-back only occurs when no version source exists:** - -| Read From | Write To | Message | -| -------------------- | ---------------------- | ------------------------------------------------------- | -| `.node-version` | No write | - | -| `engines.node` | No write | - | -| `devEngines.runtime` | No write | - | -| No source | Create `.node-version` | "Using Node {version} - saved version to .node-version" | - -**Key behaviors:** - -1. Only write when no version source exists (respects user's explicit version requirements) -2. Use three-part version without `v` prefix with Unix line ending -3. Print informational message when saving version - -**Example: Before download (no version source)** - -Project structure: - -``` -my-project/ -└── package.json -``` - -**After download (.node-version created)** - -Project structure: - -``` -my-project/ -├── .node-version # Contains: 24.5.0 -└── package.json -``` +**Note:** `.node-version` is only written explicitly via `vp env pin`. ## Download Sources @@ -723,7 +682,7 @@ pub enum Error { 9. ✅ Support semver ranges (^, ~, etc.) with version resolution 10. ✅ Version index caching with 1-hour TTL 11. ✅ Support both single runtime and array of runtimes in devEngines -12. ✅ Write resolved version to `.node-version` file +12. ~~Write resolved version to `.node-version` file~~ (removed — `.node-version` is only written by `vp env pin`) 13. ✅ Optimized version resolution (skip network for exact versions, check local cache for ranges) 14. ✅ Multi-source version reading with priority: `.node-version` > `engines.node` > `devEngines.runtime` 15. ✅ Support `.node-version` file format (with/without v prefix, partial versions)