mirror of
https://github.com/oven-sh/bun
synced 2026-02-03 15:38:46 +00:00
Compare commits
4 Commits
dylan/dev-
...
jarred/dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac093817d | ||
|
|
8b03f2434a | ||
|
|
438e27e99c | ||
|
|
1042043d9b |
@@ -56,6 +56,8 @@ behavior: Behavior = .{},
|
||||
/// 2. name ASC
|
||||
/// "name" must be ASC so that later, when we rebuild the lockfile
|
||||
/// we insert it back in reverse order without an extra sorting pass
|
||||
/// Note: For workspace packages with both dev and peer dependencies,
|
||||
/// dev dependencies are prioritized to prevent unnecessary network requests
|
||||
pub fn isLessThan(string_buf: []const u8, lhs: Dependency, rhs: Dependency) bool {
|
||||
const behavior = lhs.behavior.cmp(rhs.behavior);
|
||||
if (behavior != .eq) {
|
||||
@@ -1420,6 +1422,19 @@ pub const Behavior = packed struct(u8) {
|
||||
.lt;
|
||||
}
|
||||
|
||||
// Special handling for workspace packages with both dev and peer dependencies
|
||||
// If both behaviors have workspace flag, prioritize dev over peer
|
||||
if (lhs.workspace and rhs.workspace) {
|
||||
if (lhs.dev and lhs.peer and rhs.peer and !rhs.dev) {
|
||||
// lhs is workspace + dev + peer, rhs is workspace + peer only
|
||||
return .gt;
|
||||
}
|
||||
if (rhs.dev and rhs.peer and lhs.peer and !lhs.dev) {
|
||||
// rhs is workspace + dev + peer, lhs is workspace + peer only
|
||||
return .lt;
|
||||
}
|
||||
}
|
||||
|
||||
if (lhs.isDev() != rhs.isDev()) {
|
||||
return if (lhs.isDev())
|
||||
.gt
|
||||
@@ -1427,15 +1442,15 @@ pub const Behavior = packed struct(u8) {
|
||||
.lt;
|
||||
}
|
||||
|
||||
if (lhs.isOptional() != rhs.isOptional()) {
|
||||
return if (lhs.isOptional())
|
||||
if (lhs.isPeer() != rhs.isPeer()) {
|
||||
return if (lhs.isPeer())
|
||||
.gt
|
||||
else
|
||||
.lt;
|
||||
}
|
||||
|
||||
if (lhs.isPeer() != rhs.isPeer()) {
|
||||
return if (lhs.isPeer())
|
||||
if (lhs.isOptional() != rhs.isOptional()) {
|
||||
return if (lhs.isOptional())
|
||||
.gt
|
||||
else
|
||||
.lt;
|
||||
|
||||
185
test/js/bun/install/dependency-behavior-priority.test.ts
Normal file
185
test/js/bun/install/dependency-behavior-priority.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
// Mock the Behavior struct for testing
|
||||
class MockBehavior {
|
||||
prod: boolean = false;
|
||||
dev: boolean = false;
|
||||
peer: boolean = false;
|
||||
optional: boolean = false;
|
||||
workspace: boolean = false;
|
||||
bundled: boolean = false;
|
||||
|
||||
constructor(options: Partial<MockBehavior> = {}) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
|
||||
isProd() { return this.prod; }
|
||||
isDev() { return this.dev; }
|
||||
isPeer() { return this.peer; }
|
||||
isOptional() { return this.optional && !this.peer; }
|
||||
isWorkspace() { return this.workspace; }
|
||||
isBundled() { return this.bundled; }
|
||||
isWorkspaceOnly() { return this.workspace && !this.dev && !this.prod && !this.optional && !this.peer; }
|
||||
|
||||
eq(other: MockBehavior) {
|
||||
return this.prod === other.prod &&
|
||||
this.dev === other.dev &&
|
||||
this.peer === other.peer &&
|
||||
this.optional === other.optional &&
|
||||
this.workspace === other.workspace &&
|
||||
this.bundled === other.bundled;
|
||||
}
|
||||
|
||||
// Mirror the comparison logic from Zig
|
||||
cmp(other: MockBehavior): "lt" | "eq" | "gt" {
|
||||
if (this.eq(other)) {
|
||||
return "eq";
|
||||
}
|
||||
|
||||
if (this.isWorkspaceOnly() !== other.isWorkspaceOnly()) {
|
||||
return this.isWorkspaceOnly() ? "lt" : "gt";
|
||||
}
|
||||
|
||||
if (this.isProd() !== other.isProd()) {
|
||||
return this.isProd() ? "gt" : "lt";
|
||||
}
|
||||
|
||||
// Special handling for workspace packages with both dev and peer dependencies
|
||||
// If both behaviors have workspace flag, prioritize dev over peer
|
||||
if (this.workspace && other.workspace) {
|
||||
if (this.dev && this.peer && other.peer && !other.dev) {
|
||||
// this is workspace + dev + peer, other is workspace + peer only
|
||||
return "gt";
|
||||
}
|
||||
if (other.dev && other.peer && this.peer && !this.dev) {
|
||||
// other is workspace + dev + peer, this is workspace + peer only
|
||||
return "lt";
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isDev() !== other.isDev()) {
|
||||
return this.isDev() ? "gt" : "lt";
|
||||
}
|
||||
|
||||
if (this.isPeer() !== other.isPeer()) {
|
||||
return this.isPeer() ? "gt" : "lt";
|
||||
}
|
||||
|
||||
if (this.isOptional() !== other.isOptional()) {
|
||||
return this.isOptional() ? "gt" : "lt";
|
||||
}
|
||||
|
||||
if (this.isWorkspace() !== other.isWorkspace()) {
|
||||
return this.isWorkspace() ? "gt" : "lt";
|
||||
}
|
||||
|
||||
return "eq";
|
||||
}
|
||||
}
|
||||
|
||||
test("dependency behavior comparison for workspace packages prioritizes dev+peer over peer-only", () => {
|
||||
const workspaceDevPeer = new MockBehavior({ workspace: true, dev: true, peer: true });
|
||||
const workspacePeerOnly = new MockBehavior({ workspace: true, peer: true });
|
||||
|
||||
// workspace + dev + peer should have higher priority than workspace + peer only
|
||||
expect(workspaceDevPeer.cmp(workspacePeerOnly)).toBe("gt");
|
||||
expect(workspacePeerOnly.cmp(workspaceDevPeer)).toBe("lt");
|
||||
});
|
||||
|
||||
test("regular dev vs peer dependencies follow standard priority", () => {
|
||||
const devBehavior = new MockBehavior({ dev: true });
|
||||
const peerBehavior = new MockBehavior({ peer: true });
|
||||
|
||||
// Without workspace flag, dev and peer follow standard ordering
|
||||
expect(devBehavior.cmp(peerBehavior)).toBe("gt");
|
||||
expect(peerBehavior.cmp(devBehavior)).toBe("lt");
|
||||
});
|
||||
|
||||
test("dependency behavior comparison handles production dependencies", () => {
|
||||
const prodBehavior = new MockBehavior({ prod: true });
|
||||
const devBehavior = new MockBehavior({ dev: true });
|
||||
const peerBehavior = new MockBehavior({ peer: true });
|
||||
|
||||
// Production dependencies should have highest priority
|
||||
expect(prodBehavior.cmp(devBehavior)).toBe("gt");
|
||||
expect(prodBehavior.cmp(peerBehavior)).toBe("gt");
|
||||
expect(devBehavior.cmp(prodBehavior)).toBe("lt");
|
||||
expect(peerBehavior.cmp(prodBehavior)).toBe("lt");
|
||||
});
|
||||
|
||||
test("dependency behavior comparison handles workspace dependencies", () => {
|
||||
const workspaceOnlyBehavior = new MockBehavior({ workspace: true });
|
||||
const devBehavior = new MockBehavior({ dev: true });
|
||||
const peerBehavior = new MockBehavior({ peer: true });
|
||||
|
||||
// Workspace-only dependencies should have highest priority
|
||||
expect(workspaceOnlyBehavior.cmp(devBehavior)).toBe("lt");
|
||||
expect(workspaceOnlyBehavior.cmp(peerBehavior)).toBe("lt");
|
||||
expect(devBehavior.cmp(workspaceOnlyBehavior)).toBe("gt");
|
||||
expect(peerBehavior.cmp(workspaceOnlyBehavior)).toBe("gt");
|
||||
});
|
||||
|
||||
test("dependency behavior comparison handles optional dependencies", () => {
|
||||
const optionalBehavior = new MockBehavior({ optional: true });
|
||||
const devBehavior = new MockBehavior({ dev: true });
|
||||
const peerBehavior = new MockBehavior({ peer: true });
|
||||
|
||||
// Optional dependencies should have lower priority than dev/peer dependencies
|
||||
expect(devBehavior.cmp(optionalBehavior)).toBe("gt");
|
||||
expect(peerBehavior.cmp(optionalBehavior)).toBe("gt");
|
||||
expect(optionalBehavior.cmp(devBehavior)).toBe("lt");
|
||||
expect(optionalBehavior.cmp(peerBehavior)).toBe("lt");
|
||||
});
|
||||
|
||||
test("workspace-specific behavior for dev+peer vs peer dependencies", () => {
|
||||
// Test the specific Next.js monorepo scenario
|
||||
const workspaceDevPeer = new MockBehavior({ workspace: true, dev: true, peer: true });
|
||||
const workspacePeer = new MockBehavior({ workspace: true, peer: true });
|
||||
const workspaceDev = new MockBehavior({ workspace: true, dev: true });
|
||||
|
||||
// Workspace dev+peer should be prioritized over workspace peer-only
|
||||
expect(workspaceDevPeer.cmp(workspacePeer)).toBe("gt");
|
||||
expect(workspacePeer.cmp(workspaceDevPeer)).toBe("lt");
|
||||
|
||||
// Workspace dev+peer vs workspace dev-only follows standard rules
|
||||
expect(workspaceDevPeer.cmp(workspaceDev)).toBe("gt"); // peer flag adds to priority
|
||||
});
|
||||
|
||||
test("non-workspace behavior remains unchanged", () => {
|
||||
const devPeerBehavior = new MockBehavior({ dev: true, peer: true });
|
||||
const peerOnlyBehavior = new MockBehavior({ peer: true });
|
||||
const devOnlyBehavior = new MockBehavior({ dev: true });
|
||||
|
||||
// Without workspace flag, behavior follows standard priority rules
|
||||
expect(devPeerBehavior.cmp(devPeerBehavior)).toBe("eq");
|
||||
expect(devPeerBehavior.cmp(peerOnlyBehavior)).toBe("gt");
|
||||
expect(devPeerBehavior.cmp(devOnlyBehavior)).toBe("gt"); // dev+peer has higher priority than dev-only
|
||||
});
|
||||
|
||||
test("dependency sorting order matches intended priority", () => {
|
||||
const behaviors = [
|
||||
new MockBehavior({ workspace: true }), // workspace-only (highest priority)
|
||||
new MockBehavior({ prod: true }), // production
|
||||
new MockBehavior({ dev: true }), // dev
|
||||
new MockBehavior({ peer: true }), // peer
|
||||
new MockBehavior({ optional: true }), // optional (lowest priority)
|
||||
];
|
||||
|
||||
// Test that each behavior has higher priority than the ones that come after it
|
||||
for (let i = 0; i < behaviors.length - 1; i++) {
|
||||
for (let j = i + 1; j < behaviors.length; j++) {
|
||||
const result = behaviors[i].cmp(behaviors[j]);
|
||||
const reverseResult = behaviors[j].cmp(behaviors[i]);
|
||||
|
||||
// Workspace-only should be "lt" (higher priority = lower in sort order)
|
||||
// Others should be "gt" (higher priority = greater in comparison)
|
||||
if (i === 0) {
|
||||
expect(result).toBe("lt");
|
||||
expect(reverseResult).toBe("gt");
|
||||
} else {
|
||||
expect(result).toBe("gt");
|
||||
expect(reverseResult).toBe("lt");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
281
test/regression/issue/test-dev-peer-dependency-priority.test.ts
Normal file
281
test/regression/issue/test-dev-peer-dependency-priority.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
|
||||
import { join } from "path";
|
||||
import { mkdirSync, rmSync } from "fs";
|
||||
|
||||
test("workspace devDependencies should take priority over peerDependencies for resolution", async () => {
|
||||
const dir = tempDirWithFiles("dev-peer-priority", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-monorepo",
|
||||
version: "1.0.0",
|
||||
workspaces: {
|
||||
packages: ["packages/*"],
|
||||
nodeLinker: "isolated"
|
||||
},
|
||||
}),
|
||||
"packages/lib/package.json": JSON.stringify({
|
||||
name: "lib",
|
||||
version: "1.0.0",
|
||||
dependencies: {},
|
||||
devDependencies: {
|
||||
"my-dep": "workspace:*" // Use workspace protocol for dev
|
||||
},
|
||||
peerDependencies: {
|
||||
"my-dep": "^1.0.0" // Range that wants 1.x
|
||||
},
|
||||
}),
|
||||
"packages/lib/test.js": `const dep = require("my-dep"); console.log(dep.version);`,
|
||||
// Only provide workspace package with version 2.0.0
|
||||
"packages/my-dep/package.json": JSON.stringify({
|
||||
name: "my-dep",
|
||||
version: "2.0.0",
|
||||
main: "index.js",
|
||||
}),
|
||||
"packages/my-dep/index.js": `module.exports = { version: "2.0.0" };`,
|
||||
});
|
||||
|
||||
// Run bun install with a dead registry to ensure no network requests
|
||||
const { stdout, stderr, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => {
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "install", "--no-progress", "--no-summary"],
|
||||
cwd: dir,
|
||||
env: {
|
||||
...bunEnv,
|
||||
NPM_CONFIG_REGISTRY: "http://localhost:9999/", // Dead URL - will fail if used
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
proc.exited.then((exitCode) => {
|
||||
Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]).then(([stdout, stderr]) => {
|
||||
resolve({ stdout, stderr, exitCode });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error("Install failed with exit code:", exitCode);
|
||||
console.error("stdout:", stdout);
|
||||
console.error("stderr:", stderr);
|
||||
}
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Check that no network requests were made for packages that should be resolved locally
|
||||
expect(stderr).not.toContain("GET");
|
||||
expect(stderr).not.toContain("http");
|
||||
|
||||
// Check that the lockfile was created correctly
|
||||
const lockfilePath = join(dir, "bun.lock");
|
||||
expect(await Bun.file(lockfilePath).exists()).toBe(true);
|
||||
|
||||
// Verify that version 2.0.0 (devDependency) was linked
|
||||
// If peerDependency range ^1.0.0 was used, it would try to fetch from npm and fail
|
||||
const testResult = await new Promise<string>((resolve) => {
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "packages/lib/test.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
});
|
||||
|
||||
new Response(proc.stdout).text().then(resolve);
|
||||
});
|
||||
|
||||
expect(testResult.trim()).toBe("2.0.0");
|
||||
});
|
||||
|
||||
test("devDependencies and peerDependencies with different versions should coexist", async () => {
|
||||
const dir = tempDirWithFiles("dev-peer-different-versions", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-monorepo",
|
||||
version: "1.0.0",
|
||||
workspaces: {
|
||||
packages: ["packages/*"],
|
||||
nodeLinker: "isolated"
|
||||
},
|
||||
}),
|
||||
"packages/lib/package.json": JSON.stringify({
|
||||
name: "lib",
|
||||
version: "1.0.0",
|
||||
dependencies: {},
|
||||
devDependencies: {
|
||||
"utils": "1.0.0"
|
||||
},
|
||||
peerDependencies: {
|
||||
"utils": "^1.0.0"
|
||||
},
|
||||
}),
|
||||
"packages/lib/index.js": `console.log("lib");`,
|
||||
"packages/utils/package.json": JSON.stringify({
|
||||
name: "utils",
|
||||
version: "1.0.0",
|
||||
main: "index.js",
|
||||
}),
|
||||
"packages/utils/index.js": `console.log("utils");`,
|
||||
});
|
||||
|
||||
// Run bun install in the monorepo
|
||||
const { stdout, stderr, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => {
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "install", "--no-progress", "--no-summary"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
proc.exited.then((exitCode) => {
|
||||
Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]).then(([stdout, stderr]) => {
|
||||
resolve({ stdout, stderr, exitCode });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error("Install failed with exit code:", exitCode);
|
||||
console.error("stdout:", stdout);
|
||||
console.error("stderr:", stderr);
|
||||
}
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Check that the lockfile was created correctly
|
||||
const lockfilePath = join(dir, "bun.lock");
|
||||
expect(await Bun.file(lockfilePath).exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("dependency behavior comparison prioritizes devDependencies", async () => {
|
||||
const dir = tempDirWithFiles("behavior-comparison", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-app",
|
||||
version: "1.0.0",
|
||||
dependencies: {},
|
||||
devDependencies: {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
peerDependencies: {
|
||||
"typescript": "^4.0.0 || ^5.0.0"
|
||||
},
|
||||
}),
|
||||
"index.js": `console.log("app");`,
|
||||
});
|
||||
|
||||
// Run bun install
|
||||
const { stdout, stderr, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => {
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "install", "--no-progress", "--no-summary"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
proc.exited.then((exitCode) => {
|
||||
Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]).then(([stdout, stderr]) => {
|
||||
resolve({ stdout, stderr, exitCode });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error("Install failed with exit code:", exitCode);
|
||||
console.error("stdout:", stdout);
|
||||
console.error("stderr:", stderr);
|
||||
}
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Check that the lockfile was created correctly
|
||||
const lockfilePath = join(dir, "bun.lock");
|
||||
expect(await Bun.file(lockfilePath).exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("Next.js monorepo scenario should not make unnecessary network requests", async () => {
|
||||
const dir = tempDirWithFiles("nextjs-monorepo", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "nextjs-monorepo",
|
||||
version: "1.0.0",
|
||||
workspaces: {
|
||||
packages: ["packages/*"],
|
||||
nodeLinker: "isolated"
|
||||
},
|
||||
}),
|
||||
"packages/web/package.json": JSON.stringify({
|
||||
name: "web",
|
||||
version: "1.0.0",
|
||||
dependencies: {},
|
||||
devDependencies: {
|
||||
"next": "15.0.0-canary.119" // Specific canary version for dev
|
||||
},
|
||||
peerDependencies: {
|
||||
"next": "^14.0.0 || ^15.0.0" // Range that would accept 14.x or 15.x stable
|
||||
},
|
||||
}),
|
||||
"packages/web/test.js": `const next = require("next/package.json"); console.log(next.version);`,
|
||||
// Only provide the canary version that matches devDependencies
|
||||
"packages/next/package.json": JSON.stringify({
|
||||
name: "next",
|
||||
version: "15.0.0-canary.119",
|
||||
main: "index.js",
|
||||
}),
|
||||
"packages/next/index.js": `console.log("next workspace");`,
|
||||
});
|
||||
|
||||
// Run bun install with dead registry
|
||||
const { stdout, stderr, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => {
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "install", "--no-progress", "--no-summary"],
|
||||
cwd: dir,
|
||||
env: {
|
||||
...bunEnv,
|
||||
NPM_CONFIG_REGISTRY: "http://localhost:9999/", // Dead URL
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
proc.exited.then((exitCode) => {
|
||||
Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]).then(([stdout, stderr]) => {
|
||||
resolve({ stdout, stderr, exitCode });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// The key test: should not make network requests for packages that exist in workspace
|
||||
// When devDependencies are prioritized over peerDependencies, the workspace version should be used
|
||||
expect(stderr).not.toContain("GET");
|
||||
expect(stderr).not.toContain("404");
|
||||
expect(stderr).not.toContain("http");
|
||||
|
||||
// Check that the lockfile was created correctly
|
||||
const lockfilePath = join(dir, "bun.lock");
|
||||
expect(await Bun.file(lockfilePath).exists()).toBe(true);
|
||||
|
||||
// Verify that version 15.0.0-canary.119 (devDependency) was used
|
||||
// If peer range was used, it would try to fetch a stable version from npm and fail
|
||||
const testResult = await new Promise<string>((resolve) => {
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "packages/web/test.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
});
|
||||
|
||||
new Response(proc.stdout).text().then(resolve);
|
||||
});
|
||||
|
||||
expect(testResult.trim()).toBe("15.0.0-canary.119");
|
||||
});
|
||||
Reference in New Issue
Block a user