diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index 65c31c833473..b2bafc385fee 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -2,7 +2,16 @@ import { file, spawn, write } from "bun"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, lstatSync, readFileSync, readlinkSync, statSync } from "fs"; import { mkdir, readlink, rm, symlink } from "fs/promises"; -import { VerdaccioRegistry, bunEnv, bunExe, readdirSorted, runBunInstall, tempDir } from "harness"; +import { + VerdaccioRegistry, + bunEnv, + bunExe, + installEnv, + normalizeBunSnapshot, + readdirSorted, + runBunInstall, + tempDir, +} from "harness"; import { createRequire } from "module"; import { basename, dirname, join } from "path"; import { pathToFileURL } from "url"; @@ -40,6 +49,47 @@ function storeEntryName(name: string, resolution: string): string { return `${name}@${resolution.slice(0, CUT_RESOLUTION_LEN)}+${urlHash(resolution)}`; } +// The cases run concurrently, so every install runs with the `env` that pins +// the cache to the case's own project: from `registry.createTestDir`, or +// `installEnv` for the cases that build their project with `tempDir`. + +// Everything bun wrote to stderr, except the two progress lines it prints to a +// non-TTY stderr while it resolves: the count in "Resolved, downloaded and +// extracted [N]" is the number of network tasks, which depends on the cache. +function stderrLines(stderr: string): string[] { + return stderr + .split(/\r?\n/) + .filter( + line => + line !== "" && line !== "Resolving dependencies" && !/^Resolved, downloaded and extracted \[\d+\]$/.test(line), + ); +} + +// `bun install` in `cwd` must exit 0 with exactly `stderr` on stderr (see +// `stderrLines`). The default is the one line bun prints after it wrote +// bun.lock; an install that leaves the lockfile alone prints nothing. +async function install( + env: NodeJS.Dict, + cwd: string, + { args = [], stderr: expectedStderr = ["Saved lockfile"] }: { args?: string[]; stderr?: string[] } = {}, +) { + await using proc = spawn({ + cmd: [bunExe(), "install", ...args], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderrLines(stderr)).toEqual(expectedStderr); + expect(exitCode).toBe(0); +} + +// The cases that build a git repository need the git executable. +const gitExecutable = Bun.which("git"); +const testWithGit = test.concurrent.skipIf(!gitExecutable); + beforeAll(async () => { await registry.start(); }); @@ -48,9 +98,9 @@ afterAll(() => { registry.stop(); }); -describe("basic", () => { +describe.concurrent("basic", () => { test("single dependency", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -62,7 +112,7 @@ describe("basic", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(readlinkSync(join(packageDir, "node_modules", "no-deps"))).toBe( join(".bun", "no-deps@1.0.0", "node_modules", "no-deps"), @@ -81,7 +131,7 @@ describe("basic", () => { }); test("scope package", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -93,7 +143,7 @@ describe("basic", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(readlinkSync(join(packageDir, "node_modules", "@types", "is-number"))).toBe( join("..", ".bun", "@types+is-number@1.0.0", "node_modules", "@types", "is-number"), @@ -121,7 +171,7 @@ describe("basic", () => { }); test("transitive dependencies", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -133,7 +183,7 @@ describe("basic", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "two-range-deps"]); expect(readlinkSync(join(packageDir, "node_modules", "two-range-deps"))).toBe( @@ -211,8 +261,8 @@ describe("basic", () => { }); }); -test("handles cyclic dependencies", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("handles cyclic dependencies", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -224,7 +274,7 @@ test("handles cyclic dependencies", async () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(readlinkSync(join(packageDir, "node_modules", "a-dep-b"))).toBe( join(".bun", "a-dep-b@1.0.0", "node_modules", "a-dep-b"), @@ -265,8 +315,8 @@ test("handles cyclic dependencies", async () => { }); }); -test("package with dependency on previous self works", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("package with dependency on previous self works", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -278,7 +328,7 @@ test("package with dependency on previous self works", async () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect( await Promise.all([ @@ -300,8 +350,8 @@ test("package with dependency on previous self works", async () => { ]); }); -test("can install folder dependencies", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("can install folder dependencies", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -315,7 +365,7 @@ test("can install folder dependencies", async () => { await write(join(packageDir, "pkg-1", "package.json"), JSON.stringify({ name: "folder-dep", version: "1.0.0" })); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(readlinkSync(join(packageDir, "node_modules", "folder-dep"))).toBe( join(".bun", "folder-dep@file+pkg-1", "node_modules", "folder-dep"), @@ -331,7 +381,7 @@ test("can install folder dependencies", async () => { await write(join(packageDir, "pkg-1", "index.js"), "module.exports = 'hello from pkg-1';"); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(readlinkSync(join(packageDir, "node_modules", "folder-dep"))).toBe( join(".bun", "folder-dep@file+pkg-1", "node_modules", "folder-dep"), ); @@ -342,8 +392,8 @@ test("can install folder dependencies", async () => { ).toBe("module.exports = 'hello from pkg-1';"); }); -test("can install folder dependencies on root package", async () => { - const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("can install folder dependencies on root package", async () => { + const { packageDir, packageJson, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await Promise.all([ write( @@ -367,7 +417,7 @@ test("can install folder dependencies on root package", async () => { ), ]); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect( await Promise.all([ @@ -382,9 +432,9 @@ test("can install folder dependencies on root package", async () => { ]); }); -describe("isolated workspaces", () => { +describe.concurrent("isolated workspaces", () => { test("basic", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await Promise.all([ write( @@ -423,7 +473,7 @@ describe("isolated workspaces", () => { ), ]); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(existsSync(join(packageDir, "node_modules", "pkg-1"))).toBeFalse(); expect(readlinkSync(join(packageDir, "pkg-1", "node_modules", "pkg-2"))).toBe(join("..", "..", "pkg-2")); @@ -457,7 +507,7 @@ describe("isolated workspaces", () => { }); test("workspace self dependencies create symlinks", async () => { - const { packageDir } = await registry.createTestDir({ + const { packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { "package.json": JSON.stringify({ @@ -486,7 +536,7 @@ describe("isolated workspaces", () => { }, }); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect( await Promise.all([ @@ -506,7 +556,7 @@ describe("isolated workspaces", () => { }); }); -describe("optional peers", () => { +describe.concurrent("optional peers", () => { const tests = [ // non-optional versions { @@ -544,7 +594,7 @@ describe("optional peers", () => { for (const { deps, expected, name } of tests) { test(`will resolve if available through another importer (${name})`, async () => { - const { packageDir } = await registry.createTestDir({ + const { packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { "package.json": JSON.stringify({ @@ -562,41 +612,33 @@ describe("optional peers", () => { }, }); - async function checkInstall() { - const { exited } = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - }); - - expect(await exited).toBe(0); + async function checkInstall(savesLockfile: boolean) { + await install(env, packageDir, { stderr: savesLockfile ? ["Saved lockfile"] : [] }); expect(await readdirSorted(join(packageDir, "node_modules/.bun"))).toEqual(expected); } // without lockfile // without node_modules - await checkInstall(); + await checkInstall(true); // with lockfile // without node_modules await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - await checkInstall(); + await checkInstall(false); // without lockfile // with node_modules await rm(join(packageDir, "bun.lock"), { force: true }); - await checkInstall(); + await checkInstall(true); // with lockfile // with node_modules - await checkInstall(); + await checkInstall(false); }); } test("successfully resolves optional peer with nested package", async () => { - const { packageDir } = await registry.createTestDir({ + const { packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { "package.json": JSON.stringify({ @@ -616,13 +658,8 @@ describe("optional peers", () => { }, }); - async function checkInstall() { - let { exited } = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - env: bunEnv, - }); - expect(await exited).toBe(0); + async function checkInstall(savesLockfile: boolean) { + await install(env, packageDir, { stderr: savesLockfile ? ["Saved lockfile"] : [] }); expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "one-dep", "one-one-dep"]); expect(await readdirSorted(join(packageDir, "node_modules/.bun"))).toEqual([ @@ -633,14 +670,14 @@ describe("optional peers", () => { ]); } - await checkInstall(); - await checkInstall(); + await checkInstall(true); + await checkInstall(false); }); }); // https://github.com/oven-sh/bun/issues/28147 -test("patched package shared by multiple peer variants is materialized into the cache once", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("patched package shared by multiple peer variants is materialized into the cache once", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); // `peer-deps@1.0.0` has `peerDependencies: { "no-deps": "*" }`. Giving each // workspace a different `no-deps` version forces one isolated store variant @@ -681,31 +718,28 @@ index 0000000000000000000000000000000000000000..3b18e512dba79e4c8300dd08aeb37f8e ); } - // CI exports BUN_INSTALL_CACHE_DIR, which overrides bunfig's `cache`. Pin it - // so the patched cache directory is created where the assertions look. + // The cache `env` pins; the patched cache directory is created here. const cacheDir = join(packageDir, ".bun-cache"); // Force the hardlink backend so the inode assertions below hold on every // platform (macOS defaults to clonefile, which copies). - async function install() { - const { stdout, stderr, exited } = spawn({ - cmd: [bunExe(), "install", "--backend", "hardlink"], - cwd: packageDir, - env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: cacheDir }, - stdout: "pipe", - stderr: "pipe", - }); - const [out, err, exitCode] = await Promise.all([stdout.text(), stderr.text(), exited]); - expect(err).not.toContain("error:"); - expect(out).toContain("packages installed"); - expect(exitCode).toBe(0); - } + const installArgs = ["--backend", "hardlink"]; + + // One variant per workspace; the suffix hashes the peer set (no-deps@1.0.0, + // 1.1.0, 2.0.0 and 1.0.1, in this order). + const storeDirs = [ + "peer-deps@1.0.0+7347ae2d86f1441a", + "peer-deps@1.0.0+7ff199101204a65d", + "peer-deps@1.0.0+e27e69f8c16af2a6", + "peer-deps@1.0.0+f8a822eca018d0a1", + ]; async function checkInstall() { - const storeDirs = (await readdirSorted(join(packageDir, "node_modules", ".bun"))).filter(dir => - dir.startsWith("peer-deps@1.0.0"), - ); - expect(storeDirs.length).toBe(noDepsVersions.length); + expect(await readdirSorted(join(packageDir, "node_modules", ".bun"))).toEqual([ + ...noDepsVersions.map(version => `no-deps@${version}`), + "node_modules", + ...storeDirs, + ]); // Exactly one patched cache directory exists for the package. const cacheDirs = (await readdirSorted(cacheDir)).filter( @@ -729,18 +763,18 @@ index 0000000000000000000000000000000000000000..3b18e512dba79e4c8300dd08aeb37f8e expect(inodes.size).toBe(1); } - await install(); + await install(env, packageDir, { args: installArgs }); await checkInstall(); // Reinstall with a warm cache and no node_modules: the patched cache // directory already exists and must not be rebuilt per variant. await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - await install(); + await install(env, packageDir, { args: installArgs, stderr: [] }); await checkInstall(); }); -test("adding, removing and re-adding a patch for an npm dependency", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("adding, removing and re-adding a patch for an npm dependency", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); const rootPackageJson = { name: "npm-patch-cycle", dependencies: { "no-deps": "1.0.0" } }; const patched = { ...rootPackageJson, @@ -768,16 +802,8 @@ index 0000000000000000000000000000000000000000..3b18e512dba79e4c8300dd08aeb37f8e for (const [step, [manifest, expectPatched]] of steps.entries()) { await write(packageJson, JSON.stringify(manifest)); // hardlink (the Linux default) installs into the store entry in place; clonefile replaces it. - await using proc = spawn({ - cmd: [bunExe(), "install", "--backend", "hardlink"], - cwd: packageDir, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(err).not.toContain("error:"); - expect(exitCode).toBe(0); + // Every step changes patchedDependencies, so every step saves the lockfile. + await install(env, packageDir, { args: ["--backend", "hardlink"] }); expect({ step, patched: existsSync(patchedFile) }).toEqual({ step, patched: expectPatched }); } }); @@ -788,8 +814,12 @@ index 0000000000000000000000000000000000000000..3b18e512dba79e4c8300dd08aeb37f8e // re-enqueued the same tarball task and parked the store entry on the // completed task's already-drained callback list, so the pending-task count // never reached zero. -test("adding and removing a patch for a github dependency in a workspace completes", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("adding and removing a patch for a github dependency in a workspace completes", async () => { + const { + packageJson, + packageDir, + env: baseEnv, + } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); // Minimal gzipped tarball shaped like a github codeload tarball: a single // root directory wrapping the package contents. @@ -830,26 +860,7 @@ test("adding and removing a patch for a github dependency in a workspace complet fetch: () => new Response(tarball, { headers: { "Content-Type": "application/gzip" } }), }); - const env = { - ...bunEnv, - GITHUB_API_URL: `http://localhost:${server.port}`, - // CI exports BUN_INSTALL_CACHE_DIR; pin it so this test's cache state is - // its own. - BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"), - }; - - async function install() { - await using proc = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - env, - stdout: "pipe", - stderr: "pipe", - }); - const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); - expect(err).not.toContain("error:"); - expect(exitCode).toBe(0); - } + const env = { ...baseEnv, GITHUB_API_URL: `http://localhost:${server.port}` }; const rootPackageJson = { name: "patched-github-workspace", @@ -880,7 +891,7 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f const installedIndexJs = file(join(packageDir, "packages", "member", "node_modules", "gh-dep", "index.js")); - await install(); + await install(env, packageDir); expect(await installedIndexJs.text()).toBe('console.log("original");\n'); // Adding the patch triggers a re-resolution; this install hung forever @@ -894,7 +905,7 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f }, }), ); - await install(); + await install(env, packageDir); expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); // Cold cache with the patch still in the lockfile: the install phase itself @@ -902,14 +913,14 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f await rm(join(packageDir, ".bun-cache"), { recursive: true, force: true }); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); await rm(join(packageDir, "packages", "member", "node_modules"), { recursive: true, force: true }); - await install(); + await install(env, packageDir, { stderr: [] }); expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); // Removing the patch re-resolves again and rebuilds the store entry from // the unpatched cache folder (the PatchInfo::Remove path, which hung the // same way). await write(packageJson, JSON.stringify(rootPackageJson)); - await install(); + await install(env, packageDir); expect(await installedIndexJs.text()).toBe('console.log("original");\n'); // Re-adding the same patch must patch again. @@ -922,7 +933,7 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f }, }), ); - await install(); + await install(env, packageDir); expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); }); @@ -930,9 +941,12 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f // instead of a tarball download). The repo is served over git's dumb HTTP // protocol: after `git update-server-info`, a bare repo is plain static // files. Requires the git executable to build the fixture repository. -const gitExecutable = Bun.which("git"); -test.skipIf(!gitExecutable)("adding and removing a patch for a git dependency in a workspace completes", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +testWithGit("adding and removing a patch for a git dependency in a workspace completes", async () => { + const { + packageJson, + packageDir, + env: baseEnv, + } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); const srcDir = join(packageDir, "git-src"); const bareDir = join(packageDir, "repo.git"); @@ -979,24 +993,7 @@ test.skipIf(!gitExecutable)("adding and removing a patch for a git dependency in }); const repoUrl = `git+http://127.0.0.1:${server.port}/repo.git`; - const env = { - ...bunEnv, - ...gitConfigEnv, - BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"), - }; - - async function install() { - await using proc = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - env, - stdout: "pipe", - stderr: "pipe", - }); - const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); - expect(err).not.toContain("error:"); - expect(exitCode).toBe(0); - } + const env = { ...baseEnv, ...gitConfigEnv }; const rootPackageJson = { name: "patched-git-workspace", @@ -1027,7 +1024,7 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f const installedIndexJs = file(join(packageDir, "packages", "member", "node_modules", "git-dep", "index.js")); - await install(); + await install(env, packageDir); expect(await installedIndexJs.text()).toBe('console.log("original");\n'); // The patchedDependencies key must carry the resolved commit; a key without @@ -1041,7 +1038,7 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f }, }), ); - await install(); + await install(env, packageDir); expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); // Cold cache with the patch still in the lockfile: the install phase @@ -1049,11 +1046,11 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f await rm(join(packageDir, ".bun-cache"), { recursive: true, force: true }); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); await rm(join(packageDir, "packages", "member", "node_modules"), { recursive: true, force: true }); - await install(); + await install(env, packageDir, { stderr: [] }); expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); await write(packageJson, JSON.stringify(rootPackageJson)); - await install(); + await install(env, packageDir); expect(await installedIndexJs.text()).toBe('console.log("original");\n'); // Re-adding the same patch must patch again. @@ -1066,13 +1063,13 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f }, }), ); - await install(); + await install(env, packageDir); expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); }); for (const backend of ["clonefile", "hardlink", "copyfile"]) { - test(`isolated install with backend: ${backend}`, async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + test.concurrent(`isolated install with backend: ${backend}`, async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await Promise.all([ write( @@ -1102,20 +1099,31 @@ for (const backend of ["clonefile", "hardlink", "copyfile"]) { ), ]); - const { stdout, stderr, exited } = spawn({ - cmd: [bunExe(), "install", "--backend", backend], - cwd: packageDir, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - - expect(await exited).toBe(0); - const out = await stdout.text(); - const err = await stderr.text(); - - expect(err).not.toContain("error"); - expect(err).not.toContain("warning"); + await install(env, packageDir, { args: ["--backend", backend] }); + + expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([ + ".bun", + "1-peer-dep-a", + "@scoped", + "alias-loop-1", + "alias-loop-2", + "basic-1", + "file-dep", + "is-number", + "no-deps", + ]); + // `+7347ae2d86f1441a` hashes the peer set `no-deps@1.0.0`. + expect(await readdirSorted(join(packageDir, "node_modules", ".bun"))).toEqual([ + "1-peer-dep-a@1.0.0+7347ae2d86f1441a", + "@scoped+file-dep@file+scoped-file-dep", + "alias-loop-1@1.0.0", + "alias-loop-2@1.0.0", + "basic-1@1.0.0", + "file-dep@file+file-dep", + "is-number@1.0.0", + "no-deps@1.0.0", + "node_modules", + ]); expect( await file( @@ -1182,17 +1190,22 @@ for (const backend of ["clonefile", "hardlink", "copyfile"]) { }); } -test("ranged peer dependency resolution is stable across installs from bun.lock", async () => { +test.concurrent("ranged peer dependency resolution is stable across installs from bun.lock", async () => { // `peer-deps-fixed` has a peer on `no-deps@^1.0.0`. The graph contains both // no-deps@1.0.1 (exact pin via normal-dep-and-dev-dep, hoisted to the root - // of the saved tree) and no-deps@1.1.0 (via two-range-deps). The fresh - // resolve binds the peer edge to the highest satisfying version (1.1.0) in - // its deferred-peer phase; reloading bun.lock used to re-derive the edge + // of the saved tree) and no-deps@1.1.0 (exact pin via the `nd11` alias). The + // fresh resolve binds the peer edge to the highest satisfying version (1.1.0) + // in its deferred-peer phase; reloading bun.lock used to re-derive the edge // from the saved tree paths instead, rebinding it to the hoisted 1.0.1. // That silently changed the runtime dependency tree on the second install // and re-keyed the isolated store entry (`+` suffix) on every // warm install. - const { packageJson, packageDir } = await registry.createTestDir({ + // + // 1.1.0 used to come from two-range-deps' `no-deps@^1.0.0`, which only adds + // its own copy when it is resolved before the 1.0.1 pin is appended; when + // the manifests arrive the other way round it dedupes onto 1.0.1 and the + // graph has one no-deps. Two exact pins do not depend on that order. + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, }); @@ -1203,12 +1216,15 @@ test("ranged peer dependency resolution is stable across installs from bun.lock" dependencies: { "peer-deps-fixed": "1.0.0", "normal-dep-and-dev-dep": "1.0.0", - "two-range-deps": "1.0.0", + "nd11": "npm:no-deps@1.1.0", }, }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); + + // the premise: the saved tree hoists 1.0.1, which a path walk would bind to + expect(await file(join(packageDir, "bun.lock")).text()).toContain('"no-deps": ["no-deps@1.0.1"'); const bunDir = join(packageDir, "node_modules", ".bun"); // highest satisfying ^1.0.0 in the graph; `toContain` prints the full @@ -1221,7 +1237,7 @@ test("ranged peer dependency resolution is stable across installs from bun.lock" // reinstall from bun.lock: same peer variant, same resolved version await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect((await readdirSorted(bunDir)).filter(e => e.startsWith("peer-deps-fixed@"))).toEqual([entryName]); expect(await file(join(bunDir, entryName, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ @@ -1229,14 +1245,14 @@ test("ranged peer dependency resolution is stable across installs from bun.lock" }); }); -test("aliased peer dependency binds to its real package across installs from bun.lock", async () => { +test.concurrent("aliased peer dependency binds to its real package across installs from bun.lock", async () => { // The peer alias `no-deps` points at `npm:a-dep@^1.0.2` while the real // no-deps package (in two versions) is also in the graph. Loading bun.lock // must look the edge up under the aliased *real* name (a-dep) the way the // fresh resolver does; a lookup under the alias would find the real // no-deps packages, whose versions also satisfy ^1.0.2, and rebind the // edge to the wrong package. - const { packageDir } = await registry.createTestDir({ + const { packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { "package.json": JSON.stringify({ @@ -1255,7 +1271,7 @@ test("aliased peer dependency binds to its real package across installs from bun }, }); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const aliasLink = join(packageDir, "packages", "m", "node_modules", "no-deps", "package.json"); const fresh = await file(aliasLink).json(); expect(fresh).toMatchObject({ name: "a-dep" }); @@ -1263,11 +1279,11 @@ test("aliased peer dependency binds to its real package across installs from bun // reinstall from bun.lock: still the aliased package, same version await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); await rm(join(packageDir, "packages", "m", "node_modules"), { recursive: true, force: true }); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(await file(aliasLink).json()).toEqual(fresh); }); -test("optional ranged peer keeps its hoisted-tree binding across installs from bun.lock", async () => { +test.concurrent("optional ranged peer keeps its hoisted-tree binding across installs from bun.lock", async () => { // Optional peers never reach the fresh resolver's deferred-peer phase: it // returns before the version scan and the edge is bound to the // hoisted-tree sibling during tree resolution, which the printed tree's @@ -1275,7 +1291,7 @@ test("optional ranged peer keeps its hoisted-tree binding across installs from b // in the graph, binding the optional peer by version on load would pick // the highest satisfying (1.1.0) while the fresh install bound the hoisted // 1.0.1, re-keying the entry on the first reinstall. - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, }); @@ -1291,7 +1307,7 @@ test("optional ranged peer keeps its hoisted-tree binding across installs from b }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const bunDir = join(packageDir, "node_modules", ".bun"); const freshEntries = (await readdirSorted(bunDir)).filter(e => e.startsWith("one-optional-peer-dep@")); @@ -1302,7 +1318,7 @@ test("optional ranged peer keeps its hoisted-tree binding across installs from b }); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect((await readdirSorted(bunDir)).filter(e => e.startsWith("one-optional-peer-dep@"))).toEqual(freshEntries); expect(await file(join(bunDir, freshEntries[0], "node_modules", "no-deps", "package.json")).json()).toMatchObject({ @@ -1310,14 +1326,14 @@ test("optional ranged peer keeps its hoisted-tree binding across installs from b }); }); -test("overridden peer dependency keeps the override across installs from bun.lock", async () => { +test.concurrent("overridden peer dependency keeps the override across installs from bun.lock", async () => { // `overrides` rewrites the peer range before the fresh resolver's version // scan, binding the peer to no-deps@1.0.1 even though the graph also // contains no-deps@1.1.0 (via an override-exempt npm: alias). Loading // bun.lock must not re-filter candidates with the raw ^1.0.0 manifest // range, which the override replaced; that would rebind the edge to 1.1.0 // and re-key the entry on the first reinstall. - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, }); @@ -1336,7 +1352,7 @@ test("overridden peer dependency keeps the override across installs from bun.loc }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const bunDir = join(packageDir, "node_modules", ".bun"); const entryName = "peer-deps-fixed@1.0.0+f8a822eca018d0a1"; @@ -1349,7 +1365,7 @@ test("overridden peer dependency keeps the override across installs from bun.loc }); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(await file(aliasManifest).json()).toMatchObject({ name: "no-deps", version: "1.1.0" }); expect((await readdirSorted(bunDir)).filter(e => e.startsWith("peer-deps-fixed@"))).toEqual([entryName]); @@ -1358,7 +1374,7 @@ test("overridden peer dependency keeps the override across installs from bun.loc }); }); -test("peer satisfied by a workspace package keeps the workspace across installs from bun.lock", async () => { +test.concurrent("peer satisfied by a workspace package keeps the workspace across installs from bun.lock", async () => { // The fresh resolver binds an npm-range peer to a same-named workspace // package before any deferral when linkWorkspacePackages is on and the // workspace version satisfies the range. The graph also contains npm @@ -1366,7 +1382,7 @@ test("peer satisfied by a workspace package keeps the workspace across installs // 1.0.0 cannot satisfy), and that version satisfies the peer's ^1.0.0 too; // loading bun.lock must not rebind the peer from the workspace to the npm // package through the version scan. - const { packageDir } = await registry.createTestDir({ + const { packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { "package.json": JSON.stringify({ @@ -1385,7 +1401,7 @@ test("peer satisfied by a workspace package keeps the workspace across installs }, }); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const bunDir = join(packageDir, "node_modules", ".bun"); const freshEntries = (await readdirSorted(bunDir)).filter(e => e.startsWith("peer-deps-fixed@")); @@ -1396,7 +1412,7 @@ test("peer satisfied by a workspace package keeps the workspace across installs }); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect((await readdirSorted(bunDir)).filter(e => e.startsWith("peer-deps-fixed@"))).toEqual(freshEntries); expect(await file(join(bunDir, freshEntries[0], "node_modules", "no-deps", "package.json")).json()).toMatchObject({ @@ -1405,9 +1421,9 @@ test("peer satisfied by a workspace package keeps the workspace across installs }); }); -describe("existing node_modules, missing node_modules/.bun", () => { +describe.concurrent("existing node_modules, missing node_modules/.bun", () => { test("root and workspace node_modules are reset", async () => { - const { packageDir } = await registry.createTestDir({ + const { packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { "package.json": JSON.stringify({ @@ -1436,25 +1452,23 @@ describe("existing node_modules, missing node_modules/.bun", () => { }, }); - let { exited } = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - }); - - expect(await exited).toBe(0); + await install(env, packageDir); expect( await Promise.all([ readdirSorted(join(packageDir, "node_modules")), + readdirSorted(join(packageDir, "node_modules", ".bun")), readdirSorted(join(packageDir, "packages", "pkg1", "node_modules")), readdirSorted(join(packageDir, "packages", "pkg2", "node_modules")), ]), - ).toEqual([[".bun", expect.stringContaining(".old_modules-"), "a-dep", "no-deps"], ["no-deps"], ["no-deps"]]); + ).toEqual([ + [".bun", expect.stringContaining(".old_modules-"), "a-dep", "no-deps"], + ["a-dep@1.0.1", "no-deps@1.0.0", "no-deps@1.0.1", "no-deps@2.0.0", "node_modules"], + ["no-deps"], + ["no-deps"], + ]); }); test("some workspaces don't have node_modules", async () => { - const { packageDir } = await registry.createTestDir({ + const { packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { "package.json": JSON.stringify({ @@ -1481,15 +1495,7 @@ describe("existing node_modules, missing node_modules/.bun", () => { }, }); - let { exited } = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - }); - - expect(await exited).toBe(0); + await install(env, packageDir); expect( await Promise.all([ readdirSorted(join(packageDir, "node_modules")), @@ -1515,15 +1521,7 @@ describe("existing node_modules, missing node_modules/.bun", () => { write(join(packageDir, "packages", "pkg2", "node_modules", "oops2"), "HI2"), ]); - ({ exited } = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - })); - - expect(await exited).toBe(0); + await install(env, packageDir, { stderr: [] }); expect( await Promise.all([ @@ -1539,9 +1537,20 @@ describe("existing node_modules, missing node_modules/.bun", () => { }); }); -describe("--linker flag", () => { +describe.concurrent("--linker flag", () => { + // The layout `no-deps@1.0.0` gets from each linker. The linker is not part + // of bun.lock, so only the first install of a case saves it. + async function expectLinker(packageDir: string, linker: "isolated" | "hoisted") { + const noDeps = join(packageDir, "node_modules", "no-deps"); + expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual( + linker === "isolated" ? [".bun", "no-deps"] : ["no-deps"], + ); + expect(lstatSync(noDeps).isSymbolicLink()).toBe(linker === "isolated"); + expect(await file(join(noDeps, "package.json")).json()).toEqual({ name: "no-deps", version: "1.0.0" }); + } + test("can override linker from bunfig", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -1553,49 +1562,22 @@ describe("--linker flag", () => { }), ); - let { exited } = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - }); - - expect(await exited).toBe(0); - - expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeTrue(); + await install(env, packageDir); + await expectLinker(packageDir, "isolated"); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - ({ exited } = spawn({ - cmd: [bunExe(), "install", "--linker", "hoisted"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - })); - - expect(await exited).toBe(0); - - expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeFalse(); + await install(env, packageDir, { args: ["--linker", "hoisted"], stderr: [] }); + await expectLinker(packageDir, "hoisted"); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - ({ exited } = spawn({ - cmd: [bunExe(), "install", "--linker", "isolated"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - })); - - expect(await exited).toBe(0); - - expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeTrue(); + await install(env, packageDir, { args: ["--linker", "isolated"], stderr: [] }); + await expectLinker(packageDir, "isolated"); }); test("works as the only config option", async () => { - const { packageJson, packageDir } = await registry.createTestDir(); + const { packageJson, packageDir, env } = await registry.createTestDir(); await write( packageJson, @@ -1607,63 +1589,28 @@ describe("--linker flag", () => { }), ); - let { exited } = spawn({ - cmd: [bunExe(), "install", "--linker", "isolated"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - }); - - expect(await exited).toBe(0); - - expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeTrue(); + await install(env, packageDir, { args: ["--linker", "isolated"] }); + await expectLinker(packageDir, "isolated"); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - ({ exited } = spawn({ - cmd: [bunExe(), "install", "--linker", "hoisted"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - })); - - expect(await exited).toBe(0); - - expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeFalse(); + await install(env, packageDir, { args: ["--linker", "hoisted"], stderr: [] }); + await expectLinker(packageDir, "hoisted"); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - ({ exited } = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - })); - - expect(await exited).toBe(0); - - expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeFalse(); + // without the flag, the bunfig the harness wrote (`linker = "hoisted"`) applies + await install(env, packageDir, { stderr: [] }); + await expectLinker(packageDir, "hoisted"); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - ({ exited } = spawn({ - cmd: [bunExe(), "install", "--linker", "isolated"], - cwd: packageDir, - env: bunEnv, - stdout: "ignore", - stderr: "ignore", - })); - - expect(await exited).toBe(0); - - expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeTrue(); + await install(env, packageDir, { args: ["--linker", "isolated"], stderr: [] }); + await expectLinker(packageDir, "isolated"); }); }); -test("many transitive dependencies", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("many transitive dependencies", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -1679,7 +1626,7 @@ test("many transitive dependencies", async () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([ ".bun", @@ -1740,8 +1687,8 @@ test("many transitive dependencies", async () => { ).toBe(join("..", "..", "alias-loop-1@1.0.0", "node_modules", "alias-loop-1")); }); -test("dependency names are preserved", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("dependency names are preserved", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -1753,7 +1700,7 @@ test("dependency names are preserved", async () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "alias-loop-1"]); expect(readlinkSync(join(packageDir, "node_modules", "alias-loop-1"))).toBe( @@ -1801,8 +1748,8 @@ test("dependency names are preserved", async () => { }); }); -test("same resolution, different dependency name", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("same resolution, different dependency name", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -1815,7 +1762,7 @@ test("same resolution, different dependency name", async () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "no-deps-1", "no-deps-2"]); expect(readlinkSync(join(packageDir, "node_modules", "no-deps-1"))).toBe( @@ -1835,8 +1782,8 @@ test("same resolution, different dependency name", async () => { expect(await readdirSorted(join(packageDir, "node_modules", ".bun"))).toEqual(["no-deps@1.0.0", "node_modules"]); }); -test("successfully removes and corrects symlinks", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("successfully removes and corrects symlinks", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await Promise.all([ write(join(packageDir, "old-package", "package.json"), JSON.stringify({ name: "old-package", version: "1.0.0" })), mkdir(join(packageDir, "node_modules")), @@ -1854,7 +1801,7 @@ test("successfully removes and corrects symlinks", async () => { symlink(join("..", "old-package"), join(packageDir, "node_modules", "no-deps"), "dir"), ]); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(existsSync(join(packageDir, "node_modules", "no-deps"))).toBeTrue(); @@ -1863,14 +1810,14 @@ test("successfully removes and corrects symlinks", async () => { ); }); -test("runs lifecycle scripts correctly", async () => { +test.concurrent("runs lifecycle scripts correctly", async () => { // due to binary linking between preinstall and the remaining lifecycle scripts // there is special handling for preinstall scripts we should test. // 1. only preinstall // 2. only postinstall (or any other script that isn't preinstall) // 3. preinstall and any other script - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -1885,7 +1832,7 @@ test("runs lifecycle scripts correctly", async () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const [ preinstallLink, @@ -1944,7 +1891,7 @@ test("runs lifecycle scripts correctly", async () => { // pendingTaskCount() stays at 0 and waitForPeers was skipped — leaving // the transitive peer's resolution unset (= invalid_package_id → filtered // from the install). -test("transitive peer deps are resolved when resolution is fully synchronous", async () => { +test.concurrent("transitive peer deps are resolved when resolution is fully synchronous", async () => { const packagesDir = join(import.meta.dir, "registry", "packages"); // Self-contained HTTP server that serves package manifests & tarballs @@ -2022,33 +1969,39 @@ test("transitive peer deps are resolved when resolution is fully synchronous", a }), ); + // root's no-deps@1.0.0 is what the transitive peer ends up bound to + const env = installEnv(String(packageDir)); + const expectedStderr = ['warn: incorrect peer dependency "no-deps@1.0.0"', "Saved lockfile"]; + // First install: populates manifest cache (with max-age=300 from server) - await runBunInstall(bunEnv, String(packageDir), { allowWarnings: true }); + await install(env, String(packageDir), { stderr: expectedStderr }); // Second install with NO lockfile and WARM cache. Manifests are fresh // (within max-age) so all loads are synchronous — this is the bug trigger. await rm(join(String(packageDir), "node_modules"), { recursive: true, force: true }); await rm(join(String(packageDir), "bun.lock"), { force: true }); - await runBunInstall(bunEnv, String(packageDir), { allowWarnings: true }); + await install(env, String(packageDir), { stderr: expectedStderr }); - // Entry names have peer hashes; find them dynamically const bunDir = join(String(packageDir), "node_modules", ".bun"); - const entries = await readdirSorted(bunDir); - const strictPeerEntry = entries.find(e => e.startsWith("strict-peer-dep@1.0.0")); - const usesStrictEntry = entries.find(e => e.startsWith("uses-strict-peer@1.0.0")); - - // strict-peer-dep must exist (auto-installed via uses-strict-peer's peer) - expect(strictPeerEntry).toBeDefined(); - expect(usesStrictEntry).toBeDefined(); + // `+7347ae2d86f1441a` hashes the peer set `no-deps@1.0.0`, `+2ddcb6ca48941e07` + // the peer set `strict-peer-dep@1.0.0`. + const strictPeerEntry = "strict-peer-dep@1.0.0+7347ae2d86f1441a"; + const usesStrictEntry = "uses-strict-peer@1.0.0+2ddcb6ca48941e07"; + // strict-peer-dep is auto-installed via uses-strict-peer's peer + expect(await readdirSorted(bunDir)).toEqual(["no-deps@1.0.0", "node_modules", strictPeerEntry, usesStrictEntry]); // strict-peer-dep's own peer `no-deps` must be resolved and symlinked. // Without the fix: this symlink is missing because the transitive peer // queue was never drained after drainDependencyList re-queued it. - expect(existsSync(join(bunDir, strictPeerEntry!, "node_modules", "no-deps"))).toBe(true); + expect(await readdirSorted(join(bunDir, strictPeerEntry, "node_modules"))).toEqual(["no-deps", "strict-peer-dep"]); + expect(await file(join(bunDir, strictPeerEntry, "node_modules", "no-deps", "package.json")).json()).toEqual({ + name: "no-deps", + version: "1.0.0", + }); // Verify the chain is intact - expect(withoutEntryHash(readlinkSync(join(bunDir, usesStrictEntry!, "node_modules", "strict-peer-dep")))).toBe( - join("..", "..", strictPeerEntry!, "node_modules", "strict-peer-dep"), + expect(withoutEntryHash(readlinkSync(join(bunDir, usesStrictEntry, "node_modules", "strict-peer-dep")))).toBe( + join("..", "..", strictPeerEntry, "node_modules", "strict-peer-dep"), ); }); @@ -2058,7 +2011,7 @@ test("transitive peer deps are resolved when resolution is fully synchronous", a // delimiter (truncating the path), and `?` is invalid in Windows filenames. // (The query string is now left out of the name altogether, see "store entry // names of URL dependencies" below.) -test("tarball URL with query string resolves at runtime", async () => { +test.concurrent("tarball URL with query string resolves at runtime", async () => { const tarball = file(join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz")); using server = Bun.serve({ @@ -2086,7 +2039,7 @@ test("tarball URL with query string resolves at runtime", async () => { "index.mjs": `import pkg from "no-deps";\nconsole.log(pkg.version);`, }); - await runBunInstall(bunEnv, String(packageDir)); + await runBunInstall({ ...bunEnv, BUN_INSTALL_CACHE_DIR: String(cacheDir) }, String(packageDir)); const bunDir = join(String(packageDir), "node_modules", ".bun"); const storeEntries = (await readdirSorted(bunDir)).filter(entry => entry !== "node_modules"); @@ -2112,7 +2065,7 @@ test("tarball URL with query string resolves at runtime", async () => { // full text. Unbounded, the entry's absolute path passes MAX_PATH on Windows, // where the package's lifecycle scripts then fail to spawn with ENOENT, and a // resolution longer than NAME_MAX cannot be created at all. -describe("long store entry names", () => { +describe.concurrent("long store entry names", () => { async function storeEntries(packageDir: string): Promise { return (await readdirSorted(join(packageDir, "node_modules", ".bun"))).filter(entry => entry !== "node_modules"); } @@ -2127,7 +2080,7 @@ describe("long store entry names", () => { const sharedPrefix1 = `${atLimit}1`; const sharedPrefix2 = `${atLimit}2`; - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { [`${atLimit}/package.json`]: JSON.stringify({ name: "at-limit", version: "1.0.0" }), @@ -2149,7 +2102,7 @@ describe("long store entry names", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const pastLimitEntry = storeEntryName("past-limit", `file+${pastLimit}`); expect(pastLimitEntry).toMatch(/^past-limit@file\+a{58}\+[0-9a-f]{16}$/); @@ -2179,13 +2132,13 @@ describe("long store entry names", () => { // The cut name is a pure function of the resolution, so the next install // finds the same entries again. - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(await storeEntries(packageDir)).toEqual(expectedEntries); }); test("the peer hash is appended after the cut resolution", async () => { const folder = Buffer.alloc(MAX_RESOLUTION_LEN, "p").toString(); - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { [`${folder}/package.json`]: JSON.stringify({ @@ -2203,7 +2156,7 @@ describe("long store entry names", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); // `+7347ae2d86f1441a` is the hash of the peer set `no-deps@1.0.0`. const entry = `${storeEntryName("has-peer", `file+${folder}`)}+7347ae2d86f1441a`; @@ -2219,7 +2172,7 @@ describe("long store entry names", () => { // byte 62. Only the shape is asserted: how non-ASCII bytes are spelled in // the name is a separate matter (#32304), the boundary handling is not. const folder = `x${Buffer.alloc(40 * 2, "\u00e9").toString()}`; - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { [`${folder}/package.json`]: JSON.stringify({ name: "non-ascii", version: "1.0.0" }) }, }); @@ -2228,7 +2181,7 @@ describe("long store entry names", () => { JSON.stringify({ name: "test-non-ascii-store-entry-name", dependencies: { "non-ascii": `file:./${folder}` } }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const entries = await storeEntries(packageDir); expect(entries).toEqual([expect.stringMatching(/^non-ascii@file\+x.*\+[0-9a-f]{16}$/)]); @@ -2250,7 +2203,7 @@ describe("long store entry names", () => { // than NAME_MAX. Unbounded, the install fails with ENAMETOOLONG. const segment = Buffer.alloc(85, "d").toString(); const deep = `${segment}/${segment}/${segment}`; - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { [`${deep}/bar-0.0.2.tgz`]: readFileSync(join(import.meta.dir, "bar-0.0.2.tgz")), @@ -2268,7 +2221,7 @@ describe("long store entry names", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const tarballEntry = storeEntryName("bar", `.+${deep.replaceAll("/", "+")}+bar-0.0.2.tgz`); const folderEntry = storeEntryName("folder-pkg", `file+${deep.replaceAll("/", "+")}+pkg`); @@ -2295,7 +2248,7 @@ describe("long store entry names", () => { { name: "folder-pkg", version: "1.0.0" }, ]); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(await storeEntries(packageDir)).toEqual(expectedEntries); }); @@ -2314,13 +2267,13 @@ describe("long store entry names", () => { }); const url = `http://localhost:${server.port}/${segment}/no-deps.tgz`; - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated", globalStore: true }, files: { "index.mjs": `import pkg from "no-deps";\nconsole.log(pkg.version);` }, }); await write(packageJson, JSON.stringify({ name: "test-long-tarball-url", dependencies: { "no-deps": url } })); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const entry = storeEntryName("no-deps", url.replaceAll(/[/:]/g, "+")); expect(entry).toMatch(/^no-deps@http\+\+\+localhost\+\d+\+t+\+[0-9a-f]{16}$/); @@ -2349,10 +2302,10 @@ describe("long store entry names", () => { // (here: a path inside the temp directory) plus the commit, and the entry is // the cwd its lifecycle scripts are spawned with. test.skipIf(!gitExecutable)("a trusted git dependency with a long URL runs its lifecycle scripts", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); const repoDir = join(packageDir, Buffer.alloc(60, "r").toString()); const gitEnv = { - ...bunEnv, + ...env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: join(packageDir, "gitconfig"), GIT_AUTHOR_NAME: "bun-test", @@ -2416,12 +2369,7 @@ describe("long store entry names", () => { // of the complete URL takes their place (see `urlHash`), which also keeps URLs // that differ only in those parts in separate entries. A URL without either // part keeps the name it always had. -describe("store entry names of URL dependencies", () => { - const installEnv = (dir: string, env: NodeJS.Dict = bunEnv) => ({ - ...env, - BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache"), - }); - +describe.concurrent("store entry names of URL dependencies", () => { async function storeEntries(dir: string): Promise { return (await readdirSorted(join(dir, "node_modules", ".bun"))).filter(entry => entry !== "node_modules"); } @@ -2623,13 +2571,13 @@ describe("store entry names of URL dependencies", () => { }); }); -describe("global virtual store", () => { +describe.concurrent("global virtual store", () => { // The global virtual store is off by default; tests that exercise it opt // in via bunfig `install.globalStore = true`. const gvsBunfigOpts = { linker: "isolated", globalStore: true } as const; test("is disabled by default", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -2639,18 +2587,21 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); // With the global store disabled (the default) the entry is a real // directory under `node_modules/.bun/` (the pre-global-store layout). const entry = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0"); expect(lstatSync(entry).isSymbolicLink()).toBe(false); expect(lstatSync(entry).isDirectory()).toBe(true); - expect(existsSync(join(entry, "node_modules", "no-deps", "package.json"))).toBe(true); + expect(await file(join(entry, "node_modules", "no-deps", "package.json")).json()).toEqual({ + name: "no-deps", + version: "1.0.0", + }); }); test("can be enabled via BUN_INSTALL_GLOBAL_STORE=1", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -2660,7 +2611,7 @@ describe("global virtual store", () => { }), ); - await runBunInstall({ ...bunEnv, BUN_INSTALL_GLOBAL_STORE: "1" }, packageDir); + await runBunInstall({ ...env, BUN_INSTALL_GLOBAL_STORE: "1" }, packageDir); const entry = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0"); expect(lstatSync(entry).isSymbolicLink()).toBe(true); @@ -2668,7 +2619,7 @@ describe("global virtual store", () => { }); test("can be enabled via bunfig install.globalStore", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -2678,7 +2629,7 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const entry = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0"); expect(lstatSync(entry).isSymbolicLink()).toBe(true); @@ -2686,7 +2637,7 @@ describe("global virtual store", () => { }); test("survives node_modules wipe", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -2697,7 +2648,7 @@ describe("global virtual store", () => { ); // First install: populates `/links/` and creates project symlinks. - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); // `node_modules/.bun/` is a symlink (to the global virtual store), // not a real directory containing a clonefiled copy of the package. @@ -2705,7 +2656,10 @@ describe("global virtual store", () => { expect(lstatSync(entry).isSymbolicLink()).toBe(true); const target = readlinkSync(entry); expect(target).toMatch(/links[\/\\]two-range-deps@1\.0\.0-[0-9a-f]{16}$/); - expect(existsSync(join(target, "node_modules", "two-range-deps", "package.json"))).toBe(true); + expect(await file(join(target, "node_modules", "two-range-deps", "package.json")).json()).toMatchObject({ + name: "two-range-deps", + version: "1.0.0", + }); // dep symlink inside the global entry points at a sibling global entry expect(readlinkSync(join(target, "node_modules", "no-deps"))).toMatch( /^\.\.[\/\\]\.\.[\/\\]no-deps@1\.1\.0-[0-9a-f]{16}[\/\\]node_modules[\/\\]no-deps$/, @@ -2715,7 +2669,7 @@ describe("global virtual store", () => { // the project entry is re-created as a symlink to the *same* global path // without re-materialising package files. await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(lstatSync(entry).isSymbolicLink()).toBe(true); expect(readlinkSync(entry)).toBe(target); @@ -2735,7 +2689,7 @@ describe("global virtual store", () => { }); test("--force replaces a corrupted global-store entry", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -2745,7 +2699,7 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const entry = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0"); expect(lstatSync(entry).isSymbolicLink()).toBe(true); @@ -2767,24 +2721,13 @@ describe("global virtual store", () => { // present and reuses it. (This pins the warm-hit semantics so the // assertion below is meaningful.) await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(existsSync(pkgJsonPath)).toBe(false); // --force must rebuild staging and swap it into place over the corrupt // final directory instead of discarding the fresh tree on EEXIST. await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - { - await using proc = Bun.spawn({ - cmd: [bunExe(), "install", "--force"], - cwd: packageDir, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).not.toContain("error:"); - expect(exitCode).toBe(0); - } + await install(env, packageDir, { args: ["--force"], stderr: [] }); expect(readlinkSync(entry)).toBe(gvsTarget); expect(await file(pkgJsonPath).text()).toBe(original); @@ -2793,13 +2736,11 @@ describe("global virtual store", () => { // The swap-aside `.old-` tree is removed once publish succeeds, so // the links/ directory is left with only final entries (no `.old-` and no // `.tmp-` siblings). - const linksDir = dirname(gvsTarget); - const siblings = await readdirSorted(linksDir); - expect(siblings.some(n => n.includes(".old-") || n.includes(".tmp-"))).toBe(false); + expect(await readdirSorted(dirname(gvsTarget))).toEqual([basename(gvsTarget)]); }); test("BUN_INSTALL_GLOBAL_STORE=0 overrides bunfig globalStore = true", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -2809,18 +2750,21 @@ describe("global virtual store", () => { }), ); - await runBunInstall({ ...bunEnv, BUN_INSTALL_GLOBAL_STORE: "0" }, packageDir); + await runBunInstall({ ...env, BUN_INSTALL_GLOBAL_STORE: "0" }, packageDir); // With the global store disabled the entry is a real directory under // `node_modules/.bun/` (the pre-global-store layout). const entry = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0"); expect(lstatSync(entry).isSymbolicLink()).toBe(false); expect(lstatSync(entry).isDirectory()).toBe(true); - expect(existsSync(join(entry, "node_modules", "no-deps", "package.json"))).toBe(true); + expect(await file(join(entry, "node_modules", "no-deps", "package.json")).json()).toEqual({ + name: "no-deps", + version: "1.0.0", + }); }); test("entry hash is deterministic across fresh installs", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -2830,7 +2774,7 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const target1 = readlinkSync(join(packageDir, "node_modules", ".bun", "two-range-deps@1.0.0")); // Full reset (lockfile + node_modules + global links). The hash is derived @@ -2841,7 +2785,7 @@ describe("global virtual store", () => { const linksDir = target1.slice(0, target1.lastIndexOf("links") + "links".length); await rm(linksDir, { recursive: true, force: true }); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const target2 = readlinkSync(join(packageDir, "node_modules", ".bun", "two-range-deps@1.0.0")); expect(target2).toBe(target1); @@ -2872,8 +2816,8 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, a.packageDir); - await runBunInstall(bunEnv, b.packageDir); + await runBunInstall(a.env, a.packageDir); + await runBunInstall(b.env, b.packageDir); const targetA = entryStoreName(readlinkSync(join(a.packageDir, "node_modules", ".bun", "two-range-deps@1.0.0"))); const targetB = entryStoreName(readlinkSync(join(b.packageDir, "node_modules", ".bun", "two-range-deps@1.0.0"))); @@ -2894,7 +2838,7 @@ describe("global virtual store", () => { }); test("re-resolving the same project re-points the entry link at the new-hash global entry", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -2904,7 +2848,7 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const entry = join(packageDir, "node_modules", ".bun", "two-range-deps@1.0.0"); const nestedNoDeps = join(entry, "node_modules", "no-deps", "package.json"); @@ -2922,7 +2866,7 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(lstatSync(entry).isSymbolicLink()).toBe(true); const after = readlinkSync(entry); @@ -2954,8 +2898,8 @@ describe("global virtual store", () => { ); } - await runBunInstall(bunEnv, a.packageDir); - await runBunInstall(bunEnv, b.packageDir); + await runBunInstall(a.env, a.packageDir); + await runBunInstall(b.env, b.packageDir); const targetA = entryStoreName(readlinkSync(join(a.packageDir, "node_modules", ".bun", "two-range-deps@1.0.0"))); const targetB = entryStoreName(readlinkSync(join(b.packageDir, "node_modules", ".bun", "two-range-deps@1.0.0"))); @@ -2970,7 +2914,7 @@ describe("global virtual store", () => { // dangling for any other project that shared the entry. The eligibility // check propagates: an entry that links to anything project-local is // itself project-local. - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await mkdir(join(packageDir, "packages", "ws-pkg"), { recursive: true }); await write( @@ -2986,7 +2930,7 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); // `no-deps` has no project-local deps so it stays global. const noDepsEntry = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0"); @@ -2999,7 +2943,7 @@ describe("global virtual store", () => { }); test("packages with trusted lifecycle scripts stay project-local", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -3010,7 +2954,7 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); // The script may mutate the install dir, so the entry must not be shared. const scriptEntry = join(packageDir, "node_modules", ".bun", "lifecycle-postinstall@1.0.0"); @@ -3025,7 +2969,7 @@ describe("global virtual store", () => { // install must reach the same conclusion from the trustedDependencies // list alone — the cold install above isn't sufficient on its own. await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(lstatSync(scriptEntry).isSymbolicLink()).toBe(false); expect(lstatSync(scriptEntry).isDirectory()).toBe(true); expect(lstatSync(noDepsEntry).isSymbolicLink()).toBe(true); @@ -3039,7 +2983,8 @@ describe("global virtual store", () => { const b = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); // Both projects must share one cache for the race to be real; the harness - // gives each test dir its own `.bun-cache/` by default. + // gives each test dir its own `.bun-cache/` by default. Both also install + // with `a.env`, which pins that cache. const sharedCache = join(a.packageDir, ".bun-cache"); await write( join(b.packageDir, "bunfig.toml"), @@ -3067,7 +3012,7 @@ describe("global virtual store", () => { // Prime the package cache so the parallel installs only race on // global-store creation, not network downloads. - await runBunInstall(bunEnv, a.packageDir); + await runBunInstall(a.env, a.packageDir); const linksDir = join(sharedCache, "links"); for (let i = 0; i < 3; i++) { @@ -3075,11 +3020,12 @@ describe("global virtual store", () => { await rm(join(a.packageDir, "node_modules"), { recursive: true, force: true }); await rm(join(b.packageDir, "node_modules"), { recursive: true, force: true }); - const [ra, rb] = await Promise.all([ - spawn({ cmd: [bunExe(), "install"], cwd: a.packageDir, env: bunEnv, stderr: "pipe", stdout: "pipe" }).exited, - spawn({ cmd: [bunExe(), "install"], cwd: b.packageDir, env: bunEnv, stderr: "pipe", stdout: "pipe" }).exited, + // The loser of the race must not report it. `b` has no bun.lock before + // its first install. + await Promise.all([ + install(a.env, a.packageDir, { stderr: [] }), + install(a.env, b.packageDir, { stderr: i === 0 ? ["Saved lockfile"] : [] }), ]); - expect({ iter: i, a: ra, b: rb }).toEqual({ iter: i, a: 0, b: 0 }); } // Both projects' `.bun/` symlinks point at the same physical directory @@ -3103,7 +3049,7 @@ describe("global virtual store", () => { // `/` as the final step, so a published entry is always complete. // A crashed earlier install can leave a staging directory behind; the // warm-hit check must look at the final path only. - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -3113,9 +3059,12 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const target = readlinkSync(join(packageDir, "node_modules", ".bun", "no-deps@1.0.0")); - expect(existsSync(join(target, "node_modules", "no-deps", "package.json"))).toBe(true); + expect(await file(join(target, "node_modules", "no-deps", "package.json")).json()).toEqual({ + name: "no-deps", + version: "1.0.0", + }); // No stamp file: the directory existing *is* the completeness signal. expect(existsSync(join(target, ".bun-ok"))).toBe(false); @@ -3123,10 +3072,13 @@ describe("global virtual store", () => { // should warm-hit unchanged. await mkdir(`${target}.tmp-deadbeef`, { recursive: true }); await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(readlinkSync(join(packageDir, "node_modules", ".bun", "no-deps@1.0.0"))).toBe(target); - expect(existsSync(join(target, "node_modules", "no-deps", "package.json"))).toBe(true); + expect(await file(join(target, "node_modules", "no-deps", "package.json")).json()).toEqual({ + name: "no-deps", + version: "1.0.0", + }); }); test("bun's resolver follows the double-hop chain into the global store", async () => { @@ -3139,7 +3091,7 @@ describe("global virtual store", () => { // resolver would then `ReadFile` a directory. Exercising an actual // `require()` through a transitive dep proves the chain resolves // end-to-end on every platform. - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -3159,7 +3111,7 @@ describe("global virtual store", () => { }));`, ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); // The entry must actually be a global-store symlink for this test to mean // anything (guards against a future default flip silently neutering it). expect(lstatSync(join(packageDir, "node_modules", ".bun", "two-range-deps@1.0.0")).isSymbolicLink()).toBe(true); @@ -3172,9 +3124,9 @@ describe("global virtual store", () => { stderr: "pipe", }); const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]); - expect(err).not.toContain("EISDIR"); - expect(code).toBe(0); + expect(err).toBe(""); expect(JSON.parse(out.trim())).toEqual({ direct: "two-range-deps", transitive: "no-deps" }); + expect(code).toBe(0); }); test("an entry that loses global-store eligibility detaches without mutating the shared entry", async () => { @@ -3184,7 +3136,7 @@ describe("global virtual store", () => { // the shared cache. On Windows the `.expect_missing` dep-symlink rewrite // then baked a project-absolute junction target into the shared entry, // which dangled after `rm -rf node_modules`. - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -3194,7 +3146,7 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const entry = join(packageDir, "node_modules", ".bun", "two-range-deps@1.0.0"); expect(lstatSync(entry).isSymbolicLink()).toBe(true); const gvsTarget = readlinkSync(entry); @@ -3210,7 +3162,7 @@ describe("global virtual store", () => { trustedDependencies: ["two-range-deps"], }), ); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); // The project entry is now a real directory… expect(lstatSync(entry).isSymbolicLink()).toBe(false); @@ -3226,7 +3178,7 @@ describe("global virtual store", () => { // real directory. Re-running install with the global store enabled must // replace that directory with a symlink (not fail with EEXIST or leave the // stale tree behind). - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -3236,14 +3188,17 @@ describe("global virtual store", () => { }), ); - await runBunInstall({ ...bunEnv, BUN_INSTALL_GLOBAL_STORE: "0" }, packageDir); + await runBunInstall({ ...env, BUN_INSTALL_GLOBAL_STORE: "0" }, packageDir); const entry = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0"); expect(lstatSync(entry).isDirectory()).toBe(true); expect(lstatSync(entry).isSymbolicLink()).toBe(false); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(lstatSync(entry).isSymbolicLink()).toBe(true); - expect(existsSync(join(entry, "node_modules", "no-deps", "package.json"))).toBe(true); + expect(await file(join(entry, "node_modules", "no-deps", "package.json")).json()).toEqual({ + name: "no-deps", + version: "1.0.0", + }); }); test("disabling the global store detaches entries on the next install", async () => { @@ -3254,7 +3209,7 @@ describe("global virtual store", () => { // warm-path existence check passes *through* a live link, so without // stale-link detection the project would silently keep running against // (and a later rebuild would write into) the shared store. - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -3264,12 +3219,12 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const entry = join(packageDir, "node_modules", ".bun", "two-range-deps@1.0.0"); expect(lstatSync(entry).isSymbolicLink()).toBe(true); const globalTarget = readlinkSync(entry); - await runBunInstall({ ...bunEnv, BUN_INSTALL_GLOBAL_STORE: "0" }, packageDir, { savesLockfile: false }); + await runBunInstall({ ...env, BUN_INSTALL_GLOBAL_STORE: "0" }, packageDir, { savesLockfile: false }); // Every entry is detached into a real project-local directory. expect(lstatSync(entry).isSymbolicLink()).toBe(false); @@ -3292,7 +3247,10 @@ describe("global virtual store", () => { }); // The shared global entry is left untouched for other projects. - expect(existsSync(join(globalTarget, "node_modules", "two-range-deps", "package.json"))).toBe(true); + expect(await file(join(globalTarget, "node_modules", "two-range-deps", "package.json")).json()).toMatchObject({ + name: "two-range-deps", + version: "1.0.0", + }); }); test("preserves bun patch workspace when install runs before --commit", async () => { @@ -3301,7 +3259,7 @@ describe("global virtual store", () => { // A subsequent `bun install` (e.g. to add another dep) before `--commit` // must not see that real directory as a stale pre-GVS layout and // `deleteTree` the user's in-progress edits. - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts }); await write( packageJson, @@ -3311,20 +3269,32 @@ describe("global virtual store", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); const workspace = join(packageDir, "node_modules", "no-deps"); expect(lstatSync(workspace).isSymbolicLink()).toBe(true); await using proc = spawn({ cmd: [bunExe(), "patch", "no-deps"], cwd: packageDir, - env: bunEnv, + env, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).not.toContain("error"); - expect(stdout).toContain("To patch"); + expect(stderr).toBe(""); + // (the folder is printed with forward slashes on every platform) + expect(stdout).toEndWith( + [ + "To patch no-deps, edit the following folder:", + "", + " node_modules/no-deps", + "", + "Once you're done with your changes, run:", + "", + " bun patch --commit 'node_modules/no-deps'", + "", + ].join("\n"), + ); expect(exitCode).toBe(0); // `bun patch` detached the top-level dep symlink into a real directory @@ -3335,7 +3305,7 @@ describe("global virtual store", () => { const edited = join(workspace, "index.js"); await write(edited, "module.exports = 'USER_EDITS';\n"); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); // The real-directory workspace is preserved across the install; before // this fix `.expect_existing` would `deleteTree` it on readlink EINVAL @@ -3345,8 +3315,8 @@ describe("global virtual store", () => { }); }); -test("rejects dependency aliases that traverse outside node_modules", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("rejects dependency aliases that traverse outside node_modules", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); // A (transitively) malicious package.json can use an arbitrary string as a // dependency alias. The alias becomes a `node_modules/` path @@ -3365,21 +3335,22 @@ test("rejects dependency aliases that traverse outside node_modules", async () = await using proc = spawn({ cmd: [bunExe(), "install"], cwd: packageDir, - env: bunEnv, + env, stdout: "pipe", stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toContain("is not a valid install folder name"); + expect(stderrLines(stderr)).toEqual(['error: "../pwned-by-alias" is not a valid install folder name']); + expect(normalizeBunSnapshot(stdout)).toBe("bun install ()"); // Nothing may be created outside of node_modules. `lstatSync` instead of // `existsSync` because the escaped artifact would be a dangling symlink. expect(() => lstatSync(join(packageDir, "pwned-by-alias"))).toThrow(); - expect(exitCode).not.toBe(0); + expect(exitCode).toBe(1); }); -test("rejects a dependency alias with more than one path component", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("rejects a dependency alias with more than one path component", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -3394,18 +3365,19 @@ test("rejects a dependency alias with more than one path component", async () => await using proc = spawn({ cmd: [bunExe(), "install"], cwd: packageDir, - env: bunEnv, + env, stdout: "pipe", stderr: "pipe", }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toContain(`"somepkg/lib" is not a valid install folder name`); + expect(stderrLines(stderr)).toEqual(['error: "somepkg/lib" is not a valid install folder name']); + expect(normalizeBunSnapshot(stdout)).toBe("bun install ()"); expect(() => lstatSync(join(packageDir, "node_modules", "somepkg", "lib"))).toThrow(); - expect(exitCode).not.toBe(0); + expect(exitCode).toBe(1); }); -test("invalid --linker value is echoed back in the error", async () => { +test.concurrent("invalid --linker value is echoed back in the error", async () => { using dir = tempDir("install-linker-err", { "package.json": JSON.stringify({ name: "t" }), }); @@ -3416,14 +3388,14 @@ test("invalid --linker value is echoed back in the error", async () => { stdout: "pipe", stderr: "pipe", }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toContain('--linker: "isoalted"'); - expect(stderr).toContain("'isolated' or 'hoisted'"); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(`error: Invalid value for --linker: "isoalted". Must be 'isolated' or 'hoisted'.\n`); + expect(stdout).toBe(""); expect(exitCode).toBe(1); }); -test("store build timings are printed by --verbose only", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test.concurrent("store build timings are printed by --verbose only", async () => { + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); await write( packageJson, @@ -3435,38 +3407,32 @@ test("store build timings are printed by --verbose only", async () => { }), ); - async function install(...args: string[]) { - await using proc = spawn({ - cmd: [bunExe(), "install", ...args], - cwd: packageDir, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).not.toContain("error:"); - expect(exitCode).toBe(0); - return stderr; - } - - const verbose = await install("--verbose"); + await using proc = spawn({ + cmd: [bunExe(), "install", "--verbose"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, verbose, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(verbose).not.toContain("error:"); expect(verbose).toMatch(/^Resolved peers \[\S+\]$/m); expect(verbose).toMatch(/^Created store \[\S+\]$/m); + expect(exitCode).toBe(0); + // without --verbose, the rebuild of the store prints nothing at all await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); - const quiet = await install(); - expect(quiet).not.toContain("Resolved peers"); - expect(quiet).not.toContain("Created store"); + await install(env, packageDir, { stderr: [] }); }); -describe("hoist", () => { +describe.concurrent("hoist", () => { // `node_modules/.bun/node_modules` holds a symlink to every installed // package and sits on the upward resolution path of every store entry, so // by default a store package can resolve dependencies it never declared. // `install.hoist = false` (pnpm's `hoist=false`) skips that fallback // directory without touching the rest of the layout. test("hoist = false skips the hidden fallback directory", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated", hoist: false }, }); @@ -3481,7 +3447,7 @@ describe("hoist", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(existsSync(join(packageDir, "node_modules", ".bun", "node_modules"))).toBeFalse(); @@ -3525,7 +3491,7 @@ describe("hoist", () => { }); test("hoist = false keeps publicHoistPattern working", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated", hoist: false, publicHoistPattern: "*types*" }, }); @@ -3539,7 +3505,7 @@ describe("hoist", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); // the transitive @types/is-number is still publicly hoisted to the root expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "@types", "two-range-deps"]); @@ -3551,7 +3517,7 @@ describe("hoist", () => { }); test("hoist = false removes a stale fallback directory from a previous install", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, }); @@ -3565,7 +3531,7 @@ describe("hoist", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); // hoisting is on by default: transitive deps resolve through the fallback expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "no-deps"))).toBe( @@ -3573,7 +3539,7 @@ describe("hoist", () => { ); await registry.writeBunfig(packageDir, { linker: "isolated", hoist: false }); - await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + await runBunInstall(env, packageDir, { savesLockfile: false }); expect(existsSync(join(packageDir, "node_modules", ".bun", "node_modules"))).toBeFalse(); @@ -3593,7 +3559,7 @@ describe("hoist", () => { }); test("hoist = false takes precedence over hoistPattern", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated", hoist: false, hoistPattern: "*" }, }); @@ -3607,13 +3573,13 @@ describe("hoist", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(existsSync(join(packageDir, "node_modules", ".bun", "node_modules"))).toBeFalse(); }); test("npmrc hoist=false", async () => { - const { packageJson, packageDir } = await registry.createTestDir({ + const { packageJson, packageDir, env } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, files: { ".npmrc": "hoist=false", @@ -3630,7 +3596,7 @@ describe("hoist", () => { }), ); - await runBunInstall(bunEnv, packageDir); + await runBunInstall(env, packageDir); expect(existsSync(join(packageDir, "node_modules", ".bun", "node_modules"))).toBeFalse(); expect(readlinkSync(join(packageDir, "node_modules", "two-range-deps"))).toBe( diff --git a/test/harness.ts b/test/harness.ts index b3167919ec84..8b61e04039de 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -1880,6 +1880,23 @@ export function textLockfile(version: number, pkgs: any): string { }); } +// The install cache `VerdaccioRegistry.writeBunfig` gives a project of its own. +function installCacheDir(dir: string): string { + return join(dir, ".bun-cache"); +} + +/** + * The env to spawn `bun install` with for a project under `dir` that must not + * share its install cache (and with it the global store, `/links`) with + * any other project. The `install.cache` in the project's bunfig loses to the + * BUN_INSTALL_CACHE_DIR the CI runner exports per test file, so projects + * spawned with plain `bunEnv` share one cache. Concurrent installs that share + * one fail on Windows (#28062). + */ +export function installEnv(dir: string, env: NodeJS.Dict = bunEnv): NodeJS.Dict { + return { ...env, BUN_INSTALL_CACHE_DIR: installCacheDir(dir) }; +} + export class VerdaccioRegistry { port: number; process: ChildProcess | undefined; @@ -1999,7 +2016,9 @@ export class VerdaccioRegistry { const packageJson = join(packageDir, "package.json"); await this.writeBunfig(packageDir, opts.bunfigOpts); this.users = {}; - return { packageDir: String(packageDir), packageJson }; + const dir = String(packageDir); + // `env` pins the cache `writeBunfig` gave the project (see `installEnv`). + return { packageDir: dir, packageJson, env: installEnv(dir) }; } async writeBunfig(dir: string, opts: BunfigOpts = {}) { @@ -2007,7 +2026,7 @@ export class VerdaccioRegistry { join(dir, "bunfig.toml"), Bun.TOML.stringify({ install: { - cache: join(dir, ".bun-cache"), + cache: installCacheDir(dir), saveTextLockfile: opts.saveTextLockfile, registry: opts.npm ? undefined : this.registryUrl(), linker: opts.linker,