Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 111 additions & 162 deletions crates/vp_js_runtime/src/providers/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -688,13 +688,41 @@ mod tests {
use super::*;
use crate::platform::{Arch, Os};

/// Shared VP_HOME for tests that fetch the real Node.js version index:
/// pinning isolates them from concurrent `with_vars` 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-js-runtime-tests-vp-home");
std::fs::create_dir_all(&dir).unwrap();
dir
/// A fixed version index, so alias resolution doesn't move with 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": "v21.0.0", "lts": false},
{"version": "v20.19.0", "lts": "Iron"},
{"version": "v20.18.0", "lts": "Iron"},
{"version": "v20.17.0", "lts": "Iron"}
]"#;

/// Run `test` against a local mirror serving [`NODE_INDEX`] with an empty `VP_HOME`.
/// The index cache must absorb repeated lookups, so the mirror expects one request.
async fn with_mock_index<Fut: Future<Output = ()>>(test: impl FnOnce(NodeProvider) -> Fut) {
use httpmock::prelude::*;

let vp_home = tempfile::TempDir::new().unwrap();
let server = MockServer::start_async().await;
let index = server
.mock_async(|when, then| {
when.method(GET).path("/index.json");
then.status(200).body(NODE_INDEX);
})
.await;
vp_shared::EnvConfig::with_vars_async(
[
(env_vars::VP_HOME, vp_home.path().as_os_str()),
(env_vars::VP_NODE_DIST_MIRROR, std::ffi::OsStr::new(&server.base_url())),
],
|_| test(NodeProvider::new()),
)
.await;
index.assert_hits_async(1).await;
}

#[test]
Expand Down Expand Up @@ -971,25 +999,16 @@ fedcba987654 node-v22.13.1-win-x64.zip";

#[tokio::test]
async fn test_fetch_version_index() {
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();
let versions = provider.fetch_version_index().await.unwrap();

// Should have at least some versions
assert!(!versions.is_empty());

// First entry should be the latest version
let first = &versions[0];
assert!(first.version.starts_with('v'));

// Should contain some known versions
let has_v20 = versions.iter().any(|v| v.version.starts_with("v20."));
assert!(has_v20, "Should contain Node.js v20.x versions");
},
)
with_mock_index(|provider| async move {
let versions = provider.fetch_version_index().await.unwrap();
assert_eq!(versions.len(), 9);
assert_eq!(&*versions[0].version, "v25.1.0");
assert!(matches!(&versions[1].lts, LtsInfo::Codename(name) if name == "Krypton"));

// The second call is served from the index cache.
let cached = provider.fetch_version_index().await.unwrap();
assert_eq!(cached.len(), versions.len());
})
.await;
}

Expand Down Expand Up @@ -1242,57 +1261,51 @@ fedcba987654 node-v22.13.1-win-x64.zip";

let temp_dir = TempDir::new().unwrap();
let cache_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
let vp_home = shared_vp_home();

vp_shared::EnvConfig::with_vars_async(
[(env_vars::VP_HOME, Some(vp_home.as_os_str())), (env_vars::VP_NODE_DIST_MIRROR, None)],
|_| async {
let provider = NodeProvider::new();

// Initially, no cache exists
let result = provider.find_cached_version("^20.18.0", &cache_dir).await.unwrap();
assert!(result.is_none());

// Create mock cached versions
let node_cache = cache_dir.join("node");
tokio::fs::create_dir_all(&node_cache).await.unwrap();

// Create version directories with mock binary
let platform = Platform::current();
let binary_path = provider.binary_relative_path(platform);

for version in ["20.17.0", "20.18.0", "20.19.0", "21.0.0"] {
let version_dir = node_cache.join(version);
let binary_full_path = version_dir.join(&binary_path);
tokio::fs::create_dir_all(binary_full_path.parent().unwrap()).await.unwrap();
tokio::fs::write(&binary_full_path, "mock binary").await.unwrap();
}
with_mock_index(|provider| async move {
// Initially, no cache exists
let result = provider.find_cached_version("^20.18.0", &cache_dir).await.unwrap();
assert!(result.is_none());

// Create incomplete installation (no binary)
let incomplete_dir = node_cache.join("20.20.0");
tokio::fs::create_dir_all(&incomplete_dir).await.unwrap();
// Create mock cached versions
let node_cache = cache_dir.join("node");
tokio::fs::create_dir_all(&node_cache).await.unwrap();

// Test: ^20.18.0 should find highest matching version (20.19.0)
let result = provider.find_cached_version("^20.18.0", &cache_dir).await.unwrap();
assert_eq!(result, Some("20.19.0".into()));
// Create version directories with mock binary
let platform = Platform::current();
let binary_path = provider.binary_relative_path(platform);

// Test: ~20.18.0 should find highest 20.18.x (only 20.18.0)
let result = provider.find_cached_version("~20.18.0", &cache_dir).await.unwrap();
assert_eq!(result, Some("20.18.0".into()));
for version in ["20.17.0", "20.18.0", "20.19.0", "21.0.0"] {
let version_dir = node_cache.join(version);
let binary_full_path = version_dir.join(&binary_path);
tokio::fs::create_dir_all(binary_full_path.parent().unwrap()).await.unwrap();
tokio::fs::write(&binary_full_path, "mock binary").await.unwrap();
}

// Test: ^21.0.0 should find 21.0.0
let result = provider.find_cached_version("^21.0.0", &cache_dir).await.unwrap();
assert_eq!(result, Some("21.0.0".into()));
// Create incomplete installation (no binary)
let incomplete_dir = node_cache.join("20.20.0");
tokio::fs::create_dir_all(&incomplete_dir).await.unwrap();

// Test: ^22.0.0 should find nothing
let result = provider.find_cached_version("^22.0.0", &cache_dir).await.unwrap();
assert!(result.is_none());
// Test: ^20.18.0 should find highest matching version (20.19.0)
let result = provider.find_cached_version("^20.18.0", &cache_dir).await.unwrap();
assert_eq!(result, Some("20.19.0".into()));

// Test: ^20.20.0 should find nothing (20.20.0 exists but no binary)
let result = provider.find_cached_version("^20.20.0", &cache_dir).await.unwrap();
assert!(result.is_none());
},
)
// Test: ~20.18.0 should find highest 20.18.x (only 20.18.0)
let result = provider.find_cached_version("~20.18.0", &cache_dir).await.unwrap();
assert_eq!(result, Some("20.18.0".into()));

// Test: ^21.0.0 should find 21.0.0
let result = provider.find_cached_version("^21.0.0", &cache_dir).await.unwrap();
assert_eq!(result, Some("21.0.0".into()));

// Test: ^22.0.0 should find nothing
let result = provider.find_cached_version("^22.0.0", &cache_dir).await.unwrap();
assert!(result.is_none());

// Test: ^20.20.0 should find nothing (20.20.0 exists but no binary)
let result = provider.find_cached_version("^20.20.0", &cache_dir).await.unwrap();
assert!(result.is_none());
})
.await;
}

Expand Down Expand Up @@ -1465,128 +1478,64 @@ fedcba987654 node-v22.13.1-win-x64.zip";

#[tokio::test]
async fn test_resolve_lts_alias_latest() {
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();

// lts/* should resolve to the latest LTS version
let version = provider.resolve_lts_alias("lts/*").await.unwrap();

// Should be a valid semver version
let parsed = Version::parse(&version).expect("Should parse as semver");

// As of 2026, latest LTS is at least v24.x (Krypton)
assert!(parsed.major >= 24, "Latest LTS should be at least v24.x, got {}", version);
},
)
with_mock_index(|provider| async move {
// The newest LTS line wins over newer non-LTS releases.
assert_eq!(&*provider.resolve_lts_alias("lts/*").await.unwrap(), "24.11.0");
})
.await;
}

#[tokio::test]
async fn test_resolve_lts_alias_codename_iron() {
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();

// lts/iron should resolve to v20.x
let version = provider.resolve_lts_alias("lts/iron").await.unwrap();
let parsed = Version::parse(&version).expect("Should parse as semver");
assert_eq!(parsed.major, 20, "lts/iron should resolve to v20.x, got {}", version);
},
)
with_mock_index(|provider| async move {
assert_eq!(&*provider.resolve_lts_alias("lts/iron").await.unwrap(), "20.19.0");
})
.await;
}

#[tokio::test]
async fn test_resolve_lts_alias_codename_jod() {
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();

// lts/jod should resolve to v22.x
let version = provider.resolve_lts_alias("lts/jod").await.unwrap();
let parsed = Version::parse(&version).expect("Should parse as semver");
assert_eq!(parsed.major, 22, "lts/jod should resolve to v22.x, got {}", version);
},
)
with_mock_index(|provider| async move {
assert_eq!(&*provider.resolve_lts_alias("lts/jod").await.unwrap(), "22.21.0");
})
.await;
}

#[tokio::test]
async fn test_resolve_lts_alias_codename_case_insensitive() {
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();

// Should be case-insensitive for codenames
let version_lower = provider.resolve_lts_alias("lts/iron").await.unwrap();
let version_mixed = provider.resolve_lts_alias("lts/Iron").await.unwrap();

assert_eq!(version_lower, version_mixed, "LTS codename should be case-insensitive");
},
)
with_mock_index(|provider| async move {
assert_eq!(&*provider.resolve_lts_alias("lts/Iron").await.unwrap(), "20.19.0");
})
.await;
}

#[tokio::test]
async fn test_resolve_lts_alias_offset() {
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();

// lts/-1 should resolve to the second-highest LTS line
// As of 2026: lts/* = 24.x (Krypton), lts/-1 = 22.x (Jod)
let version = provider.resolve_lts_alias("lts/-1").await.unwrap();
let parsed = Version::parse(&version).expect("Should parse as semver");
assert_eq!(
parsed.major, 22,
"lts/-1 should resolve to v22.x (Jod), got {}",
version
);
},
)
with_mock_index(|provider| async move {
assert_eq!(&*provider.resolve_lts_alias("lts/-1").await.unwrap(), "22.21.0");
assert_eq!(&*provider.resolve_lts_alias("lts/-2").await.unwrap(), "20.19.0");
})
.await;
}

#[tokio::test]
async fn test_resolve_lts_alias_unknown_codename() {
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();

// Unknown codename should error
let result = provider.resolve_lts_alias("lts/unknown").await;
assert!(result.is_err(), "Unknown LTS codename should return error");
},
)
with_mock_index(|provider| async move {
let result = provider.resolve_lts_alias("lts/unknown").await;
assert!(matches!(result, Err(Error::UnknownLtsCodename { .. })), "{result:?}");
})
.await;
}

#[tokio::test]
async fn test_resolve_lts_alias_invalid_offset() {
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();

// Too large offset should error (there aren't 100 LTS lines)
let result = provider.resolve_lts_alias("lts/-100").await;
assert!(result.is_err(), "Invalid LTS offset should return error");
},
)
with_mock_index(|provider| async move {
let result = provider.resolve_lts_alias("lts/-3").await;
assert!(
matches!(result, Err(Error::InvalidLtsOffset { offset: -3, available: 3 })),
"{result:?}"
);
})
.await;
}
}
Loading