Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@bench/ui","version":"1.0.0","main":"./src/index.js"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports={};

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = {};
169 changes: 169 additions & 0 deletions src/tests/symlink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ use crate::tests::windows::get_dos_device_path;
use std::path::PathBuf;
use std::{fs, io, path::Path};

use walkdir::WalkDir;

use super::fixture_root;
use crate::{ResolveOptions, Resolver};

#[derive(Debug, Clone, Copy)]
Expand Down Expand Up @@ -239,3 +242,169 @@ fn test_circular_symlink() {
_ = fs::remove_file(&link1_path);
_ = fs::remove_file(&link2_path);
}

// ----------------------------------------------------------------------------
// `node_modules` canonicalization: assert the resolver's canonicalization equals
// `std::fs::canonicalize` across package-manager layouts and symlink shapes.
// ----------------------------------------------------------------------------

const COMBOS: &[&str] = &[
"npm-flat",
"pnpm-isolated",
"pnpm-hoisted",
"yarn-flat",
"yarn-isolated",
"yarn-pnp",
"bun-flat",
"bun-isolated",
];

/// Walk every entry under each installed `fixtures/bench-pm/installs/<combo>/node_modules` tree
/// (all npm/pnpm/yarn/bun × flat/isolated/hoisted/pnp layouts) and assert the resolver's
/// canonicalization equals `std::fs::canonicalize`, including the symlinks (and, on Windows,
/// junctions) into isolated virtual stores and the `.bin` shim directory. The fixtures are produced
/// by `just install-bench-fixtures` and are not committed, so combos that are not installed are
/// skipped.
#[test]
fn canonicalize_matches_os_for_all_node_modules() {
let installs = fixture_root().join("bench-pm").join("installs");
let mut combos_checked = 0u32;
let mut paths_checked = 0u32;

for combo in COMBOS {
let node_modules = installs.join(combo).join("node_modules");
if !node_modules.is_dir() {
continue;
}
combos_checked += 1;
let resolver = Resolver::new(ResolveOptions::default());

for entry in WalkDir::new(&node_modules).follow_links(false) {
let Ok(entry) = entry else { continue };
let path = entry.path();
// Ground truth from the OS. Skip broken symlinks / unreadable entries.
let Ok(expected) = fs::canonicalize(path) else { continue };
// `std::fs::canonicalize` returns a `\\?\` verbatim prefix on Windows; the resolver
// strips it (`strip_windows_prefix`), so run the oracle through the same strip to
// compare the two in the same representation.
#[cfg(target_os = "windows")]
let Ok(expected) = crate::windows::strip_windows_prefix(expected) else { continue };
let cached = resolver.cache.value(path);
let actual = resolver.cache.canonicalize(&cached).unwrap_or_else(|err| {
panic!("{combo}: resolver canonicalize({}) failed: {err}", path.display())
});
assert_eq!(actual, expected, "{combo}: canonicalize mismatch for {}", path.display());
paths_checked += 1;
}
}

if combos_checked == 0 {
eprintln!(
"skip canonicalize_matches_os_for_all_node_modules: no bench-pm installs found \
(run `just install-bench-fixtures`)"
);
return;
}
eprintln!("checked {paths_checked} paths across {combos_checked} bench-pm combos");
}

/// A symlinked workspace package anchor with a symlink in the suffix below it: canonicalization must
/// follow both the anchor link and the inner link.
#[test]
fn symlinked_package_anchor_walks_suffix_symlinks() {
let root = fixture_root().join("node_modules-canonicalize/symlinked-workspace");
let anchor = root.join("node_modules/pkg");
let inner_link = anchor.join("src/link");

if !fs::symlink_metadata(&anchor).is_ok_and(|m| m.is_symlink())
|| !fs::symlink_metadata(&inner_link).is_ok_and(|m| m.is_symlink())
{
eprintln!("skip symlinked_package_anchor_walks_suffix_symlinks: symlinks unavailable");
return;
}

let path = inner_link.join("file.js");
let expected = fs::canonicalize(&path).unwrap();
#[cfg(target_os = "windows")]
let expected = crate::windows::strip_windows_prefix(expected).unwrap();

let resolver = Resolver::new(ResolveOptions::default());
let cached = resolver.cache.value(&path);
let actual = resolver.cache.canonicalize(&cached).unwrap();

assert_eq!(actual, expected);
assert_eq!(expected, root.join("packages/pkg/real/file.js"));
}

/// A package in a recognized (lockfile-marked) flat layout can still ship a symlink below its real
/// `node_modules/<pkg>` anchor — a directory link (`lib -> dist`) or a re-export file link.
/// Canonicalization must follow it, not append the suffix across it.
#[test]
fn real_package_anchor_walks_internal_symlinks() {
let root = fixture_root().join("node_modules-canonicalize/internal-symlink");
let dir_link = root.join("node_modules/pkg/lib");
let file_link = root.join("node_modules/pkg/reexport.js");

if !fs::symlink_metadata(&dir_link).is_ok_and(|m| m.is_symlink())
|| !fs::symlink_metadata(&file_link).is_ok_and(|m| m.is_symlink())
{
eprintln!("skip real_package_anchor_walks_internal_symlinks: symlinks unavailable");
return;
}

let real = root.join("node_modules/pkg/real/file.js");
let resolver = Resolver::new(ResolveOptions::default());
for path in [dir_link.join("file.js"), file_link] {
let expected = fs::canonicalize(&path).unwrap();
#[cfg(target_os = "windows")]
let expected = crate::windows::strip_windows_prefix(expected).unwrap();
let cached = resolver.cache.value(&path);
let actual = resolver.cache.canonicalize(&cached).unwrap();
assert_eq!(actual, expected, "{}", path.display());
assert_eq!(expected, real, "{}", path.display());
}
}

/// A monorepo with a version conflict: the root resolves `dep@2` while a workspace package nests
/// its own `dep@1` (an isolated-store symlink). Canonicalization must pick each package's own
/// deepest `node_modules/<pkg>` anchor and follow it to the correct store version. Every path is
/// compared against `std::fs::canonicalize`, walking through symlinked directories too.
#[test]
fn nested_monorepo_canonicalize_matches_os() {
let root = fixture_root().join("node_modules-canonicalize/nested-monorepo");
let nested = root.join("packages/ui/node_modules/dep");
if !fs::symlink_metadata(&nested).is_ok_and(|m| m.is_symlink()) {
eprintln!("skip nested_monorepo_canonicalize_matches_os: symlinks unavailable");
return;
}

let resolver = Resolver::new(ResolveOptions::default());
let walk = WalkDir::new(&root)
.follow_links(false)
.into_iter()
.chain(WalkDir::new(&root).follow_links(true).max_depth(12));
for entry in walk {
let Ok(entry) = entry else { continue };
let path = entry.path();
let Ok(expected) = fs::canonicalize(path) else { continue };
#[cfg(target_os = "windows")]
let Ok(expected) = crate::windows::strip_windows_prefix(expected) else { continue };
let cached = resolver.cache.value(path);
let actual = resolver.cache.canonicalize(&cached).unwrap();
assert_eq!(actual, expected, "canonicalize mismatch for {}", path.display());
}

// The conflicting versions resolve to their respective stores.
let resolve = |p: &Path| {
let cached = resolver.cache.value(p);
resolver.cache.canonicalize(&cached).unwrap()
};
assert_eq!(
resolve(&nested.join("index.js")),
root.join("node_modules/.pnpm/dep@1.0.0/node_modules/dep/index.js")
);
assert_eq!(
resolve(&root.join("node_modules/dep/index.js")),
root.join("node_modules/.pnpm/dep@2.0.0/node_modules/dep/index.js")
);
}
Loading