use super::*;
use aube_registry::{Dist, Packument, VersionMetadata};
use miette::Diagnostic;
#[test]
fn no_match_help_renders_context() {
let err = Error::NoMatch(Box::new(NoMatchDetails {
name: "bisection".into(),
range: "^9.9.9".into(),
importer: "packages/app".into(),
ancestors: vec![("parent-pkg".into(), "1.2.3".into())],
original_spec: Some("catalog:evens".into()),
available: vec!["1.0.1".into(), "1.0.0".into(), "0.1.0".into()],
total_versions: 3,
only_prereleases: false,
}));
let help = err.help().expect("help set").to_string();
assert!(help.contains("importer: packages/app"));
assert!(help.contains("chain: parent-pkg@1.2.3 > bisection"));
assert!(help.contains("original spec: `catalog:evens`"));
assert!(help.contains("available versions: 1.0.1, 1.0.0, 0.1.0"));
}
#[test]
fn no_match_help_flags_empty_packument() {
let err = Error::NoMatch(Box::new(NoMatchDetails {
name: "ghost".into(),
range: "^1".into(),
importer: ".".into(),
ancestors: vec![],
original_spec: None,
available: vec![],
total_versions: 0,
only_prereleases: false,
}));
let help = err.help().expect("help set").to_string();
assert!(help.contains("packument has no versions"));
assert!(!help.contains("importer:"));
}
#[test]
fn no_match_help_flags_prerelease_only_packument() {
let err = Error::NoMatch(Box::new(NoMatchDetails {
name: "bleeding".into(),
range: "^1".into(),
importer: ".".into(),
ancestors: vec![],
original_spec: None,
available: vec!["2.0.0-rc.3".into(), "2.0.0-rc.2".into()],
total_versions: 2,
only_prereleases: true,
}));
let help = err.help().expect("help set").to_string();
assert!(help.contains("no stable versions published"));
assert!(help.contains("2.0.0-rc.3"));
assert!(help.contains("bleeding@2.0.0-rc.3"));
assert!(help.contains("`next` dist-tag"));
}
#[test]
fn build_age_gate_resolves_dist_tag_range() {
let packument = make_packument("foo", &["1.0.0", "2.0.0", "3.0.0"], "3.0.0");
let task = ResolveTask {
name: "foo".into(),
range: "latest".into(),
dep_type: DepType::Production,
is_root: true,
parent: None,
importer: ".".into(),
original_specifier: None,
real_name: None,
ancestors: Vec::new(),
range_from_override: false,
};
let d = build_age_gate(&task, &packument, 60);
// `latest` → 3.0.0; the exact-version range only matches 3.0.0.
assert_eq!(d.gated, vec!["3.0.0".to_string()]);
}
#[test]
fn build_no_match_falls_back_to_prereleases() {
let packument = make_packument(
"alpha",
&["1.0.0-alpha.1", "1.0.0-alpha.2"],
"1.0.0-alpha.2",
);
let task = ResolveTask {
name: "alpha".into(),
range: "^2".into(),
dep_type: DepType::Production,
is_root: true,
parent: None,
importer: ".".into(),
original_specifier: None,
real_name: None,
ancestors: Vec::new(),
range_from_override: false,
};
let d = build_no_match(&task, &packument);
assert!(d.only_prereleases);
assert_eq!(d.total_versions, 2);
assert_eq!(
d.available,
vec!["1.0.0-alpha.2".to_string(), "1.0.0-alpha.1".to_string()]
);
}
#[test]
fn classify_registry_error_is_case_insensitive() {
assert!(matches!(
classify_registry_error("fetch https://reg.example: HTTP 403"),
RegistryErrorKind::Fetch
));
assert!(matches!(
classify_registry_error("fetch https://reg.example: http 403"),
RegistryErrorKind::Fetch
));
assert!(matches!(
classify_registry_error("tarball https://x/y.tgz: Integrity mismatch"),
RegistryErrorKind::Tarball
));
assert!(matches!(
classify_registry_error("readPackage hook: TypeError"),
RegistryErrorKind::Hook
));
assert!(matches!(
classify_registry_error("READPACKAGE hook: error"),
RegistryErrorKind::Hook
));
}
#[test]
fn classify_registry_error_prefers_hook_over_http_url() {
// `readPackage hook:` messages can embed an HTTPS URL from the
// hook's own error payload — must land in Hook, not Fetch.
assert!(matches!(
classify_registry_error(
"readPackage hook: Error: failed to fetch https://internal.example/thing"
),
RegistryErrorKind::Hook
));
assert!(matches!(
classify_registry_error("readPackage hook: TypeError: Cannot read property"),
RegistryErrorKind::Hook
));
}
#[test]
fn unknown_catalog_entry_help_explains_chained_value() {
// Chained-catalog case: the help path suggests a concrete semver
// range instead of listing siblings (which would match the user's
// own input and produce a bogus "did you mean `react`?").
let err = Error::UnknownCatalogEntry(Box::new(CatalogDetails {
name: "react".into(),
spec: "catalog:".into(),
catalog: "default (value catalog:other is itself a catalog: reference, catalogs \
cannot chain)"
.into(),
available: Vec::new(),
chained_value: Some("catalog:other".into()),
}));
let help = err.help().expect("help set").to_string();
assert!(!help.contains("did you mean"));
assert!(!help.contains("is empty"));
assert!(help.contains("catalogs cannot chain"));
assert!(help.contains("catalog:other"));
assert!(help.contains("concrete semver range"));
}
#[test]
fn classify_registry_error_prefers_git_over_http_url() {
// `git resolve {range}: ...` with an https:// or git+https:// range
// must land in Git, not Fetch — the substring `http` inside the URL
// would otherwise steal it into the Fetch bucket.
assert!(matches!(
classify_registry_error("git resolve https://github.com/foo/bar.git#v1: auth failed"),
RegistryErrorKind::Git
));
assert!(matches!(
classify_registry_error("git resolve git+https://host/x.git: ref not found"),
RegistryErrorKind::Git
));
assert!(matches!(
classify_registry_error("git task panicked: join error"),
RegistryErrorKind::Git
));
assert!(matches!(
classify_registry_error("git dep https://github.com/...: nested install failed"),
RegistryErrorKind::Git
));
}
#[test]
fn age_gate_help_lists_gated_versions_and_bypass() {
let err = Error::AgeGate(Box::new(AgeGateDetails {
name: "lodash".into(),
range: "^4".into(),
minutes: 60,
importer: "packages/app".into(),
ancestors: vec![("parent".into(), "1.0.0".into())],
gated: vec!["4.17.21".into(), "4.17.20".into()],
}));
let help = err.help().expect("help set").to_string();
assert!(help.contains("importer: packages/app"));
assert!(help.contains("chain: parent@1.0.0 > lodash"));
assert!(help.contains("blocked by age gate: 4.17.21, 4.17.20"));
assert!(help.contains("minimumReleaseAgeStrict=false"));
assert!(help.contains("minimumReleaseAgeExclude"));
}
#[test]
fn registry_help_classifies_common_subtypes() {
let tarball = format_registry_help("lodash", "tarball https://x/y.tgz: eof");
assert!(tarball.contains("aube store prune"));
let fetch = format_registry_help("lodash", "fetch https://registry.npmjs.org: 403");
assert!(fetch.contains("registry URL"));
let git = format_registry_help("some-pkg", "git resolve git+ssh://...: auth");
assert!(git.contains("git dep"));
let local = format_registry_help("pkg", "unparseable local specifier: file:../x");
assert!(local.contains("local specifier"));
let hook = format_registry_help("pkg", "readPackage hook: TypeError");
assert!(hook.contains("readPackage"));
let bug = format_registry_help("(resolver)", "3 transitives still deferred");
assert!(bug.contains("report at"));
}
#[test]
fn unknown_catalog_help_lists_defined() {
let err = Error::UnknownCatalog(Box::new(CatalogDetails {
name: "react".into(),
spec: "catalog:missing".into(),
catalog: "missing".into(),
available: vec!["default".into(), "evens".into()],
chained_value: None,
}));
let help = err.help().expect("help set").to_string();
assert!(help.contains("defined catalogs: default, evens"));
}
#[test]
fn unknown_catalog_help_when_none_defined() {
let err = Error::UnknownCatalog(Box::new(CatalogDetails {
name: "react".into(),
spec: "catalog:".into(),
catalog: "default".into(),
available: vec![],
chained_value: None,
}));
let help = err.help().expect("help set").to_string();
assert!(help.contains("no catalogs are defined"));
}
#[test]
fn unknown_catalog_entry_help_suggests_similar() {
let err = Error::UnknownCatalogEntry(Box::new(CatalogDetails {
name: "reactt".into(),
spec: "catalog:".into(),
catalog: "default".into(),
available: vec!["react".into(), "react-dom".into()],
chained_value: None,
}));
let help = err.help().expect("help set").to_string();
assert!(help.contains("defines: react, react-dom"));
assert!(help.contains("did you mean `react`"));
}
#[test]
fn exotic_subdep_help_shows_chain_and_fix() {
let err = Error::BlockedExoticSubdep(Box::new(ExoticSubdepDetails {
name: "xlsx".into(),
spec: "https://cdn.sheetjs.com/xlsx-0.20.3.tgz".into(),
parent: "some-pkg@1.0.0".into(),
ancestors: vec![("some-pkg".into(), "1.0.0".into())],
importer: ".".into(),
}));
let help = err.help().expect("help set").to_string();
assert!(help.contains("chain: some-pkg@1.0.0 > xlsx"));
assert!(help.contains("pin `xlsx`"));
assert!(help.contains("blockExoticSubdeps=false"));
}
#[test]
fn test_version_satisfies() {
assert!(version_satisfies("4.17.21", "^4.17.0"));
assert!(version_satisfies("4.17.21", "^4.0.0"));
assert!(!version_satisfies("3.10.0", "^4.0.0"));
assert!(version_satisfies("1.0.0", ">=1.0.0"));
assert!(version_satisfies("2.0.0", ">=1.0.0 <3.0.0"));
}
#[test]
fn test_version_satisfies_exact() {
assert!(version_satisfies("1.0.0", "1.0.0"));
assert!(!version_satisfies("1.0.1", "1.0.0"));
}
#[test]
fn test_version_satisfies_tilde() {
assert!(version_satisfies("1.2.3", "~1.2.0"));
assert!(version_satisfies("1.2.9", "~1.2.0"));
assert!(!version_satisfies("1.3.0", "~1.2.0"));
}
#[test]
fn test_version_satisfies_star() {
assert!(version_satisfies("1.0.0", "*"));
assert!(version_satisfies("99.99.99", "*"));
}
#[test]
fn test_version_satisfies_invalid() {
assert!(!version_satisfies("notaversion", "^1.0.0"));
assert!(!version_satisfies("1.0.0", "notarange"));
}
#[test]
fn test_version_satisfies_empty_range_is_any() {
// `hashring@0.0.8` in the wild declares `"bisection": ""`.
// npm / pnpm / yarn treat empty and whitespace-only ranges as
// `"*"`; aube must match.
assert!(version_satisfies("0.0.3", ""));
assert!(version_satisfies("99.99.99", ""));
assert!(version_satisfies("1.2.3", " "));
}
#[test]
fn dependency_policy_default_blocks_exotic_subdeps() {
assert!(DependencyPolicy::default().block_exotic_subdeps);
}
#[test]
fn exotic_subdeps_from_local_parents_are_allowed() {
let task = ResolveTask {
name: "xlsx".to_string(),
range: "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz".to_string(),
dep_type: DepType::Production,
is_root: false,
parent: Some("pi-web-ui@file+abc123".to_string()),
importer: ".".to_string(),
original_specifier: None,
real_name: None,
ancestors: Vec::new(),
range_from_override: false,
};
let mut resolved = BTreeMap::new();
resolved.insert(
"pi-web-ui@file+abc123".to_string(),
LockedPackage {
name: "pi-web-ui".to_string(),
version: "0.68.1".to_string(),
dep_path: "pi-web-ui@file+abc123".to_string(),
local_source: Some(LocalSource::Directory(PathBuf::from("packages/web-ui"))),
..Default::default()
},
);
assert!(!should_block_exotic_subdep(&task, &resolved, true));
}
#[test]
fn exotic_subdeps_from_unknown_parents_stay_blocked() {
let task = ResolveTask {
name: "xlsx".to_string(),
range: "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz".to_string(),
dep_type: DepType::Production,
is_root: false,
parent: Some("pi-web-ui@file+missing".to_string()),
importer: ".".to_string(),
original_specifier: None,
real_name: None,
ancestors: Vec::new(),
range_from_override: false,
};
assert!(should_block_exotic_subdep(&task, &BTreeMap::new(), true));
}
#[test]
fn exotic_subdeps_from_registry_parents_stay_blocked() {
let task = ResolveTask {
name: "xlsx".to_string(),
range: "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz".to_string(),
dep_type: DepType::Production,
is_root: false,
parent: Some("pi-web-ui@0.68.1".to_string()),
importer: ".".to_string(),
original_specifier: None,
real_name: None,
ancestors: Vec::new(),
range_from_override: false,
};
let mut resolved = BTreeMap::new();
resolved.insert(
"pi-web-ui@0.68.1".to_string(),
LockedPackage {
name: "pi-web-ui".to_string(),
version: "0.68.1".to_string(),
dep_path: "pi-web-ui@0.68.1".to_string(),
..Default::default()
},
);
assert!(should_block_exotic_subdep(&task, &resolved, true));
}
#[test]
fn strip_alias_prefix_extracts_version_tail() {
assert_eq!(strip_alias_prefix("npm:bar@1.2.3"), "1.2.3");
assert_eq!(
strip_alias_prefix("npm:@descript/immer@6.0.9-patched.1"),
"6.0.9-patched.1"
);
assert_eq!(strip_alias_prefix("jsr:@std/fmt@1.0.0"), "1.0.0");
assert_eq!(strip_alias_prefix("^1.2.3"), "^1.2.3");
// Edge cases: alias without a version tail falls through.
assert_eq!(strip_alias_prefix("npm:bar"), "bar");
assert_eq!(strip_alias_prefix("jsr:^1.0.0"), "^1.0.0");
}
#[test]
fn pick_override_spec_respects_aliased_version_tail() {
use override_rule::compile;
// Override `immer@>=7.0.0 <9.0.6`, real dep is
// `npm:@descript/immer@6.0.9-patched.1`. The version tail is
// outside the selector's range, so the override must NOT fire
// (pnpm parity). Regression for #174.
let mut raw = BTreeMap::new();
raw.insert("immer@>=7.0.0 <9.0.6".to_string(), "11.1.4".to_string());
let rules = compile(&raw);
assert_eq!(
pick_override_spec(&rules, "immer", "npm:@descript/immer@6.0.9-patched.1", &[]),
None,
);
// A matching version tail still fires.
assert_eq!(
pick_override_spec(&rules, "immer", "npm:@descript/immer@8.0.0", &[]),
Some("11.1.4".to_string()),
);
}
#[test]
fn package_extension_selector_matches_scoped_and_versioned_names() {
assert!(package_selector_matches(
"@scope/pkg@^1",
"@scope/pkg",
"1.2.3"
));
assert!(package_selector_matches("plain", "plain", "9.0.0"));
assert!(!package_selector_matches(
"@scope/pkg@^2",
"@scope/pkg",
"1.2.3"
));
}
#[test]
fn package_extensions_merge_dependency_maps() {
let mut pkg = make_version("host", "1.0.0");
let extension = PackageExtension {
selector: "host@1".to_string(),
dependencies: [("missing".to_string(), "^2.0.0".to_string())]
.into_iter()
.collect(),
optional_dependencies: BTreeMap::new(),
peer_dependencies: [("peer".to_string(), "^3.0.0".to_string())]
.into_iter()
.collect(),
peer_dependencies_meta: [(
"peer".to_string(),
aube_registry::PeerDepMeta { optional: true },
)]
.into_iter()
.collect(),
};
apply_package_extensions(&mut pkg, &[extension]);
assert_eq!(pkg.dependencies.get("missing").unwrap(), "^2.0.0");
assert_eq!(pkg.peer_dependencies.get("peer").unwrap(), "^3.0.0");
assert!(pkg.peer_dependencies_meta.get("peer").unwrap().optional);
}
#[test]
fn package_extensions_do_not_overwrite_existing_dependency_maps() {
let mut pkg = make_version("host", "1.0.0");
pkg.dependencies
.insert("dep".to_string(), "^1.0.0".to_string());
pkg.optional_dependencies
.insert("optional".to_string(), "^2.0.0".to_string());
pkg.peer_dependencies
.insert("peer".to_string(), "^3.0.0".to_string());
pkg.peer_dependencies_meta.insert(
"peer".to_string(),
aube_registry::PeerDepMeta { optional: false },
);
let extension = PackageExtension {
selector: "host".to_string(),
dependencies: [
("dep".to_string(), "^9.0.0".to_string()),
("missing".to_string(), "^4.0.0".to_string()),
]
.into_iter()
.collect(),
optional_dependencies: [
("optional".to_string(), "^9.0.0".to_string()),
("missing-optional".to_string(), "^5.0.0".to_string()),
]
.into_iter()
.collect(),
peer_dependencies: [
("peer".to_string(), "^9.0.0".to_string()),
("missing-peer".to_string(), "^6.0.0".to_string()),
]
.into_iter()
.collect(),
peer_dependencies_meta: [
(
"peer".to_string(),
aube_registry::PeerDepMeta { optional: true },
),
(
"missing-peer".to_string(),
aube_registry::PeerDepMeta { optional: true },
),
]
.into_iter()
.collect(),
};
apply_package_extensions(&mut pkg, &[extension]);
assert_eq!(pkg.dependencies.get("dep").unwrap(), "^1.0.0");
assert_eq!(pkg.dependencies.get("missing").unwrap(), "^4.0.0");
assert_eq!(pkg.optional_dependencies.get("optional").unwrap(), "^2.0.0");
assert_eq!(
pkg.optional_dependencies.get("missing-optional").unwrap(),
"^5.0.0"
);
assert_eq!(pkg.peer_dependencies.get("peer").unwrap(), "^3.0.0");
assert_eq!(pkg.peer_dependencies.get("missing-peer").unwrap(), "^6.0.0");
assert!(!pkg.peer_dependencies_meta.get("peer").unwrap().optional);
assert!(
pkg.peer_dependencies_meta
.get("missing-peer")
.unwrap()
.optional
);
}
#[test]
fn allowed_deprecated_versions_match_package_ranges() {
let allowed = [("old".to_string(), "<2".to_string())]
.into_iter()
.collect();
assert!(is_deprecation_allowed("old", "1.9.0", &allowed));
assert!(!is_deprecation_allowed("old", "2.0.0", &allowed));
assert!(!is_deprecation_allowed("other", "1.0.0", &allowed));
}
#[test]
fn test_dep_path_for() {
assert_eq!(dep_path_for("lodash", "4.17.21"), "lodash@4.17.21");
assert_eq!(dep_path_for("@babel/core", "7.24.0"), "@babel/core@7.24.0");
}
fn make_version(name: &str, version: &str) -> VersionMetadata {
VersionMetadata {
name: name.to_string(),
version: version.to_string(),
dependencies: BTreeMap::new(),
dev_dependencies: BTreeMap::new(),
peer_dependencies: BTreeMap::new(),
peer_dependencies_meta: BTreeMap::new(),
optional_dependencies: BTreeMap::new(),
bundled_dependencies: None,
dist: Some(Dist {
tarball: format!("https://registry.npmjs.org/{name}/-/{name}-{version}.tgz"),
integrity: Some(format!("sha512-fake-{name}-{version}")),
shasum: None,
unpacked_size: None,
attestations: None,
}),
os: vec![],
cpu: vec![],
libc: vec![],
engines: BTreeMap::new(),
license: None,
funding_url: None,
bin: BTreeMap::new(),
has_install_script: false,
deprecated: None,
npm_user: None,
}
}
fn make_packument(name: &str, versions: &[&str], latest: &str) -> Packument {
let mut ver_map = BTreeMap::new();
for v in versions {
ver_map.insert(v.to_string(), make_version(name, v));
}
let mut dist_tags = BTreeMap::new();
dist_tags.insert("latest".to_string(), latest.to_string());
Packument {
name: name.to_string(),
modified: None,
versions: ver_map,
dist_tags,
time: BTreeMap::new(),
}
}
#[test]
fn test_pick_version_highest_match() {
// `latest=2.0.0` does NOT satisfy `^1.0.0` (`<2.0.0`), so the
// dist-tag preference doesn't apply and we fall through to the
// strictly-highest version inside the range — 1.2.0.
let packument = make_packument("foo", &["1.0.0", "1.1.0", "1.2.0", "2.0.0"], "2.0.0");
let result = pick_version(&packument, "^1.0.0", None, false, None, false).unwrap();
assert_eq!(result.version, "1.2.0");
}
#[test]
fn test_pick_version_prefers_dist_tag_latest_when_in_range() {
// npm/pnpm parity: when `dist-tags.latest` falls inside the
// user's range, return the publisher's tagged build instead of
// the highest version — the publisher used `latest` to anchor
// the canonical install; a stray higher version inside the
// range (hotfix on an old line, withdrawn experimental publish,
// mid-rollback intermediary) shouldn't silently win.
//
// Regression for the pnpm_update.bats `add_dist_tag latest 100.0.0
// -> aube add foo@^100.0.0` flow, which expects the lockfile to
// pin 100.0.0 even though 100.1.0 is available.
let packument = make_packument("foo", &["1.0.0", "1.1.0", "1.2.0"], "1.0.0");
let result = pick_version(&packument, "^1.0.0", None, false, None, false).unwrap();
assert_eq!(result.version, "1.0.0");
}
#[test]
fn test_pick_version_falls_through_when_latest_outside_range() {
// `latest=2.0.0` is outside the user's `^1.0.0`, so the dist-tag
// preference is a no-op; the strictly-highest matching version
// (1.1.0) wins.
let packument = make_packument("foo", &["1.0.0", "1.1.0", "2.0.0"], "2.0.0");
let result = pick_version(&packument, "^1.0.0", None, false, None, false).unwrap();
assert_eq!(result.version, "1.1.0");
}
#[test]
fn test_pick_version_lowest_ignores_dist_tag_preference() {
// TimeBased mode (`pick_lowest=true`) wants the floor of the
// range, not whatever the publisher tagged latest. Confirm the
// dist-tag preference is suppressed when pick_lowest is set.
let packument = make_packument("foo", &["1.0.0", "1.1.0", "1.2.0"], "1.2.0");
let result = pick_version(&packument, "^1.0.0", None, true, None, false).unwrap();
assert_eq!(result.version, "1.0.0");
}
#[test]
fn test_pick_version_exact() {
let packument = make_packument("foo", &["1.0.0", "1.1.0"], "1.1.0");
let result = pick_version(&packument, "1.0.0", None, false, None, false).unwrap();
assert_eq!(result.version, "1.0.0");
}
#[test]
fn test_pick_version_no_match() {
let packument = make_packument("foo", &["1.0.0", "1.1.0"], "1.1.0");
let result = pick_version(&packument, "^2.0.0", None, false, None, false);
assert!(matches!(result, PickResult::NoMatch));
}
#[test]
fn test_pick_version_strict_distinguishes_age_gate_from_no_match() {
// A version satisfies the range but is filtered by the cutoff.
// Strict mode should report `AgeGated`, not `NoMatch`, so the
// caller can surface a meaningful error message.
let mut packument = make_packument("foo", &["1.0.0", "1.1.0"], "1.1.0");
packument
.time
.insert("1.0.0".into(), "2024-01-01T00:00:00.000Z".into());
packument
.time
.insert("1.1.0".into(), "2024-06-01T00:00:00.000Z".into());
let cutoff = "2020-01-01T00:00:00.000Z";
let result = pick_version(&packument, "^1.0.0", None, false, Some(cutoff), true);
assert!(matches!(result, PickResult::AgeGated));
// No version satisfies the range at all → still NoMatch even
// in strict mode.
let result = pick_version(&packument, "^9.0.0", None, false, Some(cutoff), true);
assert!(matches!(result, PickResult::NoMatch));
}
#[test]
fn test_pick_version_prefers_locked() {
let packument = make_packument("foo", &["1.0.0", "1.1.0", "1.2.0"], "1.2.0");
let result = pick_version(&packument, "^1.0.0", Some("1.1.0"), false, None, false).unwrap();
assert_eq!(result.version, "1.1.0");
}
#[test]
fn test_pick_version_locked_out_of_range() {
let packument = make_packument("foo", &["1.0.0", "2.0.0"], "2.0.0");
// Locked version doesn't satisfy range, should pick highest match
let result = pick_version(&packument, "^2.0.0", Some("1.0.0"), false, None, false).unwrap();
assert_eq!(result.version, "2.0.0");
}
#[test]
fn test_pick_version_dist_tag() {
let packument = make_packument("foo", &["1.0.0", "2.0.0-beta.1"], "1.0.0");
let result = pick_version(&packument, "latest", None, false, None, false).unwrap();
assert_eq!(result.version, "1.0.0");
}
#[test]
fn test_pick_version_lowest_picks_smallest_satisfying() {
let packument = make_packument("foo", &["1.0.0", "1.1.0", "1.2.0", "2.0.0"], "2.0.0");
let result = pick_version(&packument, "^1.0.0", None, true, None, false).unwrap();
assert_eq!(result.version, "1.0.0");
}
#[test]
fn test_pick_version_cutoff_filters_future_versions() {
let mut packument = make_packument("foo", &["1.0.0", "1.1.0", "1.2.0"], "1.2.0");
packument
.time
.insert("1.0.0".into(), "2020-01-01T00:00:00.000Z".into());
packument
.time
.insert("1.1.0".into(), "2021-01-01T00:00:00.000Z".into());
packument
.time
.insert("1.2.0".into(), "2023-01-01T00:00:00.000Z".into());
// Highest pick, but cutoff forbids 1.2.0 → fall back to 1.1.0.
let cutoff = "2022-06-01T00:00:00.000Z";
let result = pick_version(&packument, "^1.0.0", None, false, Some(cutoff), false).unwrap();
assert_eq!(result.version, "1.1.0");
}
#[test]
fn test_pick_version_lenient_falls_back_to_lowest_when_cutoff_excludes_all() {
// Mirrors pnpm's lenient `pickPackageFromMetaUsingTime`: when
// every satisfying version is younger than the cutoff, fall
// back to the lowest satisfying version (ignoring the cutoff).
let mut packument = make_packument("foo", &["1.0.0", "1.1.0", "1.2.0"], "1.2.0");
packument
.time
.insert("1.0.0".into(), "2024-01-01T00:00:00.000Z".into());
packument
.time
.insert("1.1.0".into(), "2024-06-01T00:00:00.000Z".into());
packument
.time
.insert("1.2.0".into(), "2025-01-01T00:00:00.000Z".into());
let cutoff = "2020-01-01T00:00:00.000Z";
let result = pick_version(&packument, "^1.0.0", None, false, Some(cutoff), false).unwrap();
assert_eq!(result.version, "1.0.0");
}
#[test]
fn test_pick_version_strict_returns_age_gated_when_cutoff_excludes_all() {
let mut packument = make_packument("foo", &["1.0.0", "1.1.0"], "1.1.0");
packument
.time
.insert("1.0.0".into(), "2024-01-01T00:00:00.000Z".into());
packument
.time
.insert("1.1.0".into(), "2024-06-01T00:00:00.000Z".into());
let cutoff = "2020-01-01T00:00:00.000Z";
let result = pick_version(&packument, "^1.0.0", None, false, Some(cutoff), true);
assert!(matches!(result, PickResult::AgeGated));
}
#[test]
fn test_minimum_release_age_cutoff_format() {
let mra = MinimumReleaseAge {
minutes: 60,
..Default::default()
};
let cutoff = mra.cutoff().expect("non-zero minutes produces a cutoff");
// Sanity-check the shape; the actual instant depends on now().
assert_eq!(cutoff.len(), 24, "ISO-8601 with millis is 24 chars");
assert!(cutoff.ends_with("Z"));
assert_eq!(&cutoff[4..5], "-");
assert_eq!(&cutoff[10..11], "T");
}
#[test]
fn test_minimum_release_age_zero_disables() {
let mra = MinimumReleaseAge::default();
assert!(mra.cutoff().is_none());
}
#[tokio::test]
async fn minimum_release_age_fetches_full_packument_directly() {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut packument = make_packument("foo", &["1.0.0"], "1.0.0");
packument.modified = Some("2024-01-01T00:00:00.000Z".to_string());
let body = serde_json::to_vec(&packument).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let registry = format!("http://{}/", listener.local_addr().unwrap());
let requests = Arc::new(AtomicUsize::new(0));
let request_count = requests.clone();
let server = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
request_count.fetch_add(1, Ordering::Relaxed);
let body = body.clone();
tokio::spawn(async move {
let mut buf = [0_u8; 2048];
let _ = socket.read(&mut buf).await;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&body).await.unwrap();
});
}
});
let base = std::env::temp_dir().join(format!(
"aube-resolver-mra-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let cache_dir = base.join("packuments");
let full_cache_dir = base.join("packuments-full");
std::fs::create_dir_all(&cache_dir).unwrap();
std::fs::create_dir_all(&full_cache_dir).unwrap();
let client = Arc::new(aube_registry::client::RegistryClient::new(®istry));
let mut resolver = Resolver::new(client)
.with_packument_cache(cache_dir)
.with_packument_full_cache(full_cache_dir)
.with_minimum_release_age(Some(MinimumReleaseAge {
minutes: 60,
..Default::default()
}));
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("foo".to_string(), "1.0.0".to_string());
let graph = resolver.resolve(&manifest, None).await.unwrap();
assert!(graph_has_package(&graph, "foo", "1.0.0"));
assert_eq!(requests.load(Ordering::Relaxed), 1);
server.abort();
let _ = std::fs::remove_dir_all(base);
}
/// Regression: when both `minimumReleaseAge` and `trustPolicy=NoDowngrade`
/// are active, the resolver must use a full packument with `time`;
/// using an abbreviated corgi packument would make the trust check fail
/// with a spurious `TrustCheckMissingTime` for every version. Reported
/// by Cursor Bugbot on PR #333.
#[tokio::test]
async fn trust_policy_disables_minimum_release_age_short_circuit() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Two packument bodies sharing one name. The corgi body (served to
// requests carrying the corgi Accept header) has an empty `time`
// map — exactly what a real npmjs.org corgi response looks like.
// The full body has the same versions but a populated `time` map.
// The resolver should go straight to the full fetch, the trust
// check should see a populated `time`, find no prior versions to
// compare against, and resolve cleanly.
let mut corgi = make_packument("foo", &["1.0.0"], "1.0.0");
corgi.modified = Some("2024-01-01T00:00:00.000Z".to_string());
let corgi_body = serde_json::to_vec(&corgi).unwrap();
let mut full = make_packument("foo", &["1.0.0"], "1.0.0");
full.modified = Some("2024-01-01T00:00:00.000Z".to_string());
full.time
.insert("1.0.0".to_string(), "2024-01-01T00:00:00.000Z".to_string());
let full_body = serde_json::to_vec(&full).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let registry = format!("http://{}/", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let corgi_body = corgi_body.clone();
let full_body = full_body.clone();
tokio::spawn(async move {
let mut buf = vec![0_u8; 4096];
let n = socket.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]);
let body = if request.contains("application/vnd.npm.install-v1+json") {
corgi_body
} else {
full_body
};
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&body).await.unwrap();
});
}
});
let base = std::env::temp_dir().join(format!(
"aube-resolver-trust-mra-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(base.join("packuments")).unwrap();
std::fs::create_dir_all(base.join("packuments-full")).unwrap();
let client = Arc::new(aube_registry::client::RegistryClient::new(®istry));
let policy = crate::DependencyPolicy {
trust_policy: crate::TrustPolicy::NoDowngrade,
..crate::DependencyPolicy::default()
};
let mut resolver = Resolver::new(client)
.with_packument_cache(base.join("packuments"))
.with_packument_full_cache(base.join("packuments-full"))
.with_minimum_release_age(Some(MinimumReleaseAge {
minutes: 60,
..Default::default()
}))
.with_dependency_policy(policy);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("foo".to_string(), "1.0.0".to_string());
let result = resolver.resolve(&manifest, None).await;
assert!(
!matches!(result, Err(Error::TrustCheckMissingTime(_))),
"shortcircuit must be suppressed when trustPolicy=NoDowngrade — got {result:?}"
);
let graph = result.expect("clean resolve");
assert!(graph_has_package(&graph, "foo", "1.0.0"));
server.abort();
let _ = std::fs::remove_dir_all(base);
}
#[tokio::test]
async fn trust_policy_no_downgrade_blocks_downgraded_install() {
use aube_registry::Attestations;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Three versions of `foo`. 2.0.0 has provenance attestation;
// 3.0.0 lost it (the supply-chain incident shape pnpm's check is
// designed for). With trustPolicy=no-downgrade, picking 3.0.0
// must fail with Error::TrustDowngrade.
let mut packument = make_packument("foo", &["1.0.0", "2.0.0", "3.0.0"], "3.0.0");
packument
.time
.insert("1.0.0".to_string(), "2025-01-01T00:00:00.000Z".to_string());
packument
.time
.insert("2.0.0".to_string(), "2025-02-01T00:00:00.000Z".to_string());
packument
.time
.insert("3.0.0".to_string(), "2025-03-01T00:00:00.000Z".to_string());
let v2 = packument.versions.get_mut("2.0.0").unwrap();
v2.dist.as_mut().unwrap().attestations = Some(Attestations {
provenance: Some(serde_json::json!({
"predicateType": "https://slsa.dev/provenance/v1"
})),
});
let body = serde_json::to_vec(&packument).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let registry = format!("http://{}/", listener.local_addr().unwrap());
let requests = Arc::new(AtomicUsize::new(0));
let request_count = requests.clone();
let server = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
request_count.fetch_add(1, Ordering::Relaxed);
let body = body.clone();
tokio::spawn(async move {
let mut buf = [0_u8; 2048];
let _ = socket.read(&mut buf).await;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&body).await.unwrap();
});
}
});
let base = std::env::temp_dir().join(format!(
"aube-resolver-trust-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(base.join("packuments")).unwrap();
std::fs::create_dir_all(base.join("packuments-full")).unwrap();
let client = Arc::new(aube_registry::client::RegistryClient::new(®istry));
let policy = crate::DependencyPolicy {
trust_policy: crate::TrustPolicy::NoDowngrade,
..crate::DependencyPolicy::default()
};
let mut resolver = Resolver::new(client)
.with_packument_cache(base.join("packuments"))
.with_packument_full_cache(base.join("packuments-full"))
.with_dependency_policy(policy);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("foo".to_string(), "3.0.0".to_string());
let err = resolver
.resolve(&manifest, None)
.await
.expect_err("3.0.0 must be rejected as a trust downgrade");
match err {
Error::TrustDowngrade(d) => {
assert_eq!(d.name, "foo");
assert_eq!(d.picked_version, "3.0.0");
assert_eq!(d.prior_version, "2.0.0");
assert!(matches!(
d.prior_evidence,
crate::trust::TrustEvidence::Provenance
));
assert!(d.current_evidence.is_none());
}
other => panic!("expected TrustDowngrade, got {other:?}"),
}
// Verify the suggested fix path actually unblocks the install.
let mut excluded_policy = crate::DependencyPolicy {
trust_policy: crate::TrustPolicy::NoDowngrade,
..crate::DependencyPolicy::default()
};
excluded_policy.trust_policy_exclude = crate::TrustExcludeRules::parse(["foo@3.0.0"]).unwrap();
let mut resolver = Resolver::new(Arc::new(aube_registry::client::RegistryClient::new(
®istry,
)))
.with_packument_cache(base.join("packuments"))
.with_packument_full_cache(base.join("packuments-full"))
.with_dependency_policy(excluded_policy);
let graph = resolver
.resolve(&manifest, None)
.await
.expect("excluded version installs cleanly");
assert!(graph_has_package(&graph, "foo", "3.0.0"));
server.abort();
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn test_format_iso8601_known_epoch() {
// 2024-01-01T00:00:00Z = 1704067200
assert_eq!(
format_iso8601_utc(1_704_067_200),
"2024-01-01T00:00:00.000Z"
);
// 1970-01-01T00:00:00Z = 0
assert_eq!(format_iso8601_utc(0), "1970-01-01T00:00:00.000Z");
}
#[test]
fn test_pick_version_cutoff_allows_missing_time_entries() {
let packument = make_packument("foo", &["1.0.0", "1.1.0"], "1.1.0");
// Packument has no `time` entries at all — cutoff must not
// remove every candidate, or the resolver can never make
// progress on abbreviated-packument registries.
let cutoff = "2000-01-01T00:00:00.000Z";
let result = pick_version(&packument, "^1.0.0", None, false, Some(cutoff), false).unwrap();
assert_eq!(result.version, "1.1.0");
}
#[test]
fn test_pick_version_with_deps() {
let mut packument = make_packument("foo", &["1.0.0"], "1.0.0");
packument
.versions
.get_mut("1.0.0")
.unwrap()
.dependencies
.insert("bar".to_string(), "^2.0.0".to_string());
let result = pick_version(&packument, "^1.0.0", None, false, None, false).unwrap();
assert_eq!(result.dependencies.get("bar").unwrap(), "^2.0.0");
}
fn mk_locked(
name: &str,
version: &str,
deps: &[(&str, &str)],
peer_deps: &[(&str, &str)],
) -> LockedPackage {
let mut dependencies = BTreeMap::new();
for (n, v) in deps {
dependencies.insert((*n).to_string(), (*v).to_string());
}
let mut peer_dependencies = BTreeMap::new();
for (n, r) in peer_deps {
peer_dependencies.insert((*n).to_string(), (*r).to_string());
}
LockedPackage {
name: name.to_string(),
version: version.to_string(),
integrity: None,
dependencies,
peer_dependencies,
peer_dependencies_meta: BTreeMap::new(),
dep_path: format!("{name}@{version}"),
..Default::default()
}
}
fn graph_has_package(graph: &LockfileGraph, name: &str, version: &str) -> bool {
graph
.packages
.values()
.any(|pkg| pkg.name == name && pkg.version == version)
}
// Regression guard for the cycle-break branch in `visit_peer_context`
// flagged by greptile on #40. Two packages peer-depend on each other:
//
// a@1.0.0 -> dep=b@1.0.0, peer=b@^1
// b@1.0.0 -> dep=a@1.0.0, peer=a@^1
//
// Starting the DFS from importer root `a`, we should:
// 1. Visit `a`, recurse into `b`
// 2. Visit `b`, recurse into `a` (cycle hit — `visiting` guard fires)
// 3. Cycle branch returns `a`'s contextualized dep_path WITHOUT
// waiting for the in-progress insertion to land
// 4. `b` completes, gets inserted
// 5. `a` completes, gets inserted
//
// By the time the function returns, every dep_path referenced from
// any `dependencies` tail must exist as a key in `out_packages`.
#[test]
fn apply_peer_contexts_handles_mutual_peer_cycle() {
let a = mk_locked("a", "1.0.0", &[("b", "1.0.0")], &[("b", "^1")]);
let b = mk_locked("b", "1.0.0", &[("a", "1.0.0")], &[("a", "^1")]);
let mut packages = BTreeMap::new();
packages.insert("a@1.0.0".to_string(), a);
packages.insert("b@1.0.0".to_string(), b);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "a".to_string(),
dep_path: "a@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let canonical = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = apply_peer_contexts(canonical, &PeerContextOptions::default())
.expect("test graph should converge");
// Both packages got contextualized dep_paths with each other's
// resolved version baked in.
let a_key = "a@1.0.0(b@1.0.0)";
let b_key = "b@1.0.0(a@1.0.0)";
assert!(
out.packages.contains_key(a_key),
"expected {a_key} in {:?}",
out.packages.keys().collect::<Vec<_>>()
);
assert!(
out.packages.contains_key(b_key),
"expected {b_key} in {:?}",
out.packages.keys().collect::<Vec<_>>()
);
// Every referenced dependency tail resolves to a real entry in
// out_packages — the cycle-break branch didn't leak a dangling
// reference.
for pkg in out.packages.values() {
for (child_name, child_tail) in &pkg.dependencies {
let child_key = format!("{child_name}@{child_tail}");
assert!(
out.packages.contains_key(&child_key),
"dangling dep_path {child_key} referenced from {}",
pkg.dep_path
);
}
}
// Importer's direct dep now points at the contextualized `a`.
let root = out.importers.get(".").unwrap();
assert_eq!(root.len(), 1);
assert_eq!(root[0].dep_path, a_key);
}
// When a declared peer has its own resolved peers, the outer
// package's suffix must carry the *nested* form — this is what
// pnpm writes for React ecosystem projects where
// `@testing-library/react` peers on both `react` and `react-dom`,
// and `react-dom` itself peers on `react`. The expected snapshot
// key is `@testing-library/react@14(react@18)(react-dom@18(react@18))`.
//
// This test uses a simplified three-package fixture
// (consumer → adapter → core) where `core` is only a peer and
// `adapter` peers on `core`. The `consumer` peers on both and
// should serialize the `adapter` entry in its suffix with the
// nested `(core@...)` tail.
#[test]
fn apply_peer_contexts_produces_nested_peer_suffixes() {
// consumer declares peers [adapter, core]. adapter declares
// peer [core]. core has no deps or peers.
let mut consumer = mk_locked(
"consumer",
"1.0.0",
&[("adapter", "1.0.0"), ("core", "1.0.0")],
&[("adapter", "^1"), ("core", "^1")],
);
consumer.dep_path = "consumer@1.0.0".to_string();
let mut adapter = mk_locked("adapter", "1.0.0", &[("core", "1.0.0")], &[("core", "^1")]);
adapter.dep_path = "adapter@1.0.0".to_string();
let core = mk_locked("core", "1.0.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("adapter@1.0.0".to_string(), adapter);
packages.insert("core@1.0.0".to_string(), core);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = apply_peer_contexts(graph, &PeerContextOptions::default())
.expect("test graph should converge");
// adapter's standalone key should have just its own peer (core).
assert!(
out.packages.contains_key("adapter@1.0.0(core@1.0.0)"),
"expected nested adapter variant: {:?}",
out.packages.keys().collect::<Vec<_>>()
);
// consumer's key should reference adapter's NESTED tail, i.e.
// `(adapter@1.0.0(core@1.0.0))(core@1.0.0)` — that's the pnpm
// byte-identical shape.
let consumer_key = "consumer@1.0.0(adapter@1.0.0(core@1.0.0))(core@1.0.0)";
assert!(
out.packages.contains_key(consumer_key),
"expected nested consumer key {consumer_key} in {:?}",
out.packages.keys().collect::<Vec<_>>()
);
// Every referenced dependency tail must resolve to a real entry.
for pkg in out.packages.values() {
for (child_name, child_tail) in &pkg.dependencies {
let child_key = format!("{child_name}@{child_tail}");
assert!(
out.packages.contains_key(&child_key),
"dangling dep_path {child_key} referenced from {}",
pkg.dep_path
);
}
}
}
// Repro for the johnpyp/aube-vite-peer-variant case: a workspace
// importer pins a peer version that DOESN'T satisfy a sibling's
// declared peer range, while the workspace ROOT pins a satisfying
// one. The peer context must follow node_modules-resolution order
// — closest-ancestor-wins, even when its version misses the range
// — and emit an unmet-peer warning rather than reaching past the
// importer to grab a more-distant matching version. Matches what
// pnpm and bun produce for the same shape.
#[test]
fn apply_peer_contexts_prefers_incompatible_ancestor_over_root() {
// consumer peers on dep@^5. Workspace `app` directly depends on
// BOTH consumer and dep@8 (out of range). Root pins dep@5 (in
// range). The expected pick is the closest provider — `app`'s
// dep@8 — not root's dep@5.
let mut consumer = mk_locked("consumer", "1.0.0", &[], &[("dep", "^5")]);
consumer.dep_path = "consumer@1.0.0".to_string();
let dep5 = mk_locked("dep", "5.0.0", &[], &[]);
let dep8 = mk_locked("dep", "8.0.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("dep@5.0.0".to_string(), dep5);
packages.insert("dep@8.0.0".to_string(), dep8);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "dep".to_string(),
dep_path: "dep@5.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("5.0.0".to_string()),
}],
);
importers.insert(
"packages/app".to_string(),
vec![
DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
},
DirectDep {
name: "dep".to_string(),
dep_path: "dep@8.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("8.0.0".to_string()),
},
],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = apply_peer_contexts(graph, &PeerContextOptions::default())
.expect("test graph should converge");
// consumer must follow `app`'s dep@8 (its actual node_modules
// sibling), even though dep@8 doesn't satisfy `^5`.
assert!(
out.packages.contains_key("consumer@1.0.0(dep@8.0.0)"),
"consumer must pick app's incompatible dep@8 over root's compatible dep@5: {:?}",
out.packages.keys().collect::<Vec<_>>()
);
assert!(
!out.packages.contains_key("consumer@1.0.0(dep@5.0.0)"),
"consumer was incorrectly pinned to root's dep@5: {:?}",
out.packages.keys().collect::<Vec<_>>()
);
// detect_unmet_peers should flag the mismatch so the CLI prints
// a warning, matching pnpm/bun behavior.
let unmet = detect_unmet_peers(&out);
assert!(
unmet
.iter()
.any(|u| u.peer_name == "dep" && u.found.as_deref() == Some("8.0.0")),
"expected unmet-peer warning for consumer's dep peer: {unmet:?}"
);
}
// Per-peer-range cross-subtree satisfaction: two sibling packages
// that declare peer react with INCOMPATIBLE ranges should each
// end up pinned to the version satisfying their own range, even
// if an ancestor scope carries the wrong version. This is pnpm's
// "duplicate package per peer context" behavior.
//
// The fixture mirrors the real React/Testing-Library case: the
// user pins `react@17` at the root (which is what the hoist
// propagates into every child's ancestor scope), but a sibling
// dep declares `peer react: ^18`. That sibling must resolve to
// `react@18.x`, not `react@17`.
#[test]
fn apply_peer_contexts_per_range_satisfaction() {
// consumer17 wants react@^17. consumer18 wants react@^18.
// Both peer on react. The graph has BOTH versions in play
// (the BFS resolver already emits both when the ranges
// conflict — see `resolved_versions` dedupe logic).
let mut consumer17 = mk_locked(
"consumer17",
"1.0.0",
&[("react", "17.0.2")],
&[("react", "^17")],
);
consumer17.dep_path = "consumer17@1.0.0".to_string();
let mut consumer18 = mk_locked(
"consumer18",
"1.0.0",
&[("react", "18.2.0")],
&[("react", "^18")],
);
consumer18.dep_path = "consumer18@1.0.0".to_string();
let react17 = mk_locked("react", "17.0.2", &[], &[]);
let react18 = mk_locked("react", "18.2.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer17@1.0.0".to_string(), consumer17);
packages.insert("consumer18@1.0.0".to_string(), consumer18);
packages.insert("react@17.0.2".to_string(), react17);
packages.insert("react@18.2.0".to_string(), react18);
// Importer has BOTH consumers plus react@17 hoisted (the hoist
// pass picks the first-encountered version, matching what
// happens live when a user pins the older version).
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![
DirectDep {
name: "consumer17".to_string(),
dep_path: "consumer17@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
},
DirectDep {
name: "consumer18".to_string(),
dep_path: "consumer18@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
},
DirectDep {
name: "react".to_string(),
dep_path: "react@17.0.2".to_string(),
dep_type: DepType::Production,
specifier: Some("^17".to_string()),
},
],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = apply_peer_contexts(graph, &PeerContextOptions::default())
.expect("test graph should converge");
// consumer17 should be suffixed with react@17 (satisfies ^17).
assert!(
out.packages.contains_key("consumer17@1.0.0(react@17.0.2)"),
"consumer17 must pick react@17: {:?}",
out.packages.keys().collect::<Vec<_>>()
);
// consumer18 must NOT reuse the root's react@17.0.2 — its own
// declared range `^18` rejects it, so the peer-context pass
// should fall back to the BFS-resolved react@18.2.0.
assert!(
out.packages.contains_key("consumer18@1.0.0(react@18.2.0)"),
"consumer18 must fall back to react@18: {:?}",
out.packages.keys().collect::<Vec<_>>()
);
// And specifically must NOT have been glued to react@17 just
// because the ancestor scope happened to have it.
assert!(
!out.packages.contains_key("consumer18@1.0.0(react@17.0.2)"),
"consumer18 was incorrectly pinned to react@17: {:?}",
out.packages.keys().collect::<Vec<_>>()
);
}
// Regression for greptile feedback on #67: the `from_graph_scan`
// fallback in `visit_peer_context` must return the full dep_path
// TAIL, not just `p.version`. On Pass 2+ of the fixed-point loop
// the input graph's keys carry peer suffixes — e.g. `react-dom`
// lives at `react-dom@18.2.0(react@18.2.0)` — and downstream
// lookups that reconstruct `format!("{name}@{tail}")` need the
// tail to match the actual key. Returning `p.version` would give
// `react-dom@18.2.0`, which Pass 2 lookups would miss, silently
// dropping the peer from `new_dependencies`.
//
// The scenario: consumer peers on a package (helper) whose own
// peer context already exists in the graph's suffixed form.
// Neither ancestor scope nor the consumer's own `pkg.dependencies`
// has helper (so the scan path is actually reached), forcing
// `from_graph_scan` to be the resolution source. The resulting
// `consumer` entry must reference the suffixed `helper` tail.
#[test]
fn from_graph_scan_returns_full_dep_path_tail() {
// helper@1.0.0 has its own peer `core`. `consumer` peers on
// helper but has no entry for it in its `pkg.dependencies`,
// so the scan is the only resolution source.
let mut consumer = mk_locked("consumer", "1.0.0", &[], &[("helper", "^1")]);
consumer.dep_path = "consumer@1.0.0".to_string();
// `helper@1.0.0(core@1.0.0)` — already contextualized as it
// would be after one iteration of the fixed-point loop.
let mut helper = mk_locked("helper", "1.0.0", &[("core", "1.0.0")], &[("core", "^1")]);
helper.dep_path = "helper@1.0.0(core@1.0.0)".to_string();
let mut core = mk_locked("core", "1.0.0", &[], &[]);
core.dep_path = "core@1.0.0".to_string();
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("helper@1.0.0(core@1.0.0)".to_string(), helper);
packages.insert("core@1.0.0".to_string(), core);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = apply_peer_contexts(graph, &PeerContextOptions::default())
.expect("test graph should converge");
// consumer's key must reference helper with its CONTEXTUALIZED
// tail. Returning `p.version` would have produced
// `consumer@1.0.0(helper@1.0.0)` and then silently dropped
// `helper` from new_dependencies when the lookup missed.
assert!(
out.packages
.contains_key("consumer@1.0.0(helper@1.0.0(core@1.0.0))"),
"consumer must reference helper's contextualized tail: {:?}",
out.packages.keys().collect::<Vec<_>>()
);
// And consumer.new_dependencies[helper] must be a non-dangling
// reference into out_packages.
let consumer_out = out
.packages
.get("consumer@1.0.0(helper@1.0.0(core@1.0.0))")
.unwrap();
let helper_tail = consumer_out
.dependencies
.get("helper")
.expect("consumer must wire helper as a dep");
assert_eq!(helper_tail, "1.0.0(core@1.0.0)");
let helper_key = format!("helper@{helper_tail}");
assert!(
out.packages.contains_key(&helper_key),
"consumer.dependencies[helper] must resolve to an existing package key"
);
}
// `dedupe-peer-dependents=true` (the pnpm default) should collapse
// two importer dependents that peer on the same name and resolve
// to the same peer version into a single variant. Here two
// consumers (consumer-a, consumer-b) both peer on react and both
// end up with react@18.0.0 — the peer-context pass should emit a
// single canonical consumer-a key and a single canonical
// consumer-b key, but crucially when two *different ancestor
// subtrees* pin the same peer version we still collapse to one
// variant rather than keeping one per subtree.
#[test]
fn dedupe_peer_dependents_merges_equivalent_subtrees() {
// Two sibling middle packages that each peer on react. The
// importer has react@18.0.0 available, and the middle
// packages' shared declared peer range (^18) would match.
// Without dedupe-peer-dependents, the outer fixed-point loop
// can emit duplicate variants for the same peer resolution
// when the middle packages are reached via different sibling
// paths. With the flag on, `dedupe_peer_variants` merges them.
let mut consumer_a = mk_locked(
"consumer",
"1.0.0",
&[("react", "18.0.0")],
&[("react", "^18")],
);
consumer_a.dep_path = "consumer@1.0.0".to_string();
let react = mk_locked("react", "18.0.0", &[], &[]);
let mut packages = BTreeMap::new();
// Seed two peer-suffixed keys manually to simulate mid-fixpoint
// state where distinct subtrees produced the same peer
// resolution. The dedupe pass should merge them.
packages.insert(
"consumer@1.0.0(react@18.0.0)".to_string(),
LockedPackage {
dep_path: "consumer@1.0.0(react@18.0.0)".to_string(),
dependencies: {
let mut m = BTreeMap::new();
m.insert("react".to_string(), "18.0.0".to_string());
m
},
..consumer_a.clone()
},
);
// A second variant with identical peer resolution but a
// different suffix encoding — simulating a stale subtree from
// an earlier fixpoint iteration.
let mut variant = consumer_a.clone();
variant.dep_path = "consumer@1.0.0(react@18.0.0)(react@18.0.0)".to_string();
variant
.dependencies
.insert("react".to_string(), "18.0.0".to_string());
packages.insert(
"consumer@1.0.0(react@18.0.0)(react@18.0.0)".to_string(),
variant,
);
packages.insert("react@18.0.0".to_string(), react);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0(react@18.0.0)(react@18.0.0)".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = dedupe_peer_variants(graph);
// Only one consumer variant should survive — the
// lexicographically smallest key.
let consumer_keys: Vec<_> = out
.packages
.keys()
.filter(|k| k.starts_with("consumer@"))
.collect();
assert_eq!(
consumer_keys.len(),
1,
"expected single canonical consumer variant after dedupe, got: {:?}",
consumer_keys
);
assert_eq!(
consumer_keys[0], "consumer@1.0.0(react@18.0.0)",
"canonical should be lex-smallest key"
);
// Importer reference was rewritten to the canonical dep_path.
let root = out.importers.get(".").unwrap();
assert_eq!(root[0].dep_path, "consumer@1.0.0(react@18.0.0)");
}
// `dedupe-peer-dependents=false` should preserve every distinct
// peer-suffixed variant, even when they would merge under the
// default `true` setting. `apply_peer_contexts` is the only call
// gated by the flag, so the meaningful assertion is that calling
// `dedupe_peer_variants` explicitly merges the two variants, and
// skipping the call (the flag-off codepath) leaves both intact.
#[test]
fn dedupe_peer_dependents_disabled_keeps_variants() {
let consumer_a = mk_locked(
"consumer",
"1.0.0",
&[("react", "18.0.0")],
&[("react", "^18")],
);
let react = mk_locked("react", "18.0.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert(
"consumer@1.0.0(react@18.0.0)".to_string(),
LockedPackage {
dep_path: "consumer@1.0.0(react@18.0.0)".to_string(),
dependencies: {
let mut m = BTreeMap::new();
m.insert("react".to_string(), "18.0.0".to_string());
m
},
..consumer_a.clone()
},
);
let mut variant = consumer_a.clone();
variant.dep_path = "consumer@1.0.0(react@18.0.0)(react@18.0.0)".to_string();
variant
.dependencies
.insert("react".to_string(), "18.0.0".to_string());
packages.insert(
"consumer@1.0.0(react@18.0.0)(react@18.0.0)".to_string(),
variant,
);
packages.insert("react@18.0.0".to_string(), react);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0(react@18.0.0)".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
// Flag-off codepath: dedupe_peer_variants is never called,
// so both variants survive untouched.
let consumer_keys_off: Vec<_> = graph
.packages
.keys()
.filter(|k| k.starts_with("consumer@"))
.cloned()
.collect();
assert_eq!(
consumer_keys_off.len(),
2,
"expected both variants to survive with dedupe_peer_dependents=false, got: {:?}",
consumer_keys_off
);
// Flag-on codepath (for comparison): dedupe_peer_variants
// collapses the two peer-equivalent variants into one.
let merged = dedupe_peer_variants(graph);
let consumer_keys_on: Vec<_> = merged
.packages
.keys()
.filter(|k| k.starts_with("consumer@"))
.cloned()
.collect();
assert_eq!(
consumer_keys_on.len(),
1,
"expected single canonical variant with dedupe_peer_dependents=true, got: {:?}",
consumer_keys_on
);
}
// `dedupe-peers=true` should emit suffixes as `(version)` instead
// of `(name@version)`. The `parse_dep_path` function in
// aube-lockfile handles both forms (splits on the first `(`), so
// round-tripping the key still gives back the package name and
// canonical version.
#[test]
fn dedupe_peers_suffix_is_version_only() {
let mut consumer = mk_locked(
"consumer",
"1.0.0",
&[("react", "18.2.0")],
&[("react", "^18")],
);
consumer.dep_path = "consumer@1.0.0".to_string();
let react = mk_locked("react", "18.2.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("react@18.2.0".to_string(), react);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let options = PeerContextOptions {
dedupe_peers: true,
..PeerContextOptions::default()
};
let out = apply_peer_contexts(graph, &options).expect("test graph should converge");
// Suffix should be `(18.2.0)`, not `(react@18.2.0)`.
assert!(
out.packages.contains_key("consumer@1.0.0(18.2.0)"),
"expected version-only suffix: {:?}",
out.packages.keys().collect::<Vec<_>>()
);
assert!(
!out.packages.contains_key("consumer@1.0.0(react@18.2.0)"),
"name-based suffix should not appear under dedupe-peers=true"
);
}
// `resolve-peers-from-workspace-root=true` should satisfy an
// unresolved peer from the root importer's direct deps BEFORE the
// graph-wide scan tier. Fixture: workspace importer `packages/app`
// directly depends on `consumer`, which peers on react@>=17.
// `packages/app` itself has no react in its deps. Root importer
// pins `react@17.0.2`; the graph also contains `react@18.2.0`
// reachable via some other path. Because ancestor_scope for
// consumer is built from `packages/app`'s direct deps (NOT root's),
// react is missing from the ancestor chain — so only the
// root-tier and graph-scan tiers can satisfy it, and they resolve
// to different versions. Paired on/off assertions distinguish
// which tier ran.
#[test]
fn resolve_peers_from_workspace_root_prefers_root() {
let build_graph = || {
let mut consumer = mk_locked("consumer", "1.0.0", &[], &[("react", ">=17")]);
consumer.dep_path = "consumer@1.0.0".to_string();
let react17 = mk_locked("react", "17.0.2", &[], &[]);
let react18 = mk_locked("react", "18.2.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("react@17.0.2".to_string(), react17);
packages.insert("react@18.2.0".to_string(), react18);
let mut importers = BTreeMap::new();
// Root importer: pins react@17.0.2. Feeds root_scope.
importers.insert(
".".to_string(),
vec![DirectDep {
name: "react".to_string(),
dep_path: "react@17.0.2".to_string(),
dep_type: DepType::Production,
specifier: Some("^17".to_string()),
}],
);
// Workspace importer: depends on consumer, but does NOT
// have react in its own direct deps. Consumer's
// ancestor_scope therefore does not include react, forcing
// peer resolution down to the root-or-scan tiers.
importers.insert(
"packages/app".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
LockfileGraph {
importers,
packages,
..Default::default()
}
};
let options_on = PeerContextOptions {
resolve_from_workspace_root: true,
..PeerContextOptions::default()
};
let out_on =
apply_peer_contexts(build_graph(), &options_on).expect("test graph should converge");
assert!(
out_on.packages.contains_key("consumer@1.0.0(react@17.0.2)"),
"with flag on, consumer should resolve peer from workspace root (17.0.2): {:?}",
out_on.packages.keys().collect::<Vec<_>>()
);
let options_off = PeerContextOptions {
resolve_from_workspace_root: false,
..PeerContextOptions::default()
};
let out_off =
apply_peer_contexts(build_graph(), &options_off).expect("test graph should converge");
assert!(
out_off
.packages
.contains_key("consumer@1.0.0(react@18.2.0)"),
"with flag off, consumer should fall through to graph-wide scan (18.2.0): {:?}",
out_off.packages.keys().collect::<Vec<_>>()
);
}
// Mutual-peer cycle fixture with `dedupe-peers=true` should still
// converge without hitting MAX_ITERATIONS. The cycle-break
// handling in `contains_canonical_back_ref` uses the `name@version`
// form of the canonical base, but when `dedupe_peers=true` the
// suffix uses just `version` — the check still succeeds because
// nested tails reach back to the same `canonical_base` computed
// from the input key (which is still `name@version`).
#[test]
fn dedupe_peers_cycle_break_still_converges() {
let a = mk_locked("a", "1.0.0", &[("b", "1.0.0")], &[("b", "^1")]);
let b = mk_locked("b", "1.0.0", &[("a", "1.0.0")], &[("a", "^1")]);
let mut packages = BTreeMap::new();
packages.insert("a@1.0.0".to_string(), a);
packages.insert("b@1.0.0".to_string(), b);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "a".to_string(),
dep_path: "a@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let canonical = LockfileGraph {
importers,
packages,
..Default::default()
};
let options = PeerContextOptions {
dedupe_peers: true,
..PeerContextOptions::default()
};
let out = apply_peer_contexts(canonical, &options).expect("test graph should converge");
// Under dedupe_peers=true the keys collapse to version-only
// suffixes.
let a_key = "a@1.0.0(1.0.0)";
let b_key = "b@1.0.0(1.0.0)";
assert!(
out.packages.contains_key(a_key),
"expected {a_key} in {:?}",
out.packages.keys().collect::<Vec<_>>()
);
assert!(
out.packages.contains_key(b_key),
"expected {b_key} in {:?}",
out.packages.keys().collect::<Vec<_>>()
);
// Every referenced dependency tail resolves to a real entry
// — proves the cycle break didn't strand references.
for pkg in out.packages.values() {
for (child_name, child_tail) in &pkg.dependencies {
let child_key = format!("{child_name}@{child_tail}");
assert!(
out.packages.contains_key(&child_key),
"dangling dep_path {child_key} referenced from {}",
pkg.dep_path
);
}
}
}
// Regression: under `dedupe-peers=true`, a package whose canonical
// version coincidentally matches a nested peer's version in an
// unrelated subtree must NOT collide. Cycle detection runs against
// the full `name@version` form during the fixed-point loop, and
// `dedupe_peer_suffixes` rewrites the suffix to version-only as a
// purely cosmetic post-pass — so A@1.0.0's cycle check against
// B's tail `2.0.0(c@1.0.0)` distinguishes "C at 1.0.0" from
// "back-ref to A at 1.0.0".
#[test]
fn dedupe_peers_no_false_positive_on_version_collision() {
// A@1.0.0 peers on B. B@2.0.0 peers on C. C@1.0.0 has no peers.
// A and C share version 1.0.0 but are otherwise unrelated.
// Under `dedupe_peers=true` B's deduped tail is `(2.0.0(1.0.0))`
// — the inner `1.0.0` is C's peer, not a back-ref to A.
let a = mk_locked("a", "1.0.0", &[("b", "2.0.0")], &[("b", "^2")]);
let b = mk_locked("b", "2.0.0", &[("c", "1.0.0")], &[("c", "^1")]);
let c = mk_locked("c", "1.0.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("a@1.0.0".to_string(), a);
packages.insert("b@2.0.0".to_string(), b);
packages.insert("c@1.0.0".to_string(), c);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "a".to_string(),
dep_path: "a@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let options = PeerContextOptions {
dedupe_peers: true,
..PeerContextOptions::default()
};
let out = apply_peer_contexts(graph, &options).expect("test graph should converge");
// A's key must carry B's full nested tail including C's peer.
// If cycle detection false-positived on the bare version, B's
// tail would collapse to `(2.0.0)` (dropping `(1.0.0)`) and
// we'd see `a@1.0.0(2.0.0)` instead.
assert!(
out.packages.contains_key("a@1.0.0(2.0.0(1.0.0))"),
"expected A's key to preserve B's nested peer chain: {:?}",
out.packages.keys().collect::<Vec<_>>()
);
assert!(
!out.packages.contains_key("a@1.0.0(2.0.0)"),
"false-positive cycle break would produce the truncated form"
);
}
// Unit test for the dedupe-peers post-pass: given a key with
// `name@version` suffix segments, produce the version-only form.
#[test]
fn apply_dedupe_peers_to_key_strips_names_in_suffix() {
assert_eq!(
apply_dedupe_peers_to_key("react-dom@18.2.0(react@18.2.0)"),
"react-dom@18.2.0(18.2.0)"
);
assert_eq!(
apply_dedupe_peers_to_key("a@1.0.0(b@2.0.0(c@3.0.0))"),
"a@1.0.0(2.0.0(3.0.0))"
);
// No parens = no change.
assert_eq!(apply_dedupe_peers_to_key("react@18.2.0"), "react@18.2.0");
// Already deduped (no `name@` inside parens) = no change.
assert_eq!(
apply_dedupe_peers_to_key("a@1.0.0(18.2.0)"),
"a@1.0.0(18.2.0)"
);
}
// Regression: two peer-variant keys that differ only in which peer
// NAME they declared (but whose peer versions coincide) must not
// silently collapse into each other when `dedupe_peers=true`.
// `apply_dedupe_peers_to_key` strips peer names, so naive insertion
// into a `BTreeMap` would drop one variant. `dedupe_peer_suffixes`
// detects the collision and keeps both sides in full form.
#[test]
fn dedupe_peer_suffixes_preserves_full_form_on_name_collision() {
// Construct two distinct variants that would collide after
// naive suffix rewriting:
// consumer@1.0.0(foo@1.0.0) and consumer@1.0.0(bar@1.0.0)
let consumer_foo = {
let mut pkg = mk_locked("consumer", "1.0.0", &[("foo", "1.0.0")], &[("foo", "^1")]);
pkg.dep_path = "consumer@1.0.0(foo@1.0.0)".to_string();
pkg
};
let consumer_bar = {
let mut pkg = mk_locked("consumer", "1.0.0", &[("bar", "1.0.0")], &[("bar", "^1")]);
pkg.dep_path = "consumer@1.0.0(bar@1.0.0)".to_string();
pkg
};
let foo = mk_locked("foo", "1.0.0", &[], &[]);
let bar = mk_locked("bar", "1.0.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0(foo@1.0.0)".to_string(), consumer_foo);
packages.insert("consumer@1.0.0(bar@1.0.0)".to_string(), consumer_bar);
packages.insert("foo@1.0.0".to_string(), foo);
packages.insert("bar@1.0.0".to_string(), bar);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![
DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0(foo@1.0.0)".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
},
DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0(bar@1.0.0)".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
},
],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = dedupe_peer_suffixes(graph);
// Both variants must survive: colliding keys fall back to the
// original full-form keys instead of silently overwriting each
// other.
let consumer_keys: BTreeSet<_> = out
.packages
.keys()
.filter(|k| k.starts_with("consumer@"))
.cloned()
.collect();
assert_eq!(
consumer_keys.len(),
2,
"both consumer variants must survive collision: {consumer_keys:?}"
);
assert!(consumer_keys.contains("consumer@1.0.0(foo@1.0.0)"));
assert!(consumer_keys.contains("consumer@1.0.0(bar@1.0.0)"));
// Importer references to the full-form keys must stay pointing
// at the preserved variants.
let importer_keys: BTreeSet<_> = out
.importers
.get(".")
.unwrap()
.iter()
.map(|d| d.dep_path.clone())
.collect();
assert!(importer_keys.contains("consumer@1.0.0(foo@1.0.0)"));
assert!(importer_keys.contains("consumer@1.0.0(bar@1.0.0)"));
}
// Scoped packages have two `@` chars (scope prefix + version
// separator); the version separator is the rightmost one, so the
// suffix-stripper must use `rfind('@')`. Regression for a bug
// where `find('@')` returned the scope's leading `@` and produced
// malformed keys like `(types/react@18.2.0)`.
#[test]
fn apply_dedupe_peers_to_key_handles_scoped_packages() {
assert_eq!(
apply_dedupe_peers_to_key("consumer@1.0.0(@types/react@18.2.0)"),
"consumer@1.0.0(18.2.0)"
);
// Scoped head and scoped peer.
assert_eq!(
apply_dedupe_peers_to_key("@foo/bar@1.0.0(@types/react@18.2.0)"),
"@foo/bar@1.0.0(18.2.0)"
);
// Nested scoped peers.
assert_eq!(
apply_dedupe_peers_to_key("a@1.0.0(@types/react@18.2.0(@babel/core@7.0.0))"),
"a@1.0.0(18.2.0(7.0.0))"
);
}
// Cycle helper sanity check: a value that contains the canonical
// back-ref should be recognized only at proper boundaries, not
// inside longer version strings.
#[test]
fn contains_canonical_back_ref_respects_boundaries() {
assert!(contains_canonical_back_ref("1.0.0(a@1.0.0)", "a@1.0.0"));
assert!(contains_canonical_back_ref(
"1.0.0(a@1.0.0(b@1.0.0))",
"a@1.0.0"
));
// False positive guard: "a@1.0" should NOT match inside
// "a@1.0.5" because the following char ('.') is not a boundary.
assert!(!contains_canonical_back_ref("1.0.0(a@1.0.5)", "a@1.0"));
// No match when the canonical isn't inside a peer suffix at all.
assert!(!contains_canonical_back_ref("1.0.0", "a@1.0.0"));
}
// A package whose only dep is another package that declares a peer
// should hoist that peer to the importer — matching pnpm's
// `auto-install-peers=true` default. The hoisted DirectDep carries
// the declared peer range as its specifier.
#[test]
fn hoist_auto_installed_peers_hoists_unmet_peers_to_importer() {
// consumer declares `peer react: ^17 || ^18` and already has
// `react@18.2.0` wired via its auto-install dependencies map.
let mut consumer = mk_locked(
"consumer",
"1.0.0",
&[("react", "18.2.0")],
&[("react", "^17 || ^18")],
);
consumer.dep_path = "consumer@1.0.0".to_string();
let react = mk_locked("react", "18.2.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("react@18.2.0".to_string(), react);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let hoisted = hoist_auto_installed_peers(graph);
let root = hoisted.importers.get(".").unwrap();
// Sorted by name → [consumer, react].
assert_eq!(root.len(), 2);
assert_eq!(root[0].name, "consumer");
assert_eq!(root[1].name, "react");
assert_eq!(root[1].dep_path, "react@18.2.0");
assert_eq!(root[1].dep_type, DepType::Production);
// Specifier carries the declared peer range verbatim.
assert_eq!(root[1].specifier.as_deref(), Some("^17 || ^18"));
}
// Peers declared by transitive dependencies are still resolved and
// sibling-linked by the peer-context pass, but pnpm does not expose
// them as root importer deps or top-level node_modules entries.
#[test]
fn hoist_auto_installed_peers_does_not_hoist_transitive_peers_to_importer() {
let parent = mk_locked("parent", "1.0.0", &[("consumer", "1.0.0")], &[]);
let consumer = mk_locked(
"consumer",
"1.0.0",
&[("react", "18.2.0")],
&[("react", "^17 || ^18")],
);
let react = mk_locked("react", "18.2.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("parent@1.0.0".to_string(), parent);
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("react@18.2.0".to_string(), react);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "parent".to_string(),
dep_path: "parent@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let hoisted = hoist_auto_installed_peers(graph);
let root = hoisted.importers.get(".").unwrap();
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "parent");
}
// pnpm 11 keeps peers of auto-installed peers contextual to the
// virtual store. If direct dep `consumer` peers on `plugin`, and the
// auto-installed `plugin` peers on `host`, only `plugin` becomes an
// importer dep; `host` is wired later by `apply_peer_contexts`.
#[test]
fn hoist_auto_installed_peers_does_not_hoist_auto_peer_peers_to_importer() {
let consumer = mk_locked(
"consumer",
"1.0.0",
&[("plugin", "2.0.0")],
&[("plugin", "^2")],
);
let plugin = mk_locked("plugin", "2.0.0", &[("host", "3.0.0")], &[("host", "^3")]);
let host = mk_locked("host", "3.0.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("plugin@2.0.0".to_string(), plugin);
packages.insert("host@3.0.0".to_string(), host);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let hoisted = hoist_auto_installed_peers(graph);
let root = hoisted.importers.get(".").unwrap();
assert_eq!(root.len(), 2);
assert_eq!(root[0].name, "consumer");
assert_eq!(root[1].name, "plugin");
assert_eq!(root[1].dep_path, "plugin@2.0.0");
}
// If the peer is already in the importer's direct deps, hoist is a
// no-op — we don't duplicate or shadow the user's own specifier.
#[test]
fn hoist_auto_installed_peers_leaves_already_satisfied_peers_alone() {
let consumer = mk_locked(
"consumer",
"1.0.0",
&[("react", "17.0.2")],
&[("react", "^17 || ^18")],
);
let react = mk_locked("react", "17.0.2", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("react@17.0.2".to_string(), react);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![
DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
},
DirectDep {
name: "react".to_string(),
dep_path: "react@17.0.2".to_string(),
dep_type: DepType::Production,
specifier: Some("17.0.2".to_string()),
},
],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let hoisted = hoist_auto_installed_peers(graph);
let root = hoisted.importers.get(".").unwrap();
// Still just the two original entries — no extra react snuck in.
assert_eq!(root.len(), 2);
let react_dep = root.iter().find(|d| d.name == "react").unwrap();
// The user's own pin (17.0.2) survives — not clobbered by the
// peer range.
assert_eq!(react_dep.specifier.as_deref(), Some("17.0.2"));
}
// `detect_unmet_peers` should flag a package whose declared peer
// range isn't satisfied by whatever the graph ends up providing.
// This is the core case: user pins `react@15.7.0`, a consumer
// declares `peer react: ^18`, and we need a warning so the user
// knows their runtime will break.
#[test]
fn detect_unmet_peers_flags_version_mismatch() {
let mut consumer = mk_locked(
"consumer",
"1.0.0",
&[("react", "15.7.0")],
&[("react", "^18")],
);
consumer.dep_path = "consumer@1.0.0(react@15.7.0)".to_string();
let mut packages = BTreeMap::new();
packages.insert(consumer.dep_path.clone(), consumer);
let graph = LockfileGraph {
importers: BTreeMap::new(),
packages,
..Default::default()
};
let unmet = detect_unmet_peers(&graph);
assert_eq!(unmet.len(), 1, "expected one unmet peer, got {unmet:?}");
let u = &unmet[0];
assert_eq!(u.from_name, "consumer");
assert_eq!(u.peer_name, "react");
assert_eq!(u.declared, "^18");
assert_eq!(u.found.as_deref(), Some("15.7.0"));
}
// When the resolved version *does* satisfy the declared range, no
// warning should fire.
#[test]
fn detect_unmet_peers_silent_when_satisfied() {
let mut consumer = mk_locked(
"consumer",
"1.0.0",
&[("react", "18.2.0")],
&[("react", "^17 || ^18")],
);
consumer.dep_path = "consumer@1.0.0(react@18.2.0)".to_string();
let mut packages = BTreeMap::new();
packages.insert(consumer.dep_path.clone(), consumer);
let graph = LockfileGraph {
importers: BTreeMap::new(),
packages,
..Default::default()
};
assert!(detect_unmet_peers(&graph).is_empty());
}
// Peer declared but completely absent from `pkg.dependencies` —
// exercises the `found: None` branch that drives the "missing
// required peer" display path in `check_unmet_peers`. Rare in
// practice because the BFS peer walk usually drags *some* version
// in, but possible for corner cases (registry fetch failure, etc).
#[test]
fn detect_unmet_peers_flags_completely_missing_peer() {
let mut consumer = mk_locked("consumer", "1.0.0", &[], &[("react", "^18")]);
consumer.dep_path = "consumer@1.0.0".to_string();
let mut packages = BTreeMap::new();
packages.insert(consumer.dep_path.clone(), consumer);
let graph = LockfileGraph {
importers: BTreeMap::new(),
packages,
..Default::default()
};
let unmet = detect_unmet_peers(&graph);
assert_eq!(unmet.len(), 1);
let u = &unmet[0];
assert_eq!(u.from_name, "consumer");
assert_eq!(u.peer_name, "react");
assert_eq!(u.declared, "^18");
assert_eq!(u.found, None);
}
// Optional peers are suppressed even when they would otherwise be
// flagged — matches pnpm's `peerDependenciesMeta.optional` behavior
// with `auto-install-peers=true`.
#[test]
fn detect_unmet_peers_skips_optional_peers() {
let mut consumer = mk_locked(
"consumer",
"1.0.0",
&[("react", "15.7.0")],
&[("react", "^18")],
);
consumer.dep_path = "consumer@1.0.0(react@15.7.0)".to_string();
consumer.peer_dependencies_meta.insert(
"react".to_string(),
aube_lockfile::PeerDepMeta { optional: true },
);
let mut packages = BTreeMap::new();
packages.insert(consumer.dep_path.clone(), consumer);
let graph = LockfileGraph {
importers: BTreeMap::new(),
packages,
..Default::default()
};
assert!(detect_unmet_peers(&graph).is_empty());
}
// Mutual dependency cycles must not hang the BFS resolver. The
// walker dedupes on `name@version`, so the second time the cycle
// brings us back to a package we already resolved, we wire the
// parent edge but skip recursing into its transitives.
//
// cycle-a@1.0.0 -> cycle-b@1.0.0
// cycle-b@1.0.0 -> cycle-a@1.0.0
#[tokio::test]
async fn resolve_terminates_on_dependency_cycle() {
let mut a = make_packument("cycle-a", &["1.0.0"], "1.0.0");
a.versions
.get_mut("1.0.0")
.unwrap()
.dependencies
.insert("cycle-b".to_string(), "1.0.0".to_string());
let mut b = make_packument("cycle-b", &["1.0.0"], "1.0.0");
b.versions
.get_mut("1.0.0")
.unwrap()
.dependencies
.insert("cycle-a".to_string(), "1.0.0".to_string());
// The RegistryClient is never hit because we pre-seed the cache.
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
resolver.cache.insert("cycle-a".to_string(), a);
resolver.cache.insert("cycle-b".to_string(), b);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("cycle-a".to_string(), "1.0.0".to_string());
let graph = tokio::time::timeout(
std::time::Duration::from_secs(5),
resolver.resolve(&manifest, None),
)
.await
.expect("resolver hung on dependency cycle")
.expect("resolve failed");
assert!(graph.packages.contains_key("cycle-a@1.0.0"));
assert!(graph.packages.contains_key("cycle-b@1.0.0"));
assert_eq!(
graph.packages["cycle-a@1.0.0"].dependencies.get("cycle-b"),
Some(&"1.0.0".to_string())
);
assert_eq!(
graph.packages["cycle-b@1.0.0"].dependencies.get("cycle-a"),
Some(&"1.0.0".to_string())
);
}
#[tokio::test]
async fn auto_install_peers_installs_missing_required_peer() {
let mut consumer = make_packument("consumer", &["1.0.0"], "1.0.0");
consumer
.versions
.get_mut("1.0.0")
.unwrap()
.peer_dependencies
.insert("react".to_string(), "^18".to_string());
let react = make_packument("react", &["18.2.0"], "18.2.0");
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
resolver.cache.insert("consumer".to_string(), consumer);
resolver.cache.insert("react".to_string(), react);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("consumer".to_string(), "1.0.0".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("resolve failed");
assert!(graph_has_package(&graph, "consumer", "1.0.0"));
assert!(
graph_has_package(&graph, "react", "18.2.0"),
"missing required peer should be auto-installed"
);
}
#[tokio::test]
async fn auto_install_peers_uses_importer_declared_peer_name_without_extra_version() {
let mut plugin = make_packument("plugin", &["1.0.0"], "1.0.0");
plugin
.versions
.get_mut("1.0.0")
.unwrap()
.peer_dependencies
.insert("eslint".to_string(), "^8.56.0".to_string());
let eslint = make_packument("eslint", &["8.57.1", "9.0.0"], "9.0.0");
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
resolver.cache.insert("plugin".to_string(), plugin);
resolver.cache.insert("eslint".to_string(), eslint);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("eslint".to_string(), "^9".to_string());
manifest
.dependencies
.insert("plugin".to_string(), "1.0.0".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("resolve failed");
assert!(graph_has_package(&graph, "eslint", "9.0.0"));
assert!(graph_has_package(&graph, "plugin", "1.0.0"));
assert!(
!graph_has_package(&graph, "eslint", "8.57.1"),
"importer-declared peer name should not pull a second compatible peer tree"
);
let unmet = detect_unmet_peers(&graph);
assert!(
unmet.iter().any(|unmet| unmet.from_name == "plugin"
&& unmet.peer_name == "eslint"
&& unmet.declared == "^8.56.0"
&& unmet.found.as_deref() == Some("9.0.0")),
"incompatible importer peer should surface as a version-mismatch warning"
);
}
#[tokio::test]
async fn auto_install_peers_skips_unrequested_optional_peer_alternatives() {
let mut loader = make_packument("loader", &["1.0.0"], "1.0.0");
let loader_meta = loader.versions.get_mut("1.0.0").unwrap();
loader_meta
.peer_dependencies
.insert("sass".to_string(), "^1".to_string());
loader_meta
.peer_dependencies
.insert("webpack".to_string(), "^5".to_string());
loader_meta
.peer_dependencies
.insert("@rspack/core".to_string(), "^1".to_string());
loader_meta
.peer_dependencies
.insert("node-sass".to_string(), "^9".to_string());
loader_meta.peer_dependencies_meta.insert(
"@rspack/core".to_string(),
aube_registry::PeerDepMeta { optional: true },
);
loader_meta.peer_dependencies_meta.insert(
"node-sass".to_string(),
aube_registry::PeerDepMeta { optional: true },
);
let sass = make_packument("sass", &["1.69.0"], "1.69.0");
let webpack = make_packument("webpack", &["5.0.0"], "5.0.0");
let rspack = make_packument("@rspack/core", &["1.0.0"], "1.0.0");
let node_sass = make_packument("node-sass", &["9.0.0"], "9.0.0");
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
resolver.cache.insert("loader".to_string(), loader);
resolver.cache.insert("sass".to_string(), sass);
resolver.cache.insert("webpack".to_string(), webpack);
resolver.cache.insert("@rspack/core".to_string(), rspack);
resolver.cache.insert("node-sass".to_string(), node_sass);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("loader".to_string(), "1.0.0".to_string());
manifest
.dependencies
.insert("sass".to_string(), "^1".to_string());
manifest
.dependencies
.insert("webpack".to_string(), "^5".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("resolve failed");
assert!(graph_has_package(&graph, "loader", "1.0.0"));
assert!(graph_has_package(&graph, "sass", "1.69.0"));
assert!(graph_has_package(&graph, "webpack", "5.0.0"));
assert!(
!graph_has_package(&graph, "@rspack/core", "1.0.0"),
"optional peer alternative should not be auto-installed"
);
assert!(
!graph_has_package(&graph, "node-sass", "9.0.0"),
"optional peer alternative should not be auto-installed"
);
}
// Scenario test for the bug Cursor Bugbot flagged on #142:
// lockfile has `dep-a@1.0.0`; manifest wants both `dep-a@^1`
// (matches lockfile) AND `other-a@^2` (fresh); `other-a@2.0.0`
// declares a transitive `dep-a@^2` that no lockfile entry
// satisfies.
//
// Correct behavior: resolver picks dep-a@1.0.0 for the direct
// dep (via lockfile reuse) and dep-a@2.0.0 for the transitive
// (via the fetch path).
//
// The original bug: `ensure_fetch!` wrongly skipped the spawn
// when `resolved_versions[dep-a]` was non-empty, regardless of
// whether the packument was actually in `self.cache`. The
// lockfile-reuse path populates `resolved_versions` without
// ever caching the packument, so the transitive dep-a@^2 task
// fell through to the fetch-wait loop, called `ensure_fetch!`,
// got skipped, and panicked with "packument fetch disappeared
// before completing". The fix removes the `resolved_versions`
// guard from `ensure_fetch!` — the macro now checks only
// in-flight + cache, and prefetch gating on lockfile-covered
// names is done by callers via an explicit `existing_names`
// check.
//
// Note: this test pre-seeds the resolver cache with both
// packuments, so the wait-for-fetch loop exits immediately
// without actually calling `ensure_fetch!` — which means the
// test passes with or without the fix. It's kept as an
// end-to-end scenario assertion (resolver produces the
// expected two-version graph) rather than a direct regression
// test for the `ensure_fetch!` bug itself. Triggering the
// actual bug requires a real registry mock that returns the
// packument during the wait loop, which the unit-test harness
// doesn't have; the BATS suite covers the end-to-end path
// through a local Verdaccio registry.
#[tokio::test]
async fn resolve_handles_lockfile_reused_name_with_incompatible_transitive_range() {
// Packument for `dep-a` has both a 1.x and a 2.x line; only
// 1.0.0 is in the (fake) lockfile, so the fetch path has to
// cover the 2.x case.
let dep_a = make_packument("dep-a", &["1.0.0", "2.0.0"], "2.0.0");
// `other-a@2.0.0` is the package that triggers the
// transitive `dep-a@^2` task.
let mut other_a = make_packument("other-a", &["2.0.0"], "2.0.0");
other_a
.versions
.get_mut("2.0.0")
.unwrap()
.dependencies
.insert("dep-a".to_string(), "^2".to_string());
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
// Pre-seed the in-memory packument cache so the resolver
// never needs to touch the fake registry URL.
resolver.cache.insert("dep-a".to_string(), dep_a);
resolver.cache.insert("other-a".to_string(), other_a);
// Existing lockfile: has `dep-a@1.0.0` (the lockfile-reuse
// hit) but nothing else. `other-a@^2` is a fresh dep that
// won't lockfile-reuse.
let mut existing_pkgs: BTreeMap<String, LockedPackage> = BTreeMap::new();
existing_pkgs.insert(
"dep-a@1.0.0".to_string(),
LockedPackage {
name: "dep-a".to_string(),
version: "1.0.0".to_string(),
dep_path: "dep-a@1.0.0".to_string(),
..Default::default()
},
);
let existing = LockfileGraph {
packages: existing_pkgs,
importers: BTreeMap::new(),
settings: Default::default(),
overrides: BTreeMap::new(),
ignored_optional_dependencies: BTreeSet::new(),
times: BTreeMap::new(),
skipped_optional_dependencies: BTreeMap::new(),
catalogs: BTreeMap::new(),
bun_config_version: None,
patched_dependencies: BTreeMap::new(),
trusted_dependencies: Vec::new(),
extra_fields: BTreeMap::new(),
workspace_extra_fields: BTreeMap::new(),
};
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("dep-a".to_string(), "^1".to_string());
manifest
.dependencies
.insert("other-a".to_string(), "^2".to_string());
let graph = tokio::time::timeout(
std::time::Duration::from_secs(5),
resolver.resolve(&manifest, Some(&existing)),
)
.await
.expect("resolver hung")
.expect("resolve failed");
// Both versions of dep-a should be in the resolved graph:
// 1.0.0 from lockfile-reuse, 2.0.0 from the fetch path.
assert!(
graph.packages.contains_key("dep-a@1.0.0"),
"dep-a@1.0.0 missing (lockfile reuse)"
);
assert!(
graph.packages.contains_key("dep-a@2.0.0"),
"dep-a@2.0.0 missing (transitive fetch fell through the ensure_fetch guard)"
);
assert!(graph.packages.contains_key("other-a@2.0.0"));
}
#[tokio::test]
async fn lockfile_reuse_preserves_transitive_optional_edges() {
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
let mut existing_pkgs: BTreeMap<String, LockedPackage> = BTreeMap::new();
existing_pkgs.insert(
"host@1.0.0".to_string(),
LockedPackage {
name: "host".to_string(),
version: "1.0.0".to_string(),
dep_path: "host@1.0.0".to_string(),
dependencies: [("native".to_string(), "1.0.0".to_string())].into(),
optional_dependencies: [("native".to_string(), "1.0.0".to_string())].into(),
..Default::default()
},
);
existing_pkgs.insert(
"native@1.0.0".to_string(),
LockedPackage {
name: "native".to_string(),
version: "1.0.0".to_string(),
dep_path: "native@1.0.0".to_string(),
..Default::default()
},
);
let existing = LockfileGraph {
packages: existing_pkgs,
importers: BTreeMap::new(),
settings: Default::default(),
overrides: BTreeMap::new(),
ignored_optional_dependencies: BTreeSet::new(),
times: BTreeMap::new(),
skipped_optional_dependencies: BTreeMap::new(),
catalogs: BTreeMap::new(),
bun_config_version: None,
patched_dependencies: BTreeMap::new(),
trusted_dependencies: Vec::new(),
extra_fields: BTreeMap::new(),
workspace_extra_fields: BTreeMap::new(),
};
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("host".to_string(), "1.0.0".to_string());
let graph = resolver
.resolve(&manifest, Some(&existing))
.await
.expect("resolve failed");
let host = graph.packages.get("host@1.0.0").unwrap();
assert_eq!(host.dependencies.get("native").unwrap(), "1.0.0");
assert_eq!(
host.optional_dependencies.get("native").unwrap(),
"1.0.0",
"lockfile reuse must keep the optional edge metadata for write()"
);
}
// Bun and yarn parsers store transitive deps in `pkg.dependencies`
// using the full dep_path form (`is-number@6.0.0`), while pnpm uses
// bare versions (`6.0.0`). The resolver's lockfile-reuse path
// previously used the dep value verbatim as a semver range, which
// hard-failed on bun/yarn lockfiles with a malformed range like
// `is-number@6.0.0`. Strip the `name@` prefix before treating the
// value as a range.
#[tokio::test]
async fn lockfile_reuse_handles_name_at_version_dep_form() {
let is_number = make_packument("is-number", &["6.0.0", "7.0.0"], "7.0.0");
let mut is_odd = make_packument("is-odd", &["3.0.1"], "3.0.1");
is_odd
.versions
.get_mut("3.0.1")
.unwrap()
.dependencies
.insert("is-number".to_string(), "^6.0.0".to_string());
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
resolver.cache.insert("is-number".to_string(), is_number);
resolver.cache.insert("is-odd".to_string(), is_odd);
// Mimic the bun/yarn parser: `dependencies` value is the full
// dep_path, not a bare version.
let mut existing_pkgs: BTreeMap<String, LockedPackage> = BTreeMap::new();
existing_pkgs.insert(
"is-odd@3.0.1".to_string(),
LockedPackage {
name: "is-odd".to_string(),
version: "3.0.1".to_string(),
dep_path: "is-odd@3.0.1".to_string(),
dependencies: [("is-number".to_string(), "is-number@6.0.0".to_string())].into(),
..Default::default()
},
);
existing_pkgs.insert(
"is-number@6.0.0".to_string(),
LockedPackage {
name: "is-number".to_string(),
version: "6.0.0".to_string(),
dep_path: "is-number@6.0.0".to_string(),
..Default::default()
},
);
let existing = LockfileGraph {
packages: existing_pkgs,
..Default::default()
};
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("is-odd".to_string(), "3.0.1".to_string());
let graph = resolver
.resolve(&manifest, Some(&existing))
.await
.expect("resolve failed");
assert!(graph_has_package(&graph, "is-odd", "3.0.1"));
assert!(
graph_has_package(&graph, "is-number", "6.0.0"),
"transitive must reuse the locked 6.0.0, not fail or pick 7.0.0"
);
}
// ===== peersSuffixMaxLength =====
//
// Helpers exercised directly: `hash_peer_suffix` for the format
// invariant; `apply_peer_contexts` for the integration path that
// reads the cap and decides whether to swap the suffix.
#[test]
fn hash_peer_suffix_matches_expected_format() {
let out = hash_peer_suffix("(react@18.2.0)");
// `_` prefix, 10 hex chars, nothing else.
assert!(out.starts_with('_'), "expected `_` prefix: {out:?}");
assert_eq!(out.len(), 11, "expected `_` + 10 hex chars: {out:?}");
assert!(
out[1..]
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"expected lowercase hex after `_`: {out:?}"
);
// Stable output — regression guard against accidental format changes.
assert_eq!(hash_peer_suffix("(react@18.2.0)"), out);
}
// Small cap forces the suffix to collapse to `_<hex>`. Uses the
// nested-peer fixture that already proves correct behavior at the
// default cap — same fixture, different cap, different output.
#[test]
fn peer_suffix_is_hashed_when_exceeding_cap() {
let mut consumer = mk_locked(
"consumer",
"1.0.0",
&[("adapter", "1.0.0"), ("core", "1.0.0")],
&[("adapter", "^1"), ("core", "^1")],
);
consumer.dep_path = "consumer@1.0.0".to_string();
let mut adapter = mk_locked("adapter", "1.0.0", &[("core", "1.0.0")], &[("core", "^1")]);
adapter.dep_path = "adapter@1.0.0".to_string();
let core = mk_locked("core", "1.0.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("adapter@1.0.0".to_string(), adapter);
packages.insert("core@1.0.0".to_string(), core);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
// Cap of 10 bytes is smaller than any realistic suffix.
let options = PeerContextOptions {
peers_suffix_max_length: 10,
..PeerContextOptions::default()
};
let out = apply_peer_contexts(graph, &options).expect("test graph should converge");
// At least one package should have a hashed suffix. The outer
// `consumer` package is the one most likely to overflow (nested
// suffix `(adapter@1.0.0(core@1.0.0))(core@1.0.0)` = 42 bytes).
let consumer_key = out
.packages
.keys()
.find(|k| k.starts_with("consumer@1.0.0"))
.cloned()
.expect("consumer@1.0.0 variant missing");
let suffix = consumer_key.strip_prefix("consumer@1.0.0").unwrap();
assert!(
suffix.starts_with('_') && suffix.len() == 11,
"expected hashed suffix _<10-hex>, got {suffix:?} from {consumer_key:?}"
);
}
// Default cap leaves the nested form byte-identical to pre-cap output.
// Regression guard: the wiring must not change behavior when the cap
// isn't hit — which is the overwhelmingly common case.
#[test]
fn peer_suffix_unchanged_when_within_cap() {
let mut consumer = mk_locked(
"consumer",
"1.0.0",
&[("adapter", "1.0.0"), ("core", "1.0.0")],
&[("adapter", "^1"), ("core", "^1")],
);
consumer.dep_path = "consumer@1.0.0".to_string();
let mut adapter = mk_locked("adapter", "1.0.0", &[("core", "1.0.0")], &[("core", "^1")]);
adapter.dep_path = "adapter@1.0.0".to_string();
let core = mk_locked("core", "1.0.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("adapter@1.0.0".to_string(), adapter);
packages.insert("core@1.0.0".to_string(), core);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = apply_peer_contexts(graph, &PeerContextOptions::default())
.expect("test graph should converge");
// The nested-peer test's expected key must still be produced.
assert!(
out.packages
.contains_key("consumer@1.0.0(adapter@1.0.0(core@1.0.0))(core@1.0.0)"),
"default cap corrupted output: {:?}",
out.packages.keys().collect::<Vec<_>>()
);
}
// Fresh resolve: when the root manifest carries
// `"odd-alias": "npm:is-odd@3.0.1"`, the resolver must emit the
// graph keyed by the *alias* and stash the real registry name in
// `alias_of`. Before this fix, `task.name` was clobbered to
// `is-odd` at the `npm:` rewrite site, which collapsed
// `node_modules/odd-alias/` to `node_modules/is-odd/` and broke
// `require("odd-alias")` at runtime.
#[tokio::test]
async fn fresh_resolve_preserves_npm_alias_as_folder_name() {
let is_odd = make_packument("is-odd", &["3.0.1"], "3.0.1");
// Pre-seed the cache under the *real* package name — the
// whole point of the fix is that the registry fetch keys by
// the real name (`is-odd`), not the alias-qualified
// `odd-alias` that would 404 the registry.
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
resolver.cache.insert("is-odd".to_string(), is_odd);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("odd-alias".to_string(), "npm:is-odd@3.0.1".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("alias resolve failed");
// Graph key and `LockedPackage.name` both carry the alias —
// that's what the linker drops into `node_modules/` and what
// any `require("odd-alias")` walks to.
let pkg = graph
.packages
.get("odd-alias@3.0.1")
.expect("aliased package must be keyed by the alias dep_path");
assert_eq!(pkg.name, "odd-alias");
assert_eq!(pkg.version, "3.0.1");
assert_eq!(pkg.alias_of.as_deref(), Some("is-odd"));
assert_eq!(pkg.registry_name(), "is-odd");
// No stray `is-odd@3.0.1` entry from the rewrite leaking the
// real name past the alias boundary.
assert!(!graph.packages.contains_key("is-odd@3.0.1"));
let root = graph.importers.get(".").unwrap();
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "odd-alias");
assert_eq!(root[0].dep_path, "odd-alias@3.0.1");
}
// Catalog-aliased dep + selector override targeting the original
// (alias) name with a bare-range replacement. Reproduces the
// pnpm/pnpm `js-yaml: npm:@zkochan/js-yaml@0.0.11` + `js-yaml@<3.14.2:
// ^3.14.2` shape: the catalog rewrites js-yaml to the @zkochan
// package, then the override fires by user-facing name and
// replaces the range with `^3.14.2`. Without clearing
// `task.real_name` in the override path, the resolver kept fetching
// `@zkochan/js-yaml`'s packument and bailed with "no version of
// js-yaml matches range ^3.14.2".
#[tokio::test]
async fn override_with_bare_range_undoes_prior_catalog_alias() {
let real_js_yaml = make_packument("js-yaml", &["3.14.2"], "3.14.2");
let aliased = make_packument("@zkochan/js-yaml", &["0.0.11"], "0.0.11");
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut catalogs: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
catalogs.entry("default".to_string()).or_default().insert(
"js-yaml".to_string(),
"npm:@zkochan/js-yaml@0.0.11".to_string(),
);
let mut overrides: BTreeMap<String, String> = BTreeMap::new();
overrides.insert("js-yaml@<3.14.2".to_string(), "^3.14.2".to_string());
let mut resolver = Resolver::new(client)
.with_catalogs(catalogs)
.with_overrides(overrides);
resolver.cache.insert("js-yaml".to_string(), real_js_yaml);
resolver
.cache
.insert("@zkochan/js-yaml".to_string(), aliased);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("js-yaml".to_string(), "catalog:".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("override should redirect back to real js-yaml");
let pkg = graph
.packages
.get("js-yaml@3.14.2")
.expect("override target must resolve to real js-yaml@3.14.2");
assert_eq!(pkg.name, "js-yaml");
assert_eq!(pkg.version, "3.14.2");
assert!(
pkg.alias_of.is_none(),
"bare-range override must clear the prior npm: alias, got alias_of={:?}",
pkg.alias_of,
);
assert!(!graph.packages.contains_key("js-yaml@0.0.11"));
assert!(!graph.packages.contains_key("@zkochan/js-yaml@0.0.11"));
}
#[tokio::test]
async fn fresh_resolve_preserves_jsr_name_as_folder_name() {
let jsr_collections = make_packument("@jsr/std__collections", &["1.1.6"], "1.1.6");
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
resolver
.cache
.insert("@jsr/std__collections".to_string(), jsr_collections);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("@std/collections".to_string(), "jsr:^1.1.6".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("jsr resolve failed");
let pkg = graph
.packages
.get("@std/collections@1.1.6")
.expect("jsr package must be keyed by the user-facing dep_path");
assert_eq!(pkg.name, "@std/collections");
assert_eq!(pkg.version, "1.1.6");
assert_eq!(pkg.alias_of.as_deref(), Some("@jsr/std__collections"));
assert_eq!(pkg.registry_name(), "@jsr/std__collections");
assert!(
pkg.tarball_url
.as_deref()
.is_some_and(|url| url.contains("@jsr/std__collections")),
"JSR resolver output must preserve dist.tarball"
);
assert!(!graph.packages.contains_key("@jsr/std__collections@1.1.6"));
let root = graph.importers.get(".").unwrap();
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "@std/collections");
assert_eq!(root[0].dep_path, "@std/collections@1.1.6");
}
// A package listed in both `dependencies` and `devDependencies`
// must appear in the resolved importer's direct-dep list exactly
// once, with `dep_type = Production` (matches pnpm: production
// wins, dev entry is silently dropped). Without dedupe the linker
// sees the same name twice and parallel step 2 races to create
// the shared `node_modules/<name>` symlink, producing EEXIST.
#[tokio::test]
async fn same_dep_in_dependencies_and_dev_dependencies_dedupes() {
let pmap = make_packument("p-map", &["7.0.4"], "7.0.4");
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
resolver.cache.insert("p-map".to_string(), pmap);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("p-map".to_string(), "7.0.4".to_string());
manifest
.dev_dependencies
.insert("p-map".to_string(), "7.0.4".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("resolve failed");
let root = graph.importers.get(".").unwrap();
assert_eq!(
root.len(),
1,
"p-map must appear once in root deps, got {root:?}"
);
assert_eq!(root[0].name, "p-map");
assert_eq!(root[0].dep_type, DepType::Production);
}
// `dependencies` also wins over `optionalDependencies` when the
// same name appears in both — same race hazard, same fix.
#[tokio::test]
async fn same_dep_in_dependencies_and_optional_dependencies_dedupes() {
let pmap = make_packument("p-map", &["7.0.4"], "7.0.4");
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
resolver.cache.insert("p-map".to_string(), pmap);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("p-map".to_string(), "7.0.4".to_string());
manifest
.optional_dependencies
.insert("p-map".to_string(), "7.0.4".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("resolve failed");
let root = graph.importers.get(".").unwrap();
assert_eq!(
root.len(),
1,
"p-map must appear once in root deps, got {root:?}"
);
assert_eq!(root[0].name, "p-map");
assert_eq!(root[0].dep_type, DepType::Production);
}
// With no `dependencies` entry, `devDependencies` wins over
// `optionalDependencies`. Covers the remaining overlap branch.
#[tokio::test]
async fn same_dep_in_dev_and_optional_dependencies_dedupes() {
let pmap = make_packument("p-map", &["7.0.4"], "7.0.4");
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
let mut resolver = Resolver::new(client);
resolver.cache.insert("p-map".to_string(), pmap);
let mut manifest = PackageJson::default();
manifest
.dev_dependencies
.insert("p-map".to_string(), "7.0.4".to_string());
manifest
.optional_dependencies
.insert("p-map".to_string(), "7.0.4".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("resolve failed");
let root = graph.importers.get(".").unwrap();
assert_eq!(
root.len(),
1,
"p-map must appear once in root deps, got {root:?}"
);
assert_eq!(root[0].name, "p-map");
assert_eq!(root[0].dep_type, DepType::Dev);
}
#[test]
fn pick_version_exact_pin_not_hijacked_by_dist_tag() {
let mut packument = make_packument("foo", &["1.0.0", "1.5.0"], "1.5.0");
packument
.dist_tags
.insert("1.0.0".to_string(), "1.5.0".to_string());
let result = pick_version(&packument, "1.0.0", None, false, None, false).unwrap();
assert_eq!(result.version, "1.0.0");
}
fn assert_protocol_hijack_blocked(spec: &str) {
let mut packument = make_packument("@victim/utils", &["1.0.0"], "1.0.0");
packument
.dist_tags
.insert(spec.to_string(), "1.0.0".to_string());
let result = pick_version(&packument, spec, None, false, None, false);
assert!(
matches!(result, super::semver_util::PickResult::NoMatch),
"protocol-prefixed range {spec:?} reached dist-tag fallback",
);
}
#[test]
fn cve_audit_protocol_dist_tag_hijack_blocked() {
assert_protocol_hijack_blocked("workspace:*");
assert_protocol_hijack_blocked("catalog:");
assert_protocol_hijack_blocked("npm:other-pkg@1.0.0");
assert_protocol_hijack_blocked("Workspace:*");
assert_protocol_hijack_blocked("GIT+FILE:/local");
}
// pnpm-parity: a non-peer-declaring package between an importer and a
// peer-declaring descendant must carry the descendant's
// `(peer@version)` suffix on its own dep_path, AND on the importer
// row's `version:` field. Aube's `propagate_peer_suffixes_to_ancestors`
// post-pass closes the gap that previously only tagged the
// peer-declarer itself. Mirrors the assertion at
// `pnpm/test/monorepo/dedupePeers.test.ts:191`.
#[test]
fn peer_suffix_propagates_through_non_peer_intermediary() {
// parent (no peers) -> leaf (peers on `peer-a`).
let parent = mk_locked("parent", "1.0.0", &[("leaf", "1.0.0")], &[]);
let leaf = mk_locked("leaf", "1.0.0", &[("peer-a", "1.0.0")], &[("peer-a", "^1")]);
let peer_a = mk_locked("peer-a", "1.0.0", &[], &[]);
let mut packages = BTreeMap::new();
packages.insert("parent@1.0.0".to_string(), parent);
packages.insert("leaf@1.0.0".to_string(), leaf);
packages.insert("peer-a@1.0.0".to_string(), peer_a);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "parent".to_string(),
dep_path: "parent@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = apply_peer_contexts(graph, &PeerContextOptions::default())
.expect("test graph should converge");
let parent_key = "parent@1.0.0(peer-a@1.0.0)";
assert!(
out.packages.contains_key(parent_key),
"parent's snapshot key must propagate the descendant peer suffix; got {:?}",
out.packages.keys().collect::<Vec<_>>()
);
let leaf_key = "leaf@1.0.0(peer-a@1.0.0)";
assert!(
out.packages.contains_key(leaf_key),
"leaf keeps its self-peer suffix; got {:?}",
out.packages.keys().collect::<Vec<_>>()
);
let importer_dep_path = &out.importers["."][0].dep_path;
assert_eq!(
importer_dep_path, parent_key,
"importer DirectDep.dep_path tracks the propagated parent key"
);
}
// Mutual peer cycle (a -> peer b, b -> peer a). The propagation post-
// pass must NOT lift `(a@…)` onto a's own dep_path — a node listing
// itself as a peer is not valid pnpm shape. The cycle break suppresses
// the package's own canonical name from cumulative additions.
#[test]
fn peer_suffix_propagation_skips_self_in_mutual_cycle() {
let a = mk_locked("a", "1.0.0", &[("b", "1.0.0")], &[("b", "^1")]);
let b = mk_locked("b", "1.0.0", &[("a", "1.0.0")], &[("a", "^1")]);
let mut packages = BTreeMap::new();
packages.insert("a@1.0.0".to_string(), a);
packages.insert("b@1.0.0".to_string(), b);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "a".to_string(),
dep_path: "a@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = apply_peer_contexts(graph, &PeerContextOptions::default())
.expect("test graph should converge");
// No `(a@…)` self-reference on a, no `(b@…)` self-reference on b.
assert!(
out.packages.contains_key("a@1.0.0(b@1.0.0)"),
"a keeps its existing (b@…) suffix only; got {:?}",
out.packages.keys().collect::<Vec<_>>()
);
assert!(
!out.packages.contains_key("a@1.0.0(a@1.0.0)(b@1.0.0)"),
"propagation must NOT lift a's name back onto itself"
);
assert!(
out.packages.contains_key("b@1.0.0(a@1.0.0)"),
"b keeps its existing (a@…) suffix only"
);
assert!(
!out.packages.contains_key("b@1.0.0(a@1.0.0)(b@1.0.0)"),
"propagation must NOT lift b's name back onto itself"
);
}
// Nested self-suffix: when a package's own peer suffix already encodes
// a peer name transitively (e.g. `consumer@1(helper@1(core@1))`), the
// propagation must not double-emit `(core@…)` as a separate flat
// segment. `peer_names_in_segments_recursive` covers names at any
// depth in the self set.
#[test]
fn peer_suffix_propagation_dedupes_nested_self_segments() {
// consumer peers on helper. helper peers on core. consumer's
// suffix nests as `(helper@1(core@1))`; we must NOT add a flat
// `(core@1)` from the descendant scan.
let mut consumer = mk_locked("consumer", "1.0.0", &[], &[("helper", "^1")]);
consumer.dep_path = "consumer@1.0.0".to_string();
let mut helper = mk_locked("helper", "1.0.0", &[("core", "1.0.0")], &[("core", "^1")]);
helper.dep_path = "helper@1.0.0(core@1.0.0)".to_string();
let mut core = mk_locked("core", "1.0.0", &[], &[]);
core.dep_path = "core@1.0.0".to_string();
let mut packages = BTreeMap::new();
packages.insert("consumer@1.0.0".to_string(), consumer);
packages.insert("helper@1.0.0(core@1.0.0)".to_string(), helper);
packages.insert("core@1.0.0".to_string(), core);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "consumer".to_string(),
dep_path: "consumer@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("^1".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let out = apply_peer_contexts(graph, &PeerContextOptions::default())
.expect("test graph should converge");
// Consumer's nested self-suffix is preserved exactly — no flat
// `(core@1.0.0)` is appended.
assert!(
out.packages
.contains_key("consumer@1.0.0(helper@1.0.0(core@1.0.0))"),
"consumer's nested self-suffix is preserved; got {:?}",
out.packages.keys().collect::<Vec<_>>()
);
assert!(
!out.packages
.contains_key("consumer@1.0.0(core@1.0.0)(helper@1.0.0(core@1.0.0))"),
"propagation must not double-emit a peer name already covered transitively in the self suffix"
);
}
// ---------------------------------------------------------------------------
// direct_dep_info
// ---------------------------------------------------------------------------
fn direct_dep_info_resolver() -> Resolver {
let client = Arc::new(aube_registry::client::RegistryClient::new(
"http://127.0.0.1:0",
));
Resolver::new(client)
}
fn direct_dep_info_graph(
direct: &[(&str, &str, &str)],
packages: &[LockedPackage],
) -> LockfileGraph {
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
direct
.iter()
.map(|(name, dep_path, spec)| DirectDep {
name: (*name).to_string(),
dep_path: (*dep_path).to_string(),
dep_type: DepType::Production,
specifier: Some((*spec).to_string()),
})
.collect(),
);
let mut pkg_map = BTreeMap::new();
for pkg in packages {
pkg_map.insert(pkg.dep_path.clone(), pkg.clone());
}
LockfileGraph {
importers,
packages: pkg_map,
..Default::default()
}
}
#[test]
fn direct_dep_info_flags_deprecated_resolved_version() {
let mut packument = make_packument("foo", &["1.0.0", "1.0.1"], "1.0.1");
packument.versions.get_mut("1.0.0").unwrap().deprecated = Some("use 1.0.1 instead".to_string());
let mut resolver = direct_dep_info_resolver();
resolver.cache.insert("foo".to_string(), packument);
let pkg = mk_locked("foo", "1.0.0", &[], &[]);
let graph = direct_dep_info_graph(&[("foo", "foo@1.0.0", "^1")], &[pkg]);
let info = resolver.direct_dep_info(&graph);
let entry = info.get("foo@1.0.0").expect("foo@1.0.0 should have info");
assert!(entry.deprecated, "resolved version is deprecated");
assert_eq!(entry.latest.as_deref(), Some("1.0.1"));
}
#[test]
fn direct_dep_info_omits_latest_when_already_on_latest() {
let packument = make_packument("bar", &["2.0.0"], "2.0.0");
let mut resolver = direct_dep_info_resolver();
resolver.cache.insert("bar".to_string(), packument);
let pkg = mk_locked("bar", "2.0.0", &[], &[]);
let graph = direct_dep_info_graph(&[("bar", "bar@2.0.0", "^2")], &[pkg]);
let info = resolver.direct_dep_info(&graph);
// No deprecated and latest == current → no entry at all.
assert!(
!info.contains_key("bar@2.0.0"),
"got unexpected entry: {info:?}"
);
}
#[test]
fn direct_dep_info_skips_local_source_deps() {
// file: / link: / git: deps don't have packuments and shouldn't be
// probed for "latest" even if a same-name packument happens to be
// cached (e.g. a transitive registry sibling). Local-source deps
// get no badge.
let packument = make_packument("baz", &["9.9.9"], "9.9.9");
let mut resolver = direct_dep_info_resolver();
resolver.cache.insert("baz".to_string(), packument);
let mut pkg = mk_locked("baz", "0.0.0", &[], &[]);
pkg.local_source = Some(LocalSource::Directory(std::path::PathBuf::from(
"./vendor/baz",
)));
let graph = direct_dep_info_graph(&[("baz", "baz@0.0.0", "file:./vendor/baz")], &[pkg]);
let info = resolver.direct_dep_info(&graph);
assert!(
info.is_empty(),
"local-source dep should be skipped: {info:?}"
);
}
#[test]
fn direct_dep_info_uses_registry_name_for_aliased_dep() {
// `"h3-v2": "npm:h3@2.0.1-rc.20"` — the manifest alias is `h3-v2`
// and the packument lives under the registry name `h3`. The
// resolver's cache is keyed by registry name, so direct_dep_info
// must follow `LockedPackage::registry_name()` (not `name`) to
// find the packument.
let mut packument = make_packument("h3", &["2.0.0", "2.0.1"], "2.0.1");
packument.versions.get_mut("2.0.0").unwrap().deprecated = Some("rc only".to_string());
let mut resolver = direct_dep_info_resolver();
resolver.cache.insert("h3".to_string(), packument);
let mut pkg = mk_locked("h3-v2", "2.0.0", &[], &[]);
pkg.alias_of = Some("h3".to_string());
pkg.dep_path = "h3-v2@2.0.0".to_string();
let graph = direct_dep_info_graph(&[("h3-v2", "h3-v2@2.0.0", "npm:h3@2.0.0")], &[pkg]);
let info = resolver.direct_dep_info(&graph);
let entry = info
.get("h3-v2@2.0.0")
.expect("aliased entry should resolve via registry_name");
assert!(entry.deprecated, "aliased resolved version is deprecated");
assert_eq!(entry.latest.as_deref(), Some("2.0.1"));
}
#[test]
fn direct_dep_info_empty_when_no_packument_cached() {
// Frozen-lockfile reuse: the resolver never fetches packuments, so
// direct_dep_info should produce no entries rather than blowing up.
let resolver = direct_dep_info_resolver();
let pkg = mk_locked("ghost", "1.0.0", &[], &[]);
let graph = direct_dep_info_graph(&[("ghost", "ghost@1.0.0", "^1")], &[pkg]);
let info = resolver.direct_dep_info(&graph);
assert!(info.is_empty(), "no packument cached → empty map");
}
#[tokio::test]
async fn optional_dep_is_skipped_while_required_dep_resolves() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let registry = format!("http://{}/", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let mut buf = [0_u8; 2048];
let _ = socket.read(&mut buf).await;
let response =
"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
socket.write_all(response.as_bytes()).await.unwrap();
});
}
});
// Pre-seed cache for the required dep so it never hits the network.
let pmap = make_packument("p-map", &["7.0.4"], "7.0.4");
let client = Arc::new(aube_registry::client::RegistryClient::new(®istry));
let mut resolver = Resolver::new(client);
resolver.cache.insert("p-map".to_string(), pmap);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("p-map".to_string(), "7.0.4".to_string());
manifest
.optional_dependencies
.insert("missing-optional".to_string(), "1.0.0".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("optional dep 404 must not fail the resolve");
assert!(graph_has_package(&graph, "p-map", "7.0.4"));
assert!(!graph_has_package(&graph, "missing-optional", "1.0.0"));
// pnpm parity: fetch-failed optional deps leave no trace in the
// lockfile — no skippedOptionalDependencies entry.
assert!(
graph
.skipped_optional_dependencies
.get(".")
.map_or(true, |skipped| !skipped.contains_key("missing-optional"))
);
server.abort();
}
#[tokio::test]
async fn required_dep_propagates_registry_error() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let registry = format!("http://{}/", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let mut buf = [0_u8; 2048];
let _ = socket.read(&mut buf).await;
let response =
"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
socket.write_all(response.as_bytes()).await.unwrap();
});
}
});
let client = Arc::new(aube_registry::client::RegistryClient::new(®istry));
let mut resolver = Resolver::new(client);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("missing-required".to_string(), "1.0.0".to_string());
let err = resolver
.resolve(&manifest, None)
.await
.expect_err("required dep 404 must propagate");
match err {
Error::Registry(name, _) => {
assert_eq!(name, "missing-required");
}
other => panic!("expected Error::Registry, got {other:?}"),
}
server.abort();
}
#[tokio::test]
async fn optional_dep_with_both_fetches_in_flight() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Server returns 200 for p-map and 404 for missing-optional,
// so BOTH deps trigger real concurrent fetches.
let pkg_body = serde_json::to_vec(&make_packument("p-map", &["7.0.4"], "7.0.4")).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let registry = format!("http://{}/", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let body = pkg_body.clone();
tokio::spawn(async move {
let mut buf = [0_u8; 2048];
let _ = socket.read(&mut buf).await;
let path = std::str::from_utf8(&buf)
.ok()
.and_then(|s| s.split_whitespace().nth(1))
.unwrap_or("/");
if path.contains("missing-optional") {
let response =
"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
socket.write_all(response.as_bytes()).await.unwrap();
} else {
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&body).await.unwrap();
}
});
}
});
let client = Arc::new(aube_registry::client::RegistryClient::new(®istry));
let mut resolver = Resolver::new(client);
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert("p-map".to_string(), "7.0.4".to_string());
manifest
.optional_dependencies
.insert("missing-optional".to_string(), "1.0.0".to_string());
let graph = resolver
.resolve(&manifest, None)
.await
.expect("optional dep 404 must not fail even with concurrent fetches");
assert!(graph_has_package(&graph, "p-map", "7.0.4"));
assert!(!graph_has_package(&graph, "missing-optional", "1.0.0"));
// pnpm parity: fetch-failed optional deps leave no trace in the
// lockfile — no skippedOptionalDependencies entry.
assert!(
graph
.skipped_optional_dependencies
.get(".")
.map_or(true, |skipped| !skipped.contains_key("missing-optional"))
);
server.abort();
}