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) { 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 text", () => { const env = { ...bunEnv, NO_COLOR: "1" }; const usage: [string, string][] = [ ["install", "bun install [flags] @"], ["add", "bun add [flags] <@version>"], ["remove", "bun remove [flags] []"], ["update", "bun update [flags] @"], ["link", "bun link [flags] []"], ["patch", "bun patch [flags or options] @"], ["patch-commit", "bun patch-commit [flags or options] "], ["info", "bun info [flags] [@]"], ]; 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 (low,"], ["test", "--rerun-each", "Re-run each test file times"], ["test", "--bail", "Exit the test suite after failures"], ["build", "--allow-unresolved", "Use '' 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; // \ renders to \x1b[34m\x1b[0m, not \x1b[34m\x1b[0m expect(out).toContain("\x1b[34m\x1b[0m"); // raw tag markup must not leak through expect(out).not.toContain(""); 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); } }); }); });