use super::layout::package_name_from_install_path;
use super::source::local_git_source_from_resolved;
use super::*;
use crate::{DepType, DirectDep, Error, GitSource, LocalSource, LockedPackage, LockfileGraph};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[test]
fn test_package_name_from_install_path() {
assert_eq!(
package_name_from_install_path("node_modules/foo"),
Some("foo".to_string())
);
assert_eq!(
package_name_from_install_path("node_modules/@scope/pkg"),
Some("@scope/pkg".to_string())
);
assert_eq!(
package_name_from_install_path("node_modules/foo/node_modules/bar"),
Some("bar".to_string())
);
assert_eq!(
package_name_from_install_path("node_modules/foo/node_modules/@scope/pkg"),
Some("@scope/pkg".to_string())
);
}
#[test]
fn test_parse_simple() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "foo": "^1.0.0" },
"devDependencies": { "bar": "^2.0.0" }
},
"node_modules/foo": {
"version": "1.2.3",
"integrity": "sha512-aaa",
"dependencies": { "nested": "^3.0.0" }
},
"node_modules/nested": {
"version": "3.1.0",
"integrity": "sha512-bbb"
},
"node_modules/bar": {
"version": "2.5.0",
"integrity": "sha512-ccc",
"dev": true
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert_eq!(graph.packages.len(), 3);
assert!(graph.packages.contains_key("foo@1.2.3"));
assert!(graph.packages.contains_key("nested@3.1.0"));
assert!(graph.packages.contains_key("bar@2.5.0"));
let foo = &graph.packages["foo@1.2.3"];
assert_eq!(foo.integrity.as_deref(), Some("sha512-aaa"));
// `LockedPackage.dependencies` values are dep_path *tails* (the
// substring after `<name>@`), not full dep_paths — matches the
// pnpm parser and the linker's sibling-symlink builder.
assert_eq!(
foo.dependencies.get("nested").map(String::as_str),
Some("3.1.0")
);
let root = graph.importers.get(".").unwrap();
assert_eq!(root.len(), 2);
assert!(
root.iter()
.any(|d| d.name == "foo" && d.dep_type == DepType::Production)
);
assert!(
root.iter()
.any(|d| d.name == "bar" && d.dep_type == DepType::Dev)
);
}
#[test]
fn test_parse_git_resolved_package() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let sha = "abcdef1234567890abcdef1234567890abcdef12";
let content = format!(
r#"{{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 2,
"packages": {{
"": {{
"name": "test",
"version": "1.0.0",
"dependencies": {{ "git-only": "github:owner/repo#{sha}" }}
}},
"node_modules/git-only": {{
"version": "1.2.3",
"resolved": "git+ssh://git@github.com/owner/repo.git#{sha}",
"integrity": "sha512-aaa"
}}
}}
}}"#
);
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let root = &graph.importers["."];
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "git-only");
assert!(!graph.packages.contains_key("git-only@1.2.3"));
let pkg = &graph.packages[&root[0].dep_path];
assert_eq!(pkg.name, "git-only");
assert_eq!(pkg.version, "1.2.3");
assert_eq!(pkg.integrity.as_deref(), Some("sha512-aaa"));
assert!(pkg.tarball_url.is_none());
let Some(LocalSource::Git(git)) = &pkg.local_source else {
panic!("expected git local source, got {:?}", pkg.local_source);
};
assert_eq!(git.url, "ssh://git@github.com/owner/repo.git");
assert_eq!(git.committish.as_deref(), Some(sha));
assert_eq!(git.resolved, sha);
}
#[test]
fn test_unpinned_git_resolved_url_is_not_locked_git_source() {
assert!(local_git_source_from_resolved("git+https://github.com/owner/repo.git").is_none());
}
#[test]
fn test_write_preserves_git_resolved_url() {
let sha = "abcdef1234567890abcdef1234567890abcdef12";
let mut graph = LockfileGraph::default();
let local = LocalSource::Git(GitSource {
url: "ssh://git@github.com/owner/repo.git".to_string(),
committish: Some(sha.to_string()),
resolved: sha.to_string(),
subpath: None,
});
let dep_path = local.dep_path("git-only");
graph.packages.insert(
dep_path.clone(),
LockedPackage {
name: "git-only".to_string(),
version: "1.2.3".to_string(),
dep_path: dep_path.clone(),
local_source: Some(local),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "git-only".to_string(),
dep_path,
dep_type: DepType::Production,
specifier: Some(format!("github:owner/repo#{sha}")),
}],
);
let manifest = aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("git-only".to_string(), format!("github:owner/repo#{sha}"))]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let body = std::fs::read_to_string(out.path()).unwrap();
assert!(
body.contains(&format!(
"\"resolved\": \"git+ssh://git@github.com/owner/repo.git#{sha}\""
)),
"expected git resolved URL emitted; got:\n{body}"
);
let reparsed = parse(out.path()).unwrap();
let pkg = &reparsed.packages[&graph.importers["."][0].dep_path];
assert!(matches!(pkg.local_source, Some(LocalSource::Git(_))));
}
#[test]
fn test_write_skips_non_git_local_sources() {
let local = LocalSource::Directory(PathBuf::from("vendor/local-dir"));
let dep_path = local.dep_path("local-dir");
let mut graph = LockfileGraph::default();
graph.packages.insert(
dep_path.clone(),
LockedPackage {
name: "local-dir".to_string(),
version: "1.0.0".to_string(),
dep_path: dep_path.clone(),
local_source: Some(local),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "local-dir".to_string(),
dep_path,
dep_type: DepType::Production,
specifier: Some("file:vendor/local-dir".to_string()),
}],
);
let manifest = aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("local-dir".to_string(), "file:vendor/local-dir".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let body = std::fs::read_to_string(out.path()).unwrap();
assert!(!body.contains("\"node_modules/local-dir\""));
}
#[test]
fn test_parse_file_resolved_without_link() {
// npm writes `resolved: "file:..."` without `link: true` for
// local tarball deps (`npm install file:../foo-1.0.0.tgz`) and
// for some directory deps. Both shapes must surface as a
// LocalSource so the resolver dispatches the local-source
// branch and doesn't fall through to a registry fetch.
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": {
"tar-dep": "file:../utils/tar-dep-1.0.0.tgz",
"dir-dep": "file:../utils"
}
},
"node_modules/tar-dep": {
"version": "1.0.0",
"resolved": "file:../utils/tar-dep-1.0.0.tgz",
"integrity": "sha512-aaa"
},
"node_modules/dir-dep": {
"version": "1.0.0",
"resolved": "file:../utils"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let tar_pkg = graph
.packages
.values()
.find(|p| p.name == "tar-dep")
.expect("tar-dep entry");
assert!(
matches!(&tar_pkg.local_source, Some(LocalSource::Tarball(p)) if p == Path::new("../utils/tar-dep-1.0.0.tgz")),
"expected Tarball source, got {:?}",
tar_pkg.local_source,
);
assert!(
tar_pkg.dep_path.starts_with("tar-dep@file+"),
"tarball dep_path should be local-source-keyed, got {}",
tar_pkg.dep_path,
);
let dir_pkg = graph
.packages
.values()
.find(|p| p.name == "dir-dep")
.expect("dir-dep entry");
assert!(
matches!(&dir_pkg.local_source, Some(LocalSource::Directory(p)) if p == Path::new("../utils")),
"expected Directory source, got {:?}",
dir_pkg.local_source,
);
assert!(
dir_pkg.dep_path.starts_with("dir-dep@file+"),
"directory dep_path should be local-source-keyed, got {}",
dir_pkg.dep_path,
);
let root = graph.importers.get(".").unwrap();
let tar_direct = root.iter().find(|d| d.name == "tar-dep").unwrap();
assert_eq!(tar_direct.dep_path, tar_pkg.dep_path);
let dir_direct = root.iter().find(|d| d.name == "dir-dep").unwrap();
assert_eq!(dir_direct.dep_path, dir_pkg.dep_path);
}
#[test]
fn test_parse_scoped_package() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": { "@scope/pkg": "^1.0.0" }
},
"node_modules/@scope/pkg": {
"version": "1.0.0",
"integrity": "sha512-zzz"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert!(graph.packages.contains_key("@scope/pkg@1.0.0"));
let root = graph.importers.get(".").unwrap();
assert_eq!(root[0].name, "@scope/pkg");
assert_eq!(root[0].dep_path, "@scope/pkg@1.0.0");
}
#[test]
fn test_parse_multi_version_nested() {
// bar exists at two versions: 2.0.0 hoisted to root, 1.0.0 nested under foo.
// foo's transitive dep on bar must resolve to 1.0.0, not 2.0.0.
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": { "foo": "^1.0.0", "bar": "^2.0.0" }
},
"node_modules/bar": {
"version": "2.0.0",
"integrity": "sha512-top-bar"
},
"node_modules/foo": {
"version": "1.0.0",
"integrity": "sha512-foo",
"dependencies": { "bar": "^1.0.0" }
},
"node_modules/foo/node_modules/bar": {
"version": "1.0.0",
"integrity": "sha512-nested-bar"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
// Both versions of bar should be present.
assert!(graph.packages.contains_key("bar@2.0.0"));
assert!(graph.packages.contains_key("bar@1.0.0"));
assert!(graph.packages.contains_key("foo@1.0.0"));
// foo's transitive dep must point to the nested (1.0.0), not the hoisted (2.0.0).
// Value is the dep_path tail (version) — see the `LockedPackage.dependencies` doc.
let foo = &graph.packages["foo@1.0.0"];
assert_eq!(
foo.dependencies.get("bar").map(String::as_str),
Some("1.0.0")
);
// Root's direct bar dep points to the hoisted 2.0.0.
let root = graph.importers.get(".").unwrap();
let root_bar = root.iter().find(|d| d.name == "bar").unwrap();
assert_eq!(root_bar.dep_path, "bar@2.0.0");
}
/// Regression: a package reachable from both a dev root and
/// an optional root (but *not* from any production root) must
/// be written with `devOptional: true`, not with both `dev: true`
/// and `optional: true`. Emitting both trips `npm install
/// --omit=dev` (and `--omit=optional`) into dropping a package
/// the other chain still needs.
#[test]
fn test_write_dev_and_optional_reachable_uses_dev_optional() {
let mut graph = LockfileGraph::default();
let mk = |name: &str| LockedPackage {
name: name.to_string(),
version: "1.0.0".to_string(),
integrity: Some(format!("sha512-{name}")),
dep_path: format!("{name}@1.0.0"),
dependencies: [("shared".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
graph
.packages
.insert("dev-root@1.0.0".to_string(), mk("dev-root"));
graph
.packages
.insert("opt-root@1.0.0".to_string(), mk("opt-root"));
graph.packages.insert(
"shared@1.0.0".to_string(),
LockedPackage {
name: "shared".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-shared".to_string()),
dep_path: "shared@1.0.0".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "dev-root".to_string(),
dep_path: "dev-root@1.0.0".to_string(),
dep_type: DepType::Dev,
specifier: None,
},
DirectDep {
name: "opt-root".to_string(),
dep_path: "opt-root@1.0.0".to_string(),
dep_type: DepType::Optional,
specifier: None,
},
],
);
let manifest = aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dev_dependencies: [("dev-root".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
optional_dependencies: [("opt-root".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let shared = &json["packages"]["node_modules/shared"];
assert_eq!(shared["devOptional"], true, "expected devOptional flag");
assert!(
shared.get("dev").is_none(),
"must not emit dev: true alongside devOptional",
);
assert!(
shared.get("optional").is_none(),
"must not emit optional: true alongside devOptional",
);
// Roots themselves retain their specific flag.
assert_eq!(json["packages"]["node_modules/dev-root"]["dev"], true);
assert_eq!(json["packages"]["node_modules/opt-root"]["optional"], true);
}
/// Regression: the npm writer must drop `dependencies` entries
/// whose target isn't in the canonical map. Platform-filtered
/// optionals and `ignoredOptionalDependencies` leave the parent's
/// declared `dependencies` map pointing at packages the resolver
/// already removed; emitting them anyway produces a lockfile
/// where `npm ci` sees a reference with no matching `packages`
/// entry and refuses to install. Must match the bun/yarn
/// writers, which already filter this way.
#[test]
fn test_write_filters_missing_canonical_deps() {
let mut graph = LockfileGraph::default();
// Root has one real package, `foo`, which declares a dep on
// `ghost@1.0.0` — but `ghost` was filtered out of the graph
// (e.g. a platform-gated optional). The canonical map won't
// contain it.
graph.packages.insert(
"foo@1.0.0".to_string(),
LockedPackage {
name: "foo".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-foo".to_string()),
dep_path: "foo@1.0.0".to_string(),
dependencies: [("ghost".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "foo".to_string(),
dep_path: "foo@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
}],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
// Parse the raw JSON directly — the aube reparser tolerates
// dangling references so we assert on the serialized shape.
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let foo_entry = &json["packages"]["node_modules/foo"];
assert!(
foo_entry
.get("dependencies")
.and_then(|d| d.get("ghost"))
.is_none(),
"writer emitted a ghost dep that has no packages entry: {foo_entry}",
);
// And there should be no node_modules/ghost entry at all.
assert!(
json["packages"].get("node_modules/ghost").is_none(),
"writer hallucinated a ghost entry",
);
}
/// Regression for the shadow-nesting bug: if an intermediate
/// ancestor carries the *wrong* version of a dep, Node's
/// runtime walk stops there and never reaches a correct entry
/// at root. The writer must nest a fresh entry inside the
/// current parent's own `node_modules` instead of assuming
/// hoisting is fine just because root happens to have the
/// right version.
///
/// Shape:
/// root → foo → baz, baz depends on bar@2.0.0
/// foo already pulled in bar@1.0.0 for a sibling, so bar@1.0.0
/// lives at node_modules/foo/node_modules/bar
/// root has bar@2.0.0 at node_modules/bar
///
/// When we walk baz's deps and get to bar@2.0.0, the nearest
/// ancestor hit is bar@1.0.0 (shadowing), not root. We must
/// place a fresh entry at
/// `node_modules/foo/node_modules/baz/node_modules/bar` so
/// Node resolves the right version.
#[test]
fn test_nested_shadow_forces_nested_placement() {
// Build a graph by hand to control the dep order deterministically.
let mut graph = LockfileGraph::default();
let mk = |name: &str, version: &str, deps: &[(&str, &str)]| LockedPackage {
name: name.to_string(),
version: version.to_string(),
integrity: Some(format!("sha512-{name}-{version}")),
dep_path: format!("{name}@{version}"),
dependencies: deps
.iter()
.map(|(n, v)| (n.to_string(), (*v).to_string()))
.collect(),
..Default::default()
};
graph.packages.insert(
"foo@1.0.0".to_string(),
mk(
"foo",
"1.0.0",
&[
// foo pulls in bar@1.0.0 and baz@1.0.0 as siblings.
("bar", "1.0.0"),
("baz", "1.0.0"),
],
),
);
graph.packages.insert(
"baz@1.0.0".to_string(),
// baz wants bar@2.0.0, which matches the root version.
mk("baz", "1.0.0", &[("bar", "2.0.0")]),
);
graph
.packages
.insert("bar@1.0.0".to_string(), mk("bar", "1.0.0", &[]));
graph
.packages
.insert("bar@2.0.0".to_string(), mk("bar", "2.0.0", &[]));
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "foo".to_string(),
dep_path: "foo@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
DirectDep {
name: "bar".to_string(),
dep_path: "bar@2.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let reparsed = parse(out.path()).unwrap();
// baz's transitive dep must resolve to bar@2.0.0, not the
// shadowing bar@1.0.0 under foo. Value is the dep_path tail
// (version) so the linker can recombine it with the dep name.
let baz = &reparsed.packages["baz@1.0.0"];
assert_eq!(
baz.dependencies.get("bar").map(String::as_str),
Some("2.0.0"),
"baz's bar dep was shadowed by foo/bar@1.0.0 — shadow-nest fix regressed",
);
}
#[test]
fn test_parse_npm_preserves_platform_optional_metadata() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "platform-optional-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "platform-optional-root",
"version": "1.0.0",
"dependencies": { "host": "file:host" }
},
"node_modules/host": {
"resolved": "host",
"link": true
},
"host": {
"name": "host",
"version": "1.0.0",
"optionalDependencies": { "native-win": "1.0.0" }
},
"node_modules/native-win": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/native-win/-/native-win-1.0.0.tgz",
"integrity": "sha512-native",
"optional": true,
"os": ["win32"],
"cpu": ["x64"],
"libc": ["glibc"]
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let host_dep_path = &graph.importers["."][0].dep_path;
let host = &graph.packages[host_dep_path];
assert_eq!(
host.dependencies.get("native-win").map(String::as_str),
Some("1.0.0")
);
assert_eq!(
host.optional_dependencies
.get("native-win")
.map(String::as_str),
Some("1.0.0")
);
let native = &graph.packages["native-win@1.0.0"];
assert_eq!(
native.os.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["win32"]
);
assert_eq!(
native.cpu.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["x64"]
);
assert_eq!(
native.libc.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["glibc"]
);
}
/// npm sometimes emits `os` / `cpu` / `libc` as scalar strings instead
/// of arrays (e.g. `sass-embedded-linux-arm@1.99.0` ships
/// `"libc": "glibc"`). Verbatim-roundtripped into package-lock.json,
/// the field stays scalar — accept both shapes the same way the
/// pnpm + bun parsers already do.
#[test]
fn parse_npm_package_platform_fields_accept_scalar_strings() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "scalar-platform-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "scalar-platform-root",
"version": "1.0.0",
"dependencies": { "sass-embedded-linux-arm": "1.99.0" }
},
"node_modules/sass-embedded-linux-arm": {
"version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.99.0.tgz",
"integrity": "sha512-native",
"cpu": "arm",
"os": "linux",
"libc": "glibc"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let pkg = &graph.packages["sass-embedded-linux-arm@1.99.0"];
assert_eq!(
pkg.os.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["linux"]
);
assert_eq!(
pkg.cpu.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["arm"]
);
assert_eq!(
pkg.libc.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["glibc"]
);
}
#[test]
fn test_write_npm_preserves_platform_optional_metadata() {
let mut graph = LockfileGraph::default();
graph.packages.insert(
"host@1.0.0".to_string(),
LockedPackage {
name: "host".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-host".to_string()),
dep_path: "host@1.0.0".to_string(),
dependencies: [("native-win".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
optional_dependencies: [("native-win".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
declared_dependencies: [("native-win".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
},
);
graph.packages.insert(
"native-win@1.0.0".to_string(),
LockedPackage {
name: "native-win".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-native".to_string()),
dep_path: "native-win@1.0.0".to_string(),
os: vec!["win32".to_string()].into(),
cpu: vec!["x64".to_string()].into(),
libc: vec!["glibc".to_string()].into(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "host".to_string(),
dep_path: "host@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("1.0.0".to_string()),
}],
);
let manifest = aube_manifest::PackageJson {
name: Some("platform-optional-root".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("host".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let host = &json["packages"]["node_modules/host"];
assert_eq!(host["optionalDependencies"]["native-win"], "1.0.0");
assert!(
host.get("dependencies")
.and_then(|deps| deps.get("native-win"))
.is_none(),
"optional child must not be duplicated as a required dependency: {host}",
);
let native = &json["packages"]["node_modules/native-win"];
assert_eq!(native["os"], serde_json::json!(["win32"]));
assert_eq!(native["cpu"], serde_json::json!(["x64"]));
assert_eq!(native["libc"], serde_json::json!(["glibc"]));
let reparsed = parse(out.path()).unwrap();
let host = &reparsed.packages["host@1.0.0"];
assert_eq!(
host.optional_dependencies
.get("native-win")
.map(String::as_str),
Some("1.0.0")
);
assert_eq!(
host.dependencies.get("native-win").map(String::as_str),
Some("1.0.0")
);
let native = &reparsed.packages["native-win@1.0.0"];
assert_eq!(
native.os.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["win32"]
);
assert_eq!(
native.cpu.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["x64"]
);
assert_eq!(
native.libc.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["glibc"]
);
}
/// Regression: `canonical_key_from_dep_path` must strip the
/// `(peer@ver)` suffix *before* splitting on `@`. A naive
/// `rfind('@')` lands inside the peer suffix and returns the
/// input unchanged, which silently drops every peer-contextualized
/// root dep from the written lockfile. Hashed suffixes use the
/// same canonical identity; otherwise long peer suffixes drop
/// out of npm package-lock output.
#[test]
fn test_canonical_key_strips_peer_suffix() {
assert_eq!(canonical_key_from_dep_path("foo@1.0.0"), "foo@1.0.0");
assert_eq!(
canonical_key_from_dep_path("styled-components@6.1.0(react@18.2.0)"),
"styled-components@6.1.0"
);
assert_eq!(
canonical_key_from_dep_path("@scope/pkg@2.0.0(peer@1.0.0)"),
"@scope/pkg@2.0.0"
);
assert_eq!(
canonical_key_from_dep_path("expo-router@4.0.22_94c00fd028"),
"expo-router@4.0.22"
);
assert_eq!(
child_canonical_key("expo-router", "4.0.22_94c00fd028"),
"expo-router@4.0.22"
);
assert_eq!(
dep_value_as_version("expo-router", "expo-router@4.0.22_94c00fd028"),
"4.0.22"
);
assert_eq!(
canonical_key_from_dep_path("expo-router@4.0.22_94C00FD028"),
"expo-router@4.0.22"
);
}
fn test_manifest() -> aube_manifest::PackageJson {
aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [
("foo".to_string(), "^1.0.0".to_string()),
("bar".to_string(), "^2.0.0".to_string()),
]
.into_iter()
.collect(),
..Default::default()
}
}
/// Parse a fixture, write it back, re-parse: the resulting graph
/// must have the same packages, direct deps, and integrity hashes.
/// Catches silent data loss in the hoist/nest walk.
#[test]
fn test_write_roundtrip_multi_version() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "foo": "^1.0.0", "bar": "^2.0.0" }
},
"node_modules/bar": {
"version": "2.0.0",
"integrity": "sha512-top-bar"
},
"node_modules/foo": {
"version": "1.0.0",
"integrity": "sha512-foo",
"dependencies": { "bar": "^1.0.0" }
},
"node_modules/foo/node_modules/bar": {
"version": "1.0.0",
"integrity": "sha512-nested-bar"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let reparsed = parse(out.path()).unwrap();
// Both versions of bar survived the round-trip.
assert!(reparsed.packages.contains_key("bar@1.0.0"));
assert!(reparsed.packages.contains_key("bar@2.0.0"));
assert!(reparsed.packages.contains_key("foo@1.0.0"));
assert_eq!(
reparsed.packages["bar@2.0.0"].integrity.as_deref(),
Some("sha512-top-bar")
);
assert_eq!(
reparsed.packages["bar@1.0.0"].integrity.as_deref(),
Some("sha512-nested-bar")
);
// foo's nested bar dep still resolves to 1.0.0, not the
// hoisted 2.0.0. If the writer failed to nest, reparse would
// snap this to bar@2.0.0. Value is the dep_path tail.
assert_eq!(
reparsed.packages["foo@1.0.0"]
.dependencies
.get("bar")
.map(String::as_str),
Some("1.0.0")
);
}
/// Dev-only and optional-only packages get the right flags after
/// round-trip so `npm install --omit=dev` on the written file
/// does the right thing.
#[test]
fn test_write_dev_optional_flags() {
let mut graph = LockfileGraph::default();
graph.packages.insert(
"foo@1.0.0".to_string(),
LockedPackage {
name: "foo".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-foo".to_string()),
dep_path: "foo@1.0.0".to_string(),
..Default::default()
},
);
graph.packages.insert(
"devdep@1.0.0".to_string(),
LockedPackage {
name: "devdep".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-dev".to_string()),
dep_path: "devdep@1.0.0".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "foo".to_string(),
dep_path: "foo@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
DirectDep {
name: "devdep".to_string(),
dep_path: "devdep@1.0.0".to_string(),
dep_type: DepType::Dev,
specifier: None,
},
],
);
let manifest = aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("foo".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
dev_dependencies: [("devdep".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let packages = &json["packages"];
assert_eq!(packages["node_modules/devdep"]["dev"], true);
// Prod dep should have no dev field (skipped when false).
assert!(packages["node_modules/foo"].get("dev").is_none());
}
#[test]
fn test_reject_v1() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"lockfileVersion": 1,
"dependencies": {}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let err = parse(tmp.path()).unwrap_err();
assert!(matches!(err, Error::Parse(_, msg) if msg.contains("lockfileVersion 1")));
}
/// Pre-npm-2.x packages (e.g. `ansi-html-community@0.0.8`) ship
/// `"engines": ["node >= 0.8.0"]` as an array; npm preserves that
/// shape verbatim in v2/v3 lockfiles. Without tolerant parsing, a
/// single such entry blows up the whole `aube ci`. Normalize to an
/// empty map (matches what modern npm does for engine-strict on
/// the array shape) so the install proceeds.
#[test]
fn test_parse_legacy_array_engines() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "ansi-html-community": "0.0.8" }
},
"node_modules/ansi-html-community": {
"version": "0.0.8",
"integrity": "sha512-aaa",
"engines": ["node >= 0.8.0"]
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let pkg = &graph.packages["ansi-html-community@0.0.8"];
// Array shape gets normalized to an empty map — same as the
// manifest parser, and same as what modern npm honors for the
// engine-strict check on the array form.
assert!(pkg.engines.is_empty());
}
/// npm writes `"h3-v2": "npm:h3@..."` aliases as a packages entry
/// at `node_modules/h3-v2` with `name: "h3"` and the real registry
/// `resolved:` URL. Aube keys the graph on the *alias* (so
/// `node_modules/h3-v2` ends up at `.aube/h3-v2@.../node_modules/h3-v2`)
/// but remembers the real package name in `alias_of` so fetches
/// and store-index lookups use the URL that actually exists.
#[test]
fn test_parse_npm_alias_dependency() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "h3-v2": "npm:h3@2.0.1-rc.20" }
},
"node_modules/h3-v2": {
"name": "h3",
"version": "2.0.1-rc.20",
"resolved": "https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz",
"integrity": "sha512-aliased"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert_eq!(graph.packages.len(), 1);
// Graph key and LockedPackage.name both carry the alias —
// that's what consumers (and the linker's folder-name logic)
// refer to when they say "h3-v2".
let pkg = graph
.packages
.get("h3-v2@2.0.1-rc.20")
.expect("aliased entry should be keyed by the alias dep_path");
assert_eq!(pkg.name, "h3-v2");
assert_eq!(pkg.version, "2.0.1-rc.20");
assert_eq!(pkg.alias_of.as_deref(), Some("h3"));
assert_eq!(pkg.registry_name(), "h3");
// `resolved:` round-trips into `tarball_url` so the fetcher
// skips re-deriving from the alias-qualified name (which
// would 404 the registry).
assert_eq!(
pkg.tarball_url.as_deref(),
Some("https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz")
);
let root = graph.importers.get(".").unwrap();
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "h3-v2");
assert_eq!(root[0].dep_path, "h3-v2@2.0.1-rc.20");
}
/// Non-aliased entries (the common case) leave `alias_of` unset
/// and `registry_name()` degenerates to `name`. Regression guard
/// against over-aggressive alias detection that would flag every
/// entry carrying an explicit `name:` field (npm sometimes emits
/// one for non-aliased roots too).
#[test]
fn test_parse_non_alias_preserves_empty_alias_of() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "foo": "^1.0.0" }
},
"node_modules/foo": {
"name": "foo",
"version": "1.2.3",
"integrity": "sha512-foo"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let pkg = &graph.packages["foo@1.2.3"];
assert_eq!(pkg.name, "foo");
assert!(pkg.alias_of.is_none());
assert_eq!(pkg.registry_name(), "foo");
assert!(pkg.tarball_url.is_none());
}
/// Round-trip: writer must emit `name:` and `resolved:` for the
/// aliased entry so a subsequent `parse()` still recognizes it as
/// an alias. Without both fields the re-parser would see
/// `node_modules/h3-v2` with no `name:` and treat it as a plain
/// package called `h3-v2` — which doesn't exist on the registry.
#[test]
fn test_write_roundtrip_npm_alias() {
let mut graph = LockfileGraph::default();
graph.packages.insert(
"h3-v2@2.0.1-rc.20".to_string(),
LockedPackage {
name: "h3-v2".to_string(),
version: "2.0.1-rc.20".to_string(),
integrity: Some("sha512-aliased".to_string()),
dep_path: "h3-v2@2.0.1-rc.20".to_string(),
alias_of: Some("h3".to_string()),
tarball_url: Some("https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz".to_string()),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "h3-v2".to_string(),
dep_path: "h3-v2@2.0.1-rc.20".to_string(),
dep_type: DepType::Production,
specifier: Some("npm:h3@2.0.1-rc.20".to_string()),
}],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let body = std::fs::read_to_string(out.path()).unwrap();
assert!(
body.contains("\"name\": \"h3\""),
"expected `name: h3` emitted for aliased entry; got:\n{body}"
);
assert!(
body.contains("\"resolved\": \"https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz\""),
"expected `resolved:` URL emitted for aliased entry; got:\n{body}"
);
let reparsed = parse(out.path()).unwrap();
let pkg = &reparsed.packages["h3-v2@2.0.1-rc.20"];
assert_eq!(pkg.alias_of.as_deref(), Some("h3"));
assert_eq!(pkg.registry_name(), "h3");
}
/// npm v7+ writes `peerDependencies` / `peerDependenciesMeta` onto
/// every package entry. The parser must populate the matching
/// `LockedPackage` fields so the resolver's `apply_peer_contexts`
/// pass (run on npm-lockfile installs to wire peer siblings in the
/// isolated virtual store) actually has peer info to work with.
/// Before this parser change, peer-dependent packages like
/// `@tanstack/devtools-vite` would install without a sibling
/// `vite` link and die at runtime.
#[test]
fn test_parse_peer_dependencies() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "peer-test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "peer-test",
"version": "1.0.0",
"dependencies": { "devtools-vite": "0.6.0", "vite": "8.0.0" }
},
"node_modules/devtools-vite": {
"version": "0.6.0",
"integrity": "sha512-a",
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"vite": { "optional": false }
}
},
"node_modules/vite": {
"version": "8.0.0",
"integrity": "sha512-b"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let devtools = &graph.packages["devtools-vite@0.6.0"];
assert_eq!(
devtools.peer_dependencies.get("vite").map(String::as_str),
Some("^6.0.0 || ^7.0.0 || ^8.0.0")
);
assert_eq!(
devtools
.peer_dependencies_meta
.get("vite")
.map(|m| m.optional),
Some(false)
);
}
/// Packages without peer fields keep both maps empty — guard
/// against accidental defaulting to `optional: true` or spurious
/// keys showing up in the LockedPackage from serde leak paths.
#[test]
fn test_parse_no_peer_fields_stays_empty() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "no-peers",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": { "name": "no-peers", "version": "1.0.0", "dependencies": { "foo": "1.0.0" } },
"node_modules/foo": { "version": "1.0.0", "integrity": "sha512-x" }
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let foo = &graph.packages["foo@1.0.0"];
assert!(foo.peer_dependencies.is_empty());
assert!(foo.peer_dependencies_meta.is_empty());
}
/// Writer round-trips `peerDependencies` so a second `parse()` on
/// the rewritten lockfile still feeds the peer-context pass. The
/// install path writes out the lockfile after every install; if
/// peers vanished on the first write-back, the *next* install
/// would ship without peer siblings again.
#[test]
fn test_write_roundtrip_peer_dependencies() {
let mut graph = LockfileGraph::default();
let mut peer_deps = BTreeMap::new();
peer_deps.insert("vite".to_string(), "^6.0.0 || ^7.0.0 || ^8.0.0".to_string());
// Include an `optional: true` entry so the round-trip covers
// `peerDependenciesMeta` — without it, the writer's meta
// block isn't exercised and the round-trip would silently
// re-flag the peer as required on every subsequent install
// (see `hoist_auto_installed_peers` + `detect_unmet_peers`,
// which key off `optional`).
let mut peer_deps_meta = BTreeMap::new();
peer_deps_meta.insert("vite".to_string(), crate::PeerDepMeta { optional: true });
graph.packages.insert(
"devtools-vite@0.6.0".to_string(),
LockedPackage {
name: "devtools-vite".to_string(),
version: "0.6.0".to_string(),
integrity: Some("sha512-a".to_string()),
dep_path: "devtools-vite@0.6.0".to_string(),
peer_dependencies: peer_deps,
peer_dependencies_meta: peer_deps_meta,
..Default::default()
},
);
graph.packages.insert(
"vite@8.0.0".to_string(),
LockedPackage {
name: "vite".to_string(),
version: "8.0.0".to_string(),
integrity: Some("sha512-b".to_string()),
dep_path: "vite@8.0.0".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "devtools-vite".to_string(),
dep_path: "devtools-vite@0.6.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
DirectDep {
name: "vite".to_string(),
dep_path: "vite@8.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let body = std::fs::read_to_string(out.path()).unwrap();
assert!(
body.contains("\"peerDependencies\""),
"expected peerDependencies block to round-trip; got:\n{body}"
);
assert!(
body.contains("\"peerDependenciesMeta\""),
"expected peerDependenciesMeta block to round-trip; got:\n{body}"
);
let reparsed = parse(out.path()).unwrap();
let devtools = &reparsed.packages["devtools-vite@0.6.0"];
assert_eq!(
devtools.peer_dependencies.get("vite").map(String::as_str),
Some("^6.0.0 || ^7.0.0 || ^8.0.0")
);
assert_eq!(
devtools
.peer_dependencies_meta
.get("vite")
.map(|m| m.optional),
Some(true),
"peerDependenciesMeta.optional must survive write → parse round-trip"
);
}
#[test]
fn test_parse_npm_workspace_importers() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "workspace-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "workspace-root",
"version": "1.0.0",
"workspaces": ["web"]
},
"node_modules/mise-versions-web": {
"resolved": "web",
"link": true
},
"web": {
"name": "mise-versions-web",
"version": "0.0.1",
"dependencies": { "astro": "^6.0.0" },
"devDependencies": { "vite": "^7.3.2" }
},
"web/node_modules/astro": {
"version": "6.2.1",
"integrity": "sha512-astro"
},
"web/node_modules/vite": {
"version": "7.3.2",
"integrity": "sha512-vite"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let root = graph.importers.get(".").expect("root importer");
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "mise-versions-web");
assert!(matches!(
graph.packages[&root[0].dep_path].local_source,
Some(LocalSource::Link(_))
));
let web = graph.importers.get("web").expect("web importer");
assert_eq!(web.len(), 2);
assert!(web.iter().any(|dep| {
dep.name == "astro"
&& dep.dep_type == DepType::Production
&& dep.specifier.as_deref() == Some("^6.0.0")
}));
assert!(web.iter().any(|dep| {
dep.name == "vite"
&& dep.dep_type == DepType::Dev
&& dep.specifier.as_deref() == Some("^7.3.2")
}));
}
#[test]
fn test_write_npm_workspace_importers() {
let mut graph = LockfileGraph::default();
let web_link = LocalSource::Link(PathBuf::from("web"));
let web_dep_path = web_link.dep_path("mise-versions-web");
graph.packages.insert(
web_dep_path.clone(),
LockedPackage {
name: "mise-versions-web".to_string(),
version: "0.0.1".to_string(),
dep_path: web_dep_path.clone(),
local_source: Some(web_link),
..Default::default()
},
);
graph.packages.insert(
"astro@6.2.1".to_string(),
LockedPackage {
name: "astro".to_string(),
version: "6.2.1".to_string(),
integrity: Some("sha512-astro".to_string()),
dep_path: "astro@6.2.1".to_string(),
..Default::default()
},
);
graph.packages.insert(
"vite@7.3.2".to_string(),
LockedPackage {
name: "vite".to_string(),
version: "7.3.2".to_string(),
integrity: Some("sha512-vite".to_string()),
dep_path: "vite@7.3.2".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "mise-versions-web".to_string(),
dep_path: web_dep_path.clone(),
dep_type: DepType::Production,
specifier: None,
}],
);
graph.importers.insert(
"web".to_string(),
vec![
DirectDep {
name: "astro".to_string(),
dep_path: "astro@6.2.1".to_string(),
dep_type: DepType::Production,
specifier: Some("^6.0.0".to_string()),
},
DirectDep {
name: "vite".to_string(),
dep_path: "vite@7.3.2".to_string(),
dep_type: DepType::Dev,
specifier: Some("^7.3.2".to_string()),
},
],
);
let manifest = aube_manifest::PackageJson {
name: Some("workspace-root".to_string()),
version: Some("1.0.0".to_string()),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
assert_eq!(
json["packages"]["node_modules/mise-versions-web"]["link"],
true
);
assert_eq!(
json["packages"]["node_modules/mise-versions-web"]["resolved"],
"web"
);
assert_eq!(json["packages"]["web"]["dependencies"]["astro"], "^6.0.0");
assert_eq!(json["packages"]["web"]["devDependencies"]["vite"], "^7.3.2");
assert_eq!(
json["packages"]["web/node_modules/astro"]["version"],
"6.2.1"
);
assert_eq!(
json["packages"]["web/node_modules/vite"]["version"],
"7.3.2"
);
let reparsed = parse(out.path()).unwrap();
assert!(reparsed.importers.contains_key("web"));
}
/// When the root tree already hoists a package to
/// `node_modules/<name>`, the workspace tree must NOT emit a
/// redundant `<workspace>/node_modules/<name>` for the same
/// version — Node's upward `node_modules` walk resolves the root
/// copy. Real `npm install` omits the redundant entry, and
/// emitting it produces a diff on every round-trip.
#[test]
fn test_write_npm_workspace_skips_root_hoisted_dups() {
let mut graph = LockfileGraph::default();
let web_link = LocalSource::Link(PathBuf::from("web"));
let web_dep_path = web_link.dep_path("workspace-web");
graph.packages.insert(
web_dep_path.clone(),
LockedPackage {
name: "workspace-web".to_string(),
version: "0.0.1".to_string(),
dep_path: web_dep_path.clone(),
local_source: Some(web_link),
..Default::default()
},
);
graph.packages.insert(
"astro@6.2.1".to_string(),
LockedPackage {
name: "astro".to_string(),
version: "6.2.1".to_string(),
integrity: Some("sha512-astro".to_string()),
dep_path: "astro@6.2.1".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "astro".to_string(),
dep_path: "astro@6.2.1".to_string(),
dep_type: DepType::Production,
specifier: Some("^6.0.0".to_string()),
},
DirectDep {
name: "workspace-web".to_string(),
dep_path: web_dep_path.clone(),
dep_type: DepType::Production,
specifier: None,
},
],
);
graph.importers.insert(
"web".to_string(),
vec![DirectDep {
name: "astro".to_string(),
dep_path: "astro@6.2.1".to_string(),
dep_type: DepType::Production,
specifier: Some("^6.0.0".to_string()),
}],
);
let manifest = aube_manifest::PackageJson {
name: Some("workspace-root".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("astro".to_string(), "^6.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
assert_eq!(json["packages"]["node_modules/astro"]["version"], "6.2.1");
assert!(
json["packages"].get("web/node_modules/astro").is_none(),
"redundant workspace-nested astro should not be emitted"
);
}
/// Byte-parity with a real `npm install`-generated lockfile. The
/// fixture at `tests/fixtures/npm-native.json` was produced by
/// `npm install` (v11) against a `{ chalk, picocolors, semver }`
/// manifest. A parse → write round-trip must reproduce the exact
/// bytes. Covers `resolved:` on every entry, `license:` /
/// `engines:` / `bin:` / `funding:` field preservation, and the
/// sibling declared-range preservation that rides on
/// `declared_dependencies`.
#[test]
fn test_write_byte_identical_to_native_npm() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/npm-native.json");
// Same LF normalization as the pnpm / bun byte-parity tests —
// Windows' `core.autocrlf=true` rewrites the checked-out
// fixture to CRLF even with `.gitattributes eol=lf`.
let original = std::fs::read_to_string(&fixture)
.unwrap()
.replace("\r\n", "\n");
let graph = parse(&fixture).unwrap();
let manifest = aube_manifest::PackageJson {
name: Some("aube-lockfile-stability".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [
("chalk".to_string(), "^4.1.2".to_string()),
("picocolors".to_string(), "^1.1.1".to_string()),
("semver".to_string(), "^7.6.3".to_string()),
]
.into_iter()
.collect(),
..Default::default()
};
let tmp = tempfile::NamedTempFile::new().unwrap();
write(tmp.path(), &graph, &manifest).unwrap();
let written = std::fs::read_to_string(tmp.path()).unwrap();
if written != original {
panic!(
"npm writer drifted from native npm output.\n\n--- expected ---\n{original}\n--- got ---\n{written}"
);
}
}
#[test]
fn test_parse_workspace_links() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "workspace-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "workspace-root",
"version": "1.0.0",
"dependencies": { "@scope/app": "file:packages/app" }
},
"node_modules/@scope/app": {
"resolved": "packages/app",
"link": true
},
"node_modules/chalk": {
"version": "5.4.1",
"integrity": "sha512-chalk"
},
"packages/app": {
"name": "@scope/app",
"version": "0.68.1",
"dependencies": {
"chalk": "^5.4.1"
}
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let dep_path = LocalSource::Link(PathBuf::from("packages/app")).dep_path("@scope/app");
let importer = &graph.importers["."];
assert_eq!(importer.len(), 1);
assert_eq!(importer[0].name, "@scope/app");
assert_eq!(importer[0].dep_path, dep_path);
assert!(matches!(importer[0].dep_type, DepType::Production));
assert!(importer[0].specifier.is_none());
let app = &graph.packages[&importer[0].dep_path];
assert_eq!(app.version, "0.68.1");
assert_eq!(
app.local_source,
Some(LocalSource::Link(PathBuf::from("packages/app")))
);
assert_eq!(
app.dependencies.get("chalk").map(String::as_str),
Some("5.4.1")
);
assert!(!graph.packages.contains_key("@scope/app@0.68.1"));
}
/// npm workspaces that aren't listed in the root manifest's
/// `dependencies`/`devDependencies` still get a `node_modules/<name>`
/// link entry in the lockfile — npm symlinks every workspace member
/// at the workspace root regardless. The siemens/element repo
/// (https://github.com/siemens/element) hits this: its 11 workspace
/// projects under `projects/*` aren't declared as deps of the root
/// `package.json`, so the linker had nothing to link at the root and
/// `node_modules/@siemens/element-ng` (and friends) silently went
/// missing — breaking Angular CLI builds that resolve workspace
/// libraries from the repo root.
#[test]
fn test_parse_workspace_links_undeclared_in_root_deps() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "workspace-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "workspace-root",
"version": "1.0.0",
"workspaces": ["projects/element-ng", "projects/charts-ng"],
"dependencies": { "chalk": "^5.4.1" }
},
"node_modules/@siemens/element-ng": {
"resolved": "projects/element-ng",
"link": true
},
"node_modules/@siemens/charts-ng": {
"resolved": "projects/charts-ng",
"link": true
},
"node_modules/chalk": {
"version": "5.4.1",
"integrity": "sha512-chalk"
},
"projects/element-ng": {
"name": "@siemens/element-ng",
"version": "21.0.0"
},
"projects/charts-ng": {
"name": "@siemens/charts-ng",
"version": "21.0.0"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let importer = &graph.importers["."];
let names: Vec<&str> = importer.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"chalk"));
assert!(
names.contains(&"@siemens/element-ng"),
"workspace package `@siemens/element-ng` should be a direct dep of root \
so the linker creates `node_modules/@siemens/element-ng`, even though \
the root manifest doesn't list it; got importer deps {names:?}"
);
assert!(
names.contains(&"@siemens/charts-ng"),
"workspace package `@siemens/charts-ng` should be a direct dep of root; \
got importer deps {names:?}"
);
// Each workspace dep_path round-trips through LocalSource::Link.
let element_ng = importer
.iter()
.find(|d| d.name == "@siemens/element-ng")
.unwrap();
assert_eq!(
graph.packages[&element_ng.dep_path].local_source,
Some(LocalSource::Link(PathBuf::from("projects/element-ng")))
);
}
/// npm copies `funding:` verbatim from each package's
/// `package.json`, so all three registry-permitted shapes (bare
/// string, `{url}` object, mixed array of either) appear in real
/// lockfiles. The pre-fix parser only accepted the object form
/// and would hard-fail on any project pulling in `htmlparser2`,
/// `@csstools/*`, etc. Aube only carries one URL per package, so
/// the contract is "first URL wins, no shape rejected".
#[test]
fn test_parse_funding_all_shapes() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": {
"string-funding": "1.0.0",
"object-funding": "1.0.0",
"array-funding": "1.0.0",
"mixed-array-funding": "1.0.0",
"no-funding": "1.0.0"
}
},
"node_modules/string-funding": {
"version": "1.0.0",
"integrity": "sha512-aaa",
"funding": "https://example.com/sponsor"
},
"node_modules/object-funding": {
"version": "1.0.0",
"integrity": "sha512-bbb",
"funding": { "type": "github", "url": "https://github.com/sponsors/foo" }
},
"node_modules/array-funding": {
"version": "1.0.0",
"integrity": "sha512-ccc",
"funding": [
{ "type": "github", "url": "https://github.com/sponsors/csstools" },
{ "type": "opencollective", "url": "https://opencollective.com/csstools" }
]
},
"node_modules/mixed-array-funding": {
"version": "1.0.0",
"integrity": "sha512-ddd",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{ "type": "github", "url": "https://github.com/sponsors/fb55" }
]
},
"node_modules/no-funding": {
"version": "1.0.0",
"integrity": "sha512-eee"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert_eq!(
graph.packages["string-funding@1.0.0"]
.funding_url
.as_deref(),
Some("https://example.com/sponsor"),
);
assert_eq!(
graph.packages["object-funding@1.0.0"]
.funding_url
.as_deref(),
Some("https://github.com/sponsors/foo"),
);
// Array form: aube collapses to the first URL.
assert_eq!(
graph.packages["array-funding@1.0.0"].funding_url.as_deref(),
Some("https://github.com/sponsors/csstools"),
);
// Mixed array (bare string + object): first element is a
// string, so its value is the URL.
assert_eq!(
graph.packages["mixed-array-funding@1.0.0"]
.funding_url
.as_deref(),
Some("https://github.com/fb55/htmlparser2?sponsor=1"),
);
assert!(graph.packages["no-funding@1.0.0"].funding_url.is_none());
}
/// Real-world `package-lock.json` entries can carry the legacy
/// object / array-of-objects shapes for `license:` (npm copies
/// whatever's in the package's `package.json` verbatim, and older
/// packages like `tv4` still ship the deprecated forms). Regression
/// guard for https://github.com/endevco/aube/discussions/510.
#[test]
fn test_parse_license_all_shapes() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": {
"string-license": "1.0.0",
"object-license": "1.0.0",
"array-license": "1.0.0",
"mixed-array-license": "1.0.0",
"no-license": "1.0.0"
}
},
"node_modules/string-license": {
"version": "1.0.0",
"integrity": "sha512-aaa",
"license": "MIT"
},
"node_modules/object-license": {
"version": "1.0.0",
"integrity": "sha512-bbb",
"license": { "type": "ISC", "url": "https://example.com/ISC" }
},
"node_modules/array-license": {
"version": "1.0.0",
"integrity": "sha512-ccc",
"license": [
{ "type": "Public Domain", "url": "http://geraintluff.github.io/tv4/LICENSE.txt" },
{ "type": "MIT", "url": "http://jsonary.com/LICENSE.txt" }
]
},
"node_modules/mixed-array-license": {
"version": "1.0.0",
"integrity": "sha512-ddd",
"license": [
"MIT",
{ "type": "Apache-2.0", "url": "https://example.com/apache" }
]
},
"node_modules/no-license": {
"version": "1.0.0",
"integrity": "sha512-eee"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert_eq!(
graph.packages["string-license@1.0.0"].license.as_deref(),
Some("MIT"),
);
assert_eq!(
graph.packages["object-license@1.0.0"].license.as_deref(),
Some("ISC"),
);
// Array form: aube collapses to the first license type.
assert_eq!(
graph.packages["array-license@1.0.0"].license.as_deref(),
Some("Public Domain"),
);
// Mixed array (bare string + object): first element is a
// string, so its value is the license.
assert_eq!(
graph.packages["mixed-array-license@1.0.0"]
.license
.as_deref(),
Some("MIT"),
);
assert!(graph.packages["no-license@1.0.0"].license.is_none());
}