Skip to content

Commit 9e110de

Browse files
committed
test(crawlers): more npm + composer coverage
* npm: extract `parse_yarn_dir_output` and `parse_pnpm_root_output` from their shell-out wrappers so the path-derivation logic is unit-testable without a real `yarn` / `pnpm` binary; add tests covering happy path + empty stdout for both parsers and for the previously-extracted `parse_bun_bin_output`. * npm: cover `read_package_json` empty-string branches, `NpmCrawler` construction, `get_node_modules_paths` global_prefix passthrough and global-mode-without-prefix, and `find_workspace_node_modules` recursion / skip-list behavior. * composer: cover `get_global_vendor_paths` via COMPOSER_HOME env var and the HOME/.composer + HOME/.config/composer platform fallbacks, plus `crawl_all` dedup across vendor paths. Assisted-by: Claude Code:claude-opus-4-7
1 parent cb96d0d commit 9e110de

3 files changed

Lines changed: 294 additions & 3 deletions

File tree

‎crates/socket-patch-core/src/crawlers/npm_crawler.rs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,14 @@ pub fn get_yarn_global_prefix() -> Option<String> {
113113
if !output.status.success() {
114114
return None;
115115
}
116+
parse_yarn_dir_output(&String::from_utf8_lossy(&output.stdout))
117+
}
116118

117-
let dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
119+
/// Pure parser for `yarn global dir` stdout. Returns `<dir>/node_modules`
120+
/// or `None` on empty input. Extracted so the path-derivation logic is
121+
/// unit-testable without shelling out.
122+
pub fn parse_yarn_dir_output(stdout: &str) -> Option<String> {
123+
let dir = stdout.trim().to_string();
118124
if dir.is_empty() {
119125
return None;
120126
}
@@ -134,8 +140,13 @@ pub fn get_pnpm_global_prefix() -> Option<String> {
134140
if !output.status.success() {
135141
return None;
136142
}
143+
parse_pnpm_root_output(&String::from_utf8_lossy(&output.stdout))
144+
}
137145

138-
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
146+
/// Pure parser for `pnpm root -g` stdout. Returns the trimmed path or
147+
/// `None` on empty input.
148+
pub fn parse_pnpm_root_output(stdout: &str) -> Option<String> {
149+
let path = stdout.trim().to_string();
139150
if path.is_empty() {
140151
return None;
141152
}

‎crates/socket-patch-core/tests/crawler_composer_e2e.rs‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,147 @@ async fn get_vendor_paths_local_full_setup_returns_vendor() {
216216
assert_eq!(paths, vec![vendor]);
217217
}
218218

219+
// ── global mode discovery ──────────────────────────────────────
220+
221+
/// `get_vendor_paths(global=true, global_prefix=None)` falls through to
222+
/// `get_global_vendor_paths` which checks `COMPOSER_HOME` env var.
223+
/// Stubbing it to a fixture root with `<root>/vendor/` populated must
224+
/// surface that path.
225+
#[tokio::test]
226+
#[serial_test::serial]
227+
async fn get_vendor_paths_global_via_composer_home_env() {
228+
let tmp = tempfile::tempdir().unwrap();
229+
let composer_home = tmp.path();
230+
let vendor = composer_home.join("vendor");
231+
tokio::fs::create_dir_all(&vendor).await.unwrap();
232+
233+
let prev_composer = std::env::var("COMPOSER_HOME").ok();
234+
std::env::set_var("COMPOSER_HOME", composer_home);
235+
236+
let crawler = ComposerCrawler;
237+
let opts = CrawlerOptions {
238+
cwd: tmp.path().to_path_buf(),
239+
global: true,
240+
global_prefix: None,
241+
batch_size: 100,
242+
};
243+
let paths = crawler.get_vendor_paths(&opts).await.unwrap();
244+
245+
std::env::remove_var("COMPOSER_HOME");
246+
if let Some(v) = prev_composer {
247+
std::env::set_var("COMPOSER_HOME", v);
248+
}
249+
250+
assert!(
251+
paths.iter().any(|p| p == &vendor),
252+
"COMPOSER_HOME-derived vendor dir must be returned; got {paths:?}"
253+
);
254+
}
255+
256+
/// COMPOSER_HOME unset + HOME pointing at a tempdir with `.composer/`
257+
/// must fall through to the HOME/.composer platform default.
258+
#[tokio::test]
259+
#[serial_test::serial]
260+
async fn get_vendor_paths_global_via_home_dot_composer_fallback() {
261+
let tmp = tempfile::tempdir().unwrap();
262+
let dot_composer = tmp.path().join(".composer");
263+
let vendor = dot_composer.join("vendor");
264+
tokio::fs::create_dir_all(&vendor).await.unwrap();
265+
266+
let prev_composer = std::env::var("COMPOSER_HOME").ok();
267+
let prev_home = std::env::var("HOME").ok();
268+
std::env::remove_var("COMPOSER_HOME");
269+
std::env::set_var("HOME", tmp.path());
270+
271+
let crawler = ComposerCrawler;
272+
let opts = CrawlerOptions {
273+
cwd: tmp.path().to_path_buf(),
274+
global: true,
275+
global_prefix: None,
276+
batch_size: 100,
277+
};
278+
let paths = crawler.get_vendor_paths(&opts).await.unwrap();
279+
280+
if let Some(v) = prev_composer {
281+
std::env::set_var("COMPOSER_HOME", v);
282+
}
283+
if let Some(v) = prev_home {
284+
std::env::set_var("HOME", v);
285+
} else {
286+
std::env::remove_var("HOME");
287+
}
288+
289+
assert!(
290+
paths.iter().any(|p| p == &vendor),
291+
"HOME/.composer fallback vendor dir must be returned; got {paths:?}"
292+
);
293+
}
294+
295+
/// HOME with `.config/composer/` but no `.composer/` exercises the
296+
/// second candidate in the platform-default list.
297+
#[tokio::test]
298+
#[serial_test::serial]
299+
async fn get_vendor_paths_global_via_home_xdg_config_composer_fallback() {
300+
let tmp = tempfile::tempdir().unwrap();
301+
let xdg = tmp.path().join(".config").join("composer");
302+
let vendor = xdg.join("vendor");
303+
tokio::fs::create_dir_all(&vendor).await.unwrap();
304+
305+
let prev_composer = std::env::var("COMPOSER_HOME").ok();
306+
let prev_home = std::env::var("HOME").ok();
307+
std::env::remove_var("COMPOSER_HOME");
308+
std::env::set_var("HOME", tmp.path());
309+
310+
let crawler = ComposerCrawler;
311+
let opts = CrawlerOptions {
312+
cwd: tmp.path().to_path_buf(),
313+
global: true,
314+
global_prefix: None,
315+
batch_size: 100,
316+
};
317+
let paths = crawler.get_vendor_paths(&opts).await.unwrap();
318+
319+
if let Some(v) = prev_composer {
320+
std::env::set_var("COMPOSER_HOME", v);
321+
}
322+
if let Some(v) = prev_home {
323+
std::env::set_var("HOME", v);
324+
} else {
325+
std::env::remove_var("HOME");
326+
}
327+
328+
assert!(
329+
paths.iter().any(|p| p == &vendor),
330+
"HOME/.config/composer fallback vendor dir must be returned; got {paths:?}"
331+
);
332+
}
333+
334+
/// `crawl_all` should dedup packages discovered across multiple
335+
/// vendor paths sharing the same installed package — exercises the
336+
/// `seen.contains` early-continue arm.
337+
#[tokio::test]
338+
async fn crawl_all_dedups_across_vendor_paths() {
339+
let tmp = tempfile::tempdir().unwrap();
340+
let custom_vendor = tmp.path().join("custom-vendor");
341+
let composer_dir = custom_vendor.join("composer");
342+
tokio::fs::create_dir_all(&composer_dir).await.unwrap();
343+
let pkg_dir = custom_vendor.join("monolog").join("monolog");
344+
tokio::fs::create_dir_all(&pkg_dir).await.unwrap();
345+
let installed = r#"{"packages":[{"name":"monolog/monolog","version":"3.5.0"},{"name":"monolog/monolog","version":"3.5.0"}]}"#;
346+
tokio::fs::write(composer_dir.join("installed.json"), installed).await.unwrap();
347+
tokio::fs::write(tmp.path().join("composer.json"), b"{}").await.unwrap();
348+
349+
let crawler = ComposerCrawler;
350+
let opts = CrawlerOptions {
351+
cwd: tmp.path().to_path_buf(),
352+
global: true,
353+
global_prefix: Some(custom_vendor),
354+
batch_size: 100,
355+
};
356+
let result = crawler.crawl_all(&opts).await;
357+
assert_eq!(result.len(), 1, "duplicates inside installed.json must dedup");
358+
}
359+
219360
#[tokio::test]
220361
async fn get_vendor_paths_local_with_lock_marker_also_works() {
221362
let tmp = tempfile::tempdir().unwrap();

‎crates/socket-patch-core/tests/crawler_npm_e2e.rs‎

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
use std::path::Path;
77

88
use socket_patch_core::crawlers::npm_crawler::{
9-
build_npm_purl, parse_bun_bin_output, parse_package_name, read_package_json,
9+
build_npm_purl, parse_bun_bin_output, parse_package_name, parse_pnpm_root_output,
10+
parse_yarn_dir_output, read_package_json,
1011
};
1112
use socket_patch_core::crawlers::types::CrawlerOptions;
1213
use socket_patch_core::crawlers::NpmCrawler;
@@ -119,6 +120,71 @@ async fn read_package_json_missing_version_returns_none() {
119120
assert_eq!(result, None);
120121
}
121122

123+
/// Both fields present but empty strings — parse succeeds but the
124+
/// downstream is_empty guard must reject.
125+
#[tokio::test]
126+
async fn read_package_json_empty_name_returns_none() {
127+
let tmp = tempfile::tempdir().unwrap();
128+
let pkg = tmp.path().join("package.json");
129+
tokio::fs::write(&pkg, r#"{"name":"","version":"1.0.0"}"#).await.unwrap();
130+
assert_eq!(read_package_json(&pkg).await, None);
131+
}
132+
133+
#[tokio::test]
134+
async fn read_package_json_empty_version_returns_none() {
135+
let tmp = tempfile::tempdir().unwrap();
136+
let pkg = tmp.path().join("package.json");
137+
tokio::fs::write(&pkg, r#"{"name":"lodash","version":""}"#).await.unwrap();
138+
assert_eq!(read_package_json(&pkg).await, None);
139+
}
140+
141+
// ── NpmCrawler construction ────────────────────────────────────
142+
143+
#[test]
144+
fn npm_crawler_new_and_default_construct_cleanly() {
145+
let _a = NpmCrawler::new();
146+
let _b = NpmCrawler::default();
147+
}
148+
149+
// ── get_node_modules_paths ─────────────────────────────────────
150+
151+
/// `global_prefix` always takes precedence over discovery, even when
152+
/// `global` flag is also set.
153+
#[tokio::test]
154+
async fn get_node_modules_paths_global_prefix_passthrough() {
155+
let tmp = tempfile::tempdir().unwrap();
156+
let custom = tmp.path().join("custom-nm");
157+
tokio::fs::create_dir_all(&custom).await.unwrap();
158+
159+
let crawler = NpmCrawler;
160+
let opts = CrawlerOptions {
161+
cwd: tmp.path().to_path_buf(),
162+
global: false,
163+
global_prefix: Some(custom.clone()),
164+
batch_size: 100,
165+
};
166+
let paths = crawler.get_node_modules_paths(&opts).await.unwrap();
167+
assert_eq!(paths, vec![custom]);
168+
}
169+
170+
/// `global_prefix` even when only `global` is set without a prefix —
171+
/// must fall through to `get_global_node_modules_paths()`. Since the
172+
/// test env may have npm/yarn/pnpm/bun installed, we just assert the
173+
/// call returns Ok (it can return any set of real or empty paths).
174+
#[tokio::test]
175+
async fn get_node_modules_paths_global_mode_no_prefix() {
176+
let tmp = tempfile::tempdir().unwrap();
177+
let crawler = NpmCrawler;
178+
let opts = CrawlerOptions {
179+
cwd: tmp.path().to_path_buf(),
180+
global: true,
181+
global_prefix: None,
182+
batch_size: 100,
183+
};
184+
// Just must not panic — the actual list depends on the host.
185+
let _paths = crawler.get_node_modules_paths(&opts).await.unwrap();
186+
}
187+
122188
// ── parse_bun_bin_output ───────────────────────────────────────
123189

124190
/// Bun's global node_modules lives at `<bun-root>/install/global/node_modules`
@@ -145,6 +211,41 @@ fn parse_bun_bin_output_root_path_returns_none() {
145211
assert_eq!(parse_bun_bin_output("/"), None);
146212
}
147213

214+
// ── parse_yarn_dir_output ──────────────────────────────────────
215+
216+
/// yarn global dir prints `<dir>`; we append `/node_modules`.
217+
#[test]
218+
fn parse_yarn_dir_output_appends_node_modules() {
219+
let parsed = parse_yarn_dir_output("/Users/foo/.yarn/global\n");
220+
assert_eq!(
221+
parsed.as_deref(),
222+
Some("/Users/foo/.yarn/global/node_modules")
223+
);
224+
}
225+
226+
#[test]
227+
fn parse_yarn_dir_output_empty_returns_none() {
228+
assert_eq!(parse_yarn_dir_output(""), None);
229+
assert_eq!(parse_yarn_dir_output("\n \n"), None);
230+
}
231+
232+
// ── parse_pnpm_root_output ─────────────────────────────────────
233+
234+
#[test]
235+
fn parse_pnpm_root_output_returns_trimmed_path() {
236+
let parsed = parse_pnpm_root_output("/home/foo/.local/share/pnpm/global/5/node_modules\n");
237+
assert_eq!(
238+
parsed.as_deref(),
239+
Some("/home/foo/.local/share/pnpm/global/5/node_modules")
240+
);
241+
}
242+
243+
#[test]
244+
fn parse_pnpm_root_output_empty_returns_none() {
245+
assert_eq!(parse_pnpm_root_output(""), None);
246+
assert_eq!(parse_pnpm_root_output(" \n "), None);
247+
}
248+
148249
// ── find_by_purls ──────────────────────────────────────────────
149250

150251
#[tokio::test]
@@ -233,6 +334,44 @@ async fn crawl_all_skips_dirs_without_package_json() {
233334
assert!(result.is_empty());
234335
}
235336

337+
/// `find_workspace_node_modules` should recurse into subdirectories
338+
/// looking for nested `node_modules`, while skipping hidden dirs and
339+
/// well-known build-output dirs.
340+
#[tokio::test]
341+
async fn crawl_all_recurses_into_workspace_packages() {
342+
let tmp = tempfile::tempdir().unwrap();
343+
// Root has no node_modules but a workspace subdir does.
344+
let pkg_dir = tmp.path().join("packages").join("ws-a");
345+
stage_npm_pkg(&pkg_dir.join("node_modules"), "lodash", "4.17.21").await;
346+
347+
let crawler = NpmCrawler;
348+
let opts = options_at(tmp.path());
349+
let result = crawler.crawl_all(&opts).await;
350+
let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
351+
assert!(
352+
names.contains(&"lodash"),
353+
"workspace recursion must discover nested node_modules; got {names:?}"
354+
);
355+
}
356+
357+
#[tokio::test]
358+
async fn crawl_all_skips_hidden_and_skip_dirs() {
359+
let tmp = tempfile::tempdir().unwrap();
360+
// Hidden dirs and SKIP_DIRS entries (dist/build/coverage/tmp/...) are skipped.
361+
stage_npm_pkg(&tmp.path().join(".hidden").join("node_modules"), "should-not-find", "1.0").await;
362+
stage_npm_pkg(&tmp.path().join("dist").join("node_modules"), "also-not", "1.0").await;
363+
// But a real workspace dir should be picked up.
364+
stage_npm_pkg(&tmp.path().join("real-ws").join("node_modules"), "found-me", "1.0").await;
365+
366+
let crawler = NpmCrawler;
367+
let opts = options_at(tmp.path());
368+
let result = crawler.crawl_all(&opts).await;
369+
let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
370+
assert!(names.contains(&"found-me"));
371+
assert!(!names.contains(&"should-not-find"), "hidden dir must be skipped");
372+
assert!(!names.contains(&"also-not"), "SKIP_DIRS dir must be skipped");
373+
}
374+
236375
#[tokio::test]
237376
async fn crawl_all_skips_dirs_with_corrupt_package_json() {
238377
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)