Files

313 lines
13 KiB
TypeScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
import { spawnSync } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isDebug, isWindows, tempDir } from "harness";
import fs from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
describe("bun", () => {
describe("NO_COLOR", () => {
for (const value of ["1", "0", "foo", " "]) {
test(`respects NO_COLOR=${JSON.stringify(value)} to disable color`, () => {
const { stdout } = spawnSync({
cmd: [bunExe()],
env: {
NO_COLOR: value,
},
});
expect(stdout.toString()).not.toMatch(/\u001b\[\d+m/);
});
}
for (const value of ["", undefined]) {
// TODO: need a way to fake a tty in order to test this,
// and cannot use FORCE_COLOR since that will always override NO_COLOR.
test.todo(`respects NO_COLOR=${JSON.stringify(value)} to enable color`, () => {
const { stdout } = spawnSync({
cmd: [bunExe()],
env:
value === undefined
? {}
: {
NO_COLOR: value,
},
});
expect(stdout.toString()).toMatch(/\u001b\[\d+m/);
});
}
});
describe("revision", () => {
test("revision generates version numbers correctly", () => {
var { stdout, exitCode } = Bun.spawnSync({
cmd: [bunExe(), "--version"],
env: bunEnv,
stderr: "inherit",
});
var version = stdout.toString().trim();
var { stdout, exitCode } = Bun.spawnSync({
cmd: [bunExe(), "--revision"],
env: bunEnv,
stderr: "inherit",
});
var revision = stdout.toString().trim();
expect(exitCode).toBe(0);
expect(revision).toStartWith(version);
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
expect(revision).toMatch(
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
);
});
});
describe("getcompletes", () => {
test("getcompletes should not panic and should not be empty", () => {
const { stdout, exitCode } = spawnSync({
cmd: [bunExe(), "getcompletes"],
env: bunEnv,
});
expect(exitCode).toBe(0);
expect(stdout.toString()).not.toBeEmpty();
});
// https://github.com/oven-sh/bun/issues/30086
test("getcompletes keeps scripts whose names start with 'pre'/'post' when no sibling script exists", () => {
using dir = tempDir("getcompletes-pre-post", {
"package.json": JSON.stringify({
name: "test",
scripts: {
// standalone scripts — nothing named `ttier`, `pare-release`, `gres`, `css`, `view`
"prettier": "echo prettier",
"prettier:fix": "echo prettier:fix",
"prepare-release": "echo prepare-release",
"postgres": "echo postgres",
"postcss": "echo postcss",
"preview": "echo preview",
// plain scripts
"build": "echo build",
"dev": "echo dev",
"lint": "echo lint",
"lint:fix": "echo lint:fix",
"fix": "echo fix",
"test": "echo test",
// real lifecycle hooks — these SHOULD be hidden (sibling exists)
"prebuild": "echo prebuild",
"postbuild": "echo postbuild",
"pretest": "echo pretest",
},
}),
});
for (const filter of ["s", "i", "r", "g", "z"]) {
const { stdout, exitCode } = spawnSync({
cmd: [bunExe(), "getcompletes", filter],
env: bunEnv,
cwd: String(dir),
});
const lines = stdout
.toString()
.split("\n")
.map(l => l.split("\t")[0]) // "z" filter emits "name\tdescription"
.filter(Boolean);
// standalone pre/post-prefixed scripts must be present
expect(lines).toContain("prettier");
expect(lines).toContain("prettier:fix");
expect(lines).toContain("prepare-release");
expect(lines).toContain("postgres");
expect(lines).toContain("postcss");
expect(lines).toContain("preview");
// real npm lifecycle hooks (sibling `build`/`test` exists) must still be hidden
expect(lines).not.toContain("prebuild");
expect(lines).not.toContain("postbuild");
expect(lines).not.toContain("pretest");
expect(exitCode).toBe(0);
}
});
});
// On Windows `bun completions` installs bunx as a hardlink (or a .cmd shim) instead of a symlink.
describe.skipIf(isWindows)("completions", () => {
const bunxName = isDebug ? "bunx-debug" : "bunx";
test("installs a bunx symlink to the executable, falling back through the install directories", async () => {
using dir = tempDir("completions-bunx", {
"bin": {},
"empty-path": {},
"install": { bin: {} },
"home-empty": {},
"home-bun": { ".bun": { bin: {} } },
"home-local": { ".local": { bin: {} } },
});
// Run a private copy of the executable so that the first candidate location, the executable's
// own directory, is inside the temporary directory. A hardlink avoids copying the binary; fall
// back to a copy when the temporary directory is on another filesystem.
const exe = join(String(dir), "bin", "bun");
try {
fs.linkSync(fs.realpathSync(bunExe()), exe);
} catch {
fs.copyFileSync(bunExe(), exe);
}
// The link is created against the resolved executable path.
const exeRealpath = fs.realpathSync(exe);
async function installBunx(env: Record<string, string | undefined>) {
await using proc = Bun.spawn({
cmd: [exe, "completions"],
// No bunx on PATH, so the symlink gets installed. No SHELL, so the command stops right
// after that step instead of writing shell completions.
env: { ...bunEnv, PATH: join(String(dir), "empty-path"), SHELL: undefined, BUN_INSTALL: undefined, ...env },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe("");
expect(stderr).toContain("Unknown or unsupported shell");
expect(exitCode).toBe(1);
}
// 1. Next to the executable.
await installBunx({ HOME: join(String(dir), "home-empty") });
expect(fs.readlinkSync(join(String(dir), "bin", bunxName))).toBe(exeRealpath);
// That link now exists, so every following run falls through to the next location.
// 2. $BUN_INSTALL/bin
await installBunx({ HOME: join(String(dir), "home-empty"), BUN_INSTALL: join(String(dir), "install") });
expect(fs.readlinkSync(join(String(dir), "install", "bin", bunxName))).toBe(exeRealpath);
// 3. $HOME/.bun/bin
await installBunx({ HOME: join(String(dir), "home-bun") });
expect(fs.readlinkSync(join(String(dir), "home-bun", ".bun", "bin", bunxName))).toBe(exeRealpath);
// 4. $HOME/.local/bin, once $HOME/.bun/bin does not exist.
await installBunx({ HOME: join(String(dir), "home-local") });
expect(fs.readlinkSync(join(String(dir), "home-local", ".local", "bin", bunxName))).toBe(exeRealpath);
});
});
describe("--help preserves <placeholder> text", () => {
const env = { ...bunEnv, NO_COLOR: "1" };
const usage: [string, string][] = [
["install", "bun install [flags] <name>@<version>"],
["add", "bun add [flags] <package><@version>"],
["remove", "bun remove [flags] [<packages>]"],
["update", "bun update [flags] <name>@<version>"],
["link", "bun link [flags] [<packages>]"],
["patch", "bun patch [flags or options] <package>@<version>"],
["patch-commit", "bun patch-commit [flags or options] <directory>"],
["info", "bun info [flags] <package>[@<version>]"],
];
test.concurrent.each(usage)("bun %s --help usage line", async (cmd, expected) => {
await using proc = Bun.spawn({ cmd: [bunExe(), cmd, "--help"], env, stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const line = (stdout + stderr).split(/\r?\n/).find(l => l.startsWith("Usage:")) ?? "";
expect(line).toBe(`Usage: ${expected}`);
expect(exitCode).toBe(0);
});
const flags: [string, string, string][] = [
["audit", "--audit-level", "greater than or equal to <level> (low,"],
["test", "--rerun-each", "Re-run each test file <NUMBER> times"],
["test", "--bail", "Exit the test suite after <NUMBER> failures"],
["build", "--allow-unresolved", "Use '<empty>' for opaque specifiers"],
["add", "-F, --filter", "Add the package(s) to the matching workspaces instead of the current package"],
["add", "--catalog", 'depend on it as "catalog:" (use --catalog=NAME for a named catalog)'],
["remove", "-F, --filter", "Remove the package(s) from the matching workspaces instead of the current package"],
["install", "-F, --filter", "Install packages for the matching workspaces"],
["install", "--catalog", 'depend on it as "catalog:" (use --catalog=NAME for a named catalog)'],
["audit", "--ignore", "Ignore advisories by GHSA or numeric advisory ID (repeatable)"],
["audit", "-L, --latest", "Also apply fixes your declared ranges exclude, rewriting package.json"],
["update", "-p, --production", "Only update dependencies and optionalDependencies (alias: --prod)"],
["install", "-p, --production", "Don't install devDependencies"],
];
test.concurrent.each(flags)("bun %s --help keeps placeholder in %s description", async (cmd, flag, expected) => {
await using proc = Bun.spawn({ cmd: [bunExe(), cmd, "--help"], env, stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const line = (stdout + stderr).split(/\r?\n/).find(l => l.includes(flag)) ?? "";
expect(line).toContain(expected);
expect(exitCode).toBe(0);
});
test("bun add --help usage line is intact with FORCE_COLOR=1", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "add", "--help"],
env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" },
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const out = stdout + stderr;
// <blue>\<package\><r> renders to \x1b[34m<package>\x1b[0m, not \x1b[34m\x1b[0m
expect(out).toContain("\x1b[34m<package>\x1b[0m");
// raw tag markup must not leak through
expect(out).not.toContain("<blue>");
expect(exitCode).toBe(0);
});
});
describe("--help lists package manager commands and examples", () => {
const env = { ...bunEnv, NO_COLOR: "1" };
const lines: [string, string[], RegExp[]][] = [
[
"bun --help",
[],
[
/^ {2}dedupe +Remove duplicate versions from the lockfile$/m,
/^ {2}prune +Remove packages that are not in the lockfile from node_modules$/m,
],
],
[
"bun pm --help",
["pm"],
[
/^ {2}bun pm ls +list the dependency tree according to the current lockfile$/m,
/^ {2}bun pm licenses +list installed packages grouped by license$/m,
],
],
[
"bun add --help",
["add"],
[
/^ {2}Add a dependency to a specific workspace in a monorepo\n {2}bun add zod --filter api$/m,
/^ {2}Add to the workspace catalog instead of pinning a version\n {2}bun add --catalog react\n {2}bun add --catalog=testing vitest$/m,
],
],
[
"bun audit --help",
["audit"],
[
/^ {2}bun audit fix upgrades vulnerable packages to the lowest safe version that still satisfies every dependent's range\.$/m,
/^ {2}Upgrade vulnerable packages in bun\.lock and node_modules; package\.json is only changed when an exact pin has to be bumped\.\n {2}bun audit fix$/m,
/^ {2}Show what bun audit fix would change without changing anything\.\n {2}bun audit fix --dry-run$/m,
/^ {2}Also apply fixes that your package\.json ranges exclude, rewriting those ranges\.\n {2}bun audit fix --latest$/m,
],
],
];
test.concurrent.each(lines)("%s", async (_, args, expected) => {
await using proc = Bun.spawn({ cmd: [bunExe(), ...args, "--help"], env, stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const out = (stdout + stderr).replaceAll("\r\n", "\n");
for (const re of expected) expect(out).toMatch(re);
expect(out).not.toContain("bun list ");
expect(exitCode).toBe(0);
});
});
describe("test command line arguments", () => {
test("test --config, issue #4128", () => {
const path = `${tmpdir()}/bunfig-${Date.now()}.toml`;
fs.writeFileSync(path, "[debug]");
const p = Bun.spawnSync({
cmd: [bunExe(), "--config=" + path],
env: {},
stderr: "inherit",
});
try {
expect(p.exitCode).toBe(0);
} finally {
fs.unlinkSync(path);
}
});
});
});