301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import { spawn, spawnSync } from "bun";
|
|||
|
|
import { install_test_helpers } from "bun:internal-for-testing";
|
||
|
|
import { beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test";
|
||
|
|
import { mkdirSync, writeFileSync } from "fs";
|
||
|
|
import { bunEnv, bunExe, isLinux, isWindows, tempDir, tmpdirSync } from "harness";
|
||
|
|
import { dirname, join } from "path";
|
||
|
|
|
||
|
|
let cwd: string;
|
||
|
|
|
||
|
|
setDefaultTimeout(1000 * 60 * 5);
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
cwd = tmpdirSync();
|
||
|
|
});
|
||
|
|
|
||
|
|
test("bad workspace path", () => {
|
||
|
|
writeFileSync(
|
||
|
|
`${cwd}/package.json`,
|
||
|
|
JSON.stringify(
|
||
|
|
{
|
||
|
|
name: "hey",
|
||
|
|
workspaces: ["i-dont-exist"],
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
const { stderr, exitCode } = spawnSync({
|
||
|
|
cmd: [bunExe(), "install"],
|
||
|
|
cwd,
|
||
|
|
env: bunEnv,
|
||
|
|
stderr: "pipe",
|
||
|
|
stdout: "pipe",
|
||
|
|
});
|
||
|
|
const text = stderr!.toString();
|
||
|
|
|
||
|
|
expect(text).toContain('Workspace not found "i-dont-exist"');
|
||
|
|
|
||
|
|
expect(exitCode).toBe(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("non-string workspaces entry prints the error without literal markup", async () => {
|
||
|
|
using dir = tempDir("bad-workspace-non-string", {
|
||
|
|
"package.json": JSON.stringify({ name: "hey", workspaces: [123] }),
|
||
|
|
});
|
||
|
|
await using proc = Bun.spawn({
|
||
|
|
cmd: [bunExe(), "install"],
|
||
|
|
cwd: String(dir),
|
||
|
|
env: bunEnv,
|
||
|
|
stdout: "pipe",
|
||
|
|
stderr: "pipe",
|
||
|
|
});
|
||
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||
|
|
|
||
|
|
expect(stderr).toContain(
|
||
|
|
'Workspaces expects an array of strings, like:\n "workspaces": [\n "path/to/package"\n ]',
|
||
|
|
);
|
||
|
|
// Pretty-markup tags ("<r>", "<green>") must not leak into the message.
|
||
|
|
expect(stdout + stderr).not.toContain("<r>");
|
||
|
|
expect(exitCode).toBe(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("workspace with ./ should not crash", () => {
|
||
|
|
writeFileSync(
|
||
|
|
`${cwd}/package.json`,
|
||
|
|
JSON.stringify(
|
||
|
|
{
|
||
|
|
name: "my-app",
|
||
|
|
version: "1.0.0",
|
||
|
|
workspaces: ["./", "some-workspace"],
|
||
|
|
devDependencies: {
|
||
|
|
"@eslint/js": "^9.28.0",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
mkdirSync(`${cwd}/some-workspace`);
|
||
|
|
writeFileSync(
|
||
|
|
`${cwd}/some-workspace/package.json`,
|
||
|
|
JSON.stringify(
|
||
|
|
{
|
||
|
|
name: "some-workspace",
|
||
|
|
version: "1.0.0",
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
const { stderr, exitCode } = spawnSync({
|
||
|
|
cmd: [bunExe(), "install"],
|
||
|
|
cwd,
|
||
|
|
env: bunEnv,
|
||
|
|
stderr: "pipe",
|
||
|
|
stdout: "pipe",
|
||
|
|
});
|
||
|
|
const text = stderr!.toString();
|
||
|
|
|
||
|
|
// Should not crash, should succeed
|
||
|
|
expect(exitCode).toBe(0);
|
||
|
|
expect(text).not.toContain("panic");
|
||
|
|
expect(text).not.toContain("Internal assertion failure");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("workspace with .\\ should not crash", () => {
|
||
|
|
writeFileSync(
|
||
|
|
`${cwd}/package.json`,
|
||
|
|
JSON.stringify(
|
||
|
|
{
|
||
|
|
name: "my-app",
|
||
|
|
version: "1.0.0",
|
||
|
|
workspaces: [".\\", "some-workspace"],
|
||
|
|
devDependencies: {
|
||
|
|
"@eslint/js": "^9.28.0",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
mkdirSync(`${cwd}/some-workspace`);
|
||
|
|
writeFileSync(
|
||
|
|
`${cwd}/some-workspace/package.json`,
|
||
|
|
JSON.stringify(
|
||
|
|
{
|
||
|
|
name: "some-workspace",
|
||
|
|
version: "1.0.0",
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
const { stderr, exitCode } = spawnSync({
|
||
|
|
cmd: [bunExe(), "install"],
|
||
|
|
cwd,
|
||
|
|
env: bunEnv,
|
||
|
|
stderr: "pipe",
|
||
|
|
stdout: "pipe",
|
||
|
|
});
|
||
|
|
const text = stderr!.toString();
|
||
|
|
|
||
|
|
// Should not crash, should succeed
|
||
|
|
expect(exitCode).toBe(0);
|
||
|
|
expect(text).not.toContain("panic");
|
||
|
|
expect(text).not.toContain("Internal assertion failure");
|
||
|
|
});
|
||
|
|
|
||
|
|
// Each `workspaces` entry is joined onto the project directory in a path buffer of
|
||
|
|
// MAX_PATH_BYTES: 4096 bytes on Linux, 1024 on macOS (on Windows 32767 * 3 + 1, more than
|
||
|
|
// any path the OS accepts, so the tests around the buffer size are POSIX only). Glob
|
||
|
|
// entries are first joined onto "package.json" in a 4096 byte buffer on every platform.
|
||
|
|
const POSIX_PATH_BUFFER_BYTES = isLinux ? 4096 : 1024;
|
||
|
|
// Longer than either buffer on every platform.
|
||
|
|
const LONG_ENTRY_BYTES = 100_000;
|
||
|
|
|
||
|
|
const PKG1 = { "pkgs/pkg1/package.json": JSON.stringify({ name: "pkg1" }) };
|
||
|
|
|
||
|
|
function rootPackageJson(workspaces: string[]) {
|
||
|
|
return JSON.stringify({ name: "root", workspaces });
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runInstall(cwd: string) {
|
||
|
|
await using proc = spawn({
|
||
|
|
cmd: [bunExe(), "install"],
|
||
|
|
cwd,
|
||
|
|
env: bunEnv,
|
||
|
|
stdout: "pipe",
|
||
|
|
stderr: "pipe",
|
||
|
|
});
|
||
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||
|
|
return { stdout, stderr, exitCode };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Installs and asserts that pkgs/pkg1 is the only workspace package that was found.
|
||
|
|
async function expectOnlyPkg1Found(dir: string) {
|
||
|
|
const { stderr, exitCode } = await runInstall(dir);
|
||
|
|
expect(stderr).not.toContain("error:");
|
||
|
|
expect(exitCode).toBe(0);
|
||
|
|
expect(Object.values(install_test_helpers.parseLockfile(dir).workspace_paths)).toEqual(["pkgs/pkg1"]);
|
||
|
|
}
|
||
|
|
|
||
|
|
describe.concurrent("workspaces entries longer than the path buffer", () => {
|
||
|
|
test("path entry fails with ENAMETOOLONG", async () => {
|
||
|
|
const entry = Buffer.alloc(LONG_ENTRY_BYTES, "a").toString();
|
||
|
|
using dir = tempDir("bad-workspace-long-path", { "package.json": rootPackageJson([entry]) });
|
||
|
|
|
||
|
|
const { stderr, exitCode } = await runInstall(String(dir));
|
||
|
|
|
||
|
|
expect(stderr).toContain(`error: ENAMETOOLONG reading package.json for workspace package "${entry}"`);
|
||
|
|
expect(exitCode).toBe(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
// A relative path of exactly `bytes` bytes made of one letter directory names, so that a
|
||
|
|
// path which fits the buffer is looked up by the OS (ENOENT) instead of exceeding its
|
||
|
|
// limit on the length of a single name.
|
||
|
|
function pathOfLength(bytes: number) {
|
||
|
|
const tail = bytes % 2 === 0 ? "dd" : "d";
|
||
|
|
return Buffer.alloc(bytes - tail.length, "d/").toString() + tail;
|
||
|
|
}
|
||
|
|
|
||
|
|
// The entry is read from `${dir}/${entry}/package.json`. One byte below the buffer size
|
||
|
|
// that path still reaches the OS; from the buffer size on it is rejected before that.
|
||
|
|
test.skipIf(isWindows).each([
|
||
|
|
["one byte below", -1, "Workspace not found"],
|
||
|
|
["exactly", 0, "ENAMETOOLONG reading package.json for workspace package"],
|
||
|
|
["one byte above", 1, "ENAMETOOLONG reading package.json for workspace package"],
|
||
|
|
])("path entry whose package.json path is %s the path buffer size", async (_, offset, message) => {
|
||
|
|
using dir = tempDir("bad-workspace-path-buffer-edge", {});
|
||
|
|
const prefixBytes = Buffer.byteLength(String(dir)) + "/".length;
|
||
|
|
const entry = pathOfLength(POSIX_PATH_BUFFER_BYTES + offset - prefixBytes - "/package.json".length);
|
||
|
|
writeFileSync(join(String(dir), "package.json"), rootPackageJson([entry]));
|
||
|
|
|
||
|
|
const { stderr, exitCode } = await runInstall(String(dir));
|
||
|
|
|
||
|
|
expect(stderr).toContain(`error: ${message} "${entry}"`);
|
||
|
|
expect(exitCode).toBe(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("glob entry is still matched", async () => {
|
||
|
|
// A brace group padded far past the buffer size; its first alternative matches `pkgs`.
|
||
|
|
const entry = "{pkgs," + Buffer.alloc(LONG_ENTRY_BYTES, "x,").toString() + "x}/*";
|
||
|
|
using dir = tempDir("bad-workspace-long-glob", { "package.json": rootPackageJson([entry]), ...PKG1 });
|
||
|
|
|
||
|
|
await expectOnlyPkg1Found(String(dir));
|
||
|
|
});
|
||
|
|
|
||
|
|
test("glob entry matching nothing is skipped like any other glob", async () => {
|
||
|
|
const entry = Buffer.alloc(LONG_ENTRY_BYTES, "a").toString() + "/*";
|
||
|
|
using dir = tempDir("bad-workspace-long-glob-no-match", {
|
||
|
|
"package.json": rootPackageJson(["pkgs/*", entry]),
|
||
|
|
...PKG1,
|
||
|
|
});
|
||
|
|
|
||
|
|
await expectOnlyPkg1Found(String(dir));
|
||
|
|
});
|
||
|
|
|
||
|
|
// What has to fit the buffer is the normalized path, not the entry as written.
|
||
|
|
test.each([
|
||
|
|
["path", "pkgs/pkg1"],
|
||
|
|
["glob", "pkgs/*"],
|
||
|
|
])("%s entry that only fits the path buffer once normalized resolves", async (_, suffix) => {
|
||
|
|
const entry = Buffer.alloc(LONG_ENTRY_BYTES, "x/../").toString() + suffix;
|
||
|
|
using dir = tempDir("bad-workspace-long-normalized", { "package.json": rootPackageJson([entry]), ...PKG1 });
|
||
|
|
|
||
|
|
await expectOnlyPkg1Found(String(dir));
|
||
|
|
});
|
||
|
|
|
||
|
|
// Directory names for a relative path of exactly `bytes` bytes: "deep" followed by names
|
||
|
|
// of at most NAME_MAX (255) bytes. The last name is at least 128 bytes long so that its
|
||
|
|
// parent directory, used as a cwd below, stays well within PATH_MAX.
|
||
|
|
function deepDirectoryNames(bytes: number) {
|
||
|
|
const names = ["deep"];
|
||
|
|
let remaining = bytes - names[0].length;
|
||
|
|
while (remaining > 256) {
|
||
|
|
const length = remaining - 256 >= 129 ? 255 : remaining - 130;
|
||
|
|
names.push(Buffer.alloc(length, "a").toString());
|
||
|
|
remaining -= "/".length + length;
|
||
|
|
}
|
||
|
|
names.push(Buffer.alloc(remaining - "/".length, "b").toString());
|
||
|
|
return names;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Globs are walked relative to the project directory, so a match can lie deeper than the
|
||
|
|
// buffer holds once the project directory is put in front of it. Windows has no such
|
||
|
|
// depth: the buffer there holds more than the longest path the OS accepts.
|
||
|
|
test.skipIf(isWindows)(
|
||
|
|
"glob match whose absolute package.json path does not fit fails with ENAMETOOLONG",
|
||
|
|
async () => {
|
||
|
|
using dir = tempDir("bad-workspace-deep-glob-match", { "package.json": rootPackageJson(["deep/**"]) });
|
||
|
|
// The directory itself fits PATH_MAX (so it can be created), `<dir>/package.json`
|
||
|
|
// does not fit the buffer.
|
||
|
|
const absoluteDirBytes = POSIX_PATH_BUFFER_BYTES - 6;
|
||
|
|
const names = deepDirectoryNames(absoluteDirBytes - Buffer.byteLength(String(dir)) - "/".length);
|
||
|
|
const workspaceDir = join(String(dir), ...names);
|
||
|
|
expect(Buffer.byteLength(workspaceDir)).toBe(absoluteDirBytes);
|
||
|
|
mkdirSync(workspaceDir, { recursive: true });
|
||
|
|
// Too long to be written by its absolute path: write it relative to the parent directory.
|
||
|
|
await using writer = spawn({
|
||
|
|
cmd: [
|
||
|
|
bunExe(),
|
||
|
|
"-e",
|
||
|
|
`require("fs").writeFileSync(process.argv.at(-1) + "/package.json", JSON.stringify({ name: "deep" }))`,
|
||
|
|
names.at(-1)!,
|
||
|
|
],
|
||
|
|
cwd: dirname(workspaceDir),
|
||
|
|
env: bunEnv,
|
||
|
|
stdout: "pipe",
|
||
|
|
stderr: "pipe",
|
||
|
|
});
|
||
|
|
expect(await writer.stderr.text()).toBe("");
|
||
|
|
expect(await writer.exited).toBe(0);
|
||
|
|
|
||
|
|
const { stderr, exitCode } = await runInstall(String(dir));
|
||
|
|
|
||
|
|
expect(stderr).toContain(`error: ENAMETOOLONG reading package.json for workspace package "${names.join("/")}"`);
|
||
|
|
expect(exitCode).toBe(1);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|