diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index dd33e38ce222..9020190bb9ae 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -281,6 +281,13 @@ pub type AsyncNetworkTaskQueue = UnboundedQueue; pub(crate) type SuccessFn = fn(&mut PackageManager, DependencyID, PackageID); pub(crate) type FailFn = fn(&mut PackageManager, &Dependency, PackageID, Error); +/// A peer held back by `find_peer_provider`, with the name and range it resolves under (after aliases, overrides and catalogs). +pub(crate) struct DeferredFolderPeer { + pub(crate) dependency_id: DependencyID, + pub(crate) name_hash: PackageNameHash, + pub(crate) version: crate::dependency::Version, +} + // Default to a maximum of 64 simultaneous HTTP requests for bun install if no proxy is specified // if a proxy IS specified, default to 64. We have different values because we might change this in the future. // https://github.com/npm/cli/issues/7072 @@ -393,6 +400,11 @@ pub struct PackageManager { pub(crate) peer_dependencies: LinearFifo>, + /// Peers a root `file:` folder or link could satisfy, held back until nothing else can join the dependency graph. + pub(crate) deferred_folder_peers: Vec, + /// Set while the deferred peers are decided: the graph is complete, so `find_peer_provider` may bind a root folder. + pub(crate) peer_graph_complete: bool, + // name hash from alias package name -> aliased package dependency version info pub(crate) known_npm_aliases: NpmAliasMap, @@ -2122,6 +2134,8 @@ pub fn init( peer_dependencies, LinearFifo::>::init() ); + wr!(deferred_folder_peers, Vec::new()); + wr!(peer_graph_complete, false); wr!(known_npm_aliases, NpmAliasMap::default()); wr!(trusted_deps_to_add_to_package_json, Vec::new()); wr!(any_failed_to_install, false); @@ -2581,6 +2595,8 @@ fn init_with_runtime_once( peer_dependencies, LinearFifo::>::init() ); + wr!(deferred_folder_peers, Vec::new()); + wr!(peer_graph_complete, false); wr!(known_npm_aliases, NpmAliasMap::default()); wr!(trusted_deps_to_add_to_package_json, Vec::new()); wr!(any_failed_to_install, false); diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index adf08bded22a..70c1035e445f 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -21,9 +21,9 @@ use crate::lockfile::PackageIndexEntry; use crate::lockfile::package::Package; use crate::lockfile_real as Lockfile; use crate::package_manager_real::{ - self, FailFn, PackageManager, SuccessFn, TaskCallbackList, determine_preinstall_state, - get_cache_directory, get_preinstall_state, get_temporary_directory, run_tasks, - set_preinstall_state, + self, DeferredFolderPeer, FailFn, PackageManager, SuccessFn, TaskCallbackList, + determine_preinstall_state, get_cache_directory, get_preinstall_state, get_temporary_directory, + run_tasks, set_preinstall_state, }; use crate::package_manager_task as Task; use crate::patch_install::EnqueueAfterState; @@ -1101,6 +1101,12 @@ pub fn enqueue_dependency_with_main_and_success_fn( result.package.meta.id, ); } + } else if this + .deferred_folder_peers + .last() + .is_some_and(|deferred| deferred.dependency_id == id) + { + // Held back by `find_peer_provider`; `resolve_deferred_folder_peers` decides it. } else if version.tag.is_npm() { // reshaped for borrowck — `name_str` borrows // `this.lockfile.buffers.string_bytes`. Route the whole @@ -2502,6 +2508,18 @@ fn get_or_put_resolved_package( ..Default::default() })); } + + if let Some(handled) = bind_peer_provider( + this, + &[existing_id], + dependency, + dependency_id, + name_hash, + version, + success_fn, + ) { + return Ok(handled); + } } } PackageIndexEntry::Ids(list) => { @@ -2554,6 +2572,19 @@ fn get_or_put_resolved_package( })); } } + + let candidates = list.to_vec(); + if let Some(handled) = bind_peer_provider( + this, + &candidates, + dependency, + dependency_id, + name_hash, + version, + success_fn, + ) { + return Ok(handled); + } } } } @@ -3078,6 +3109,119 @@ fn locked_version_in_lockfile<'a>( .map(|locked| (locked, buf)) } +/// Binds a non-registry provider to the peer, or holds the peer back for `resolve_deferred_folder_peers`; `None` means the callers' registry path takes over. +fn bind_peer_provider( + this: &mut PackageManager, + candidates: &[PackageID], + dependency: &Dependency, + dependency_id: DependencyID, + name_hash: PackageNameHash, + version: &dependency::Version, + success_fn: SuccessFn, +) -> Option> { + match find_peer_provider(this, candidates, dependency, dependency_id, version) { + PeerProvider::Package(provider) => { + success_fn(this, dependency_id, provider); + Some(Some(ResolvedPackageResult { + // we must fetch it from the packages array again, incase the package array mutates the value in the `successFn` + package: *this.lockfile.packages.get(provider as usize), + ..Default::default() + })) + } + PeerProvider::Deferred => { + this.deferred_folder_peers.push(DeferredFolderPeer { + dependency_id, + name_hash, + version: version.clone(), + }); + Some(None) + } + PeerProvider::None => None, + } +} + +pub(crate) enum PeerProvider { + Package(PackageID), + /// A root folder/link could satisfy the peer; decided once the dependency graph is complete. + Deferred, + None, +} + +/// Non-registry package satisfying an npm-range peer: the declarer's own same-named dependency, else a tarball/git package, else the root's folder/link when the declarer is installed directly under the root. +pub(crate) fn find_peer_provider( + this: &PackageManager, + candidates: &[PackageID], + dependency: &Dependency, + dependency_id: DependencyID, + version: &dependency::Version, +) -> PeerProvider { + if !matches!( + version.tag, + dependency::version::Tag::Npm | dependency::version::Tag::DistTag + ) { + return PeerProvider::None; + } + let lockfile = &this.lockfile; + let resolutions = lockfile.packages.items_resolution(); + // Some(true): installed from the cache, works anywhere; Some(false): a folder/link path that only resolves from the right spot; None: registry, left to the callers' version checks. + let usable_anywhere = |package_id: PackageID| { + let resolution = resolutions.get(package_id as usize)?; + match resolution.tag { + ResolutionTag::LocalTarball + | ResolutionTag::RemoteTarball + | ResolutionTag::Git + | ResolutionTag::Github => Some(true), + ResolutionTag::Folder | ResolutionTag::Symlink => Some(false), + _ => None, + } + }; + let declarer = lockfile.declaring_package(dependency_id); + // The declarer loads whatever it installs under this name itself, so nothing else can satisfy the peer. + let own = lockfile.resolution_of_dependency_named( + declarer, + dependency.name_hash, + dependency_id, + None, + ); + if own != invalid_package_id { + return if candidates.contains(&own) && usable_anywhere(own).is_some() { + PeerProvider::Package(own) + } else { + PeerProvider::None + }; + } + if let Some(anywhere) = candidates + .iter() + .copied() + .find(|&candidate| usable_anywhere(candidate) == Some(true)) + { + return PeerProvider::Package(anywhere); + } + let root_features = this.options.local_package_features; + let roots = lockfile.resolution_of_dependency_named( + 0, + dependency.name_hash, + dependency_id, + Some(root_features), + ); + if !candidates.contains(&roots) || usable_anywhere(roots) != Some(false) { + return PeerProvider::None; + } + // A filtered install may leave the root's dependencies out of node_modules altogether. + if !this.options.filter_patterns.is_empty() || this.filtered_link_targets.is_some() { + return PeerProvider::None; + } + // Whether every copy of the declarer sees the root's folder depends on edges that may still be arriving. + if !this.peer_graph_complete { + return PeerProvider::Deferred; + } + if lockfile.dedupes_onto_root_dependency(declarer, root_features) { + PeerProvider::Package(roots) + } else { + PeerProvider::None + } +} + fn resolution_satisfies_dependency( this: &PackageManager, resolution: &Resolution, diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index 718d9c436bcf..c21f96a896a2 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -39,6 +39,7 @@ use bun_install_types::NodeLinker::NodeLinker; // Free-function "methods" on `PackageManager` hosted in sibling modules // to avoid one giant `impl PackageManager` block. +use crate::package_manager_real::enqueue::{PeerProvider, find_peer_provider}; use crate::package_manager_real::run_tasks::{RunTasksCallbacks, run_tasks}; use crate::package_manager_real::{ UpdateRequest, enqueue_dependency_list, enqueue_dependency_with_main, enqueue_patch_task_pre, @@ -2054,21 +2055,66 @@ fn wait_for_resolution(manager: &mut PackageManager) -> crate::Result<()> { wait_for_everything_except_peers(manager)?; } - // Resolving a peer dep can create a NEW package whose own peer deps - // get re-queued to `peer_dependencies` during `drainDependencyList`. - // When all manifests are cached (synchronous resolution), no I/O tasks - // are spawned, so `pendingTaskCount() == 0`. We must drain the peer - // queue iteratively here — entering the event loop (`waitForPeers`) - // with zero pending I/O would block forever. - while manager.peer_dependencies.readable_length() > 0 { - manager.process_peer_dependency_list()?; - manager.drain_dependency_list(); - } + loop { + // A peer can add a package whose own peers land back in the queue; with every manifest cached nothing is pending, so drain here (the event loop would block on zero I/O). + while manager.peer_dependencies.readable_length() > 0 { + manager.process_peer_dependency_list()?; + manager.drain_dependency_list(); + } - if manager.pending_task_count() > 0 { - wait_for_peers(manager)?; + if manager.pending_task_count() > 0 { + wait_for_peers(manager)?; + } + + if !resolve_deferred_folder_peers(manager)? { + return Ok(()); + } } - Ok(()) +} + +/// Decides the held-back peers now that nothing is in flight: those a root folder cannot serve go to the registry first (what they pull in can change the rest), and the caller settles the graph and calls again when that happened. +fn resolve_deferred_folder_peers(manager: &mut PackageManager) -> crate::Result { + if manager.deferred_folder_peers.is_empty() { + return Ok(false); + } + let deferred = core::mem::take(&mut manager.deferred_folder_peers); + manager.peer_graph_complete = true; + let (bound, fallback): (Vec<_>, Vec<_>) = deferred.into_iter().partition(|peer| { + let lockfile = &manager.lockfile; + let candidates: &[PackageID] = match lockfile.package_index.get(&peer.name_hash) { + Some(lockfile::PackageIndexEntry::Id(id)) => core::slice::from_ref(id), + Some(lockfile::PackageIndexEntry::Ids(ids)) => ids.as_slice(), + None => &[], + }; + let dependency = &lockfile.buffers.dependencies.as_slice()[peer.dependency_id as usize]; + matches!( + find_peer_provider( + manager, + candidates, + dependency, + peer.dependency_id, + &peer.version + ), + PeerProvider::Package(_) + ) + }); + let fell_back = !fallback.is_empty(); + let to_enqueue = if fell_back { + manager.deferred_folder_peers = bound; + fallback + } else { + bound + }; + for peer in to_enqueue { + let dependency = + manager.lockfile.buffers.dependencies.as_slice()[peer.dependency_id as usize].clone(); + let resolution = + manager.lockfile.buffers.resolutions.as_slice()[peer.dependency_id as usize]; + enqueue_dependency_with_main(manager, peer.dependency_id, &dependency, resolution, true)?; + } + manager.peer_graph_complete = false; + manager.drain_dependency_list(); + Ok(fell_back) } #[cold] diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 4c721ac55ae6..bdeb98f44487 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -860,6 +860,101 @@ impl Lockfile { 0 } + /// The package whose dependency list `dep_id` belongs to. + pub(crate) fn declaring_package(&self, dep_id: DependencyID) -> PackageID { + self.packages + .items_dependencies() + .iter() + .position(|dependencies| dependencies.contains(dep_id)) + .map_or(invalid_package_id, |pkg_id| pkg_id as PackageID) + } + + /// Resolution of `pkg_id`'s dependency named `name_hash`, skipping `except`; `installed_under` limits it to placed edges. + pub(crate) fn resolution_of_dependency_named( + &self, + pkg_id: PackageID, + name_hash: PackageNameHash, + except: DependencyID, + installed_under: Option, + ) -> PackageID { + let Some(dependencies) = self.packages.items_dependencies().get(pkg_id as usize) else { + return invalid_package_id; + }; + (dependencies.begin()..dependencies.end()) + .filter(|&dep_id| { + let dep = &self.buffers.dependencies[dep_id as usize]; + dep_id != except + && dep.name_hash == name_hash + && installed_under.is_none_or(|features| dep.behavior.is_enabled(features)) + }) + .map(|dep_id| self.buffers.resolutions[dep_id as usize]) + .find(|&package_id| package_id != invalid_package_id) + .unwrap_or(invalid_package_id) + } + + /// Does this install place `package_id` under the root, and is it the only package anything installs under that name? Every dependency on it then dedupes onto the root's copy. + pub(crate) fn dedupes_onto_root_dependency( + &self, + package_id: PackageID, + root_features: Features, + ) -> bool { + if package_id == invalid_package_id { + return false; + } + let dependencies = self.buffers.dependencies.as_slice(); + let resolutions = self.buffers.resolutions.as_slice(); + let Some(first) = resolutions + .iter() + .position(|&resolved| resolved == package_id) + else { + return false; + }; + let install_name = dependencies[first].name_hash; + let root_dependencies = self.packages.items_dependencies()[0]; + let mut placed_by_root = false; + for (dep_id, dep) in dependencies.iter().enumerate() { + if resolutions[dep_id] == package_id { + if dep.name_hash != install_name { + return false; + } + placed_by_root |= root_dependencies.contains(dep_id as DependencyID) + && dep.behavior.is_enabled(root_features); + } else if dep.name_hash == install_name && resolutions[dep_id] != invalid_package_id { + return false; + } + } + placed_by_root && !self.is_dependency_of_self_contained_workspace(package_id) + } + + /// A self-contained workspace's subtree is a hoisting barrier (`Tree::process_subtree`), so its copy of a package never dedupes onto the root's. + fn is_dependency_of_self_contained_workspace(&self, package_id: PackageID) -> bool { + let mut stack = self.self_contained_workspace_ids(); + if stack.is_empty() { + return false; + } + let dependency_lists = self.packages.items_dependencies(); + let resolutions = self.buffers.resolutions.as_slice(); + let mut seen = vec![false; dependency_lists.len()]; + for &workspace in &stack { + seen[workspace as usize] = true; + } + while let Some(pkg_id) = stack.pop() { + let dependencies = dependency_lists[pkg_id as usize]; + for dep_id in dependencies.begin()..dependencies.end() { + let resolved = resolutions[dep_id as usize]; + if resolved == package_id { + return true; + } + if (resolved as usize) < seen.len() + && !std::mem::replace(&mut seen[resolved as usize], true) + { + stack.push(resolved); + } + } + } + false + } + /// Does this tree id belong to a workspace (including workspace root)? /// TODO(dylan-conway) fix! pub(crate) fn is_workspace_tree_id(&self, id: tree::Id) -> bool { diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index d41a2311f467..8cc641617463 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -1659,6 +1659,751 @@ describe.concurrent("bun-install", () => { }); }); + /** Runs `bun install ...args` in the context dir with every pipe drained. */ + async function installIn(ctx: TestContext, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), "install", ...args], + cwd: ctx.package_dir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { out, err, exitCode }; + } + + /** The lockfile bun just wrote has to load back: `--frozen-lockfile` passes and a reinstall is a no-op. */ + async function expectLockfileRoundTrips(ctx: TestContext) { + const lockfile = await file(join(ctx.package_dir, "bun.lock")).text(); + for (const args of [["--frozen-lockfile"], []]) { + const { err, exitCode } = await installIn(ctx, ...args); + expect(err).not.toContain("error:"); + expect(err).not.toContain("Saved lockfile"); + expect(exitCode).toBe(0); + } + expect(await file(join(ctx.package_dir, "bun.lock")).text()).toBe(lockfile); + return lockfile; + } + + it("should bind a registry package's peerDependency to the root's file: dependency", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + // `bar@0.0.2` (from the registry) has a peer on `zz-host`, which only exists + // as the root's `file:` dependency. The registry does not have `zz-host`. + const bar = dummyRegistryForContext(ctx, urls, { "0.0.2": { peerDependencies: { "zz-host": "*" } } }); + setContextHandler(ctx, request => { + if (new URL(request.url).pathname.startsWith(`/${ctx.id}/bar`)) return bar(request); + urls.push(request.url); + return new Response("not found", { status: 404 }); + }); + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { bar: "0.0.2", "zz-host": "file:./vendor/zz-host" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "zz-host", "package.json"), + JSON.stringify({ name: "zz-host", version: "1.0.0" }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--save-text-lockfile"); + expect(err).toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + // the root's folder satisfied the peer: it was never looked up, and it was + // not placed a second time under `bar` (where a root-relative folder path + // cannot be installed from). + expect(urls.sort()).toEqual([`${ctx.registry_url}bar`, `${ctx.registry_url}bar-0.0.2.tgz`]); + const lockfile = await expectLockfileRoundTrips(ctx); + expect([...lockfile.matchAll(/^ {4}"([^"]+)": \["zz-host@([^"]+)"/gm)].map(m => [m[1], m[2]])).toEqual([ + ["zz-host", "file:vendor/zz-host"], + ]); + expect( + await Promise.all([ + file(join(ctx.package_dir, "node_modules", "zz-host", "package.json")).json(), + exists(join(ctx.package_dir, "node_modules", "bar", "node_modules")), + ]), + ).toEqual([{ name: "zz-host", version: "1.0.0" }, false]); + }); + }); + + it("should not bind a peerDependency to a file: dependency that only a workspace member declares", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + setContextHandler(ctx, dummyRegistryForContext(ctx, urls)); + // The workspace member's folder copy of `bar` is installed inside the + // member, which is not above `zz-plugin` in the tree, so it cannot satisfy + // the plugin's peer. The peer is resolved from the registry as before. + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + workspaces: ["packages/*"], + dependencies: { "zz-plugin": "file:./vendor/zz-plugin" }, + }), + ), + write( + join(ctx.package_dir, "packages", "zz-member", "package.json"), + JSON.stringify({ + name: "zz-member", + version: "1.0.0", + dependencies: { bar: "file:../../vendor/bar" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "bar", "package.json"), + JSON.stringify({ name: "bar", version: "9.9.9" }), + ), + write( + join(ctx.package_dir, "vendor", "zz-plugin", "package.json"), + JSON.stringify({ + name: "zz-plugin", + version: "1.0.0", + peerDependencies: { bar: "*" }, + }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--save-text-lockfile"); + expect(err).toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + expect(urls.sort()).toEqual([`${ctx.registry_url}bar`, `${ctx.registry_url}bar-0.0.2.tgz`]); + expect( + await Promise.all([ + file(join(ctx.package_dir, "node_modules", "bar", "package.json")).json(), + file(join(ctx.package_dir, "packages", "zz-member", "node_modules", "bar", "package.json")).json(), + exists(join(ctx.package_dir, "node_modules", "zz-plugin", "node_modules")), + ]), + ).toMatchObject([{ name: "bar", version: "0.0.2" }, { name: "bar", version: "9.9.9" }, false]); + + await expectLockfileRoundTrips(ctx); + }); + }); + + it("should not bind a peerDependency to a root file: dependency installed under a different name", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + setContextHandler(ctx, dummyRegistryForContext(ctx, urls)); + // The fork's manifest is named `bar`, but the root installs it as + // `zz-bar-fork`, so nothing is at node_modules/bar to satisfy the peer. + // The peer is resolved from the registry as before. + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + "zz-bar-fork": "file:./vendor/bar-fork", + "zz-plugin": "file:./vendor/zz-plugin", + }, + }), + ), + write( + join(ctx.package_dir, "vendor", "bar-fork", "package.json"), + JSON.stringify({ name: "bar", version: "9.9.9" }), + ), + write( + join(ctx.package_dir, "vendor", "zz-plugin", "package.json"), + JSON.stringify({ + name: "zz-plugin", + version: "1.0.0", + peerDependencies: { bar: "*" }, + }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--save-text-lockfile"); + expect(err).toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + expect(urls.sort()).toEqual([`${ctx.registry_url}bar`, `${ctx.registry_url}bar-0.0.2.tgz`]); + expect( + await Promise.all([ + file(join(ctx.package_dir, "node_modules", "bar", "package.json")).json(), + file(join(ctx.package_dir, "node_modules", "zz-bar-fork", "package.json")).json(), + exists(join(ctx.package_dir, "node_modules", "zz-plugin", "node_modules")), + ]), + ).toMatchObject([{ name: "bar", version: "0.0.2" }, { name: "bar", version: "9.9.9" }, false]); + + await expectLockfileRoundTrips(ctx); + }); + }); + + it("should only bind a peerDependency to a root file: devDependency when dev dependencies are installed", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + // `bar@0.0.2` peers on `baz`. The root has `baz` as a `file:` devDependency; + // the registry also has baz@0.0.3. + const bar = dummyRegistryForContext(ctx, urls, { "0.0.2": { peerDependencies: { baz: "*" } } }); + const rest = dummyRegistryForContext(ctx, urls, { "0.0.3": {} }); + setContextHandler(ctx, request => + (new URL(request.url).pathname.startsWith(`/${ctx.id}/bar`) ? bar : rest)(request), + ); + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { bar: "0.0.2" }, + devDependencies: { baz: "file:./vendor/baz" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "baz", "package.json"), + JSON.stringify({ name: "baz", version: "9.9.9" }), + ), + ]); + + // dev dependencies installed: the root's folder is above `bar`, so it satisfies the peer. + { + const { err, exitCode } = await installIn(ctx, "--save-text-lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + expect(urls.sort()).toEqual([`${ctx.registry_url}bar`, `${ctx.registry_url}bar-0.0.2.tgz`]); + expect(await file(join(ctx.package_dir, "node_modules", "baz", "package.json")).json()).toEqual({ + name: "baz", + version: "9.9.9", + }); + } + + // dev dependencies omitted: the folder is not going to be installed anywhere + // `bar` can see it, so the peer is resolved from the registry instead. + await rm(join(ctx.package_dir, "node_modules"), { recursive: true, force: true }); + await rm(join(ctx.package_dir, "bun.lock"), { force: true }); + urls.length = 0; + { + const { err, exitCode } = await installIn(ctx, "--omit=dev", "--save-text-lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + expect(urls.sort()).toEqual([ + `${ctx.registry_url}bar`, + `${ctx.registry_url}bar-0.0.2.tgz`, + `${ctx.registry_url}baz`, + `${ctx.registry_url}baz-0.0.3.tgz`, + ]); + expect( + await Promise.all([ + file(join(ctx.package_dir, "node_modules", "baz", "package.json")).json(), + exists(join(ctx.package_dir, "node_modules", "bar", "node_modules")), + ]), + ).toMatchObject([{ name: "baz", version: "0.0.3" }, false]); + } + }); + }); + + it("should not bind the peerDependency of a package that also gets nested to the root's file: dependency", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + // boba depends on baz@0.0.3 and on bar@0.0.2, which peers on baz >=0.0.5. + // The root has bar@0.0.9, so boba's bar@0.0.2 is nested under boba, where + // boba's baz@0.0.3 shadows the root's folder copy of baz. The root also + // installs bar@0.0.2 itself, but under an alias, which does not stop the + // nested copy from existing. The root's folder cannot satisfy the nested + // copy's peer, so it is resolved from the registry (baz@0.0.5) as before. + const manifests: Record> = { + bar: dummyRegistryForContext(ctx, urls, { + "0.0.2": { peerDependencies: { baz: ">=0.0.5" } }, + "0.0.9": { as: "0.0.2" }, + }), + boba: dummyRegistryForContext(ctx, urls, { "0.0.2": { dependencies: { baz: "0.0.3", bar: "0.0.2" } } }), + baz: dummyRegistryForContext(ctx, urls, { "0.0.3": {}, "0.0.5": {} }), + }; + setContextHandler(ctx, request => { + const name = new URL(request.url).pathname.slice(`/${ctx.id}/`.length).replace(/-\d.*$/, ""); + return manifests[name](request); + }); + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { bar: "0.0.9", "zz-bar-old": "npm:bar@0.0.2", boba: "0.0.2", baz: "file:./vendor/baz" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "baz", "package.json"), + JSON.stringify({ name: "baz", version: "9.9.9" }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--save-text-lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + const lockfile = await expectLockfileRoundTrips(ctx); + expect([...lockfile.matchAll(/^ {4}"([^"]+)": \["baz@([^"]+)"/gm)].map(m => [m[1], m[2]]).sort()).toEqual([ + ["baz", "file:vendor/baz"], + ["boba/bar/baz", "0.0.5"], + ["boba/baz", "0.0.3"], + ]); + expect( + await file( + join(ctx.package_dir, "node_modules", "boba", "node_modules", "bar", "node_modules", "baz", "package.json"), + ).json(), + ).toMatchObject({ version: "0.0.5" }); + }); + }); + + it("should not bind the peerDependency of a package to the root's file: dependency while another version of the package is installed somewhere", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + // The root depends on bar@0.0.2 (which peers on baz >=0.0.5), on boba and on + // moo@0.2.0. boba depends on bar@0.0.9, moo@0.1.0 and baz@0.0.3, all of which + // end up nested under boba; moo@0.1.0 depends on bar@0.0.2, which cannot + // dedupe past boba's bar@0.0.9, so a second copy of bar@0.0.2 lands under + // boba/moo, where boba's baz@0.0.3 hides the root's folder from it. The + // peer is therefore resolved from the registry (baz@0.0.5) as before. + const manifests: Record> = { + bar: dummyRegistryForContext(ctx, urls, { + "0.0.2": { peerDependencies: { baz: ">=0.0.5" } }, + "0.0.9": { as: "0.0.2" }, + }), + boba: dummyRegistryForContext(ctx, urls, { + "0.0.2": { dependencies: { bar: "0.0.9", moo: "0.1.0", baz: "0.0.3" } }, + }), + moo: dummyRegistryForContext(ctx, urls, { "0.1.0": { dependencies: { bar: "0.0.2" } }, "0.2.0": {} }), + baz: dummyRegistryForContext(ctx, urls, { "0.0.3": {}, "0.0.5": {} }), + }; + setContextHandler(ctx, request => { + const name = new URL(request.url).pathname.slice(`/${ctx.id}/`.length).replace(/-\d.*$/, ""); + return manifests[name](request); + }); + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { bar: "0.0.2", boba: "0.0.2", moo: "0.2.0", baz: "file:./vendor/baz" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "baz", "package.json"), + JSON.stringify({ name: "baz", version: "9.9.9" }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--save-text-lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + const lockfile = await expectLockfileRoundTrips(ctx); + expect([...lockfile.matchAll(/^ {4}"([^"]+)": \["baz@([^"]+)"/gm)].map(m => [m[1], m[2]]).sort()).toEqual([ + ["baz", "file:vendor/baz"], + ["boba/baz", "0.0.3"], + ["boba/moo/bar/baz", "0.0.5"], + ]); + expect( + await file( + join( + ctx.package_dir, + "node_modules", + "boba", + "node_modules", + "moo", + "node_modules", + "bar", + "node_modules", + "baz", + "package.json", + ), + ).json(), + ).toMatchObject({ version: "0.0.5" }); + }); + }); + + it("should not bind the peerDependency of a root devDependency to the root's file: dependency under --omit=dev", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + // bar@0.0.2 (which peers on baz >=0.0.5) is a devDependency of the root, so + // with --omit=dev it is not installed at the root: boba's bar@0.0.9 takes + // that spot and qux's bar@0.0.2 is installed under qux, where qux's + // baz@0.0.3 hides the root's folder from it. The peer is resolved from the + // registry (baz@0.0.5) and installed next to that copy, as before. + const manifests: Record> = { + bar: dummyRegistryForContext(ctx, urls, { + "0.0.2": { peerDependencies: { baz: ">=0.0.5" } }, + "0.0.9": { as: "0.0.2" }, + }), + boba: dummyRegistryForContext(ctx, urls, { "0.0.2": { dependencies: { bar: "0.0.9" } } }), + qux: dummyRegistryForContext(ctx, urls, { "0.0.2": { dependencies: { bar: "0.0.2", baz: "0.0.3" } } }), + baz: dummyRegistryForContext(ctx, urls, { "0.0.3": {}, "0.0.5": {} }), + }; + setContextHandler(ctx, request => { + const name = new URL(request.url).pathname.slice(`/${ctx.id}/`.length).replace(/-\d.*$/, ""); + return manifests[name](request); + }); + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { boba: "0.0.2", qux: "0.0.2", baz: "file:./vendor/baz" }, + devDependencies: { bar: "0.0.2" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "baz", "package.json"), + JSON.stringify({ name: "baz", version: "9.9.9" }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--omit=dev", "--save-text-lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + expect(urls).toContain(`${ctx.registry_url}baz-0.0.5.tgz`); + expect( + await file( + join(ctx.package_dir, "node_modules", "qux", "node_modules", "bar", "node_modules", "baz", "package.json"), + ).json(), + ).toMatchObject({ version: "0.0.5" }); + + // The lockfile describes the tree with dev dependencies included, where the + // root's own bar@0.0.2 sits at the top and sees the folder, so nothing about + // the registry copy of baz is recorded and a full install leaves it alone. + const lockfile = await expectLockfileRoundTrips(ctx); + expect([...lockfile.matchAll(/^ {4}"([^"]+)": \["baz@([^"]+)"/gm)].map(m => [m[1], m[2]]).sort()).toEqual([ + ["baz", "file:vendor/baz"], + ["qux/baz", "0.0.3"], + ]); + }); + }); + + it("should not bind the peerDependency of a package a self-contained workspace depends on to the root's file: dependency", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + // The self-contained workspace gets its own copy of bar, and nothing in its + // node_modules may come from the root's. The root's folder copy of baz cannot + // be placed there, so the peer is resolved from the registry, as before, and + // the registry copy lands inside the workspace. + const manifests: Record> = { + bar: dummyRegistryForContext(ctx, urls, { "0.0.2": { peerDependencies: { baz: "*" } } }), + baz: dummyRegistryForContext(ctx, urls, { "0.0.3": {} }), + }; + setContextHandler(ctx, request => { + const name = new URL(request.url).pathname.slice(`/${ctx.id}/`.length).replace(/-\d.*$/, ""); + return manifests[name](request); + }); + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + workspaces: ["apps/*"], + dependencies: { bar: "0.0.2", baz: "file:./vendor/baz" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "baz", "package.json"), + JSON.stringify({ name: "baz", version: "9.9.9" }), + ), + write( + join(ctx.package_dir, "apps", "desktop", "package.json"), + JSON.stringify({ + name: "desktop", + version: "1.0.0", + installConfig: { hoistingLimits: "workspaces" }, + dependencies: { bar: "0.0.2" }, + }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--save-text-lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + expect(urls).toContain(`${ctx.registry_url}baz-0.0.3.tgz`); + expect( + await Promise.all([ + file(join(ctx.package_dir, "node_modules", "baz", "package.json")).json(), + file(join(ctx.package_dir, "apps", "desktop", "node_modules", "baz", "package.json")).json(), + ]), + ).toMatchObject([{ version: "9.9.9" }, { version: "0.0.3" }]); + + const lockfile = await expectLockfileRoundTrips(ctx); + expect([...lockfile.matchAll(/^ {4}"([^"]+)": \["baz@([^"]+)"/gm)].map(m => [m[1], m[2]]).sort()).toEqual([ + ["baz", "file:vendor/baz"], + ["desktop/baz", "0.0.3"], + ]); + }); + }); + + it("should not bind a peerDependency to the root's file: dependency when a package another peer pulls in installs a second version of its package", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + // Nothing but boba's peer asks for moo, so moo and everything below it only + // join the dependency graph after the peers have started resolving. moo + // brings bar@0.0.9, a baz@0.0.3 and qux@0.0.2, whose bar@0.0.2 then gets a + // second copy under moo/qux where moo's baz hides the root's folder. The + // decision for bar's peer has to wait for that, and then goes to the + // registry (baz@0.0.5) as before. + const manifests: Record> = { + bar: dummyRegistryForContext(ctx, urls, { + "0.0.2": { peerDependencies: { baz: ">=0.0.5" } }, + "0.0.9": { as: "0.0.2" }, + }), + boba: dummyRegistryForContext(ctx, urls, { "0.0.2": { peerDependencies: { moo: "*" } } }), + moo: dummyRegistryForContext(ctx, urls, { + "0.1.0": { dependencies: { bar: "0.0.9", baz: "0.0.3", qux: "0.0.2" } }, + }), + qux: dummyRegistryForContext(ctx, urls, { + "0.0.2": { dependencies: { bar: "0.0.2" } }, + "0.0.9": { as: "0.0.2" }, + }), + baz: dummyRegistryForContext(ctx, urls, { "0.0.3": {}, "0.0.5": {} }), + }; + setContextHandler(ctx, request => { + const name = new URL(request.url).pathname.slice(`/${ctx.id}/`.length).replace(/-\d.*$/, ""); + return manifests[name](request); + }); + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { bar: "0.0.2", boba: "0.0.2", qux: "0.0.9", baz: "file:./vendor/baz" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "baz", "package.json"), + JSON.stringify({ name: "baz", version: "9.9.9" }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--save-text-lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + const lockfile = await expectLockfileRoundTrips(ctx); + expect([...lockfile.matchAll(/^ {4}"([^"]+)": \["baz@([^"]+)"/gm)].map(m => [m[1], m[2]]).sort()).toEqual([ + ["baz", "file:vendor/baz"], + ["moo/baz", "0.0.3"], + ["moo/qux/bar/baz", "0.0.5"], + ]); + expect( + await file( + join( + ctx.package_dir, + "node_modules", + "moo", + "node_modules", + "qux", + "node_modules", + "bar", + "node_modules", + "baz", + "package.json", + ), + ).json(), + ).toMatchObject({ version: "0.0.5" }); + }); + }); + + it("should not bind a peerDependency to the root's file: dependency when --filter leaves the root's dependencies out", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + // Only the workspace member is installed, so the root's folder copy of baz + // is not placed anywhere bar could see it; the peer comes from the registry. + const manifests: Record> = { + bar: dummyRegistryForContext(ctx, urls, { "0.0.2": { peerDependencies: { baz: "*" } } }), + baz: dummyRegistryForContext(ctx, urls, { "0.0.3": {} }), + }; + setContextHandler(ctx, request => { + const name = new URL(request.url).pathname.slice(`/${ctx.id}/`.length).replace(/-\d.*$/, ""); + return manifests[name](request); + }); + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + workspaces: ["packages/*"], + dependencies: { bar: "0.0.2", baz: "file:./vendor/baz" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "baz", "package.json"), + JSON.stringify({ name: "baz", version: "9.9.9" }), + ), + write( + join(ctx.package_dir, "packages", "zz-member", "package.json"), + JSON.stringify({ name: "zz-member", version: "1.0.0", dependencies: { bar: "0.0.2" } }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--filter=zz-member", "--save-text-lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + expect(urls).toContain(`${ctx.registry_url}baz-0.0.3.tgz`); + expect( + await Promise.all([ + file(join(ctx.package_dir, "node_modules", "bar", "package.json")).json(), + file(join(ctx.package_dir, "node_modules", "baz", "package.json")).json(), + ]), + ).toMatchObject([{ version: "0.0.2" }, { version: "0.0.3" }]); + + // The lockfile describes the full tree, where the root's own bar sits next + // to the folder and sees it, so a full install leaves it alone. + const lockfile = await expectLockfileRoundTrips(ctx); + expect([...lockfile.matchAll(/^ {4}"([^"]+)": \["baz@([^"]+)"/gm)].map(m => [m[1], m[2]])).toEqual([ + ["baz", "file:vendor/baz"], + ]); + }); + }); + + it("should share one entry between an optional file: dependency and a file: peer on it under --omit=optional", async () => { + await withContext(defaultOpts, async ctx => { + setContextHandler(ctx, dummyRegistryForContext(ctx, [])); + // The optional entry still exists in the lockfile when it is omitted from + // the install, so the peer has to reuse it rather than add a second one. + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { "zz-plugin": "file:./vendor/zz-plugin" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "zz-host", "package.json"), + JSON.stringify({ name: "zz-host", version: "1.0.0" }), + ), + write( + join(ctx.package_dir, "vendor", "zz-plugin", "package.json"), + JSON.stringify({ + name: "zz-plugin", + version: "1.0.0", + optionalDependencies: { "zz-host": "file:../zz-host" }, + peerDependencies: { "zz-host": "file:../zz-host" }, + }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--omit=optional", "--save-text-lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + const lockfile = await expectLockfileRoundTrips(ctx); + expect([...lockfile.matchAll(/^ {4}"([^"]+)": \["zz-host@/gm)].map(m => m[1])).toEqual(["zz-plugin/zz-host"]); + }); + }); + + it("should bind a peerDependency to its package's own optional file: dependency even under --omit=optional", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + setContextHandler(ctx, dummyRegistryForContext(ctx, urls)); + // The plugin's optional copy is still recorded in the lockfile when the + // install omits it, so the peer binds to it there: not to the root's copy, + // and without asking the registry for the name. + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { "zz-plugin": "file:./vendor/zz-plugin", "zz-host": "file:./vendor/zz-host" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "zz-host", "package.json"), + JSON.stringify({ name: "zz-host", version: "2.0.0" }), + ), + write( + join(ctx.package_dir, "vendor", "zz-host-vendored", "package.json"), + JSON.stringify({ name: "zz-host", version: "1.0.0" }), + ), + write( + join(ctx.package_dir, "vendor", "zz-plugin", "package.json"), + JSON.stringify({ + name: "zz-plugin", + version: "1.0.0", + optionalDependencies: { "zz-host": "file:../zz-host-vendored" }, + peerDependencies: { "zz-host": "*" }, + }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--omit=optional", "--save-text-lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + expect(urls).toEqual([]); + + const lockfile = await expectLockfileRoundTrips(ctx); + expect([...lockfile.matchAll(/^ {4}"([^"]+)": \["zz-host@([^"]+)"/gm)].map(m => [m[1], m[2]]).sort()).toEqual([ + ["zz-host", "file:vendor/zz-host"], + ["zz-plugin/zz-host", "file:vendor/zz-host-vendored"], + ]); + expect(urls).toEqual([]); + }); + }); + + it("should not bind a peerDependency to the root's file: dependency when its package installs another copy itself", async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + // The registry only has baz@0.0.3, which the plugin depends on, so its peer + // on baz >=0.0.5 has no match. The root's folder copy of baz does not stand + // in for it: the plugin's own baz@0.0.3 is what gets installed under the + // plugin, so the peer is reported as unmet exactly as without the folder. + setContextHandler(ctx, dummyRegistryForContext(ctx, urls, { "0.0.3": {} })); + await Promise.all([ + write( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { "zz-plugin": "file:./vendor/zz-plugin", baz: "file:./vendor/baz" }, + }), + ), + write( + join(ctx.package_dir, "vendor", "baz", "package.json"), + JSON.stringify({ name: "baz", version: "9.9.9" }), + ), + write( + join(ctx.package_dir, "vendor", "zz-plugin", "package.json"), + JSON.stringify({ + name: "zz-plugin", + version: "1.0.0", + dependencies: { baz: "0.0.3" }, + peerDependencies: { baz: ">=0.0.5" }, + }), + ), + ]); + + const { err, exitCode } = await installIn(ctx, "--save-text-lockfile"); + expect(err).toContain('warn: No version matching ">=0.0.5" found for peer dependency "baz"'); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + const lockfile = await expectLockfileRoundTrips(ctx); + expect([...lockfile.matchAll(/^ {4}"([^"]+)": \["baz@([^"]+)"/gm)].map(m => [m[1], m[2]]).sort()).toEqual([ + ["baz", "file:vendor/baz"], + ["zz-plugin/baz", "0.0.3"], + ]); + expect( + await file(join(ctx.package_dir, "node_modules", "zz-plugin", "node_modules", "baz", "package.json")).json(), + ).toMatchObject({ version: "0.0.3" }); + }); + }); + it("should handle life-cycle scripts within workspaces", async () => { await withContext(defaultOpts, async ctx => { await writeFile( diff --git a/test/cli/install/bun-lock.test.ts b/test/cli/install/bun-lock.test.ts index c8ba27d17e5f..fe53a4bca8ef 100644 --- a/test/cli/install/bun-lock.test.ts +++ b/test/cli/install/bun-lock.test.ts @@ -768,6 +768,353 @@ it("should include unused resolutions in the lockfile", async () => { await runBunInstall(env, packageDir, { frozenLockfile: true }); }); +/** The `packages` keys (install paths) in `bun.lock` whose entry is the package `name`. */ +function lockfileKeysFor(lockfile: string, name: string): string[] { + return [...lockfile.matchAll(/^ {4}"([^"]+)": \["(@?[^"@]+)@/gm)].filter(m => m[2] === name).map(m => m[1]); +} + +/** The lockfile bun just wrote must load back: `--frozen-lockfile` passes and a plain install is a no-op. */ +async function expectLockfileRoundTrips(packageDir: string) { + const lockfile = await file(join(packageDir, "bun.lock")).text(); + await runBunInstall(env, packageDir, { frozenLockfile: true }); + const { err } = await runBunInstall(env, packageDir, { savesLockfile: false }); + expect(err).not.toContain("Saved lockfile"); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile); +} + +it("binds a peer dependency to the root's file: dependency instead of asking the registry", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + + // `zz-host-a` is not in the registry. The root provides it as a folder, and + // that one copy has to satisfy the plugin's peer: no registry lookup and no + // second copy placed under the plugin. + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "peer-from-root-folder", + version: "1.0.0", + dependencies: { + "zz-plugin-a": "file:./vendor/zz-plugin-a", + "zz-host-a": "file:./vendor/zz-host-a", + }, + }), + ), + write( + join(packageDir, "vendor", "zz-host-a", "package.json"), + JSON.stringify({ name: "zz-host-a", version: "1.0.0" }), + ), + write( + join(packageDir, "vendor", "zz-plugin-a", "package.json"), + JSON.stringify({ + name: "zz-plugin-a", + version: "1.0.0", + peerDependencies: { "zz-host-a": ">=1.0.0" }, + }), + ), + ]); + + await runBunInstall(env, packageDir); + + const lockfile = await file(join(packageDir, "bun.lock")).text(); + expect(lockfile).toContain('"zz-host-a@file:vendor/zz-host-a"'); + expect(lockfileKeysFor(lockfile, "zz-host-a")).toEqual(["zz-host-a"]); + expect( + await Promise.all([ + file(join(packageDir, "node_modules", "zz-host-a", "package.json")).json(), + exists(join(packageDir, "node_modules", "zz-plugin-a", "node_modules")), + ]), + ).toEqual([{ name: "zz-host-a", version: "1.0.0" }, false]); + + await expectLockfileRoundTrips(packageDir); +}); + +it("binds a peer dependency to a root file: dependency that lives outside the project", async () => { + const { packageDir } = await registry.createTestDir(); + + // The provider is `file:../zz-host-b`, a path that is only installable from + // the root's node_modules. A placement under the plugin would fail. + const project = join(packageDir, "project"); + await Promise.all([ + registry.writeBunfig(project, { linker: "hoisted" }), + write( + join(project, "package.json"), + JSON.stringify({ + name: "peer-from-outside-folder", + version: "1.0.0", + dependencies: { + "zz-plugin-b": "file:./vendor/zz-plugin-b", + "zz-host-b": "file:../zz-host-b", + }, + }), + ), + write(join(packageDir, "zz-host-b", "package.json"), JSON.stringify({ name: "zz-host-b", version: "1.0.0" })), + write( + join(project, "vendor", "zz-plugin-b", "package.json"), + JSON.stringify({ + name: "zz-plugin-b", + version: "1.0.0", + peerDependencies: { "zz-host-b": "*" }, + }), + ), + ]); + + await runBunInstall(env, project); + + const lockfile = await file(join(project, "bun.lock")).text(); + expect(lockfile).toContain('"zz-host-b@file:../zz-host-b"'); + expect(lockfileKeysFor(lockfile, "zz-host-b")).toEqual(["zz-host-b"]); + expect( + await Promise.all([ + file(join(project, "node_modules", "zz-host-b", "package.json")).json(), + exists(join(project, "node_modules", "zz-plugin-b", "node_modules")), + ]), + ).toEqual([{ name: "zz-host-b", version: "1.0.0" }, false]); + + await expectLockfileRoundTrips(project); +}); + +it("places a file: dependency that is also declared as a file: peer once", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + + // Both edges name the same folder. They have to share one entry under the + // plugin; two of them serialize as a duplicate key that bun then refuses to + // load back. + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "file-dep-and-file-peer", + version: "1.0.0", + dependencies: { "zz-plugin-e": "file:./vendor/zz-plugin-e" }, + }), + ), + write( + join(packageDir, "vendor", "zz-host-e", "package.json"), + JSON.stringify({ name: "zz-host-e", version: "1.0.0" }), + ), + write( + join(packageDir, "vendor", "zz-plugin-e", "package.json"), + JSON.stringify({ + name: "zz-plugin-e", + version: "1.0.0", + dependencies: { "zz-host-e": "file:../zz-host-e" }, + peerDependencies: { "zz-host-e": "file:../zz-host-e" }, + }), + ), + ]); + + await runBunInstall(env, packageDir); + + const lockfile = await file(join(packageDir, "bun.lock")).text(); + expect(lockfileKeysFor(lockfile, "zz-host-e")).toEqual(["zz-plugin-e/zz-host-e"]); + expect( + await file(join(packageDir, "node_modules", "zz-plugin-e", "node_modules", "zz-host-e", "package.json")).json(), + ).toEqual({ name: "zz-host-e", version: "1.0.0" }); + + await expectLockfileRoundTrips(packageDir); +}); + +it("binds a peer dependency to the file: dependency its own package declares on the same name", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + + // The optional-peer idiom: a package peers on a name and also vendors it via + // `optionalDependencies` (or `dependencies`). Neither host is in the registry; + // each peer binds to the copy its own package declared, installed once there. + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "peer-from-own-folder", + version: "1.0.0", + dependencies: { + "zz-plugin-c": "file:./vendor/zz-plugin-c", + "zz-plugin-d": "file:./vendor/zz-plugin-d", + }, + }), + ), + ...["c", "d"].map(s => + write( + join(packageDir, "vendor", `zz-host-${s}`, "package.json"), + JSON.stringify({ name: `zz-host-${s}`, version: "1.0.0" }), + ), + ), + write( + join(packageDir, "vendor", "zz-plugin-c", "package.json"), + JSON.stringify({ + name: "zz-plugin-c", + version: "1.0.0", + peerDependencies: { "zz-host-c": "*" }, + optionalDependencies: { "zz-host-c": "file:../zz-host-c" }, + }), + ), + write( + join(packageDir, "vendor", "zz-plugin-d", "package.json"), + JSON.stringify({ + name: "zz-plugin-d", + version: "1.0.0", + peerDependencies: { "zz-host-d": ">=1.0.0" }, + dependencies: { "zz-host-d": "file:../zz-host-d" }, + }), + ), + ]); + + await runBunInstall(env, packageDir); + + const lockfile = await file(join(packageDir, "bun.lock")).text(); + expect([lockfileKeysFor(lockfile, "zz-host-c"), lockfileKeysFor(lockfile, "zz-host-d")]).toEqual([ + ["zz-plugin-c/zz-host-c"], + ["zz-plugin-d/zz-host-d"], + ]); + expect( + await Promise.all([ + file(join(packageDir, "node_modules", "zz-plugin-c", "node_modules", "zz-host-c", "package.json")).json(), + file(join(packageDir, "node_modules", "zz-plugin-d", "node_modules", "zz-host-d", "package.json")).json(), + exists(join(packageDir, "node_modules", "zz-host-c")), + exists(join(packageDir, "node_modules", "zz-host-d")), + ]), + ).toEqual([{ name: "zz-host-c", version: "1.0.0" }, { name: "zz-host-d", version: "1.0.0" }, false, false]); + + await expectLockfileRoundTrips(packageDir); +}); + +it("prefers the file: dependency a package declares itself over the root's copy of the same name", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "peer-prefers-own-folder", + version: "1.0.0", + dependencies: { + "zz-plugin-f": "file:./vendor/zz-plugin-f", + "zz-host-f": "file:./vendor/zz-host-f", + }, + }), + ), + write( + join(packageDir, "vendor", "zz-host-f", "package.json"), + JSON.stringify({ name: "zz-host-f", version: "2.0.0" }), + ), + write( + join(packageDir, "vendor", "zz-host-f-vendored", "package.json"), + JSON.stringify({ name: "zz-host-f", version: "1.0.0" }), + ), + write( + join(packageDir, "vendor", "zz-plugin-f", "package.json"), + JSON.stringify({ + name: "zz-plugin-f", + version: "1.0.0", + peerDependencies: { "zz-host-f": "*" }, + optionalDependencies: { "zz-host-f": "file:../zz-host-f-vendored" }, + }), + ), + ]); + + await runBunInstall(env, packageDir); + + const lockfile = await file(join(packageDir, "bun.lock")).text(); + expect(lockfileKeysFor(lockfile, "zz-host-f").sort()).toEqual(["zz-host-f", "zz-plugin-f/zz-host-f"]); + expect( + await Promise.all([ + file(join(packageDir, "node_modules", "zz-host-f", "package.json")).json(), + file(join(packageDir, "node_modules", "zz-plugin-f", "node_modules", "zz-host-f", "package.json")).json(), + ]), + ).toEqual([ + { name: "zz-host-f", version: "2.0.0" }, + { name: "zz-host-f", version: "1.0.0" }, + ]); + + await expectLockfileRoundTrips(packageDir); +}); + +it("a file: peer lands on its own package's copy of the folder, not a sibling package's", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + + // Both plugins depend on the same folder, so two copies with the same + // resolution exist; zz-plugin-h's peer has to dedupe onto zz-plugin-h's own. + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "shared-folder-file-peer", + version: "1.0.0", + dependencies: { + "zz-plugin-g": "file:./vendor/zz-plugin-g", + "zz-plugin-h": "file:./vendor/zz-plugin-h", + }, + }), + ), + write( + join(packageDir, "vendor", "zz-shared", "package.json"), + JSON.stringify({ name: "zz-shared", version: "1.0.0" }), + ), + write( + join(packageDir, "vendor", "zz-plugin-g", "package.json"), + JSON.stringify({ + name: "zz-plugin-g", + version: "1.0.0", + dependencies: { "zz-shared": "file:../zz-shared" }, + }), + ), + write( + join(packageDir, "vendor", "zz-plugin-h", "package.json"), + JSON.stringify({ + name: "zz-plugin-h", + version: "1.0.0", + dependencies: { "zz-shared": "file:../zz-shared" }, + peerDependencies: { "zz-shared": "file:../zz-shared" }, + }), + ), + ]); + + await runBunInstall(env, packageDir); + + const lockfile = await file(join(packageDir, "bun.lock")).text(); + expect(lockfileKeysFor(lockfile, "zz-shared").sort()).toEqual(["zz-plugin-g/zz-shared", "zz-plugin-h/zz-shared"]); + + await expectLockfileRoundTrips(packageDir); +}); + +it("binds a workspace package's peer dependency to the root's file: dependency", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + + // A workspace package sits directly below the root, so the root's folder is + // the copy it resolves; `zz-host-w` is not in the registry. + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "workspace-peer-from-root-folder", + version: "1.0.0", + workspaces: ["packages/*"], + dependencies: { "zz-host-w": "file:./vendor/zz-host-w" }, + }), + ), + write( + join(packageDir, "vendor", "zz-host-w", "package.json"), + JSON.stringify({ name: "zz-host-w", version: "1.0.0" }), + ), + write( + join(packageDir, "packages", "zz-member-w", "package.json"), + JSON.stringify({ + name: "zz-member-w", + version: "1.0.0", + peerDependencies: { "zz-host-w": "*" }, + }), + ), + ]); + + await runBunInstall(env, packageDir); + + const lockfile = await file(join(packageDir, "bun.lock")).text(); + expect(lockfileKeysFor(lockfile, "zz-host-w")).toEqual(["zz-host-w"]); + expect(await exists(join(packageDir, "packages", "zz-member-w", "node_modules"))).toBe(false); + + await expectLockfileRoundTrips(packageDir); +}); + it("requires an integrity hash for an off-registry npm tarball URL at lockfileVersion 2", async () => { const { packageDir, packageJson } = await registry.createTestDir();