use std::path::Path;
use socket_patch_core::crawlers::npm_crawler::{
build_npm_purl, get_bun_global_prefix, get_bun_global_prefix_with, get_npm_global_prefix,
get_npm_global_prefix_with, get_pnpm_global_prefix, get_pnpm_global_prefix_with,
get_yarn_global_prefix, get_yarn_global_prefix_with, parse_bun_bin_output,
parse_npm_root_output, parse_package_name, parse_pnpm_root_output, parse_yarn_dir_output,
read_package_json,
};
use socket_patch_core::crawlers::types::CrawlerOptions;
use socket_patch_core::crawlers::NpmCrawler;
fn options_at(root: &Path) -> CrawlerOptions {
CrawlerOptions {
cwd: root.to_path_buf(),
global: false,
global_prefix: None,
batch_size: 100,
}
}
async fn stage_npm_pkg(node_modules: &Path, name: &str, version: &str) {
let pkg_dir = node_modules.join(name);
tokio::fs::create_dir_all(&pkg_dir).await.unwrap();
let pkg_json = format!(r#"{{"name":"{name}","version":"{version}"}}"#);
tokio::fs::write(pkg_dir.join("package.json"), pkg_json).await.unwrap();
}
#[test]
fn parse_package_name_unscoped() {
let (ns, name) = parse_package_name("lodash");
assert_eq!(ns, None);
assert_eq!(name, "lodash");
}
#[test]
fn parse_package_name_scoped() {
let (ns, name) = parse_package_name("@types/node");
assert_eq!(ns.as_deref(), Some("@types"));
assert_eq!(name, "node");
}
#[test]
fn parse_package_name_at_only_no_slash() {
let (ns, name) = parse_package_name("@oops");
assert_eq!(ns, None);
assert_eq!(name, "@oops");
}
#[test]
fn build_npm_purl_unscoped() {
let purl = build_npm_purl(None, "lodash", "4.17.21");
assert_eq!(purl, "pkg:npm/lodash@4.17.21");
}
#[test]
fn build_npm_purl_scoped() {
let purl = build_npm_purl(Some("@types"), "node", "20.0.0");
assert_eq!(purl, "pkg:npm/@types/node@20.0.0");
}
#[tokio::test]
async fn read_package_json_well_formed() {
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("package.json");
tokio::fs::write(&pkg, r#"{"name":"lodash","version":"4.17.21"}"#).await.unwrap();
let result = read_package_json(&pkg).await;
assert_eq!(
result,
Some(("lodash".to_string(), "4.17.21".to_string()))
);
}
#[tokio::test]
async fn read_package_json_missing_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let result = read_package_json(&tmp.path().join("nope.json")).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn read_package_json_malformed_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("package.json");
tokio::fs::write(&pkg, b"{ this is not json").await.unwrap();
let result = read_package_json(&pkg).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn read_package_json_missing_name_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("package.json");
tokio::fs::write(&pkg, r#"{"version":"1.0.0"}"#).await.unwrap();
let result = read_package_json(&pkg).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn read_package_json_missing_version_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("package.json");
tokio::fs::write(&pkg, r#"{"name":"lodash"}"#).await.unwrap();
let result = read_package_json(&pkg).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn read_package_json_empty_name_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("package.json");
tokio::fs::write(&pkg, r#"{"name":"","version":"1.0.0"}"#).await.unwrap();
assert_eq!(read_package_json(&pkg).await, None);
}
#[tokio::test]
async fn read_package_json_empty_version_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("package.json");
tokio::fs::write(&pkg, r#"{"name":"lodash","version":""}"#).await.unwrap();
assert_eq!(read_package_json(&pkg).await, None);
}
#[test]
fn npm_crawler_new_and_default_construct_cleanly() {
let _a = NpmCrawler::new();
let _b = NpmCrawler::default();
}
#[tokio::test]
async fn get_node_modules_paths_global_prefix_passthrough() {
let tmp = tempfile::tempdir().unwrap();
let custom = tmp.path().join("custom-nm");
tokio::fs::create_dir_all(&custom).await.unwrap();
let crawler = NpmCrawler;
let opts = CrawlerOptions {
cwd: tmp.path().to_path_buf(),
global: false,
global_prefix: Some(custom.clone()),
batch_size: 100,
};
let paths = crawler.get_node_modules_paths(&opts).await.unwrap();
assert_eq!(paths, vec![custom]);
}
#[tokio::test]
async fn get_node_modules_paths_global_mode_no_prefix() {
let tmp = tempfile::tempdir().unwrap();
let crawler = NpmCrawler;
let opts = CrawlerOptions {
cwd: tmp.path().to_path_buf(),
global: true,
global_prefix: None,
batch_size: 100,
};
let _paths = crawler.get_node_modules_paths(&opts).await.unwrap();
}
#[cfg(unix)]
#[test]
fn parse_bun_bin_output_well_formed_unix() {
let parsed = parse_bun_bin_output("/home/foo/.bun/bin\n");
assert_eq!(
parsed.as_deref(),
Some("/home/foo/.bun/install/global/node_modules")
);
}
#[test]
fn parse_bun_bin_output_empty_returns_none() {
assert_eq!(parse_bun_bin_output(""), None);
assert_eq!(parse_bun_bin_output(" \n "), None);
}
#[test]
fn parse_bun_bin_output_root_path_returns_none() {
assert_eq!(parse_bun_bin_output("/"), None);
}
fn with_empty_path<F: FnOnce()>(f: F) {
let prev = std::env::var("PATH").ok();
let empty = tempfile::tempdir().unwrap();
std::env::set_var("PATH", empty.path());
f();
if let Some(v) = prev {
std::env::set_var("PATH", v);
} else {
std::env::remove_var("PATH");
}
}
#[test]
#[serial_test::serial]
fn get_npm_global_prefix_returns_err_when_npm_not_on_path() {
with_empty_path(|| {
let result = get_npm_global_prefix();
assert!(result.is_err(), "npm-not-on-PATH must return Err; got {result:?}");
});
}
#[test]
#[serial_test::serial]
fn get_yarn_global_prefix_returns_none_when_yarn_not_on_path() {
with_empty_path(|| {
assert_eq!(get_yarn_global_prefix(), None);
});
}
#[test]
#[serial_test::serial]
fn get_pnpm_global_prefix_returns_none_when_pnpm_not_on_path() {
with_empty_path(|| {
assert_eq!(get_pnpm_global_prefix(), None);
});
}
#[test]
#[serial_test::serial]
fn get_bun_global_prefix_returns_none_when_bun_not_on_path() {
with_empty_path(|| {
assert_eq!(get_bun_global_prefix(), None);
});
}
#[test]
fn get_npm_global_prefix_with_mock_runner_returns_path() {
let runner = common::MockCommandRunner::new().with_response(
"npm",
&["root", "-g"],
Some("/usr/local/lib/node_modules\n"),
);
let result = get_npm_global_prefix_with(&runner);
assert_eq!(result, Ok("/usr/local/lib/node_modules".to_string()));
}
#[test]
fn get_npm_global_prefix_with_mock_runner_empty_stdout_returns_err() {
let runner =
common::MockCommandRunner::new().with_response("npm", &["root", "-g"], Some(""));
assert!(get_npm_global_prefix_with(&runner).is_err());
}
#[cfg(unix)]
#[test]
fn get_yarn_global_prefix_with_mock_runner_success() {
let runner =
common::MockCommandRunner::new().with_response("yarn", &["global", "dir"], Some("/Users/foo/.yarn/global\n"));
assert_eq!(
get_yarn_global_prefix_with(&runner).as_deref(),
Some("/Users/foo/.yarn/global/node_modules")
);
}
#[test]
fn get_pnpm_global_prefix_with_mock_runner_success() {
let runner = common::MockCommandRunner::new().with_response(
"pnpm",
&["root", "-g"],
Some("/Users/foo/.pnpm-global\n"),
);
assert_eq!(
get_pnpm_global_prefix_with(&runner).as_deref(),
Some("/Users/foo/.pnpm-global")
);
}
#[cfg(unix)]
#[test]
fn get_bun_global_prefix_with_mock_runner_success() {
let runner = common::MockCommandRunner::new().with_response(
"bun",
&["pm", "bin", "-g"],
Some("/Users/foo/.bun/bin\n"),
);
assert_eq!(
get_bun_global_prefix_with(&runner).as_deref(),
Some("/Users/foo/.bun/install/global/node_modules")
);
}
#[test]
fn parse_npm_root_output_well_formed() {
assert_eq!(
parse_npm_root_output("/usr/local/lib/node_modules\n").as_deref(),
Some("/usr/local/lib/node_modules")
);
}
#[test]
fn parse_npm_root_output_empty_returns_none() {
assert_eq!(parse_npm_root_output(""), None);
assert_eq!(parse_npm_root_output(" \n "), None);
}
#[cfg(unix)]
#[test]
fn parse_yarn_dir_output_appends_node_modules() {
let parsed = parse_yarn_dir_output("/Users/foo/.yarn/global\n");
assert_eq!(
parsed.as_deref(),
Some("/Users/foo/.yarn/global/node_modules")
);
}
#[test]
fn parse_yarn_dir_output_empty_returns_none() {
assert_eq!(parse_yarn_dir_output(""), None);
assert_eq!(parse_yarn_dir_output("\n \n"), None);
}
#[test]
fn parse_pnpm_root_output_returns_trimmed_path() {
let parsed = parse_pnpm_root_output("/home/foo/.local/share/pnpm/global/5/node_modules\n");
assert_eq!(
parsed.as_deref(),
Some("/home/foo/.local/share/pnpm/global/5/node_modules")
);
}
#[test]
fn parse_pnpm_root_output_empty_returns_none() {
assert_eq!(parse_pnpm_root_output(""), None);
assert_eq!(parse_pnpm_root_output(" \n "), None);
}
#[tokio::test]
async fn find_by_purls_unscoped_package() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
stage_npm_pkg(&nm, "lodash", "4.17.21").await;
let crawler = NpmCrawler;
let result = crawler
.find_by_purls(&nm, &["pkg:npm/lodash@4.17.21".to_string()])
.await
.unwrap();
assert_eq!(result.len(), 1);
}
#[tokio::test]
async fn find_by_purls_scoped_package() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
stage_npm_pkg(&nm, "@types/node", "20.0.0").await;
let crawler = NpmCrawler;
let result = crawler
.find_by_purls(&nm, &["pkg:npm/@types/node@20.0.0".to_string()])
.await
.unwrap();
assert_eq!(result.len(), 1);
}
#[tokio::test]
async fn find_by_purls_version_mismatch_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
stage_npm_pkg(&nm, "lodash", "4.17.21").await;
let crawler = NpmCrawler;
let result = crawler
.find_by_purls(&nm, &["pkg:npm/lodash@99.99.99".to_string()])
.await
.unwrap();
assert!(result.is_empty(), "version mismatch must skip");
}
#[tokio::test]
async fn find_by_purls_strips_qualifiers() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
stage_npm_pkg(&nm, "lodash", "4.17.21").await;
let crawler = NpmCrawler;
let result = crawler
.find_by_purls(
&nm,
&["pkg:npm/lodash@4.17.21?extension=tgz".to_string()],
)
.await
.unwrap();
assert!(result.is_empty(), "qualifier strip + synth mismatch must yield empty");
}
#[tokio::test]
async fn find_by_purls_purl_without_at_skipped() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
let crawler = NpmCrawler;
let result = crawler
.find_by_purls(&nm, &["pkg:npm/lodash".to_string()])
.await
.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn find_by_purls_purl_with_empty_version_skipped() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
let crawler = NpmCrawler;
let result = crawler
.find_by_purls(&nm, &["pkg:npm/lodash@".to_string()])
.await
.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn find_by_purls_scoped_purl_without_slash_skipped() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
let crawler = NpmCrawler;
let result = crawler
.find_by_purls(&nm, &["pkg:npm/@foo@1.0".to_string()])
.await
.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn find_by_purls_scoped_purl_with_empty_name_skipped() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
let crawler = NpmCrawler;
let result = crawler
.find_by_purls(&nm, &["pkg:npm/@scope/@1.0".to_string()])
.await
.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn find_by_purls_invalid_purl_skipped() {
let tmp = tempfile::tempdir().unwrap();
let crawler = NpmCrawler;
let result = crawler
.find_by_purls(
tmp.path(),
&["pkg:not-npm/foo@1.0".to_string()],
)
.await
.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn crawl_all_discovers_unscoped_and_scoped() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
stage_npm_pkg(&nm, "lodash", "4.17.21").await;
stage_npm_pkg(&nm, "@types/node", "20.0.0").await;
let crawler = NpmCrawler;
let opts = options_at(tmp.path());
let result = crawler.crawl_all(&opts).await;
let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"lodash"));
assert!(names.contains(&"node"));
}
#[tokio::test]
async fn crawl_all_skips_dirs_without_package_json() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
tokio::fs::create_dir_all(nm.join("not_a_pkg")).await.unwrap();
let crawler = NpmCrawler;
let opts = options_at(tmp.path());
let result = crawler.crawl_all(&opts).await;
assert!(result.is_empty());
}
#[tokio::test]
async fn crawl_all_recurses_into_workspace_packages() {
let tmp = tempfile::tempdir().unwrap();
let pkg_dir = tmp.path().join("packages").join("ws-a");
stage_npm_pkg(&pkg_dir.join("node_modules"), "lodash", "4.17.21").await;
let crawler = NpmCrawler;
let opts = options_at(tmp.path());
let result = crawler.crawl_all(&opts).await;
let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
assert!(
names.contains(&"lodash"),
"workspace recursion must discover nested node_modules; got {names:?}"
);
}
#[tokio::test]
async fn crawl_all_skips_hidden_and_skip_dirs() {
let tmp = tempfile::tempdir().unwrap();
stage_npm_pkg(&tmp.path().join(".hidden").join("node_modules"), "should-not-find", "1.0").await;
stage_npm_pkg(&tmp.path().join("dist").join("node_modules"), "also-not", "1.0").await;
stage_npm_pkg(&tmp.path().join("real-ws").join("node_modules"), "found-me", "1.0").await;
let crawler = NpmCrawler;
let opts = options_at(tmp.path());
let result = crawler.crawl_all(&opts).await;
let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"found-me"));
assert!(!names.contains(&"should-not-find"), "hidden dir must be skipped");
assert!(!names.contains(&"also-not"), "SKIP_DIRS dir must be skipped");
}
#[path = "common/mod.rs"]
mod common;
#[cfg(unix)]
#[tokio::test]
async fn crawl_all_handles_unreadable_node_modules() {
if common::uid_is_root() {
eprintln!("SKIP: chmod 000 is a no-op under root");
return;
}
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
stage_npm_pkg(&nm, "would-be-found", "1.0.0").await;
common::chmod_unreadable(&nm);
let crawler = NpmCrawler;
let opts = options_at(tmp.path());
let result = crawler.crawl_all(&opts).await;
common::chmod_readable(&nm);
assert!(result.is_empty(), "unreadable node_modules must yield empty");
}
#[cfg(unix)]
#[tokio::test]
async fn crawl_all_handles_unreadable_workspace_dir() {
if common::uid_is_root() {
eprintln!("SKIP: chmod 000 is a no-op under root");
return;
}
let tmp = tempfile::tempdir().unwrap();
stage_npm_pkg(&tmp.path().join("readable").join("node_modules"), "ok", "1.0.0").await;
let blocked = tmp.path().join("blocked");
tokio::fs::create_dir(&blocked).await.unwrap();
stage_npm_pkg(&blocked.join("node_modules"), "hidden", "2.0.0").await;
common::chmod_unreadable(&blocked);
let crawler = NpmCrawler;
let opts = options_at(tmp.path());
let result = crawler.crawl_all(&opts).await;
common::chmod_readable(&blocked);
let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"ok"));
assert!(!names.contains(&"hidden"), "unreadable workspace must be skipped");
}
#[tokio::test]
async fn crawl_all_handles_nested_and_messy_scope_dir() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
stage_npm_pkg(&nm, "outer", "1.0.0").await;
stage_npm_pkg(&nm.join("outer").join("node_modules"), "inner", "2.0.0").await;
stage_npm_pkg(&nm, "@scope/scoped-pkg", "3.0.0").await;
stage_npm_pkg(
&nm.join("@scope").join("scoped-pkg").join("node_modules"),
"scoped-dep",
"4.0.0",
)
.await;
tokio::fs::create_dir_all(nm.join("@scope").join(".hidden")).await.unwrap();
tokio::fs::write(nm.join("@scope").join("README.md"), b"x").await.unwrap();
tokio::fs::write(nm.join("top-level-file.txt"), b"y").await.unwrap();
stage_npm_pkg(
&nm.join("outer").join("node_modules"),
"@nest/leaf",
"5.0.0",
)
.await;
let crawler = NpmCrawler;
let opts = options_at(tmp.path());
let result = crawler.crawl_all(&opts).await;
let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"outer"));
assert!(names.contains(&"inner"));
assert!(names.contains(&"scoped-pkg"));
assert!(names.contains(&"scoped-dep"));
assert!(names.contains(&"leaf"));
}
#[tokio::test]
async fn crawl_all_skips_dirs_with_corrupt_package_json() {
let tmp = tempfile::tempdir().unwrap();
let nm = tmp.path().join("node_modules");
let bad = nm.join("broken");
tokio::fs::create_dir_all(&bad).await.unwrap();
tokio::fs::write(bad.join("package.json"), b"{ corrupt").await.unwrap();
let crawler = NpmCrawler;
let opts = options_at(tmp.path());
let result = crawler.crawl_all(&opts).await;
assert!(result.is_empty());
}