mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
### What does this PR do? fixes https://github.com/oven-sh/bun/issues/26597 ### How did you verify your code works? --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2033 lines
78 KiB
TypeScript
2033 lines
78 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { realpathSync } from "fs";
|
|
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
|
|
import path from "path";
|
|
|
|
// Helper: spawn bun with multi-run flags, returns { stdout, stderr, exitCode }
|
|
async function runMulti(
|
|
args: string[],
|
|
dir: string,
|
|
extraEnv?: Record<string, string>,
|
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
await using proc = Bun.spawn({
|
|
cmd: [bunExe(), ...args],
|
|
env: { ...bunEnv, NO_COLOR: "1", ...extraEnv },
|
|
cwd: dir,
|
|
stderr: "pipe",
|
|
stdout: "pipe",
|
|
});
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
return { stdout, stderr, exitCode };
|
|
}
|
|
|
|
/**
|
|
* Assert that `output` contains a multi-run prefixed line: `label | content`.
|
|
* Pass r.stdout for child stdout content, r.stderr for child stderr / status messages.
|
|
*/
|
|
function expectPrefixed(output: string, label: string, content: string) {
|
|
const re = new RegExp(`^${escapeRe(label)}\\s+\\| .*${escapeRe(content)}`, "m");
|
|
expect(output).toMatch(re);
|
|
}
|
|
|
|
function escapeRe(s: string) {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
/** Assert that stderr contains `label | Done in Xms` or `label | Done in Xs`. */
|
|
function expectDone(stderr: string, label: string) {
|
|
expect(stderr).toMatch(new RegExp(`^${escapeRe(label)}\\s+\\| Done`, "m"));
|
|
}
|
|
|
|
/** Assert that stderr contains `label | Exited with code N`. */
|
|
function expectExited(stderr: string, label: string, code: number) {
|
|
expect(stderr).toMatch(new RegExp(`^${escapeRe(label)}\\s+\\| Exited with code ${code}`, "m"));
|
|
}
|
|
|
|
// ─── PARALLEL: BASIC ──────────────────────────────────────────────────────────
|
|
|
|
describe("parallel: basic", () => {
|
|
test("runs two scripts in parallel", async () => {
|
|
using dir = tempDir("mr-par-basic", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
a: `${bunExe()} -e "console.log('output-a')"`,
|
|
b: `${bunExe()} -e "console.log('output-b')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "a", "b"], String(dir));
|
|
expectPrefixed(r.stdout, "a", "output-a");
|
|
expectPrefixed(r.stdout, "b", "output-b");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("runs a single script", async () => {
|
|
using dir = tempDir("mr-par-single", {
|
|
"package.json": JSON.stringify({
|
|
scripts: { only: `${bunExe()} -e "console.log('single')"` },
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "only"], String(dir));
|
|
expectPrefixed(r.stdout, "only", "single");
|
|
expectDone(r.stderr, "only");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("runs many scripts (10+)", async () => {
|
|
const scripts: Record<string, string> = {};
|
|
for (let i = 0; i < 12; i++) {
|
|
scripts[`s${i}`] = `${bunExe()} -e "console.log('out-${i}')"`;
|
|
}
|
|
using dir = tempDir("mr-par-many", {
|
|
"package.json": JSON.stringify({ scripts }),
|
|
});
|
|
const names = Object.keys(scripts);
|
|
const r = await runMulti(["run", "--parallel", ...names], String(dir));
|
|
for (let i = 0; i < 12; i++) {
|
|
expectPrefixed(r.stdout, `s${i}`, `out-${i}`);
|
|
}
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("all scripts exit 0", async () => {
|
|
using dir = tempDir("mr-par-all-ok", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
a: `${bunExe()} -e "process.exit(0)"`,
|
|
b: `${bunExe()} -e "process.exit(0)"`,
|
|
c: `${bunExe()} -e "process.exit(0)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "a", "b", "c"], String(dir));
|
|
expectDone(r.stderr, "a");
|
|
expectDone(r.stderr, "b");
|
|
expectDone(r.stderr, "c");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── PARALLEL: FILE SCRIPTS ───────────────────────────────────────────────────
|
|
|
|
describe("parallel: file scripts", () => {
|
|
test("runs .ts files in parallel", async () => {
|
|
using dir = tempDir("mr-par-ts", {
|
|
"a.ts": "console.log('file-a')",
|
|
"b.ts": "console.log('file-b')",
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "./a.ts", "./b.ts"], String(dir));
|
|
expectPrefixed(r.stdout, "./a.ts", "file-a");
|
|
expectPrefixed(r.stdout, "./b.ts", "file-b");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("runs .js files in parallel", async () => {
|
|
using dir = tempDir("mr-par-js", {
|
|
"x.js": "console.log('js-x')",
|
|
"y.js": "console.log('js-y')",
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "./x.js", "./y.js"], String(dir));
|
|
expectPrefixed(r.stdout, "./x.js", "js-x");
|
|
expectPrefixed(r.stdout, "./y.js", "js-y");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("runs file without ./ prefix if it has runnable extension", async () => {
|
|
using dir = tempDir("mr-par-ext", {
|
|
"script.ts": "console.log('ext-match')",
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "script.ts"], String(dir));
|
|
expectPrefixed(r.stdout, "script.ts", "ext-match");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("mixes package.json scripts and file scripts", async () => {
|
|
using dir = tempDir("mr-par-mix", {
|
|
"package.json": JSON.stringify({
|
|
scripts: { greet: `${bunExe()} -e "console.log('from-pkg')"` },
|
|
}),
|
|
"standalone.ts": "console.log('from-file')",
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "greet", "./standalone.ts"], String(dir));
|
|
expectPrefixed(r.stdout, "greet", "from-pkg");
|
|
expectPrefixed(r.stdout, "./standalone.ts", "from-file");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── PARALLEL: ERROR HANDLING ─────────────────────────────────────────────────
|
|
|
|
describe("parallel: error handling", () => {
|
|
test("failure kills other scripts by default", async () => {
|
|
using dir = tempDir("mr-par-fail", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
fail: `${bunExe()} -e "process.exit(1)"`,
|
|
ok: `${bunExe()} -e "console.log('ok-output')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "fail", "ok"], String(dir));
|
|
expectExited(r.stderr, "fail", 1);
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("propagates specific non-zero exit code", async () => {
|
|
using dir = tempDir("mr-par-code", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
bad: `${bunExe()} -e "process.exit(42)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "bad"], String(dir));
|
|
expectExited(r.stderr, "bad", 42);
|
|
expect(r.exitCode).toBe(42);
|
|
});
|
|
|
|
test("exit code is from first failed script (handle order)", async () => {
|
|
using dir = tempDir("mr-par-first-code", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
a: `${bunExe()} -e "process.exit(7)"`,
|
|
b: `${bunExe()} -e "process.exit(0)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "a", "b"], String(dir));
|
|
expectExited(r.stderr, "a", 7);
|
|
expect(r.exitCode).toBe(7);
|
|
});
|
|
|
|
test("--no-exit-on-error lets all finish", async () => {
|
|
using dir = tempDir("mr-par-noexit", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
fail: `${bunExe()} -e "process.exit(1)"`,
|
|
ok: `${bunExe()} -e "console.log('ok-ran')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--no-exit-on-error", "fail", "ok"], String(dir));
|
|
expectPrefixed(r.stdout, "ok", "ok-ran");
|
|
expectExited(r.stderr, "fail", 1);
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("--no-exit-on-error still reports failure exit code", async () => {
|
|
using dir = tempDir("mr-par-noexit-code", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
fail: `${bunExe()} -e "process.exit(3)"`,
|
|
ok: `${bunExe()} -e "process.exit(0)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--no-exit-on-error", "fail", "ok"], String(dir));
|
|
expectExited(r.stderr, "fail", 3);
|
|
expectDone(r.stderr, "ok");
|
|
expect(r.exitCode).toBe(3);
|
|
});
|
|
|
|
test("unknown script falls through to shell (exits non-zero)", async () => {
|
|
using dir = tempDir("mr-par-unknown", {
|
|
"package.json": JSON.stringify({ scripts: {} }),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "nonexistent-command-xyz123"], String(dir));
|
|
// Must see the multi-run prefix format even for unknown commands
|
|
expect(r.stderr).toMatch(/nonexistent-command-xyz123\s+\|/);
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── PARALLEL: OUTPUT FORMATTING ──────────────────────────────────────────────
|
|
|
|
describe("parallel: output formatting", () => {
|
|
test("each line has prefix label", async () => {
|
|
using dir = tempDir("mr-par-prefix", {
|
|
"package.json": JSON.stringify({
|
|
scripts: { hello: `${bunExe()} -e "console.log('hello-world')"` },
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "hello"], String(dir));
|
|
expect(r.stdout).toContain("hello | hello-world");
|
|
expectDone(r.stderr, "hello");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("labels are padded to equal width", async () => {
|
|
using dir = tempDir("mr-par-pad", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
a: `${bunExe()} -e "console.log('short')"`,
|
|
longname: `${bunExe()} -e "console.log('long')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "a", "longname"], String(dir));
|
|
expectPrefixed(r.stdout, "a", "short");
|
|
expectPrefixed(r.stdout, "longname", "long");
|
|
// Both prefixes should have same width up to the " | "
|
|
const stdoutLines = r.stdout.split("\n");
|
|
const aLines = stdoutLines.filter(l => l.includes("| short"));
|
|
const longLines = stdoutLines.filter(l => l.includes("| long"));
|
|
expect(aLines.length).toBeGreaterThan(0);
|
|
expect(longLines.length).toBeGreaterThan(0);
|
|
const aPrefix = aLines[0].split(" | ")[0];
|
|
const longPrefix = longLines[0].split(" | ")[0];
|
|
expect(aPrefix.length).toBe(longPrefix.length);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("multi-line output gets each line prefixed", async () => {
|
|
using dir = tempDir("mr-par-multiline", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
multi: `${bunExe()} -e "console.log('line1'); console.log('line2'); console.log('line3')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "multi"], String(dir));
|
|
expect(r.stdout).toContain("multi | line1");
|
|
expect(r.stdout).toContain("multi | line2");
|
|
expect(r.stdout).toContain("multi | line3");
|
|
expectDone(r.stderr, "multi");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("stderr output is also captured and prefixed", async () => {
|
|
using dir = tempDir("mr-par-stderr", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
err: `${bunExe()} -e "console.error('err-msg')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "err"], String(dir));
|
|
expect(r.stderr).toContain("err | err-msg");
|
|
expectDone(r.stderr, "err");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("output without trailing newline is flushed on exit", async () => {
|
|
using dir = tempDir("mr-par-notrnl", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
partial: `${bunExe()} -e "process.stdout.write('no-newline')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "partial"], String(dir));
|
|
expectPrefixed(r.stdout, "partial", "no-newline");
|
|
expectDone(r.stderr, "partial");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("very long output lines are not truncated", async () => {
|
|
const longStr = Buffer.alloc(8000, "X").toString();
|
|
using dir = tempDir("mr-par-long", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
big: `${bunExe()} -e "console.log('${longStr}')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "big"], String(dir));
|
|
expectPrefixed(r.stdout, "big", longStr);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("empty output script still shows exit status", async () => {
|
|
using dir = tempDir("mr-par-empty", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
silent: `${bunExe()} -e "0"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "silent"], String(dir));
|
|
expectDone(r.stderr, "silent");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("no color codes when NO_COLOR=1", async () => {
|
|
using dir = tempDir("mr-par-nocolor", {
|
|
"package.json": JSON.stringify({
|
|
scripts: { x: `${bunExe()} -e "console.log('nc')"` },
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "x"], String(dir));
|
|
expectPrefixed(r.stdout, "x", "nc");
|
|
expect(r.stdout).not.toContain("\x1b[");
|
|
expect(r.stderr).not.toContain("\x1b[");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("shows 'Done in Xms' for successful scripts", async () => {
|
|
using dir = tempDir("mr-par-done", {
|
|
"package.json": JSON.stringify({
|
|
scripts: { fast: `${bunExe()} -e "0"` },
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "fast"], String(dir));
|
|
expectDone(r.stderr, "fast");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("shows 'Exited with code N' for failed scripts", async () => {
|
|
using dir = tempDir("mr-par-exitcode", {
|
|
"package.json": JSON.stringify({
|
|
scripts: { bad: `${bunExe()} -e "process.exit(5)"` },
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "bad"], String(dir));
|
|
expectExited(r.stderr, "bad", 5);
|
|
expect(r.exitCode).toBe(5);
|
|
});
|
|
|
|
test("lines are not interleaved mid-line", async () => {
|
|
using dir = tempDir("mr-par-interleave", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
aa: `${bunExe()} -e "for(let i=0;i<20;i++) console.log('aaa-'+i)"`,
|
|
bb: `${bunExe()} -e "for(let i=0;i<20;i++) console.log('bbb-'+i)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "aa", "bb"], String(dir));
|
|
const lines = r.stdout.split("\n").filter(l => l.includes(" | "));
|
|
for (const line of lines) {
|
|
expect(line).toMatch(/^(aa|bb)\s+\|/);
|
|
}
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── STDOUT / STDERR SEPARATION ──────────────────────────────────────────────
|
|
|
|
describe("stdout/stderr separation", () => {
|
|
test("child stdout goes to parent stdout with prefix", async () => {
|
|
using dir = tempDir("mr-sep-stdout", {
|
|
"package.json": JSON.stringify({
|
|
scripts: { out: `${bunExe()} -e "console.log('to-stdout')"` },
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "out"], String(dir));
|
|
expectPrefixed(r.stdout, "out", "to-stdout");
|
|
// stdout content should NOT appear in stderr
|
|
expect(r.stderr).not.toContain("to-stdout");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("child stderr goes to parent stderr with prefix", async () => {
|
|
using dir = tempDir("mr-sep-stderr", {
|
|
"package.json": JSON.stringify({
|
|
scripts: { err: `${bunExe()} -e "console.error('to-stderr')"` },
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "err"], String(dir));
|
|
expectPrefixed(r.stderr, "err", "to-stderr");
|
|
// stderr content should NOT appear in stdout
|
|
expect(r.stdout).not.toContain("to-stderr");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("mixed stdout and stderr go to their respective streams", async () => {
|
|
using dir = tempDir("mr-sep-mixed", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
both: `${bunExe()} -e "console.log('OUT'); console.error('ERR')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "both"], String(dir));
|
|
expectPrefixed(r.stdout, "both", "OUT");
|
|
expectPrefixed(r.stderr, "both", "ERR");
|
|
// Verify they don't leak into the wrong stream
|
|
expect(r.stderr).not.toMatch(/both\s+\| OUT/);
|
|
expect(r.stdout).not.toMatch(/both\s+\| ERR/);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("status messages always go to stderr", async () => {
|
|
using dir = tempDir("mr-sep-status", {
|
|
"package.json": JSON.stringify({
|
|
scripts: { ok: `${bunExe()} -e "console.log('data')"` },
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "ok"], String(dir));
|
|
// Done message is on stderr
|
|
expectDone(r.stderr, "ok");
|
|
// Stdout has the data, not Done
|
|
expect(r.stdout).not.toContain("Done");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── SEQUENTIAL: BASIC ───────────────────────────────────────────────────────
|
|
|
|
describe("sequential: basic", () => {
|
|
test("runs scripts in order", async () => {
|
|
using dir = tempDir("mr-seq-order", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
first: `${bunExe()} -e "console.log('first-output')"`,
|
|
second: `${bunExe()} -e "console.log('second-output')"`,
|
|
third: `${bunExe()} -e "console.log('third-output')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "first", "second", "third"], String(dir));
|
|
expectPrefixed(r.stdout, "first", "first-output");
|
|
expectPrefixed(r.stdout, "second", "second-output");
|
|
expectPrefixed(r.stdout, "third", "third-output");
|
|
const i1 = r.stdout.search(/first\s+\|.*first-output/);
|
|
const i2 = r.stdout.search(/second\s+\|.*second-output/);
|
|
const i3 = r.stdout.search(/third\s+\|.*third-output/);
|
|
expect(i1).toBeGreaterThan(-1);
|
|
expect(i2).toBeGreaterThan(-1);
|
|
expect(i3).toBeGreaterThan(-1);
|
|
expect(i1).toBeLessThan(i2);
|
|
expect(i2).toBeLessThan(i3);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("sequential with single script", async () => {
|
|
using dir = tempDir("mr-seq-single", {
|
|
"package.json": JSON.stringify({
|
|
scripts: { only: `${bunExe()} -e "console.log('seq-single')"` },
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "only"], String(dir));
|
|
expectPrefixed(r.stdout, "only", "seq-single");
|
|
expectDone(r.stderr, "only");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("sequential stops on first failure", async () => {
|
|
using dir = tempDir("mr-seq-stop", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
fail: `${bunExe()} -e "process.exit(1)"`,
|
|
never: `${bunExe()} -e "console.log('should-not-run')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "fail", "never"], String(dir));
|
|
expectExited(r.stderr, "fail", 1);
|
|
expect(r.stdout).not.toContain("should-not-run");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("sequential propagates exit code from failed script", async () => {
|
|
using dir = tempDir("mr-seq-code", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
ok: `${bunExe()} -e "console.log('ok')"`,
|
|
bad: `${bunExe()} -e "process.exit(13)"`,
|
|
never: `${bunExe()} -e "console.log('nope')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "ok", "bad", "never"], String(dir));
|
|
expectPrefixed(r.stdout, "ok", "ok");
|
|
expectExited(r.stderr, "bad", 13);
|
|
expect(r.stdout).not.toContain("nope");
|
|
expect(r.exitCode).toBe(13);
|
|
});
|
|
|
|
test("sequential --no-exit-on-error continues after failure", async () => {
|
|
using dir = tempDir("mr-seq-noexit", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
fail: `${bunExe()} -e "process.exit(2)"`,
|
|
after: `${bunExe()} -e "console.log('ran-after')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "--no-exit-on-error", "fail", "after"], String(dir));
|
|
expectExited(r.stderr, "fail", 2);
|
|
expectPrefixed(r.stdout, "after", "ran-after");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("sequential file scripts run in order", async () => {
|
|
using dir = tempDir("mr-seq-files", {
|
|
"first.ts": "console.log('wrote');",
|
|
"second.ts": "console.log('second-ran');",
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "./first.ts", "./second.ts"], String(dir));
|
|
expectPrefixed(r.stdout, "./first.ts", "wrote");
|
|
expectPrefixed(r.stdout, "./second.ts", "second-ran");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── PRE/POST SCRIPTS ────────────────────────────────────────────────────────
|
|
|
|
describe("pre/post scripts", () => {
|
|
test("runs pre, main, post in order", async () => {
|
|
using dir = tempDir("mr-prepost-order", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
prebuild: `${bunExe()} -e "console.log('pre-ran')"`,
|
|
build: `${bunExe()} -e "console.log('build-ran')"`,
|
|
postbuild: `${bunExe()} -e "console.log('post-ran')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "build", "pre-ran");
|
|
expectPrefixed(r.stdout, "build", "build-ran");
|
|
expectPrefixed(r.stdout, "build", "post-ran");
|
|
const preIdx = r.stdout.search(/build\s+\|.*pre-ran/);
|
|
const buildIdx = r.stdout.search(/build\s+\|.*build-ran/);
|
|
const postIdx = r.stdout.search(/build\s+\|.*post-ran/);
|
|
expect(preIdx).toBeGreaterThan(-1);
|
|
expect(buildIdx).toBeGreaterThan(-1);
|
|
expect(postIdx).toBeGreaterThan(-1);
|
|
expect(preIdx).toBeLessThan(buildIdx);
|
|
expect(buildIdx).toBeLessThan(postIdx);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("only pre script (no post)", async () => {
|
|
using dir = tempDir("mr-preonly", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
pretest: `${bunExe()} -e "console.log('pre-only')"`,
|
|
test: `${bunExe()} -e "console.log('test-main')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "test"], String(dir));
|
|
expectPrefixed(r.stdout, "test", "pre-only");
|
|
expectPrefixed(r.stdout, "test", "test-main");
|
|
const preIdx = r.stdout.search(/test\s+\|.*pre-only/);
|
|
const mainIdx = r.stdout.search(/test\s+\|.*test-main/);
|
|
expect(preIdx).toBeGreaterThan(-1);
|
|
expect(mainIdx).toBeGreaterThan(-1);
|
|
expect(preIdx).toBeLessThan(mainIdx);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("only post script (no pre)", async () => {
|
|
using dir = tempDir("mr-postonly", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
deploy: `${bunExe()} -e "console.log('deploy-main')"`,
|
|
postdeploy: `${bunExe()} -e "console.log('post-only')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "deploy"], String(dir));
|
|
expectPrefixed(r.stdout, "deploy", "deploy-main");
|
|
expectPrefixed(r.stdout, "deploy", "post-only");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("pre failure prevents main and post from running", async () => {
|
|
using dir = tempDir("mr-prefail", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
prebuild: `${bunExe()} -e "process.exit(1)"`,
|
|
build: `${bunExe()} -e "console.log('main-shouldnt-run')"`,
|
|
postbuild: `${bunExe()} -e "console.log('post-shouldnt-run')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "build"], String(dir));
|
|
expectExited(r.stderr, "build", 1);
|
|
expect(r.stdout).not.toContain("main-shouldnt-run");
|
|
expect(r.stdout).not.toContain("post-shouldnt-run");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("main failure prevents post from running", async () => {
|
|
using dir = tempDir("mr-mainfail", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
prebuild: `${bunExe()} -e "console.log('pre-ok')"`,
|
|
build: `${bunExe()} -e "process.exit(1)"`,
|
|
postbuild: `${bunExe()} -e "console.log('post-shouldnt-run')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "build", "pre-ok");
|
|
expectExited(r.stderr, "build", 1);
|
|
expect(r.stdout).not.toContain("post-shouldnt-run");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("parallel: pre/post chained per group, groups run concurrently", async () => {
|
|
using dir = tempDir("mr-prepost-par", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
prebuild: `${bunExe()} -e "console.log('pre-build')"`,
|
|
build: `${bunExe()} -e "console.log('main-build')"`,
|
|
postbuild: `${bunExe()} -e "console.log('post-build')"`,
|
|
pretest: `${bunExe()} -e "console.log('pre-test')"`,
|
|
test: `${bunExe()} -e "console.log('main-test')"`,
|
|
posttest: `${bunExe()} -e "console.log('post-test')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "build", "test"], String(dir));
|
|
for (const s of ["pre-build", "main-build", "post-build", "pre-test", "main-test", "post-test"]) {
|
|
expectPrefixed(r.stdout, s.includes("build") ? "build" : "test", s);
|
|
}
|
|
// Within each group, order must be preserved
|
|
const findPrefixed = (label: string, content: string) => {
|
|
const re = new RegExp(`^${escapeRe(label)}\\s+\\| .*${escapeRe(content)}`, "m");
|
|
const m = r.stdout.match(re);
|
|
return m ? r.stdout.indexOf(m[0]) : -1;
|
|
};
|
|
expect(findPrefixed("build", "pre-build")).toBeLessThan(findPrefixed("build", "main-build"));
|
|
expect(findPrefixed("build", "main-build")).toBeLessThan(findPrefixed("build", "post-build"));
|
|
expect(findPrefixed("test", "pre-test")).toBeLessThan(findPrefixed("test", "main-test"));
|
|
expect(findPrefixed("test", "main-test")).toBeLessThan(findPrefixed("test", "post-test"));
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("sequential: pre/post chained and groups run in order", async () => {
|
|
using dir = tempDir("mr-prepost-seq", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
prebuild: `${bunExe()} -e "console.log('pre-b')"`,
|
|
build: `${bunExe()} -e "console.log('main-b')"`,
|
|
postbuild: `${bunExe()} -e "console.log('post-b')"`,
|
|
pretest: `${bunExe()} -e "console.log('pre-t')"`,
|
|
test: `${bunExe()} -e "console.log('main-t')"`,
|
|
posttest: `${bunExe()} -e "console.log('post-t')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "build", "test"], String(dir));
|
|
for (const s of ["pre-b", "main-b", "post-b", "pre-t", "main-t", "post-t"]) {
|
|
expectPrefixed(r.stdout, s.includes("-b") ? "build" : "test", s);
|
|
}
|
|
// Full sequential ordering (check in stdout)
|
|
const ordered = ["pre-b", "main-b", "post-b", "pre-t", "main-t", "post-t"];
|
|
const indices = ordered.map(s => r.stdout.indexOf(s));
|
|
for (let i = 0; i < indices.length - 1; i++) {
|
|
expect(indices[i]).toBeLessThan(indices[i + 1]);
|
|
}
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("all pre/post handles share the same label", async () => {
|
|
using dir = tempDir("mr-prepost-label", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
prebuild: `${bunExe()} -e "console.log('p')"`,
|
|
build: `${bunExe()} -e "console.log('m')"`,
|
|
postbuild: `${bunExe()} -e "console.log('o')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "build", "p");
|
|
expectPrefixed(r.stdout, "build", "m");
|
|
expectPrefixed(r.stdout, "build", "o");
|
|
// No other label should appear in stdout
|
|
const prefixedLines = r.stdout.split("\n").filter(l => l.includes(" | "));
|
|
for (const line of prefixedLines) {
|
|
expect(line).toMatch(/^build\s+\|/);
|
|
}
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── VALIDATION & ERROR MESSAGES ──────────────────────────────────────────────
|
|
|
|
describe("validation", () => {
|
|
test("error when both --parallel and --sequential", async () => {
|
|
using dir = tempDir("mr-val-both", {
|
|
"package.json": JSON.stringify({ scripts: { a: "echo a" } }),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--sequential", "a"], String(dir));
|
|
expect(r.stderr).toContain("--parallel and --sequential cannot be used together");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("error when no script names with --parallel", async () => {
|
|
using dir = tempDir("mr-val-nonames-par", {
|
|
"package.json": JSON.stringify({ scripts: {} }),
|
|
});
|
|
const r = await runMulti(["run", "--parallel"], String(dir));
|
|
expect(r.stderr).toContain("--parallel/--sequential requires at least one script name");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("error when no script names with --sequential", async () => {
|
|
using dir = tempDir("mr-val-nonames-seq", {
|
|
"package.json": JSON.stringify({ scripts: {} }),
|
|
});
|
|
const r = await runMulti(["run", "--sequential"], String(dir));
|
|
expect(r.stderr).toContain("--parallel/--sequential requires at least one script name");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("raw commands work without package.json", async () => {
|
|
using dir = tempDir("mr-val-nopkg", {});
|
|
const r = await runMulti(["run", "--parallel", "echo no-pkg-works"], String(dir));
|
|
expectPrefixed(r.stdout, "echo no-pkg-works", "no-pkg-works");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── MIXED STDOUT / STDERR ────────────────────────────────────────────────────
|
|
|
|
describe("output streams", () => {
|
|
test("captures both stdout and stderr", async () => {
|
|
using dir = tempDir("mr-streams", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
both: `${bunExe()} -e "console.log('out'); console.error('err')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "both"], String(dir));
|
|
expectPrefixed(r.stdout, "both", "out");
|
|
expectPrefixed(r.stderr, "both", "err");
|
|
expectDone(r.stderr, "both");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("script that produces only stderr output", async () => {
|
|
using dir = tempDir("mr-stderr-only", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
erronly: `${bunExe()} -e "console.error('only-err')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "erronly"], String(dir));
|
|
expectPrefixed(r.stderr, "erronly", "only-err");
|
|
expectDone(r.stderr, "erronly");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── SCRIPTS WITH SHELL FEATURES ──────────────────────────────────────────────
|
|
|
|
describe("shell features", () => {
|
|
test("scripts with pipes work", async () => {
|
|
using dir = tempDir("mr-shell-pipe", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
piped: `echo "hello world" | cat`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "piped"], String(dir));
|
|
expectPrefixed(r.stdout, "piped", "hello world");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("scripts with environment variables work", async () => {
|
|
using dir = tempDir("mr-shell-env", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
env: `${bunExe()} -e "console.log(process.env.MY_VAR)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "env"], String(dir), { MY_VAR: "test-value" });
|
|
expectPrefixed(r.stdout, "env", "test-value");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("scripts with semicolons work", async () => {
|
|
using dir = tempDir("mr-shell-semi", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
multi: `echo first; echo second`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "multi"], String(dir));
|
|
expectPrefixed(r.stdout, "multi", "first");
|
|
expectPrefixed(r.stdout, "multi", "second");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("scripts with && work", async () => {
|
|
using dir = tempDir("mr-shell-and", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
chained: `echo step1 && echo step2`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "chained"], String(dir));
|
|
expectPrefixed(r.stdout, "chained", "step1");
|
|
expectPrefixed(r.stdout, "chained", "step2");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── SCRIPT NAMES WITH SPECIAL CHARACTERS ─────────────────────────────────────
|
|
|
|
describe("script name edge cases", () => {
|
|
test("script names with colons", async () => {
|
|
using dir = tempDir("mr-colon", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
"dev:server": `${bunExe()} -e "console.log('server')"`,
|
|
"dev:client": `${bunExe()} -e "console.log('client')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "dev:server", "dev:client"], String(dir));
|
|
expectPrefixed(r.stdout, "dev:server", "server");
|
|
expectPrefixed(r.stdout, "dev:client", "client");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("script names with hyphens", async () => {
|
|
using dir = tempDir("mr-hyphen", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
"build-prod": `${bunExe()} -e "console.log('prod')"`,
|
|
"build-dev": `${bunExe()} -e "console.log('dev')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "build-prod", "build-dev"], String(dir));
|
|
expectPrefixed(r.stdout, "build-prod", "prod");
|
|
expectPrefixed(r.stdout, "build-dev", "dev");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("duplicate script names run the script multiple times", async () => {
|
|
using dir = tempDir("mr-dup", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
greet: `${bunExe()} -e "console.log('hello')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "greet", "greet"], String(dir));
|
|
// Both instances should produce prefixed output -- count "Done" lines in stderr
|
|
const doneLines = r.stderr.split("\n").filter(l => /^greet\s+\| Done/.test(l));
|
|
expect(doneLines.length).toBe(2);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── RAPID EXIT / TIMING ─────────────────────────────────────────────────────
|
|
|
|
describe("timing edge cases", () => {
|
|
test("scripts that exit immediately", async () => {
|
|
using dir = tempDir("mr-instant", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
a: `${bunExe()} -e "0"`,
|
|
b: `${bunExe()} -e "0"`,
|
|
c: `${bunExe()} -e "0"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "a", "b", "c"], String(dir));
|
|
expectDone(r.stderr, "a");
|
|
expectDone(r.stderr, "b");
|
|
expectDone(r.stderr, "c");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("sequential: rapid scripts complete in order", async () => {
|
|
using dir = tempDir("mr-seq-rapid", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
a: `${bunExe()} -e "console.log('rapid-a')"`,
|
|
b: `${bunExe()} -e "console.log('rapid-b')"`,
|
|
c: `${bunExe()} -e "console.log('rapid-c')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "a", "b", "c"], String(dir));
|
|
expectPrefixed(r.stdout, "a", "rapid-a");
|
|
expectPrefixed(r.stdout, "b", "rapid-b");
|
|
expectPrefixed(r.stdout, "c", "rapid-c");
|
|
const ia = r.stdout.indexOf("rapid-a");
|
|
const ib = r.stdout.indexOf("rapid-b");
|
|
const ic = r.stdout.indexOf("rapid-c");
|
|
expect(ia).toBeLessThan(ib);
|
|
expect(ib).toBeLessThan(ic);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── EXIT CODE PROPAGATION ───────────────────────────────────────────────────
|
|
|
|
describe("exit code propagation", () => {
|
|
test("parallel: first handle with non-zero code wins", async () => {
|
|
using dir = tempDir("mr-exitprop", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
a: `${bunExe()} -e "process.exit(0)"`,
|
|
b: `${bunExe()} -e "process.exit(99)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--no-exit-on-error", "a", "b"], String(dir));
|
|
expectDone(r.stderr, "a");
|
|
expectExited(r.stderr, "b", 99);
|
|
expect(r.exitCode).toBe(99);
|
|
});
|
|
|
|
test("all zero means exit 0", async () => {
|
|
using dir = tempDir("mr-allzero", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
a: `${bunExe()} -e "process.exit(0)"`,
|
|
b: `${bunExe()} -e "process.exit(0)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "a", "b"], String(dir));
|
|
expectDone(r.stderr, "a");
|
|
expectDone(r.stderr, "b");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("sequential: exit code of the failed script", async () => {
|
|
using dir = tempDir("mr-seq-exitcode", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
ok: `${bunExe()} -e "process.exit(0)"`,
|
|
bad: `${bunExe()} -e "process.exit(77)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "ok", "bad"], String(dir));
|
|
expectDone(r.stderr, "ok");
|
|
expectExited(r.stderr, "bad", 77);
|
|
expect(r.exitCode).toBe(77);
|
|
});
|
|
});
|
|
|
|
// ─── CWD / WORKING DIRECTORY ────────────────────────────────────────────────
|
|
|
|
describe("working directory", () => {
|
|
test("scripts run in the package.json directory", async () => {
|
|
using dir = tempDir("mr-cwd", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
pwd: `${bunExe()} -e "console.log(process.cwd())"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "pwd"], String(dir));
|
|
const realDir = realpathSync(String(dir));
|
|
// On Windows, process.cwd() returns backslash paths; normalize for comparison
|
|
const lines = r.stdout.split("\n").filter(l => /pwd\s+\|/.test(l));
|
|
expect(lines.length).toBeGreaterThan(0);
|
|
const cwdOutput = lines[0].split(" | ").slice(1).join(" | ").trim();
|
|
expect(path.normalize(cwdOutput)).toBe(path.normalize(realDir));
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── EXPLICIT RUN COMMAND ───────────────────────────────────────────────────
|
|
|
|
describe("explicit run command", () => {
|
|
test("'bun run --parallel' with run keyword", async () => {
|
|
using dir = tempDir("mr-run-explicit", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
x: `${bunExe()} -e "console.log('explicit-run')"`,
|
|
y: `${bunExe()} -e "console.log('explicit-run2')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "x", "y"], String(dir));
|
|
expectPrefixed(r.stdout, "x", "explicit-run");
|
|
expectPrefixed(r.stdout, "y", "explicit-run2");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── LARGE OUTPUT / STRESS ──────────────────────────────────────────────────
|
|
|
|
describe("stress tests", () => {
|
|
test("handles large number of output lines", async () => {
|
|
using dir = tempDir("mr-stress-lines", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
flood: `${bunExe()} -e "for(let i=0;i<500;i++) console.log('line-'+i)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "flood"], String(dir));
|
|
expectPrefixed(r.stdout, "flood", "line-0");
|
|
expectPrefixed(r.stdout, "flood", "line-499");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("handles output from multiple concurrent scripts", async () => {
|
|
const scripts: Record<string, string> = {};
|
|
for (let i = 0; i < 5; i++) {
|
|
scripts[`s${i}`] = `${bunExe()} -e "for(let j=0;j<50;j++) console.log('s${i}-'+j)"`;
|
|
}
|
|
using dir = tempDir("mr-stress-multi", {
|
|
"package.json": JSON.stringify({ scripts }),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "s0", "s1", "s2", "s3", "s4"], String(dir));
|
|
for (let i = 0; i < 5; i++) {
|
|
expectPrefixed(r.stdout, `s${i}`, `s${i}-0`);
|
|
expectPrefixed(r.stdout, `s${i}`, `s${i}-49`);
|
|
}
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── RAW COMMANDS (NOT IN PACKAGE.JSON) ─────────────────────────────────────
|
|
|
|
describe("raw shell commands", () => {
|
|
test("runs raw command not in package.json", async () => {
|
|
using dir = tempDir("mr-raw", {
|
|
"package.json": JSON.stringify({ scripts: {} }),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "echo raw-command-test"], String(dir));
|
|
expectPrefixed(r.stdout, "echo raw-command-test", "raw-command-test");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("runs multiple raw commands", async () => {
|
|
using dir = tempDir("mr-raw-multi", {
|
|
"package.json": JSON.stringify({ scripts: {} }),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "echo first-raw", "echo second-raw"], String(dir));
|
|
expectPrefixed(r.stdout, "echo first-raw", "first-raw");
|
|
expectPrefixed(r.stdout, "echo second-raw", "second-raw");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("mixes raw commands and package.json scripts", async () => {
|
|
using dir = tempDir("mr-raw-mix", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
pkg: `${bunExe()} -e "console.log('from-pkg')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "pkg", "echo from-raw"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg", "from-pkg");
|
|
expectPrefixed(r.stdout, "echo from-raw", "from-raw");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── SEQUENTIAL: SIDE EFFECTS ORDERING ──────────────────────────────────────
|
|
|
|
describe("sequential: side effects ordering", () => {
|
|
test("later scripts can see files created by earlier scripts", async () => {
|
|
using dir = tempDir("mr-seq-sideeffect", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
create: `${bunExe()} -e "require('fs').writeFileSync('marker.txt', 'created'); console.log('created')"`,
|
|
check: `${bunExe()} -e "const d = require('fs').readFileSync('marker.txt','utf8'); console.log('found:'+d)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "create", "check"], String(dir));
|
|
expectPrefixed(r.stdout, "create", "created");
|
|
expectPrefixed(r.stdout, "check", "found:created");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── NO PACKAGE.JSON ────────────────────────────────────────────────────────
|
|
|
|
describe("no package.json", () => {
|
|
test("file scripts work without package.json", async () => {
|
|
using dir = tempDir("mr-nopkg-files", {
|
|
"hello.ts": "console.log('no-pkg-hello')",
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "./hello.ts"], String(dir));
|
|
expectPrefixed(r.stdout, "./hello.ts", "no-pkg-hello");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("raw commands work without package.json", async () => {
|
|
using dir = tempDir("mr-nopkg-raw", {});
|
|
const r = await runMulti(["run", "--parallel", "echo no-pkg-raw"], String(dir));
|
|
expectPrefixed(r.stdout, "echo no-pkg-raw", "no-pkg-raw");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── ABORT / SIGNAL HANDLING ────────────────────────────────────────────────
|
|
|
|
describe("abort: failure kills long-running processes", () => {
|
|
test("parallel: fast failure kills a slow script", async () => {
|
|
using dir = tempDir("mr-abort-slow", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
slow: `${bunExe()} -e "await Bun.sleep(30000); console.log('should-not-appear')"`,
|
|
fail: `${bunExe()} -e "process.exit(1)"`,
|
|
},
|
|
}),
|
|
});
|
|
const start = Date.now();
|
|
const r = await runMulti(["run", "--parallel", "slow", "fail"], String(dir));
|
|
const elapsed = Date.now() - start;
|
|
expectExited(r.stderr, "fail", 1);
|
|
expect(r.stdout).not.toContain("should-not-appear");
|
|
expect(r.exitCode).not.toBe(0);
|
|
expect(elapsed).toBeLessThan(15000);
|
|
});
|
|
|
|
test.skipIf(isWindows)("parallel: signaled process shows Signaled message", async () => {
|
|
using dir = tempDir("mr-abort-signal", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
suicide: `${bunExe()} -e "process.kill(process.pid, 'SIGKILL')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "suicide"], String(dir));
|
|
expect(r.stderr).toMatch(/suicide\s+\| Signaled/);
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── PARTIAL LINE BUFFERING ─────────────────────────────────────────────────
|
|
|
|
describe("partial line buffering", () => {
|
|
test("chunked writes are assembled into complete lines", async () => {
|
|
using dir = tempDir("mr-chunk", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
chunky: `${bunExe()} -e "
|
|
const chars = 'CHUNKED-LINE\\n';
|
|
for (const c of chars) {
|
|
process.stdout.write(c);
|
|
}
|
|
"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "chunky"], String(dir));
|
|
expectPrefixed(r.stdout, "chunky", "CHUNKED-LINE");
|
|
expectDone(r.stderr, "chunky");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("multiple partial writes coalesce into one line", async () => {
|
|
using dir = tempDir("mr-partial-coalesce", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
parts: `${bunExe()} -e "
|
|
process.stdout.write('part1-');
|
|
process.stdout.write('part2-');
|
|
process.stdout.write('part3\\n');
|
|
"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "parts"], String(dir));
|
|
expectPrefixed(r.stdout, "parts", "part1-part2-part3");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("mixed complete and partial lines", async () => {
|
|
using dir = tempDir("mr-partial-mixed", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
mixed: `${bunExe()} -e "
|
|
process.stdout.write('complete-line\\npartial');
|
|
process.stdout.write('-rest\\n');
|
|
"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "mixed"], String(dir));
|
|
expectPrefixed(r.stdout, "mixed", "complete-line");
|
|
expectPrefixed(r.stdout, "mixed", "partial-rest");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("output with only carriage returns (no newline)", async () => {
|
|
using dir = tempDir("mr-cr", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
cr: `${bunExe()} -e "process.stdout.write('before\\\\rafter')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "cr"], String(dir));
|
|
// \r is not \n, so it stays in the line buffer and gets flushed on exit
|
|
expectPrefixed(r.stdout, "cr", "before");
|
|
expectDone(r.stderr, "cr");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("empty lines are preserved", async () => {
|
|
using dir = tempDir("mr-emptylines", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
blanks: `${bunExe()} -e "console.log('above'); console.log(''); console.log('below')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "blanks"], String(dir));
|
|
expectPrefixed(r.stdout, "blanks", "above");
|
|
expectPrefixed(r.stdout, "blanks", "below");
|
|
// The empty line should also be prefixed (in stdout)
|
|
expect(r.stdout).toMatch(/blanks\s+\| \n/);
|
|
expectDone(r.stderr, "blanks");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── MULTIPLE FAILURES WITH --no-exit-on-error ──────────────────────────────
|
|
|
|
describe("--no-exit-on-error: multiple failures", () => {
|
|
test("parallel: first handle's non-zero code wins in finalize", async () => {
|
|
using dir = tempDir("mr-noexit-multi", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
a: `${bunExe()} -e "process.exit(11)"`,
|
|
b: `${bunExe()} -e "process.exit(22)"`,
|
|
c: `${bunExe()} -e "process.exit(0)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--no-exit-on-error", "a", "b", "c"], String(dir));
|
|
expectExited(r.stderr, "a", 11);
|
|
expectExited(r.stderr, "b", 22);
|
|
expectDone(r.stderr, "c");
|
|
expect(r.exitCode).toBe(11);
|
|
});
|
|
|
|
test("parallel: all fail, first code wins", async () => {
|
|
using dir = tempDir("mr-noexit-allfail", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
x: `${bunExe()} -e "process.exit(5)"`,
|
|
y: `${bunExe()} -e "process.exit(10)"`,
|
|
z: `${bunExe()} -e "process.exit(15)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--no-exit-on-error", "x", "y", "z"], String(dir));
|
|
expectExited(r.stderr, "x", 5);
|
|
expectExited(r.stderr, "y", 10);
|
|
expectExited(r.stderr, "z", 15);
|
|
expect(r.exitCode).toBe(5);
|
|
});
|
|
|
|
test("sequential: --no-exit-on-error continues through multiple failures", async () => {
|
|
using dir = tempDir("mr-seq-noexit-multi", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
a: `${bunExe()} -e "console.log('a-ran'); process.exit(3)"`,
|
|
b: `${bunExe()} -e "console.log('b-ran'); process.exit(7)"`,
|
|
c: `${bunExe()} -e "console.log('c-ran')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "--no-exit-on-error", "a", "b", "c"], String(dir));
|
|
expectPrefixed(r.stdout, "a", "a-ran");
|
|
expectPrefixed(r.stdout, "b", "b-ran");
|
|
expectPrefixed(r.stdout, "c", "c-ran");
|
|
expect(r.exitCode).toBe(3);
|
|
});
|
|
});
|
|
|
|
// ─── PRE/POST + --no-exit-on-error INTERACTION ──────────────────────────────
|
|
|
|
describe("pre/post + --no-exit-on-error interaction", () => {
|
|
test("pre failure blocks own group but other groups continue", async () => {
|
|
using dir = tempDir("mr-pre-noexit", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
prebuild: `${bunExe()} -e "process.exit(1)"`,
|
|
build: `${bunExe()} -e "console.log('build-main')"`,
|
|
postbuild: `${bunExe()} -e "console.log('build-post')"`,
|
|
lint: `${bunExe()} -e "console.log('lint-ran')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--no-exit-on-error", "build", "lint"], String(dir));
|
|
expectPrefixed(r.stdout, "lint", "lint-ran");
|
|
expect(r.stdout).not.toContain("build-main");
|
|
expect(r.stdout).not.toContain("build-post");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("post script failure is reported correctly", async () => {
|
|
using dir = tempDir("mr-postfail", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
build: `${bunExe()} -e "console.log('build-ok')"`,
|
|
postbuild: `${bunExe()} -e "console.log('post-fail'); process.exit(44)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "build", "build-ok");
|
|
expectPrefixed(r.stdout, "build", "post-fail");
|
|
expectExited(r.stderr, "build", 44);
|
|
expect(r.exitCode).toBe(44);
|
|
});
|
|
|
|
test("sequential: pre failure with --no-exit-on-error still runs next group", async () => {
|
|
using dir = tempDir("mr-seq-pre-noexit", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
prebuild: `${bunExe()} -e "process.exit(1)"`,
|
|
build: `${bunExe()} -e "console.log('build-shouldnt')"`,
|
|
test: `${bunExe()} -e "console.log('test-ran')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "--no-exit-on-error", "build", "test"], String(dir));
|
|
expect(r.stdout).not.toContain("build-shouldnt");
|
|
expectPrefixed(r.stdout, "test", "test-ran");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── EMPTY / EDGE-CASE SCRIPT CONTENT ───────────────────────────────────────
|
|
|
|
describe("edge-case script content", () => {
|
|
test("empty script string runs without crashing", async () => {
|
|
using dir = tempDir("mr-empty-script", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
empty: "",
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "empty"], String(dir));
|
|
// Multi-run prefix must appear in stderr (status line)
|
|
expect(r.stderr).toMatch(/empty\s+\|/);
|
|
});
|
|
|
|
test("script that only writes whitespace", async () => {
|
|
using dir = tempDir("mr-whitespace", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
ws: `${bunExe()} -e "console.log(' ')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "ws"], String(dir));
|
|
expectPrefixed(r.stdout, "ws", " ");
|
|
expectDone(r.stderr, "ws");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("script with very long name", async () => {
|
|
const longName = Buffer.alloc(80, "x").toString();
|
|
using dir = tempDir("mr-longname", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
[longName]: `${bunExe()} -e "console.log('long-name-ok')"`,
|
|
short: `${bunExe()} -e "console.log('short-ok')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", longName, "short"], String(dir));
|
|
expectPrefixed(r.stdout, longName, "long-name-ok");
|
|
expectPrefixed(r.stdout, "short", "short-ok");
|
|
// "short" should be padded to match the long name
|
|
const lines = r.stdout.split("\n").filter(l => l.includes("| short-ok"));
|
|
expect(lines.length).toBeGreaterThan(0);
|
|
const prefix = lines[0].split(" | ")[0];
|
|
expect(prefix.length).toBe(longName.length);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── BINARY / UNUSUAL OUTPUT ────────────────────────────────────────────────
|
|
|
|
describe("unusual output", () => {
|
|
test("null bytes in output don't crash", async () => {
|
|
using dir = tempDir("mr-nullbyte", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
nulls: `${bunExe()} -e "process.stdout.write(Buffer.from([0x68, 0x69, 0x00, 0x0a]))"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "nulls"], String(dir));
|
|
expectDone(r.stderr, "nulls");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("very rapid line output doesn't lose data", async () => {
|
|
using dir = tempDir("mr-rapid-lines", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
rapid: `${bunExe()} -e "for(let i=0;i<1000;i++) console.log('L'+i)"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "rapid"], String(dir));
|
|
expectPrefixed(r.stdout, "rapid", "L0");
|
|
expectPrefixed(r.stdout, "rapid", "L999");
|
|
const dataLines = r.stdout.split("\n").filter(l => /rapid\s+\| L\d+/.test(l));
|
|
expect(dataLines.length).toBe(1000);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("output with unicode characters", async () => {
|
|
using dir = tempDir("mr-unicode", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
uni: `${bunExe()} -e "console.log('Hello \\u4e16\\u754c \\ud83c\\udf0d')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "uni"], String(dir));
|
|
expectPrefixed(r.stdout, "uni", "Hello \u4e16\u754c");
|
|
expectDone(r.stderr, "uni");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── SEQUENTIAL: DONE STATUS BETWEEN SCRIPTS ───────────────────────────────
|
|
|
|
describe("sequential: status messages between scripts", () => {
|
|
test("Done message appears between sequential scripts", async () => {
|
|
using dir = tempDir("mr-seq-done-between", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
first: `${bunExe()} -e "console.log('first-out')"`,
|
|
second: `${bunExe()} -e "console.log('second-out')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "first", "second"], String(dir));
|
|
expectPrefixed(r.stdout, "first", "first-out");
|
|
expectPrefixed(r.stdout, "second", "second-out");
|
|
// Done for first appears in stderr, output ordering in stdout shows correct order
|
|
expectDone(r.stderr, "first");
|
|
const firstIdx = r.stdout.indexOf("first-out");
|
|
const secondIdx = r.stdout.indexOf("second-out");
|
|
expect(firstIdx).toBeGreaterThan(-1);
|
|
expect(secondIdx).toBeGreaterThan(-1);
|
|
expect(firstIdx).toBeLessThan(secondIdx);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("Exited message appears between sequential scripts with --no-exit-on-error", async () => {
|
|
using dir = tempDir("mr-seq-exit-between", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
fail: `${bunExe()} -e "process.exit(2)"`,
|
|
next: `${bunExe()} -e "console.log('next-out')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "--no-exit-on-error", "fail", "next"], String(dir));
|
|
expectExited(r.stderr, "fail", 2);
|
|
expectPrefixed(r.stdout, "next", "next-out");
|
|
expect(r.exitCode).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ─── CONCURRENT STDOUT + STDERR FROM SAME SCRIPT ───────────────────────────
|
|
|
|
describe("concurrent stdout + stderr from same script", () => {
|
|
test("interleaved stdout and stderr are both prefixed", async () => {
|
|
using dir = tempDir("mr-interleave-streams", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
both: `${bunExe()} -e "
|
|
for (let i = 0; i < 10; i++) {
|
|
console.log('OUT-' + i);
|
|
console.error('ERR-' + i);
|
|
}
|
|
"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "both"], String(dir));
|
|
for (let i = 0; i < 10; i++) {
|
|
expectPrefixed(r.stdout, "both", `OUT-${i}`);
|
|
expectPrefixed(r.stderr, "both", `ERR-${i}`);
|
|
}
|
|
expectDone(r.stderr, "both");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── DEEP DEPENDENCY CHAIN ──────────────────────────────────────────────────
|
|
|
|
describe("dependency chains", () => {
|
|
test("sequential with pre/post creates deep chain that works", async () => {
|
|
using dir = tempDir("mr-deep-chain", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
prea: `${bunExe()} -e "console.log('pre-a')"`,
|
|
a: `${bunExe()} -e "console.log('main-a')"`,
|
|
posta: `${bunExe()} -e "console.log('post-a')"`,
|
|
preb: `${bunExe()} -e "console.log('pre-b')"`,
|
|
b: `${bunExe()} -e "console.log('main-b')"`,
|
|
postb: `${bunExe()} -e "console.log('post-b')"`,
|
|
prec: `${bunExe()} -e "console.log('pre-c')"`,
|
|
c: `${bunExe()} -e "console.log('main-c')"`,
|
|
postc: `${bunExe()} -e "console.log('post-c')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "a", "b", "c"], String(dir));
|
|
const expected = ["pre-a", "main-a", "post-a", "pre-b", "main-b", "post-b", "pre-c", "main-c", "post-c"];
|
|
for (const s of expected) {
|
|
const label = s.includes("-a") ? "a" : s.includes("-b") ? "b" : "c";
|
|
expectPrefixed(r.stdout, label, s);
|
|
}
|
|
const indices = expected.map(s => r.stdout.indexOf(s));
|
|
for (let i = 0; i < indices.length - 1; i++) {
|
|
expect(indices[i]).toBeLessThan(indices[i + 1]);
|
|
}
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("parallel with pre/post: failure in one group's chain doesn't block other groups", async () => {
|
|
using dir = tempDir("mr-chain-partial", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
prea: `${bunExe()} -e "console.log('pre-a-ok')"`,
|
|
a: `${bunExe()} -e "process.exit(1)"`,
|
|
posta: `${bunExe()} -e "console.log('post-a-no')"`,
|
|
preb: `${bunExe()} -e "console.log('pre-b-ok')"`,
|
|
b: `${bunExe()} -e "console.log('main-b-ok')"`,
|
|
postb: `${bunExe()} -e "console.log('post-b-ok')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--no-exit-on-error", "a", "b"], String(dir));
|
|
expectPrefixed(r.stdout, "a", "pre-a-ok");
|
|
expect(r.stdout).not.toContain("post-a-no");
|
|
expectPrefixed(r.stdout, "b", "pre-b-ok");
|
|
expectPrefixed(r.stdout, "b", "main-b-ok");
|
|
expectPrefixed(r.stdout, "b", "post-b-ok");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── COLOR CYCLING ──────────────────────────────────────────────────────────
|
|
|
|
describe("color cycling", () => {
|
|
test("more than 6 scripts cycle through colors", async () => {
|
|
const scripts: Record<string, string> = {};
|
|
for (let i = 0; i < 7; i++) {
|
|
scripts[`task${i}`] = `${bunExe()} -e "console.log('t${i}')"`;
|
|
}
|
|
using dir = tempDir("mr-color-cycle", {
|
|
"package.json": JSON.stringify({ scripts }),
|
|
});
|
|
await using proc = Bun.spawn({
|
|
cmd: [bunExe(), "run", "--parallel", ...Object.keys(scripts)],
|
|
env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" },
|
|
cwd: String(dir),
|
|
stderr: "pipe",
|
|
stdout: "pipe",
|
|
});
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
// Must contain ANSI color codes -- multi-run format (colors appear in both streams)
|
|
expect(stderr).toContain("\x1b[");
|
|
// All tasks should produce prefixed output (check Done lines in stderr since they're unique to multi-run)
|
|
// ANSI color codes wrap the label, so match optionally around the label name
|
|
for (let i = 0; i < 7; i++) {
|
|
expect(stderr).toMatch(new RegExp(`task${i}[^\n]*\\| Done`));
|
|
}
|
|
// Stdout should also have prefixed content
|
|
for (let i = 0; i < 7; i++) {
|
|
expect(stdout).toMatch(new RegExp(`task${i}[^\n]*\\| t${i}`));
|
|
}
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── GLOB PATTERN MATCHING ──────────────────────────────────────────────────
|
|
|
|
describe("glob pattern matching", () => {
|
|
test("build:* matches all build:xxx scripts", async () => {
|
|
using dir = tempDir("mr-glob-basic", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
"build:css": `${bunExe()} -e "console.log('css-built')"`,
|
|
"build:js": `${bunExe()} -e "console.log('js-built')"`,
|
|
"build:html": `${bunExe()} -e "console.log('html-built')"`,
|
|
"test": `${bunExe()} -e "console.log('should-not-run')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "build:*"], String(dir));
|
|
expectPrefixed(r.stdout, "build:css", "css-built");
|
|
expectPrefixed(r.stdout, "build:html", "html-built");
|
|
expectPrefixed(r.stdout, "build:js", "js-built");
|
|
expect(r.stdout).not.toContain("should-not-run");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("* matches all scripts", async () => {
|
|
using dir = tempDir("mr-glob-star", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
alpha: `${bunExe()} -e "console.log('a-out')"`,
|
|
beta: `${bunExe()} -e "console.log('b-out')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "*"], String(dir));
|
|
expectPrefixed(r.stdout, "alpha", "a-out");
|
|
expectPrefixed(r.stdout, "beta", "b-out");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("glob with no matches errors", async () => {
|
|
using dir = tempDir("mr-glob-nomatch", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
build: `echo build`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "deploy:*"], String(dir));
|
|
expect(r.stderr).toContain('No scripts match pattern "deploy:*"');
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("glob with pre/post scripts", async () => {
|
|
using dir = tempDir("mr-glob-prepost", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
"prebuild:css": `${bunExe()} -e "console.log('pre-css')"`,
|
|
"build:css": `${bunExe()} -e "console.log('main-css')"`,
|
|
"postbuild:css": `${bunExe()} -e "console.log('post-css')"`,
|
|
"build:js": `${bunExe()} -e "console.log('main-js')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "build:*"], String(dir));
|
|
expectPrefixed(r.stdout, "build:css", "pre-css");
|
|
expectPrefixed(r.stdout, "build:css", "main-css");
|
|
expectPrefixed(r.stdout, "build:css", "post-css");
|
|
expectPrefixed(r.stdout, "build:js", "main-js");
|
|
// Pre should come before main
|
|
const preIdx = r.stdout.search(/build:css\s+\|.*pre-css/);
|
|
const mainIdx = r.stdout.search(/build:css\s+\|.*main-css/);
|
|
expect(preIdx).toBeLessThan(mainIdx);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("sequential with glob runs matches in alphabetical order", async () => {
|
|
using dir = tempDir("mr-glob-seq", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
"lint:c": `${bunExe()} -e "console.log('lint-c')"`,
|
|
"lint:a": `${bunExe()} -e "console.log('lint-a')"`,
|
|
"lint:b": `${bunExe()} -e "console.log('lint-b')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "lint:*"], String(dir));
|
|
expectPrefixed(r.stdout, "lint:a", "lint-a");
|
|
expectPrefixed(r.stdout, "lint:b", "lint-b");
|
|
expectPrefixed(r.stdout, "lint:c", "lint-c");
|
|
// Order should be alphabetical
|
|
const ia = r.stdout.indexOf("lint-a");
|
|
const ib = r.stdout.indexOf("lint-b");
|
|
const ic = r.stdout.indexOf("lint-c");
|
|
expect(ia).toBeLessThan(ib);
|
|
expect(ib).toBeLessThan(ic);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("glob mixed with literal script names", async () => {
|
|
using dir = tempDir("mr-glob-mixed", {
|
|
"package.json": JSON.stringify({
|
|
scripts: {
|
|
"build:css": `${bunExe()} -e "console.log('css')"`,
|
|
"build:js": `${bunExe()} -e "console.log('js')"`,
|
|
"test": `${bunExe()} -e "console.log('test-ran')"`,
|
|
},
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "build:*", "test"], String(dir));
|
|
expectPrefixed(r.stdout, "build:css", "css");
|
|
expectPrefixed(r.stdout, "build:js", "js");
|
|
expectPrefixed(r.stdout, "test", "test-ran");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── WORKSPACE INTEGRATION ──────────────────────────────────────────────────
|
|
|
|
/** Helper to create a monorepo workspace temp directory. */
|
|
function makeWorkspace(
|
|
prefix: string,
|
|
packages: Record<string, Record<string, string>>,
|
|
rootScripts?: Record<string, string>,
|
|
) {
|
|
const files: Record<string, string> = {
|
|
"package.json": JSON.stringify({
|
|
name: "monorepo",
|
|
private: true,
|
|
workspaces: ["packages/*"],
|
|
...(rootScripts ? { scripts: rootScripts } : {}),
|
|
}),
|
|
};
|
|
for (const [name, scripts] of Object.entries(packages)) {
|
|
files[`packages/${name}/package.json`] = JSON.stringify({
|
|
name,
|
|
scripts,
|
|
});
|
|
}
|
|
return tempDir(prefix, files);
|
|
}
|
|
|
|
describe("workspace integration", () => {
|
|
test("--parallel --filter='*' runs script in all packages", async () => {
|
|
using dir = makeWorkspace("mr-ws-all", {
|
|
"pkg-a": { build: `${bunExe()} -e "console.log('a-built')"` },
|
|
"pkg-b": { build: `${bunExe()} -e "console.log('b-built')"` },
|
|
"pkg-c": { build: `${bunExe()} -e "console.log('c-built')"` },
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--filter", "*", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg-a:build", "a-built");
|
|
expectPrefixed(r.stdout, "pkg-b:build", "b-built");
|
|
expectPrefixed(r.stdout, "pkg-c:build", "c-built");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("--parallel --filter='pkg-a' runs only in matching package", async () => {
|
|
using dir = makeWorkspace("mr-ws-single", {
|
|
"pkg-a": { build: `${bunExe()} -e "console.log('a-only')"` },
|
|
"pkg-b": { build: `${bunExe()} -e "console.log('b-nope')"` },
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--filter", "pkg-a", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg-a:build", "a-only");
|
|
expect(r.stdout).not.toContain("b-nope");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("--parallel --workspaces matches all workspace packages", async () => {
|
|
using dir = makeWorkspace("mr-ws-workspaces", {
|
|
"pkg-a": { test: `${bunExe()} -e "console.log('a-test')"` },
|
|
"pkg-b": { test: `${bunExe()} -e "console.log('b-test')"` },
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--workspaces", "test"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg-a:test", "a-test");
|
|
expectPrefixed(r.stdout, "pkg-b:test", "b-test");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("--parallel --filter='*' with glob expands per-package scripts", async () => {
|
|
using dir = makeWorkspace("mr-ws-glob", {
|
|
"pkg-a": {
|
|
"build:css": `${bunExe()} -e "console.log('a-css')"`,
|
|
"build:js": `${bunExe()} -e "console.log('a-js')"`,
|
|
},
|
|
"pkg-b": {
|
|
"build:css": `${bunExe()} -e "console.log('b-css')"`,
|
|
},
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--filter", "*", "build:*"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg-a:build:css", "a-css");
|
|
expectPrefixed(r.stdout, "pkg-a:build:js", "a-js");
|
|
expectPrefixed(r.stdout, "pkg-b:build:css", "b-css");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("--sequential --filter='*' runs in sequence", async () => {
|
|
using dir = makeWorkspace("mr-ws-seq", {
|
|
"pkg-a": { build: `${bunExe()} -e "console.log('a-seq')"` },
|
|
"pkg-b": { build: `${bunExe()} -e "console.log('b-seq')"` },
|
|
});
|
|
const r = await runMulti(["run", "--sequential", "--filter", "*", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg-a:build", "a-seq");
|
|
expectPrefixed(r.stdout, "pkg-b:build", "b-seq");
|
|
// Verify sequential ordering
|
|
const ia = r.stdout.search(/pkg-a:build\s+\|.*a-seq/);
|
|
const ib = r.stdout.search(/pkg-b:build\s+\|.*b-seq/);
|
|
expect(ia).toBeGreaterThan(-1);
|
|
expect(ib).toBeGreaterThan(-1);
|
|
expect(ia).toBeLessThan(ib);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("workspace + failure aborts other scripts", async () => {
|
|
using dir = makeWorkspace("mr-ws-fail", {
|
|
"pkg-a": { build: `${bunExe()} -e "process.exit(1)"` },
|
|
"pkg-b": { build: `${bunExe()} -e "await Bun.sleep(30000); console.log('should-not-appear')"` },
|
|
});
|
|
const start = Date.now();
|
|
const r = await runMulti(["run", "--parallel", "--filter", "*", "build"], String(dir));
|
|
const elapsed = Date.now() - start;
|
|
expectExited(r.stderr, "pkg-a:build", 1);
|
|
expect(r.stdout).not.toContain("should-not-appear");
|
|
expect(r.exitCode).not.toBe(0);
|
|
expect(elapsed).toBeLessThan(15000);
|
|
});
|
|
|
|
test("workspace + --no-exit-on-error lets all finish", async () => {
|
|
using dir = makeWorkspace("mr-ws-noexit", {
|
|
"pkg-a": { build: `${bunExe()} -e "process.exit(1)"` },
|
|
"pkg-b": { build: `${bunExe()} -e "console.log('b-ok')"` },
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--no-exit-on-error", "--filter", "*", "build"], String(dir));
|
|
expectExited(r.stderr, "pkg-a:build", 1);
|
|
expectPrefixed(r.stdout, "pkg-b:build", "b-ok");
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("--workspaces skips root package", async () => {
|
|
using dir = makeWorkspace(
|
|
"mr-ws-skiproot",
|
|
{
|
|
"pkg-a": { build: `${bunExe()} -e "console.log('a-ws')"` },
|
|
},
|
|
{ build: `${bunExe()} -e "console.log('root-should-not-run')"` },
|
|
);
|
|
const r = await runMulti(["run", "--parallel", "--workspaces", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg-a:build", "a-ws");
|
|
expect(r.stdout).not.toContain("root-should-not-run");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("each workspace script runs in its own package directory", async () => {
|
|
using dir = makeWorkspace("mr-ws-cwd", {
|
|
"pkg-a": { pwd: `${bunExe()} -e "console.log(process.cwd())"` },
|
|
"pkg-b": { pwd: `${bunExe()} -e "console.log(process.cwd())"` },
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--filter", "*", "pwd"], String(dir));
|
|
// Each package should report its own directory, not the root
|
|
const realDir = realpathSync(String(dir));
|
|
const lines = r.stdout.split("\n").filter(l => l.includes(" | "));
|
|
const pkgALines = lines.filter(l => /pkg-a:pwd/.test(l));
|
|
const pkgBLines = lines.filter(l => /pkg-b:pwd/.test(l));
|
|
expect(pkgALines.length).toBeGreaterThan(0);
|
|
expect(pkgBLines.length).toBeGreaterThan(0);
|
|
// Normalize paths for cross-platform comparison (Windows uses backslashes)
|
|
const normPkgA = path.normalize(path.join(realDir, "packages", "pkg-a"));
|
|
const normPkgB = path.normalize(path.join(realDir, "packages", "pkg-b"));
|
|
expect(pkgALines.some(l => l.includes(normPkgA))).toBe(true);
|
|
expect(pkgBLines.some(l => l.includes(normPkgB))).toBe(true);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("multiple script names across workspaces", async () => {
|
|
using dir = makeWorkspace("mr-ws-multi-scripts", {
|
|
"pkg-a": {
|
|
build: `${bunExe()} -e "console.log('a-build')"`,
|
|
test: `${bunExe()} -e "console.log('a-test')"`,
|
|
},
|
|
"pkg-b": {
|
|
build: `${bunExe()} -e "console.log('b-build')"`,
|
|
test: `${bunExe()} -e "console.log('b-test')"`,
|
|
},
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--filter", "*", "build", "test"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg-a:build", "a-build");
|
|
expectPrefixed(r.stdout, "pkg-a:test", "a-test");
|
|
expectPrefixed(r.stdout, "pkg-b:build", "b-build");
|
|
expectPrefixed(r.stdout, "pkg-b:test", "b-test");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("pre/post scripts work per workspace package", async () => {
|
|
using dir = makeWorkspace("mr-ws-prepost", {
|
|
"pkg-a": {
|
|
prebuild: `${bunExe()} -e "console.log('a-pre')"`,
|
|
build: `${bunExe()} -e "console.log('a-main')"`,
|
|
postbuild: `${bunExe()} -e "console.log('a-post')"`,
|
|
},
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--filter", "*", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg-a:build", "a-pre");
|
|
expectPrefixed(r.stdout, "pkg-a:build", "a-main");
|
|
expectPrefixed(r.stdout, "pkg-a:build", "a-post");
|
|
// Order: pre -> main -> post
|
|
const preIdx = r.stdout.search(/pkg-a:build\s+\|.*a-pre/);
|
|
const mainIdx = r.stdout.search(/pkg-a:build\s+\|.*a-main/);
|
|
const postIdx = r.stdout.search(/pkg-a:build\s+\|.*a-post/);
|
|
expect(preIdx).toBeGreaterThan(-1);
|
|
expect(mainIdx).toBeGreaterThan(-1);
|
|
expect(postIdx).toBeGreaterThan(-1);
|
|
expect(preIdx).toBeLessThan(mainIdx);
|
|
expect(mainIdx).toBeLessThan(postIdx);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("--filter skips packages without the script (no error)", async () => {
|
|
using dir = makeWorkspace("mr-ws-skip-missing", {
|
|
"pkg-a": { build: `${bunExe()} -e "console.log('a-has-it')"` },
|
|
"pkg-b": { lint: `${bunExe()} -e "console.log('b-different')"` },
|
|
});
|
|
// pkg-b doesn't have 'build', should be silently skipped with --filter
|
|
const r = await runMulti(["run", "--parallel", "--filter", "*", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg-a:build", "a-has-it");
|
|
expect(r.stdout).not.toContain("b-different");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("--workspaces errors when a package is missing the script", async () => {
|
|
using dir = makeWorkspace("mr-ws-missing-err", {
|
|
"pkg-a": { build: `${bunExe()} -e "console.log('a-ok')"` },
|
|
"pkg-b": { lint: `${bunExe()} -e "console.log('no-build')"` },
|
|
});
|
|
// --workspaces (not --filter) should error on missing script
|
|
const r = await runMulti(["run", "--parallel", "--workspaces", "build"], String(dir));
|
|
expect(r.stderr).toContain('Missing "build" script');
|
|
expect(r.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("--workspaces --if-present skips missing scripts silently", async () => {
|
|
using dir = makeWorkspace("mr-ws-ifpresent", {
|
|
"pkg-a": { build: `${bunExe()} -e "console.log('a-present')"` },
|
|
"pkg-b": { lint: `${bunExe()} -e "console.log('no-build')"` },
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--workspaces", "--if-present", "build"], String(dir));
|
|
expectPrefixed(r.stdout, "pkg-a:build", "a-present");
|
|
expect(r.stdout).not.toContain("no-build");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("labels are padded correctly across workspace packages", async () => {
|
|
using dir = makeWorkspace("mr-ws-padding", {
|
|
"a": { build: `${bunExe()} -e "console.log('short')"` },
|
|
"long-package-name": { build: `${bunExe()} -e "console.log('long')"` },
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--filter", "*", "build"], String(dir));
|
|
const stdoutLines = r.stdout.split("\n").filter(l => l.includes(" | "));
|
|
const shortLines = stdoutLines.filter(l => l.includes("| short"));
|
|
const longLines = stdoutLines.filter(l => l.includes("| long"));
|
|
expect(shortLines.length).toBeGreaterThan(0);
|
|
expect(longLines.length).toBeGreaterThan(0);
|
|
// Both prefixes should have the same width
|
|
const shortPrefix = shortLines[0].split(" | ")[0];
|
|
const longPrefix = longLines[0].split(" | ")[0];
|
|
expect(shortPrefix.length).toBe(longPrefix.length);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test("package without name field uses relative path as label", async () => {
|
|
using dir = tempDir("mr-ws-noname", {
|
|
"package.json": JSON.stringify({
|
|
name: "monorepo",
|
|
private: true,
|
|
workspaces: ["packages/*"],
|
|
}),
|
|
"packages/my-pkg/package.json": JSON.stringify({
|
|
// no "name" field
|
|
scripts: { build: `${bunExe()} -e "console.log('no-name-ok')"` },
|
|
}),
|
|
});
|
|
const r = await runMulti(["run", "--parallel", "--filter", "./packages/my-pkg", "build"], String(dir));
|
|
// Label should use relative path "packages/my-pkg" instead of empty string
|
|
expectPrefixed(r.stdout, "packages/my-pkg:build", "no-name-ok");
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|