diff --git a/Cargo.lock b/Cargo.lock index c56a22561d..bb4140fd3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8469,6 +8469,7 @@ dependencies = [ "dunce", "flate2", "futures", + "httpmock", "indexmap", "indicatif", "indoc", diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index 2a322e6e9e..86158ab849 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -53,6 +53,7 @@ url = { workspace = true } uuid = { workspace = true, features = ["v4"] } [dev-dependencies] +httpmock = { workspace = true } serial_test = { workspace = true } temp-env = { workspace = true } vp_shared = { workspace = true, features = ["test-utils"] } diff --git a/crates/vp_global_cli/src/command_picker.rs b/crates/vp_global_cli/src/command_picker.rs index da3d0af487..080fb24b93 100644 --- a/crates/vp_global_cli/src/command_picker.rs +++ b/crates/vp_global_cli/src/command_picker.rs @@ -652,16 +652,6 @@ mod tests { assert_eq!(summary, None); } - #[test] - fn help_entry_uses_static_inline_description() { - let help = COMMANDS - .iter() - .find(|entry| entry.command == "help") - .expect("help command should exist"); - assert_eq!(help.label, "help"); - assert_eq!(help.summary, "View all commands and details"); - } - #[test] fn picker_instruction_mentions_search() { assert_eq!( diff --git a/crates/vp_global_cli/src/commands/env/bin_config.rs b/crates/vp_global_cli/src/commands/env/bin_config.rs index 019b1ed962..6f0eaa49e1 100644 --- a/crates/vp_global_cli/src/commands/env/bin_config.rs +++ b/crates/vp_global_cli/src/commands/env/bin_config.rs @@ -376,16 +376,6 @@ mod tests { .await; } - #[tokio::test] - async fn test_load_nonexistent() { - let temp_dir = TempDir::new().unwrap(); - vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { - let loaded = BinConfig::load("nonexistent").await.unwrap(); - assert!(loaded.is_none()); - }) - .await; - } - #[test] fn test_source_defaults_to_vp() { let config = BinConfig::new( diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 8c0e739619..136b4cbaa4 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -597,10 +597,42 @@ pub async fn resolve_version_alias( }) } +/// Run `test` with `vars` and a local `VP_NODE_DIST_MIRROR`, so version resolution stays offline. +#[cfg(test)] +pub(super) async fn with_mock_node_index>( + vars: impl IntoIterator, + test: impl FnOnce() -> Fut, +) { + use httpmock::prelude::*; + + // Fixed versions keep resolved aliases stable across new Node.js releases. + const NODE_INDEX: &str = r#"[ + {"version": "v25.1.0", "lts": false}, + {"version": "v24.11.0", "lts": "Krypton"}, + {"version": "v24.10.0", "lts": false}, + {"version": "v22.21.0", "lts": "Jod"}, + {"version": "v22.20.0", "lts": "Jod"}, + {"version": "v20.19.0", "lts": "Iron"}, + {"version": "v20.18.0", "lts": "Iron"} + ]"#; + + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(GET).path("/index.json"); + then.status(200).body(NODE_INDEX); + }) + .await; + let vars = vars + .into_iter() + .map(|(name, value)| (name, value.to_os_string())) + .chain([(vp_shared::env_vars::VP_NODE_DIST_MIRROR, server.base_url().into())]); + vp_shared::EnvConfig::with_vars_async(vars, |_| test()).await; +} + #[cfg(test)] mod tests { use tempfile::TempDir; - use vp_js_runtime::VersionSource; use vp_shared::env_vars; use vt_path::AbsolutePathBuf; @@ -737,47 +769,6 @@ mod tests { .await; } - #[tokio::test] - async fn test_resolve_version_from_engines_node() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json with engines.node - // Also create an empty .node-version to stop walk-up from finding parent project's version - let package_json = r#"{"engines":{"node":"20.18.0"}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - - // Use resolve_node_version directly with walk_up=false to test engines.node specifically - let resolution = resolve_node_version(&temp_path, false) - .await - .map_err(|e| Error::ConfigError(e.to_string().into())) - .unwrap() - .unwrap(); - - assert_eq!(&*resolution.version, "20.18.0"); - assert_eq!(resolution.source, VersionSource::EnginesNode); - } - - #[tokio::test] - async fn test_resolve_version_from_dev_engines() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json with devEngines.runtime - let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"20.18.0"}}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - - // Use resolve_node_version directly with walk_up=false to test devEngines specifically - let resolution = resolve_node_version(&temp_path, false) - .await - .map_err(|e| Error::ConfigError(e.to_string().into())) - .unwrap() - .unwrap(); - - assert_eq!(&*resolution.version, "20.18.0"); - assert_eq!(resolution.source, VersionSource::DevEnginesRuntime); - } - #[tokio::test] async fn test_resolve_version_from_nvmrc() { let temp_dir = TempDir::new().unwrap(); @@ -858,11 +849,10 @@ mod tests { } #[tokio::test] - #[ignore] // Requires running outside of any Node.js project (walk-up finds .node-version) async fn test_resolve_version_alias_default_no_source_path() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + with_mock_node_index([(env_vars::VP_HOME, temp_dir.path().as_os_str())], || async { let config = Config { default_node_version: Some("lts".to_string()), ..Default::default() }; save_config(&config).await.unwrap(); @@ -872,14 +862,15 @@ mod tests { tokio::fs::create_dir_all(&test_dir).await.unwrap(); let resolution = resolve_version(&test_dir).await.unwrap(); + assert_eq!(resolution.version, "24.11.0"); assert_eq!(resolution.source, "default"); assert!(resolution.source_path.is_none(), "Alias defaults should not have source_path"); + assert!(resolution.is_range); }) .await; } #[tokio::test] - #[ignore] // Requires running outside of any Node.js project (walk-up finds .node-version) async fn test_resolve_version_exact_default_has_source_path() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -906,7 +897,7 @@ mod tests { async fn test_resolve_version_invalid_node_version_falls_through_to_lts() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + with_mock_node_index([(env_vars::VP_HOME, temp_dir.path().as_os_str())], || async { // Create .node-version file with invalid version tokio::fs::write(temp_path.join(".node-version"), "invalid-version\n").await.unwrap(); @@ -915,6 +906,7 @@ mod tests { // Should fall through to LTS since the .node-version is invalid // and no user default is configured + assert_eq!(resolution.version, "24.11.0"); assert_eq!(resolution.source, "lts"); assert!(resolution.source_path.is_none()); assert!(resolution.is_range, "LTS fallback should be marked as range"); @@ -949,7 +941,7 @@ mod tests { async fn test_resolve_version_invalid_node_version_falls_through_to_engines_node() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + with_mock_node_index([(env_vars::VP_HOME, temp_dir.path().as_os_str())], || async { // Create .node-version file with invalid version (typo or unsupported alias) tokio::fs::write(temp_path.join(".node-version"), "laetst\n").await.unwrap(); @@ -962,12 +954,7 @@ mod tests { // Should fall through to engines.node since .node-version is invalid assert_eq!(resolution.source, "engines.node"); - // Version should be resolved from ^20.18.0 (a 20.x version) - assert!( - resolution.version.starts_with("20."), - "Expected version to start with '20.', got: {}", - resolution.version - ); + assert_eq!(resolution.version, "20.19.0"); }) .await; } @@ -976,7 +963,7 @@ mod tests { async fn test_resolve_version_invalid_node_version_falls_through_to_dev_engines() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + with_mock_node_index([(env_vars::VP_HOME, temp_dir.path().as_os_str())], || async { // Create .node-version file with invalid version tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); @@ -989,12 +976,7 @@ mod tests { // Should fall through to devEngines.runtime since .node-version is invalid assert_eq!(resolution.source, "devEngines.runtime"); - // Version should be resolved from ^20.18.0 (a 20.x version) - assert!( - resolution.version.starts_with("20."), - "Expected version to start with '20.', got: {}", - resolution.version - ); + assert_eq!(resolution.version, "20.19.0"); }) .await; } @@ -1003,7 +985,7 @@ mod tests { async fn test_resolve_version_invalid_engines_node_falls_through_to_dev_engines() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + with_mock_node_index([(env_vars::VP_HOME, temp_dir.path().as_os_str())], || async { // Create package.json with invalid engines.node but valid devEngines.runtime // No .node-version file — resolve_node_version returns EnginesNode source let package_json = r#"{"engines":{"node":"invalid"},"devEngines":{"runtime":{"name":"node","version":"^20.18.0"}}}"#; @@ -1013,11 +995,7 @@ mod tests { let resolution = resolve_version(&temp_path).await.unwrap(); assert_eq!(resolution.source, "devEngines.runtime"); - assert!( - resolution.version.starts_with("20."), - "Expected version to start with '20.', got: {}", - resolution.version - ); + assert_eq!(resolution.version, "20.19.0"); }) .await; } @@ -1140,7 +1118,7 @@ mod tests { async fn test_resolve_version_latest_alias_in_node_version() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + with_mock_node_index([(env_vars::VP_HOME, temp_dir.path().as_os_str())], || async { // Create .node-version file with "latest" alias tokio::fs::write(temp_path.join(".node-version"), "latest\n").await.unwrap(); @@ -1150,12 +1128,8 @@ mod tests { assert_eq!(resolution.source, ".node-version"); // "latest" is a range (should be re-resolved periodically) assert!(resolution.is_range, "'latest' should be marked as a range"); - // Version should be at least v20.x - assert!( - resolution.version.starts_with("2") || resolution.version.starts_with("3"), - "Expected version to be at least v20.x, got: {}", - resolution.version - ); + // "latest" includes non-LTS releases + assert_eq!(resolution.version, "25.1.0"); }) .await; } @@ -1189,16 +1163,15 @@ mod tests { async fn test_resolve_version_env_var_partial_version() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - vp_shared::EnvConfig::with_vars_async( + with_mock_node_index( [ (env_vars::VP_HOME, temp_dir.path().as_os_str()), (env_vars::VP_NODE_VERSION, std::ffi::OsStr::new("22")), ], - |_| async { + || async { let resolution = resolve_version(&temp_path).await.unwrap(); - assert!(resolution.version.starts_with("22.")); - assert!(NodeProvider::is_exact_version(&resolution.version)); + assert_eq!(resolution.version, "22.21.0"); assert_eq!(resolution.source, VERSION_ENV_VAR); }, ) diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 7fcd9f3803..d98abfeca0 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -52,8 +52,8 @@ pub async fn execute( } if specs.is_empty() { - show_pinned(&cwd).await?; - vp_shared::output::print_stdout_line(format_args!("")); + let pinned = pinned_message(&cwd).await?; + vp_shared::output::print_stdout_line(format_args!("{pinned}\n")); return show_package_manager_pin(&cwd).await; } @@ -115,87 +115,64 @@ async fn show_package_manager_pin(cwd: &AbsolutePathBuf) -> Result Result { +/// Describe the current pinned version. +async fn pinned_message(cwd: &AbsolutePathBuf) -> Result { let node_version_path = cwd.join(NODE_VERSION_FILE); // Check if .node-version exists in current directory if tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) { let content = tokio::fs::read_to_string(&node_version_path).await?; let version = content.trim(); - vp_shared::output::print_stdout_line(format_args!("Pinned version: {version}")); - vp_shared::output::print_stdout_line(format_args!( - " Source: {}", + return Ok(format!( + "Pinned version: {version}\n Source: {}", node_version_path.as_path().display() )); - return Ok(ExitStatus::default()); } // Check devEngines.runtime in the current directory's package.json if let Some(version) = read_dev_engines_node_version(cwd).await { - vp_shared::output::print_stdout_line(format_args!("Pinned version: {version}")); - vp_shared::output::print_stdout_line(format_args!( - " Source: {} (devEngines.runtime)", + return Ok(format!( + "Pinned version: {version}\n Source: {} (devEngines.runtime)", cwd.join(PACKAGE_JSON_FILE).as_path().display() )); - return Ok(ExitStatus::default()); } if let Some(resolution) = resolve_node_version(cwd, true).await? { if resolution.source == VersionSource::NvmrcFile && resolution.project_root.as_ref() == Some(cwd) { - vp_shared::output::print_stdout_line(format_args!( - "Pinned version: {}", - resolution.version - )); - vp_shared::output::print_stdout_line(format_args!( - " Source: {}", + return Ok(format!( + "Pinned version: {}\n Source: {}", + resolution.version, cwd.join(NVMRC_FILE).as_path().display() )); - return Ok(ExitStatus::default()); } if resolution.source == VersionSource::EnginesNode { let path = resolution.source_path.unwrap_or_else(|| cwd.join(PACKAGE_JSON_FILE)); - vp_shared::output::print_stdout_line(format_args!("No version pinned.")); - vp_shared::output::print_stdout_line(format_args!( - " Node.js constraint: {} from {} (engines.node)", + return Ok(format!( + "No version pinned.\n Node.js constraint: {} from {} (engines.node)", resolution.version, path.as_path().display() )); - return Ok(ExitStatus::default()); } } // Check for inherited version from parent directories if let Some((version, source)) = find_inherited_version(cwd).await? { - vp_shared::output::print_stdout_line(format_args!( - "No version pinned in current directory." + return Ok(format!( + "No version pinned in current directory.\n Inherited: {version} from {source}" )); - vp_shared::output::print_stdout_line(format_args!(" Inherited: {version} from {source}")); - return Ok(ExitStatus::default()); } // No .node-version anywhere - show default let config = load_config().await?; - match config.default_node_version { - Some(version) => { - let config_path = get_config_path()?; - vp_shared::output::print_stdout_line(format_args!("No version pinned.")); - vp_shared::output::print_stdout_line(format_args!( - " Using default: {version} (from {})", - config_path.as_path().display() - )); - } - None => { - vp_shared::output::print_stdout_line(format_args!("No version pinned.")); - vp_shared::output::print_stdout_line(format_args!( - " Run 'vp env pin ' to pin a version." - )); - } - } - - Ok(ExitStatus::default()) + Ok(match config.default_node_version { + Some(version) => format!( + "No version pinned.\n Using default: {version} (from {})", + get_config_path()?.as_path().display() + ), + None => "No version pinned.\n Run 'vp env pin ' to pin a version.".to_string(), + }) } /// Find an inherited pin (`.node-version`, `devEngines.runtime`, or `.nvmrc`) @@ -1158,7 +1135,7 @@ mod tests { use vp_shared::env_vars; use vt_path::AbsolutePathBuf; - use super::*; + use super::{super::config::with_mock_node_index, *}; #[test] fn package_manager_read_failure_does_not_claim_fields_were_checked() { @@ -1181,15 +1158,6 @@ mod tests { ); } - /// Shared VP_HOME for tests that hit the real Node.js version index: - /// pinning isolates them from concurrent scopes, and one shared root - /// keeps the index cache warm across tests and runs. - fn shared_vp_home() -> std::path::PathBuf { - let dir = std::env::temp_dir().join("vp-global-cli-tests-vp-home"); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - #[tokio::test] async fn nvmrc_pin_preserves_non_version_content() { for (before, after) in [ @@ -1345,25 +1313,31 @@ mod tests { } #[tokio::test] - async fn test_show_pinned_no_file() { + async fn test_pinned_message_no_file() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Should not error when no .node-version exists - let result = show_pinned(&temp_path).await; - assert!(result.is_ok()); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + assert_eq!( + pinned_message(&temp_path).await.unwrap(), + "No version pinned.\n Run 'vp env pin ' to pin a version." + ); + }) + .await; } #[tokio::test] - async fn test_show_pinned_with_file() { + async fn test_pinned_message_with_file() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); // Create .node-version - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + let node_version_path = temp_path.join(".node-version"); + tokio::fs::write(&node_version_path, "20.18.0\n").await.unwrap(); - let result = show_pinned(&temp_path).await; - assert!(result.is_ok()); + assert_eq!( + pinned_message(&temp_path).await.unwrap(), + format!("Pinned version: 20.18.0\n Source: {}", node_version_path.as_path().display()) + ); } #[tokio::test] @@ -1508,38 +1482,35 @@ mod tests { async fn test_do_pin_invalidates_cache() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let vp_home = shared_vp_home(); + let vp_home = TempDir::new().unwrap(); + + with_mock_node_index([(env_vars::VP_HOME, vp_home.path().as_os_str())], || async { + // Create cache file manually + let cache_dir = vp_home.path().join("cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let cache_file = cache_dir.join("resolve_cache.json"); + std::fs::write(&cache_file, r#"{"version":2,"entries":{}}"#).unwrap(); + assert!( + std::fs::metadata(cache_file.as_path()).is_ok(), + "Cache file should exist before pin" + ); - vp_shared::EnvConfig::with_vars_async( - [(env_vars::VP_HOME, vp_home.as_os_str())], - |_| async { - // Create cache file manually - let cache_dir = vp_home.join("cache"); - std::fs::create_dir_all(&cache_dir).unwrap(); - let cache_file = cache_dir.join("resolve_cache.json"); - std::fs::write(&cache_file, r#"{"version":2,"entries":{}}"#).unwrap(); - assert!( - std::fs::metadata(cache_file.as_path()).is_ok(), - "Cache file should exist before pin" - ); + // Pin an exact version (no_install=true to skip download, force=true to skip prompt) + let result = do_pin(&temp_path, "20.18.0", true, true, None).await; + assert!(result.is_ok()); - // Pin an exact version (no_install=true to skip download, force=true to skip prompt) - let result = do_pin(&temp_path, "20.18.0", true, true, None).await; - assert!(result.is_ok()); + // .node-version should be created + let node_version_path = temp_path.join(".node-version"); + assert!(tokio::fs::try_exists(&node_version_path).await.unwrap()); + let content = tokio::fs::read_to_string(&node_version_path).await.unwrap(); + assert_eq!(content.trim(), "20.18.0"); - // .node-version should be created - let node_version_path = temp_path.join(".node-version"); - assert!(tokio::fs::try_exists(&node_version_path).await.unwrap()); - let content = tokio::fs::read_to_string(&node_version_path).await.unwrap(); - assert_eq!(content.trim(), "20.18.0"); - - // Cache file should be removed by invalidate_cache() - assert!( - std::fs::metadata(cache_file.as_path()).is_err(), - "Cache file should be removed after pin" - ); - }, - ) + // Cache file should be removed by invalidate_cache() + assert!( + std::fs::metadata(cache_file.as_path()).is_err(), + "Cache file should be removed after pin" + ); + }) .await; } @@ -1558,120 +1529,105 @@ mod tests { #[tokio::test] async fn test_do_pin_targets_dev_engines_when_package_json_exists() { - let vp_home = shared_vp_home(); - vp_shared::EnvConfig::with_vars_async( - [(env_vars::VP_HOME, vp_home.as_os_str())], - |_| async { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let vp_home = TempDir::new().unwrap(); + with_mock_node_index([(env_vars::VP_HOME, vp_home.path().as_os_str())], || async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // package.json without .node-version: the pin goes into devEngines.runtime - tokio::fs::write( - temp_path.join("package.json"), - "{\n \"name\": \"test\",\n \"engines\": {\n \"node\": \">=18.0.0\"\n }\n}\n", - ) - .await - .unwrap(); + // package.json without .node-version: the pin goes into devEngines.runtime + tokio::fs::write( + temp_path.join("package.json"), + "{\n \"name\": \"test\",\n \"engines\": {\n \"node\": \">=18.0.0\"\n }\n}\n", + ) + .await + .unwrap(); - let result = do_pin(&temp_path, "20.18.0", true, true, None).await; - assert!(result.is_ok()); + let result = do_pin(&temp_path, "20.18.0", true, true, None).await; + assert!(result.is_ok()); - // .node-version is NOT created - assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); - - let content = - tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); - let entry = &pkg["devEngines"]["runtime"]; - assert_eq!(entry["name"].as_str().unwrap(), "node"); - assert_eq!(entry["version"].as_str().unwrap(), "20.18.0"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // existing engines.node is kept unchanged - assert_eq!(pkg["engines"]["node"].as_str().unwrap(), ">=18.0.0"); - // devEngines is placed right after engines - let keys: Vec<&str> = pkg.as_object().unwrap().keys().map(String::as_str).collect(); - assert_eq!(keys, ["name", "engines", "devEngines"]); - }, - ) + // .node-version is NOT created + assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); + + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + let entry = &pkg["devEngines"]["runtime"]; + assert_eq!(entry["name"].as_str().unwrap(), "node"); + assert_eq!(entry["version"].as_str().unwrap(), "20.18.0"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + // existing engines.node is kept unchanged + assert_eq!(pkg["engines"]["node"].as_str().unwrap(), ">=18.0.0"); + // devEngines is placed right after engines + let keys: Vec<&str> = pkg.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!(keys, ["name", "engines", "devEngines"]); + }) .await; } #[tokio::test] async fn test_do_pin_keeps_node_version_file_target_when_it_exists() { - let vp_home = shared_vp_home(); - vp_shared::EnvConfig::with_vars_async( - [(env_vars::VP_HOME, vp_home.as_os_str())], - |_| async { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let vp_home = TempDir::new().unwrap(); + with_mock_node_index([(env_vars::VP_HOME, vp_home.path().as_os_str())], || async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - tokio::fs::write(temp_path.join(".node-version"), "18.20.0\n").await.unwrap(); - tokio::fs::write(temp_path.join("package.json"), "{\n \"name\": \"test\"\n}\n") - .await - .unwrap(); + tokio::fs::write(temp_path.join(".node-version"), "18.20.0\n").await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), "{\n \"name\": \"test\"\n}\n") + .await + .unwrap(); - // force=true skips the overwrite prompt - let result = do_pin(&temp_path, "20.18.0", true, true, None).await; - assert!(result.is_ok()); + // force=true skips the overwrite prompt + let result = do_pin(&temp_path, "20.18.0", true, true, None).await; + assert!(result.is_ok()); - // .node-version keeps winning for writes (compatibility-first) - let content = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(content.trim(), "20.18.0"); + // .node-version keeps winning for writes (compatibility-first) + let content = tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(content.trim(), "20.18.0"); - // package.json is untouched - let pkg = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - assert!(!pkg.contains("devEngines")); - }, - ) + // package.json is untouched + let pkg = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert!(!pkg.contains("devEngines")); + }) .await; } #[tokio::test] async fn test_do_pin_explicit_dev_engines_target_wins_over_node_version_file() { - let vp_home = shared_vp_home(); - vp_shared::EnvConfig::with_vars_async( - [(env_vars::VP_HOME, vp_home.as_os_str())], - |_| async { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let vp_home = TempDir::new().unwrap(); + with_mock_node_index([(env_vars::VP_HOME, vp_home.path().as_os_str())], || async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - tokio::fs::write(temp_path.join(".node-version"), "18.20.0\n").await.unwrap(); - tokio::fs::write(temp_path.join("package.json"), "{\n \"name\": \"test\"\n}\n") - .await - .unwrap(); + tokio::fs::write(temp_path.join(".node-version"), "18.20.0\n").await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), "{\n \"name\": \"test\"\n}\n") + .await + .unwrap(); - let result = - do_pin(&temp_path, "20.18.0", true, true, Some(PinTarget::DevEngines)).await; - assert!(result.is_ok()); + let result = + do_pin(&temp_path, "20.18.0", true, true, Some(PinTarget::DevEngines)).await; + assert!(result.is_ok()); - // devEngines is written; .node-version stays untouched (a warning is printed) - let content = - tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(pkg["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); - let node_version = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version.trim(), "18.20.0"); - }, - ) + // devEngines is written; .node-version stays untouched (a warning is printed) + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(pkg["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); + let node_version = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version.trim(), "18.20.0"); + }) .await; } #[tokio::test] async fn test_do_pin_dev_engines_target_requires_package_json() { - let vp_home = shared_vp_home(); - vp_shared::EnvConfig::with_vars_async( - [(env_vars::VP_HOME, vp_home.as_os_str())], - |_| async { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let vp_home = TempDir::new().unwrap(); + with_mock_node_index([(env_vars::VP_HOME, vp_home.path().as_os_str())], || async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let result = - do_pin(&temp_path, "20.18.0", true, true, Some(PinTarget::DevEngines)).await; - assert!(result.is_err()); - }, - ) + let result = + do_pin(&temp_path, "20.18.0", true, true, Some(PinTarget::DevEngines)).await; + assert!(result.is_err()); + }) .await; } @@ -1859,41 +1815,30 @@ mod tests { #[tokio::test] async fn test_resolve_version_for_pin_partial_version() { - let vp_home = shared_vp_home(); - vp_shared::EnvConfig::with_vars_async( - [(env_vars::VP_HOME, vp_home.as_os_str())], - |_| async { - let provider = NodeProvider::new(); - - // Partial version "20" should resolve to an exact version like "20.x.y" - let (resolved, was_alias) = resolve_version_for_pin("20", &provider).await.unwrap(); - assert!(was_alias, "partial version should be treated as alias"); - - // The resolved version should be a full semver version starting with "20." - assert!( - resolved.starts_with("20."), - "expected resolved version to start with '20.', got: {resolved}" - ); - - // Should be a valid exact version (major.minor.patch) - let parts: Vec<&str> = resolved.split('.').collect(); - assert_eq!(parts.len(), 3, "expected 3 version parts, got: {resolved}"); - assert!( - parts.iter().all(|p| p.parse::().is_ok()), - "all parts should be numeric" - ); - }, - ) + let vp_home = TempDir::new().unwrap(); + with_mock_node_index([(env_vars::VP_HOME, vp_home.path().as_os_str())], || async { + let provider = NodeProvider::new(); + + // Partial version "20" resolves to the newest matching release + let (resolved, was_alias) = resolve_version_for_pin("20", &provider).await.unwrap(); + assert!(was_alias, "partial version should be treated as alias"); + assert_eq!(resolved, "20.19.0"); + }) .await; } #[tokio::test] async fn test_resolve_version_for_pin_exact_version() { - let provider = NodeProvider::new(); - - // Exact version should be returned as-is - let (resolved, was_alias) = resolve_version_for_pin("20.18.0", &provider).await.unwrap(); - assert!(!was_alias, "exact version should not be treated as alias"); - assert_eq!(resolved, "20.18.0"); + let vp_home = TempDir::new().unwrap(); + with_mock_node_index([(env_vars::VP_HOME, vp_home.path().as_os_str())], || async { + let provider = NodeProvider::new(); + + // Exact version should be returned as-is + let (resolved, was_alias) = + resolve_version_for_pin("20.18.0", &provider).await.unwrap(); + assert!(!was_alias, "exact version should not be treated as alias"); + assert_eq!(resolved, "20.18.0"); + }) + .await; } } diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 0c21031342..bdcf0303f4 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1780,28 +1780,6 @@ mod tests { ); } - #[tokio::test] - async fn test_create_env_files_creates_all_files() { - let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - vp_shared::EnvConfig::with_vars_async( - test_env_vars(temp_dir.path(), temp_dir.path()), - |_| async { - create_env_files().await.unwrap(); - - let env_path = home.join("env"); - let env_fish_path = home.join("env.fish"); - let env_nu_path = home.join("env.nu"); - let env_ps1_path = home.join("env.ps1"); - assert!(env_path.as_path().exists(), "env file should be created"); - assert!(env_fish_path.as_path().exists(), "env.fish file should be created"); - assert!(env_nu_path.as_path().exists(), "env.nu file should be created"); - assert!(env_ps1_path.as_path().exists(), "env.ps1 file should be created"); - }, - ) - .await; - } - #[tokio::test] async fn test_create_env_files_adds_one_bom_only_to_powershell() { const UTF8_BOM_BYTES: &[u8] = b"\xEF\xBB\xBF"; diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index d1493c9a62..eaf698667b 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -571,14 +571,6 @@ mod tests { assert!(!is_preview_version("latest")); } - #[test] - fn test_js_executor_new() { - let executor = JsExecutor::new(None); - assert!(executor.cli_runtime.is_none()); - assert!(executor.project_runtime.is_none()); - assert!(executor.scripts_dir.is_none()); - } - #[test] fn test_js_executor_with_scripts_dir() { let scripts_dir = if cfg!(windows) {