Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
740ae6ea31 fix(install): resolve catalog file: paths relative to root, not workspace
When a workspace package references a catalog entry containing a
`file:./...` path, the path was incorrectly resolved relative to the
workspace directory instead of the root package.json where the catalog
is defined.

This fix adds a check in the local tarball extraction code: if the
dependency's version tag is `.catalog`, the tarball path is resolved
relative to the root directory, preserving the correct behavior for
catalog entries.

Fixes #25752

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:04:55 +00:00
4 changed files with 80 additions and 178 deletions

View File

@@ -1528,16 +1528,6 @@ pub const TestCommand = struct {
// Randomize the order of test files if --randomize flag is set
if (random) |rand| {
rand.shuffle(PathString, test_files);
} else {
// Sort test files alphabetically by path for consistent ordering.
// This ensures the same execution order regardless of how files are
// specified (explicit paths vs directory scanning) or filesystem
// directory entry ordering. See: https://github.com/oven-sh/bun/issues/25797
std.mem.sort(PathString, test_files, {}, struct {
fn lessThan(_: void, a: PathString, b: PathString) bool {
return strings.order(a.slice(), b.slice()) == .lt;
}
}.lessThan);
}
vm.hot_reload = ctx.debug.hot_reload;

View File

@@ -231,7 +231,8 @@ pub fn callback(task: *ThreadPool.Task) void {
this.status = Status.success;
},
.local_tarball => {
const workspace_pkg_id = manager.lockfile.getWorkspacePkgIfWorkspaceDep(this.request.local_tarball.tarball.dependency_id);
const dependency_id = this.request.local_tarball.tarball.dependency_id;
const workspace_pkg_id = manager.lockfile.getWorkspacePkgIfWorkspaceDep(dependency_id);
var abs_buf: bun.PathBuffer = undefined;
const tarball_path, const normalize = if (workspace_pkg_id != invalid_package_id) tarball_path: {
@@ -239,6 +240,11 @@ pub fn callback(task: *ThreadPool.Task) void {
if (workspace_res.tag != .workspace) break :tarball_path .{ this.request.local_tarball.tarball.url.slice(), true };
// If the dependency originated from a catalog entry, the tarball path should be
// resolved relative to the root (where catalogs are defined), not the workspace.
const dependency: Dependency = manager.lockfile.buffers.dependencies.items[dependency_id];
if (dependency.version.tag == .catalog) break :tarball_path .{ this.request.local_tarball.tarball.url.slice(), true };
// Construct an absolute path to the tarball.
// Normally tarball paths are always relative to the root directory, but if a
// workspace depends on a tarball path, it should be relative to the workspace.
@@ -352,6 +358,7 @@ const string = []const u8;
const std = @import("std");
const install = @import("./install.zig");
const Dependency = install.Dependency;
const DependencyID = install.DependencyID;
const ExtractData = install.ExtractData;
const ExtractTarball = install.ExtractTarball;

View File

@@ -0,0 +1,72 @@
// https://github.com/oven-sh/bun/issues/25752
// Catalog entries with `file:./...` paths should resolve relative to the root
// package.json (where catalogs are defined), not the workspace that references them.
import { file } from "bun";
import { expect, test } from "bun:test";
import { bunEnv, bunExe, pack, tempDir } from "harness";
import { join } from "path";
test("catalog file: paths resolve relative to root, not workspace", async () => {
// First create a simple package and pack it to get a tarball
using pkgDir = tempDir("pkg-for-tarball", {
"package.json": JSON.stringify({
name: "catalog-pkg",
version: "1.0.0",
}),
"index.js": "module.exports = 'catalog-pkg';",
});
await pack(String(pkgDir), bunEnv);
const tarballContent = await file(join(String(pkgDir), "catalog-pkg-1.0.0.tgz")).arrayBuffer();
// Create the monorepo structure
using monorepoDir = tempDir("monorepo-25752", {
"package.json": JSON.stringify({
name: "my-monorepo",
workspaces: ["packages/*"],
catalogs: {
vendored: {
"catalog-pkg": "file:./vendored/catalog-pkg-1.0.0.tgz",
},
},
}),
vendored: {
"catalog-pkg-1.0.0.tgz": new Uint8Array(tarballContent),
},
packages: {
"my-app": {
"package.json": JSON.stringify({
name: "my-app",
dependencies: {
"catalog-pkg": "catalog:vendored",
},
}),
},
},
});
// Run bun install
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(monorepoDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The tarball should be resolved relative to the root (where catalogs is defined),
// not relative to packages/my-app where the dependency is declared
expect(stderr).not.toContain("ENOENT");
expect(stderr).not.toContain("failed to resolve");
expect(exitCode).toBe(0);
// Verify the package was installed correctly in the workspace's node_modules
const installedPkg = await file(
join(String(monorepoDir), "packages", "my-app", "node_modules", "catalog-pkg", "package.json"),
).json();
expect(installedPkg.name).toBe("catalog-pkg");
expect(installedPkg.version).toBe("1.0.0");
});

View File

@@ -1,167 +0,0 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Test that test files are sorted alphabetically for consistent execution order.
// https://github.com/oven-sh/bun/issues/25797
//
// This ensures that running `bun test .` produces the same order as running
// `bun test file1.test.ts file2.test.ts ...` (which VSCode does).
describe("issue #25797", () => {
test("test files are sorted alphabetically", async () => {
// Create test files with names that would appear in different orders
// depending on filesystem vs alphabetical sorting
using dir = tempDir("test-sort-order", {
"z_last.test.ts": `
import { test, expect } from "bun:test";
test("z_last", () => { console.log("FILE:z_last"); expect(true).toBe(true); });
`,
"a_first.test.ts": `
import { test, expect } from "bun:test";
test("a_first", () => { console.log("FILE:a_first"); expect(true).toBe(true); });
`,
"m_middle.test.ts": `
import { test, expect } from "bun:test";
test("m_middle", () => { console.log("FILE:m_middle"); expect(true).toBe(true); });
`,
});
// Run tests using directory scanning (bun test .)
await using proc1 = Bun.spawn({
cmd: [bunExe(), "test", "."],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout1, stderr1, exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]);
// Run tests using explicit file paths in reverse alphabetical order
// (simulating how VSCode might pass them in a different order)
await using proc2 = Bun.spawn({
cmd: [bunExe(), "test", "z_last.test.ts", "m_middle.test.ts", "a_first.test.ts"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]);
expect(exitCode1).toBe(0);
expect(exitCode2).toBe(0);
// Extract the order of FILE: markers from output
const getFileOrder = (output: string): string[] => {
const matches = output.match(/FILE:(\w+)/g) || [];
return matches.map(m => m.replace("FILE:", ""));
};
const order1 = getFileOrder(stdout1);
const order2 = getFileOrder(stdout2);
// Both should produce alphabetical order
expect(order1).toEqual(["a_first", "m_middle", "z_last"]);
expect(order2).toEqual(["a_first", "m_middle", "z_last"]);
// Both methods should produce the same order
expect(order1).toEqual(order2);
});
test("test files in subdirectories are sorted alphabetically", async () => {
using dir = tempDir("test-sort-subdirs", {
"tests/b_second.test.ts": `
import { test, expect } from "bun:test";
test("b_second", () => { console.log("FILE:b_second"); expect(true).toBe(true); });
`,
"tests/a_first.test.ts": `
import { test, expect } from "bun:test";
test("a_first", () => { console.log("FILE:a_first"); expect(true).toBe(true); });
`,
"other/c_third.test.ts": `
import { test, expect } from "bun:test";
test("c_third", () => { console.log("FILE:c_third"); expect(true).toBe(true); });
`,
});
// Run tests using directory scanning
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "."],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Extract the order of FILE: markers from output
const getFileOrder = (output: string): string[] => {
const matches = output.match(/FILE:(\w+)/g) || [];
return matches.map(m => m.replace("FILE:", ""));
};
const order = getFileOrder(stdout);
// Files should be sorted alphabetically by full path
// other/c_third.test.ts comes before tests/a_first.test.ts and tests/b_second.test.ts
expect(order).toEqual(["c_third", "a_first", "b_second"]);
});
test("explicit paths and directory scanning produce same order", async () => {
using dir = tempDir("test-explicit-vs-scan", {
"test_c.test.ts": `
import { test, expect } from "bun:test";
test("test_c", () => { console.log("FILE:test_c"); expect(true).toBe(true); });
`,
"test_a.test.ts": `
import { test, expect } from "bun:test";
test("test_a", () => { console.log("FILE:test_a"); expect(true).toBe(true); });
`,
"test_b.test.ts": `
import { test, expect } from "bun:test";
test("test_b", () => { console.log("FILE:test_b"); expect(true).toBe(true); });
`,
});
// Method 1: Directory scanning
await using proc1 = Bun.spawn({
cmd: [bunExe(), "test", "."],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Method 2: Explicit paths in scrambled order
await using proc2 = Bun.spawn({
cmd: [bunExe(), "test", "./test_b.test.ts", "./test_c.test.ts", "./test_a.test.ts"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout1, , exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]);
const [stdout2, , exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]);
expect(exitCode1).toBe(0);
expect(exitCode2).toBe(0);
const getFileOrder = (output: string): string[] => {
const matches = output.match(/FILE:(\w+)/g) || [];
return matches.map(m => m.replace("FILE:", ""));
};
const order1 = getFileOrder(stdout1);
const order2 = getFileOrder(stdout2);
// Both should be alphabetically sorted
expect(order1).toEqual(["test_a", "test_b", "test_c"]);
expect(order2).toEqual(["test_a", "test_b", "test_c"]);
});
});