diff --git a/docs/pm/filter.mdx b/docs/pm/filter.mdx index f64a756f54..7d00b41a9f 100644 --- a/docs/pm/filter.mdx +++ b/docs/pm/filter.mdx @@ -97,6 +97,31 @@ Filters respect your [workspace configuration](/pm/workspaces): If you have a `p bun run --filter foo myscript ``` +### Parallel and sequential mode + +Combine `--filter` or `--workspaces` with `--parallel` or `--sequential` to run scripts across workspace packages with Foreman-style prefixed output: + +```bash terminal icon="terminal" +# Run "build" in all matching packages concurrently +bun run --parallel --filter '*' build + +# Run "build" in all workspace packages sequentially +bun run --sequential --workspaces build + +# Run glob-matched scripts across all packages +bun run --parallel --filter '*' "build:*" + +# Continue running even if one package's script fails +bun run --parallel --no-exit-on-error --filter '*' test + +# Run multiple scripts across all packages +bun run --parallel --filter '*' build lint +``` + +Each line of output is prefixed with the package and script name (e.g. `pkg-a:build | ...`). Without `--filter`/`--workspaces`, the prefix is just the script name (e.g. `build | ...`). When a package's `package.json` has no `name` field, the relative path from the workspace root is used instead. + +Use `--if-present` with `--workspaces` to skip packages that don't have the requested script instead of erroring. + ### Dependency Order Bun will respect package dependency order when running scripts. Say you have a package `foo` that depends on another package `bar` in your workspace, and both packages have a `build` script. When you run `bun --filter '*' build`, you will notice that `foo` will only start running once `bar` is done. diff --git a/docs/snippets/cli/run.mdx b/docs/snippets/cli/run.mdx index c3a5900775..ba4f375f25 100644 --- a/docs/snippets/cli/run.mdx +++ b/docs/snippets/cli/run.mdx @@ -40,6 +40,18 @@ bun run Run a script in all workspace packages (from the workspaces field in package.json) + + Run multiple scripts or workspace scripts concurrently with prefixed output + + + + Run multiple scripts or workspace scripts one after another with prefixed output + + + + When using --parallel or --sequential, continue running other scripts when one fails + + ### Runtime & Process Control diff --git a/src/bun.js/api/bun/process.zig b/src/bun.js/api/bun/process.zig index 325740252e..d4a0a6ecb6 100644 --- a/src/bun.js/api/bun/process.zig +++ b/src/bun.js/api/bun/process.zig @@ -84,6 +84,7 @@ pub const ProcessExitHandler = struct { LifecycleScriptSubprocess, ShellSubprocess, ProcessHandle, + MultiRunProcessHandle, SecurityScanSubprocess, SyncProcess, }, @@ -111,6 +112,10 @@ pub const ProcessExitHandler = struct { const subprocess = this.ptr.as(ProcessHandle); subprocess.onProcessExit(process, status, rusage); }, + @field(TaggedPointer.Tag, @typeName(MultiRunProcessHandle)) => { + const subprocess = this.ptr.as(MultiRunProcessHandle); + subprocess.onProcessExit(process, status, rusage); + }, @field(TaggedPointer.Tag, @typeName(ShellSubprocess)) => { const subprocess = this.ptr.as(ShellSubprocess); subprocess.onProcessExit(process, status, rusage); @@ -2251,6 +2256,7 @@ pub const sync = struct { }; const std = @import("std"); +const MultiRunProcessHandle = @import("../../../cli/multi_run.zig").ProcessHandle; const ProcessHandle = @import("../../../cli/filter_run.zig").ProcessHandle; const bun = @import("bun"); diff --git a/src/cli.zig b/src/cli.zig index dd53d9f596..d3cdbd3b2f 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -425,6 +425,9 @@ pub const Command = struct { filters: []const []const u8 = &.{}, workspaces: bool = false, if_present: bool = false, + parallel: bool = false, + sequential: bool = false, + no_exit_on_error: bool = false, preloads: []const string = &.{}, has_loaded_global_config: bool = false, @@ -888,6 +891,13 @@ pub const Command = struct { const ctx = try Command.init(allocator, log, .RunCommand); ctx.args.target = .bun; + if (ctx.parallel or ctx.sequential) { + MultiRun.run(ctx) catch |err| { + Output.prettyErrorln("error: {s}", .{@errorName(err)}); + Global.exit(1); + }; + } + if (ctx.filters.len > 0 or ctx.workspaces) { FilterRun.runScriptsWithFilter(ctx) catch |err| { Output.prettyErrorln("error: {s}", .{@errorName(err)}); @@ -927,6 +937,13 @@ pub const Command = struct { }; ctx.args.target = .bun; + if (ctx.parallel or ctx.sequential) { + MultiRun.run(ctx) catch |err| { + Output.prettyErrorln("error: {s}", .{@errorName(err)}); + Global.exit(1); + }; + } + if (ctx.filters.len > 0 or ctx.workspaces) { FilterRun.runScriptsWithFilter(ctx) catch |err| { Output.prettyErrorln("error: {s}", .{@errorName(err)}); @@ -1762,6 +1779,7 @@ const string = []const u8; const AddCompletions = @import("./cli/add_completions.zig"); const FilterRun = @import("./cli/filter_run.zig"); +const MultiRun = @import("./cli/multi_run.zig"); const PmViewCommand = @import("./cli/pm_view_command.zig"); const fs = @import("./fs.zig"); const options = @import("./options.zig"); diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index 57f27d0223..27cea7b5b5 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -130,6 +130,9 @@ pub const auto_or_run_params = [_]ParamType{ clap.parseParam("-b, --bun Force a script or package to use Bun's runtime instead of Node.js (via symlinking node)") catch unreachable, clap.parseParam("--shell Control the shell used for package.json scripts. Supports either 'bun' or 'system'") catch unreachable, clap.parseParam("--workspaces Run a script in all workspace packages (from the \"workspaces\" field in package.json)") catch unreachable, + clap.parseParam("--parallel Run multiple scripts concurrently with Foreman-style output") catch unreachable, + clap.parseParam("--sequential Run multiple scripts sequentially with Foreman-style output") catch unreachable, + clap.parseParam("--no-exit-on-error Continue running other scripts when one fails (with --parallel/--sequential)") catch unreachable, }; pub const auto_only_params = [_]ParamType{ @@ -453,6 +456,9 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C ctx.filters = args.options("--filter"); ctx.workspaces = args.flag("--workspaces"); ctx.if_present = args.flag("--if-present"); + ctx.parallel = args.flag("--parallel"); + ctx.sequential = args.flag("--sequential"); + ctx.no_exit_on_error = args.flag("--no-exit-on-error"); if (args.option("--elide-lines")) |elide_lines| { if (elide_lines.len > 0) { diff --git a/src/cli/multi_run.zig b/src/cli/multi_run.zig new file mode 100644 index 0000000000..15199215b4 --- /dev/null +++ b/src/cli/multi_run.zig @@ -0,0 +1,838 @@ +const ScriptConfig = struct { + label: []const u8, + command: [:0]const u8, + cwd: []const u8, + PATH: []const u8, +}; + +/// Wraps a BufferedReader and tracks whether it represents stdout or stderr, +/// so output can be routed to the correct parent stream. +const PipeReader = struct { + const This = @This(); + + reader: bun.io.BufferedReader = bun.io.BufferedReader.init(This), + handle: *ProcessHandle = undefined, // set in ProcessHandle.start() + is_stderr: bool, + line_buffer: std.array_list.Managed(u8) = std.array_list.Managed(u8).init(bun.default_allocator), + + pub fn onReadChunk(this: *This, chunk: []const u8, hasMore: bun.io.ReadState) bool { + _ = hasMore; + this.handle.state.readChunk(this, chunk) catch {}; + return true; + } + + pub fn onReaderDone(this: *This) void { + _ = this; + } + + pub fn onReaderError(this: *This, err: bun.sys.Error) void { + _ = this; + _ = err; + } + + pub fn eventLoop(this: *This) *bun.jsc.MiniEventLoop { + return this.handle.state.event_loop; + } + + pub fn loop(this: *This) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.handle.state.event_loop.loop.uv_loop; + } else { + return this.handle.state.event_loop.loop; + } + } +}; + +pub const ProcessHandle = struct { + const This = @This(); + + config: *ScriptConfig, + state: *State, + color_idx: usize, + + stdout_reader: PipeReader = .{ .is_stderr = false }, + stderr_reader: PipeReader = .{ .is_stderr = true }, + + process: ?struct { + ptr: *bun.spawn.Process, + status: bun.spawn.Status = .running, + } = null, + options: bun.spawn.SpawnOptions, + + start_time: ?std.time.Instant = null, + end_time: ?std.time.Instant = null, + + remaining_dependencies: usize = 0, + /// Dependents within the same script group (pre->main->post chain). + /// These are NOT started if this handle fails, even with --no-exit-on-error. + group_dependents: std.array_list.Managed(*This) = std.array_list.Managed(*This).init(bun.default_allocator), + /// Dependents across sequential groups (group N -> group N+1). + /// These ARE started even if this handle fails when --no-exit-on-error is set. + next_dependents: std.array_list.Managed(*This) = std.array_list.Managed(*This).init(bun.default_allocator), + + fn start(this: *This) !void { + this.state.remaining_scripts += 1; + + var argv = [_:null]?[*:0]const u8{ + this.state.shell_bin, + if (Environment.isPosix) "-c" else "exec", + this.config.command, + null, + }; + + this.start_time = std.time.Instant.now() catch null; + var spawned: bun.spawn.process.SpawnProcessResult = brk: { + var arena = std.heap.ArenaAllocator.init(bun.default_allocator); + defer arena.deinit(); + const original_path = this.state.env.map.get("PATH") orelse ""; + bun.handleOom(this.state.env.map.put("PATH", this.config.PATH)); + defer bun.handleOom(this.state.env.map.put("PATH", original_path)); + const envp = try this.state.env.map.createNullDelimitedEnvMap(arena.allocator()); + break :brk try (try bun.spawn.spawnProcess(&this.options, argv[0..], envp)).unwrap(); + }; + var process = spawned.toProcess(this.state.event_loop, false); + + this.stdout_reader.handle = this; + this.stderr_reader.handle = this; + this.stdout_reader.reader.setParent(&this.stdout_reader); + this.stderr_reader.reader.setParent(&this.stderr_reader); + + if (Environment.isWindows) { + this.stdout_reader.reader.source = .{ .pipe = this.options.stdout.buffer }; + this.stderr_reader.reader.source = .{ .pipe = this.options.stderr.buffer }; + } + + if (Environment.isPosix) { + if (spawned.stdout) |stdout_fd| { + _ = bun.sys.setNonblocking(stdout_fd); + try this.stdout_reader.reader.start(stdout_fd, true).unwrap(); + } + if (spawned.stderr) |stderr_fd| { + _ = bun.sys.setNonblocking(stderr_fd); + try this.stderr_reader.reader.start(stderr_fd, true).unwrap(); + } + } else { + try this.stdout_reader.reader.startWithCurrentPipe().unwrap(); + try this.stderr_reader.reader.startWithCurrentPipe().unwrap(); + } + + this.process = .{ .ptr = process }; + process.setExitHandler(this); + + switch (process.watchOrReap()) { + .result => {}, + .err => |err| { + if (!process.hasExited()) + process.onExit(.{ .err = err }, &std.mem.zeroes(bun.spawn.Rusage)); + }, + } + } + + pub fn onProcessExit(this: *This, proc: *bun.spawn.Process, status: bun.spawn.Status, _: *const bun.spawn.Rusage) void { + this.process.?.status = status; + this.end_time = std.time.Instant.now() catch null; + _ = proc; + this.state.processExit(this) catch {}; + } + + pub fn eventLoop(this: *This) *bun.jsc.MiniEventLoop { + return this.state.event_loop; + } + + pub fn loop(this: *This) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.state.event_loop.loop.uv_loop; + } else { + return this.state.event_loop.loop; + } + } +}; + +const colors = [_][]const u8{ + "\x1b[36m", // cyan + "\x1b[33m", // yellow + "\x1b[35m", // magenta + "\x1b[32m", // green + "\x1b[34m", // blue + "\x1b[31m", // red +}; +const reset = "\x1b[0m"; + +const State = struct { + const This = @This(); + + handles: []ProcessHandle, + event_loop: *bun.jsc.MiniEventLoop, + remaining_scripts: usize = 0, + max_label_len: usize, + shell_bin: [:0]const u8, + aborted: bool = false, + no_exit_on_error: bool, + env: *bun.DotEnv.Loader, + use_colors: bool, + + pub fn isDone(this: *This) bool { + return this.remaining_scripts == 0; + } + + fn readChunk(this: *This, pipe: *PipeReader, chunk: []const u8) (std.Io.Writer.Error || bun.OOM)!void { + try pipe.line_buffer.appendSlice(chunk); + + // Route to correct parent stream: child stdout -> parent stdout, child stderr -> parent stderr + const writer = if (pipe.is_stderr) Output.errorWriter() else Output.writer(); + + // Process complete lines + while (std.mem.indexOfScalar(u8, pipe.line_buffer.items, '\n')) |newline_pos| { + const line = pipe.line_buffer.items[0 .. newline_pos + 1]; + try this.writeLineWithPrefix(pipe.handle, line, writer); + // Remove processed line from buffer + const remaining = pipe.line_buffer.items[newline_pos + 1 ..]; + std.mem.copyForwards(u8, pipe.line_buffer.items[0..remaining.len], remaining); + pipe.line_buffer.items.len = remaining.len; + } + } + + fn writeLineWithPrefix(this: *This, handle: *ProcessHandle, line: []const u8, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try this.writePrefix(handle, writer); + try writer.writeAll(line); + } + + fn writePrefix(this: *This, handle: *ProcessHandle, writer: *std.Io.Writer) std.Io.Writer.Error!void { + if (this.use_colors) { + try writer.writeAll(colors[handle.color_idx % colors.len]); + } + + try writer.writeAll(handle.config.label); + const padding = this.max_label_len -| handle.config.label.len; + for (0..padding) |_| { + try writer.writeByte(' '); + } + + if (this.use_colors) { + try writer.writeAll(reset); + } + + try writer.writeAll(" | "); + } + + fn flushPipeBuffer(this: *This, handle: *ProcessHandle, pipe: *PipeReader) std.Io.Writer.Error!void { + if (pipe.line_buffer.items.len > 0) { + const line = pipe.line_buffer.items; + const needs_newline = line.len > 0 and line[line.len - 1] != '\n'; + const writer = if (pipe.is_stderr) Output.errorWriter() else Output.writer(); + try this.writeLineWithPrefix(handle, line, writer); + if (needs_newline) { + writer.writeAll("\n") catch {}; + } + pipe.line_buffer.clearRetainingCapacity(); + } + } + + fn processExit(this: *This, handle: *ProcessHandle) std.Io.Writer.Error!void { + this.remaining_scripts -= 1; + + // Flush remaining buffers (stdout first, then stderr) + try this.flushPipeBuffer(handle, &handle.stdout_reader); + try this.flushPipeBuffer(handle, &handle.stderr_reader); + + // Print exit status to stderr (status messages always go to stderr) + const writer = Output.errorWriter(); + try this.writePrefix(handle, writer); + + switch (handle.process.?.status) { + .exited => |exited| { + if (exited.code != 0) { + try writer.print("Exited with code {d}\n", .{exited.code}); + } else { + if (handle.start_time != null and handle.end_time != null) { + const duration = handle.end_time.?.since(handle.start_time.?); + const ms = @as(f64, @floatFromInt(duration)) / 1_000_000.0; + if (ms > 1000.0) { + try writer.print("Done in {d:.2}s\n", .{ms / 1000.0}); + } else { + try writer.print("Done in {d:.0}ms\n", .{ms}); + } + } else { + try writer.writeAll("Done\n"); + } + } + }, + .signaled => |signal| { + try writer.print("Signaled: {s}\n", .{@tagName(signal)}); + }, + else => { + try writer.writeAll("Error\n"); + }, + } + + // Check if we should abort on error + const failed = switch (handle.process.?.status) { + .exited => |exited| exited.code != 0, + .signaled => true, + else => true, + }; + + if (failed and !this.no_exit_on_error) { + this.abort(); + return; + } + + if (failed) { + // Pre->main->post chain is broken -- skip group dependents. + this.skipDependents(handle.group_dependents.items); + // But cascade to next-group dependents (sequential --no-exit-on-error). + if (!this.aborted) { + this.startDependents(handle.next_dependents.items); + } + return; + } + + // Success: cascade to all dependents + if (!this.aborted) { + this.startDependents(handle.group_dependents.items); + this.startDependents(handle.next_dependents.items); + } + } + + fn startDependents(_: *This, dependents: []*ProcessHandle) void { + for (dependents) |dependent| { + dependent.remaining_dependencies -= 1; + if (dependent.remaining_dependencies == 0) { + dependent.start() catch { + Output.prettyErrorln("error: Failed to start process", .{}); + Global.exit(1); + }; + } + } + } + + /// Skip group dependents that will never start because their predecessor + /// failed. Recursively skip their group dependents too. + fn skipDependents(this: *This, dependents: []*ProcessHandle) void { + for (dependents) |dependent| { + dependent.remaining_dependencies -= 1; + if (dependent.remaining_dependencies == 0) { + this.skipDependents(dependent.group_dependents.items); + // Still cascade next_dependents so sequential chains continue + if (!this.aborted) { + this.startDependents(dependent.next_dependents.items); + } + } + } + } + + pub fn abort(this: *This) void { + this.aborted = true; + for (this.handles) |*handle| { + if (handle.process) |*proc| { + if (proc.status == .running) { + _ = proc.ptr.kill(std.posix.SIG.INT); + } + } + } + } + + pub fn finalize(this: *This) u8 { + for (this.handles) |handle| { + if (handle.process) |proc| { + switch (proc.status) { + .exited => |exited| { + if (exited.code != 0) return exited.code; + }, + .signaled => |signal| return signal.toExitCode() orelse 1, + else => return 1, + } + } + } + return 0; + } +}; + +const AbortHandler = struct { + var should_abort = false; + + fn posixSignalHandler(sig: i32, info: *const std.posix.siginfo_t, _: ?*const anyopaque) callconv(.c) void { + _ = sig; + _ = info; + should_abort = true; + } + + fn windowsCtrlHandler(dwCtrlType: std.os.windows.DWORD) callconv(.winapi) std.os.windows.BOOL { + if (dwCtrlType == std.os.windows.CTRL_C_EVENT) { + should_abort = true; + return std.os.windows.TRUE; + } + return std.os.windows.FALSE; + } + + pub fn install() void { + if (Environment.isPosix) { + const action = std.posix.Sigaction{ + .handler = .{ .sigaction = AbortHandler.posixSignalHandler }, + .mask = std.posix.sigemptyset(), + .flags = std.posix.SA.SIGINFO | std.posix.SA.RESTART | std.posix.SA.RESETHAND, + }; + std.posix.sigaction(std.posix.SIG.INT, &action, null); + } else { + const res = bun.c.SetConsoleCtrlHandler(windowsCtrlHandler, std.os.windows.TRUE); + if (res == 0) { + if (Environment.isDebug) { + Output.warn("Failed to set abort handler\n", .{}); + } + } + } + } + + pub fn uninstall() void { + if (Environment.isWindows) { + _ = bun.c.SetConsoleCtrlHandler(null, std.os.windows.FALSE); + } + } +}; + +/// Simple glob matching: `*` matches any sequence of characters. +fn matchesGlob(pattern: []const u8, name: []const u8) bool { + var pi: usize = 0; + var ni: usize = 0; + var star_pi: usize = 0; + var star_ni: usize = 0; + var have_star = false; + + while (ni < name.len or pi < pattern.len) { + if (pi < pattern.len and pattern[pi] == '*') { + have_star = true; + star_pi = pi; + star_ni = ni; + pi += 1; + } else if (pi < pattern.len and ni < name.len and pattern[pi] == name[ni]) { + pi += 1; + ni += 1; + } else if (have_star) { + pi = star_pi + 1; + star_ni += 1; + ni = star_ni; + if (ni > name.len) return false; + } else { + return false; + } + } + return true; +} + +/// Add configs for a single script name (with pre/post handling). +/// When `label_prefix` is non-null, labels become "{prefix}:{name}" (for workspace runs). +fn addScriptConfigs( + configs: *std.array_list.Managed(ScriptConfig), + group_infos: *std.array_list.Managed(GroupInfo), + raw_name: []const u8, + scripts_map: ?*const bun.StringArrayHashMap([]const u8), + allocator: std.mem.Allocator, + cwd: []const u8, + PATH: []const u8, + label_prefix: ?[]const u8, +) !void { + const group_start = configs.items.len; + + const label = if (label_prefix) |prefix| + try std.fmt.allocPrint(allocator, "{s}:{s}", .{ prefix, raw_name }) + else + raw_name; + + const script_content = if (scripts_map) |sm| sm.get(raw_name) else null; + + if (script_content) |content| { + // It's a package.json script - check for pre/post + const pre_name = try std.fmt.allocPrint(allocator, "pre{s}", .{raw_name}); + const post_name = try std.fmt.allocPrint(allocator, "post{s}", .{raw_name}); + + const pre_content = if (scripts_map) |sm| sm.get(pre_name) else null; + const post_content = if (scripts_map) |sm| sm.get(post_name) else null; + + if (pre_content) |pc| { + var cmd_buf = try std.array_list.Managed(u8).initCapacity(allocator, pc.len + 1); + try RunCommand.replacePackageManagerRun(&cmd_buf, pc); + try cmd_buf.append(0); + try configs.append(.{ + .label = label, + .command = cmd_buf.items[0 .. cmd_buf.items.len - 1 :0], + .cwd = cwd, + .PATH = PATH, + }); + } + + // Main script + { + var cmd_buf = try std.array_list.Managed(u8).initCapacity(allocator, content.len + 1); + try RunCommand.replacePackageManagerRun(&cmd_buf, content); + try cmd_buf.append(0); + try configs.append(.{ + .label = label, + .command = cmd_buf.items[0 .. cmd_buf.items.len - 1 :0], + .cwd = cwd, + .PATH = PATH, + }); + } + + if (post_content) |pc| { + var cmd_buf = try std.array_list.Managed(u8).initCapacity(allocator, pc.len + 1); + try RunCommand.replacePackageManagerRun(&cmd_buf, pc); + try cmd_buf.append(0); + try configs.append(.{ + .label = label, + .command = cmd_buf.items[0 .. cmd_buf.items.len - 1 :0], + .cwd = cwd, + .PATH = PATH, + }); + } + } else { + // Not a package.json script - run as a raw command + // If it looks like a file path, prefix with bun executable + const is_file = raw_name.len > 0 and (raw_name[0] == '.' or raw_name[0] == '/' or hasRunnableExtension(raw_name)); + const command_z = if (is_file) brk: { + const bun_path = bun.selfExePath() catch "bun"; + const cmd_str = try std.fmt.allocPrint(allocator, "{s} {s}" ++ "\x00", .{ bun_path, raw_name }); + break :brk cmd_str[0 .. cmd_str.len - 1 :0]; + } else try allocator.dupeZ(u8, raw_name); + try configs.append(.{ + .label = label, + .command = command_z, + .cwd = cwd, + .PATH = PATH, + }); + } + + try group_infos.append(.{ + .start = group_start, + .count = configs.items.len - group_start, + }); +} + +const GroupInfo = struct { start: usize, count: usize }; + +pub fn run(ctx: Command.Context) !noreturn { + // Validate flags + if (ctx.parallel and ctx.sequential) { + Output.prettyErrorln("error: --parallel and --sequential cannot be used together", .{}); + Global.exit(1); + } + + // Collect script names from positionals + passthrough + // For RunCommand: positionals[0] is "run", skip it. For AutoCommand: no "run" prefix. + var script_names = std.array_list.Managed([]const u8).init(ctx.allocator); + + var positionals = ctx.positionals; + if (positionals.len > 0 and (strings.eqlComptime(positionals[0], "run") or strings.eqlComptime(positionals[0], "r"))) { + positionals = positionals[1..]; + } + for (positionals) |pos| { + if (pos.len > 0) { + try script_names.append(pos); + } + } + for (ctx.passthrough) |pt| { + if (pt.len > 0) { + try script_names.append(pt); + } + } + + if (script_names.items.len == 0) { + Output.prettyErrorln("error: --parallel/--sequential requires at least one script name", .{}); + Global.exit(1); + } + + // Set up the transpiler/environment + const fsinstance = try bun.fs.FileSystem.init(null); + var this_transpiler: transpiler.Transpiler = undefined; + _ = try RunCommand.configureEnvForRun(ctx, &this_transpiler, null, true, false); + const cwd = fsinstance.top_level_dir; + + const event_loop = bun.jsc.MiniEventLoop.initGlobal(this_transpiler.env, null); + const shell_bin: [:0]const u8 = if (Environment.isPosix) + RunCommand.findShell(this_transpiler.env.get("PATH") orelse "", cwd) orelse return error.MissingShell + else + bun.selfExePath() catch return error.MissingShell; + + // Build ScriptConfigs and ProcessHandles + // Each script name can produce up to 3 handles (pre, main, post) + var configs = std.array_list.Managed(ScriptConfig).init(ctx.allocator); + var group_infos = std.array_list.Managed(GroupInfo).init(ctx.allocator); + + if (ctx.filters.len > 0 or ctx.workspaces) { + // Workspace-aware mode: iterate over matching workspace packages + var filters_to_use = ctx.filters; + if (ctx.workspaces) { + filters_to_use = &.{"*"}; + } + + var filter_instance = try FilterArg.FilterSet.init(ctx.allocator, filters_to_use, cwd); + var patterns = std.array_list.Managed([]u8).init(ctx.allocator); + + var root_buf: bun.PathBuffer = undefined; + const resolve_root = try FilterArg.getCandidatePackagePatterns(ctx.allocator, ctx.log, &patterns, cwd, &root_buf); + + var package_json_iter = try FilterArg.PackageFilterIterator.init(ctx.allocator, patterns.items, resolve_root); + defer package_json_iter.deinit(); + + // Phase 1: Collect matching packages (filesystem order is nondeterministic) + const MatchedPackage = struct { + name: []const u8, + dirpath: []const u8, + scripts: *const bun.StringArrayHashMap([]const u8), + PATH: []const u8, + }; + var matched_packages = std.array_list.Managed(MatchedPackage).init(ctx.allocator); + + while (try package_json_iter.next()) |package_json_path| { + const dirpath = try ctx.allocator.dupe(u8, std.fs.path.dirname(package_json_path) orelse Global.crash()); + const path = bun.strings.withoutTrailingSlash(dirpath); + + // When using --workspaces, skip the root package to prevent recursion + if (ctx.workspaces and strings.eql(path, resolve_root)) { + continue; + } + + const pkgjson = bun.PackageJSON.parse(&this_transpiler.resolver, dirpath, .invalid, null, .include_scripts, .main) orelse { + continue; + }; + + if (!filter_instance.matches(path, pkgjson.name)) + continue; + + const pkg_scripts = pkgjson.scripts orelse continue; + const pkg_PATH = try RunCommand.configurePathForRunWithPackageJsonDir(ctx, dirpath, &this_transpiler, null, dirpath, ctx.debug.run_in_bun); + const pkg_name = if (pkgjson.name.len > 0) + pkgjson.name + else + // Fallback: use relative path from workspace root + try ctx.allocator.dupe(u8, bun.path.relativePlatform(resolve_root, path, .posix, false)); + + try matched_packages.append(.{ + .name = pkg_name, + .dirpath = dirpath, + .scripts = pkg_scripts, + .PATH = pkg_PATH, + }); + } + + // Phase 2: Sort by package name, then by path as tiebreaker for deterministic ordering + std.mem.sort(MatchedPackage, matched_packages.items, {}, struct { + fn lessThan(_: void, a: MatchedPackage, b: MatchedPackage) bool { + const name_order = std.mem.order(u8, a.name, b.name); + if (name_order != .eq) return name_order == .lt; + return std.mem.order(u8, a.dirpath, b.dirpath) == .lt; + } + }.lessThan); + + // Phase 3: Build configs from sorted packages + for (matched_packages.items) |pkg| { + for (script_names.items) |raw_name| { + if (std.mem.indexOfScalar(u8, raw_name, '*') != null) { + // Glob: expand against this package's scripts + var matches = std.array_list.Managed([]const u8).init(ctx.allocator); + for (pkg.scripts.keys()) |key| { + if (matchesGlob(raw_name, key)) { + try matches.append(key); + } + } + std.mem.sort([]const u8, matches.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.lessThan); + for (matches.items) |matched_name| { + try addScriptConfigs(&configs, &group_infos, matched_name, pkg.scripts, ctx.allocator, pkg.dirpath, pkg.PATH, pkg.name); + } + } else { + if (pkg.scripts.get(raw_name) != null) { + try addScriptConfigs(&configs, &group_infos, raw_name, pkg.scripts, ctx.allocator, pkg.dirpath, pkg.PATH, pkg.name); + } else if (ctx.workspaces and !ctx.if_present) { + Output.prettyErrorln("error: Missing \"{s}\" script in package \"{s}\"", .{ raw_name, pkg.name }); + Global.exit(1); + } + } + } + } + + if (configs.items.len == 0) { + if (ctx.if_present) { + Global.exit(0); + } + if (ctx.workspaces) { + Output.prettyErrorln("error: No workspace packages have matching scripts", .{}); + } else { + Output.prettyErrorln("error: No packages matched the filter", .{}); + } + Global.exit(1); + } + } else { + // Single-package mode: use the root package.json + const PATH = try RunCommand.configurePathForRunWithPackageJsonDir(ctx, "", &this_transpiler, null, cwd, ctx.debug.run_in_bun); + + // Load package.json scripts + const root_dir_info = this_transpiler.resolver.readDirInfo(cwd) catch { + Output.prettyErrorln("error: Failed to read directory", .{}); + Global.exit(1); + } orelse { + Output.prettyErrorln("error: Failed to read directory", .{}); + Global.exit(1); + }; + + const package_json = root_dir_info.enclosing_package_json; + const scripts_map: ?*const bun.StringArrayHashMap([]const u8) = if (package_json) |pkg| pkg.scripts else null; + + for (script_names.items) |raw_name| { + // Check if this is a glob pattern + if (std.mem.indexOfScalar(u8, raw_name, '*') != null) { + if (scripts_map) |sm| { + // Collect matching script names + var matches = std.array_list.Managed([]const u8).init(ctx.allocator); + for (sm.keys()) |key| { + if (matchesGlob(raw_name, key)) { + try matches.append(key); + } + } + + // Sort alphabetically + std.mem.sort([]const u8, matches.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.lessThan); + + if (matches.items.len == 0) { + Output.prettyErrorln("error: No scripts match pattern \"{s}\"", .{raw_name}); + Global.exit(1); + } + + for (matches.items) |matched_name| { + try addScriptConfigs(&configs, &group_infos, matched_name, scripts_map, ctx.allocator, cwd, PATH, null); + } + } else { + Output.prettyErrorln("error: Cannot use glob pattern \"{s}\" without package.json scripts", .{raw_name}); + Global.exit(1); + } + } else { + try addScriptConfigs(&configs, &group_infos, raw_name, scripts_map, ctx.allocator, cwd, PATH, null); + } + } + } + + if (configs.items.len == 0) { + Output.prettyErrorln("error: No scripts to run", .{}); + Global.exit(1); + } + + // Compute max label width + var max_label_len: usize = 0; + for (configs.items) |*config| { + if (config.label.len > max_label_len) { + max_label_len = config.label.len; + } + } + + const use_colors = Output.enable_ansi_colors_stderr; + + var state = State{ + .handles = try ctx.allocator.alloc(ProcessHandle, configs.items.len), + .event_loop = event_loop, + .max_label_len = max_label_len, + .shell_bin = shell_bin, + .no_exit_on_error = ctx.no_exit_on_error, + .env = this_transpiler.env, + .use_colors = use_colors, + }; + + // Initialize handles + for (configs.items, 0..) |*config, i| { + // Find which group this belongs to, for color assignment + var color_idx: usize = 0; + for (group_infos.items, 0..) |group, gi| { + if (i >= group.start and i < group.start + group.count) { + color_idx = gi; + break; + } + } + + state.handles[i] = ProcessHandle{ + .state = &state, + .config = config, + .color_idx = color_idx, + .options = .{ + .stdin = .ignore, + .stdout = if (Environment.isPosix) .buffer else .{ .buffer = try bun.default_allocator.create(bun.windows.libuv.Pipe) }, + .stderr = if (Environment.isPosix) .buffer else .{ .buffer = try bun.default_allocator.create(bun.windows.libuv.Pipe) }, + .cwd = config.cwd, + .windows = if (Environment.isWindows) .{ .loop = bun.jsc.EventLoopHandle.init(event_loop) }, + .stream = true, + }, + }; + } + + // Set up pre->main->post chaining within each group + for (group_infos.items) |group| { + if (group.count > 1) { + var j: usize = group.start; + while (j < group.start + group.count - 1) : (j += 1) { + try state.handles[j].group_dependents.append(&state.handles[j + 1]); + state.handles[j + 1].remaining_dependencies += 1; + } + } + } + + // For sequential mode, chain groups together + if (ctx.sequential) { + var gi: usize = 0; + while (gi < group_infos.items.len - 1) : (gi += 1) { + const current_group = group_infos.items[gi]; + const next_group = group_infos.items[gi + 1]; + // Last handle of current group -> first handle of next group + const last_in_current = current_group.start + current_group.count - 1; + const first_in_next = next_group.start; + try state.handles[last_in_current].next_dependents.append(&state.handles[first_in_next]); + state.handles[first_in_next].remaining_dependencies += 1; + } + } + + // Start handles with no dependencies + for (state.handles) |*handle| { + if (handle.remaining_dependencies == 0) { + handle.start() catch { + Output.prettyErrorln("error: Failed to start process", .{}); + Global.exit(1); + }; + } + } + + AbortHandler.install(); + + while (!state.isDone()) { + if (AbortHandler.should_abort and !state.aborted) { + AbortHandler.uninstall(); + state.abort(); + } + event_loop.tickOnce(&state); + } + + const status = state.finalize(); + Global.exit(status); +} + +fn hasRunnableExtension(name: []const u8) bool { + const ext = std.fs.path.extension(name); + const loader = bun.options.defaultLoaders.get(ext) orelse return false; + return loader.canBeRunByBun(); +} + +const FilterArg = @import("./filter_arg.zig"); +const std = @import("std"); +const RunCommand = @import("./run_command.zig").RunCommand; + +const bun = @import("bun"); +const Environment = bun.Environment; +const Global = bun.Global; +const Output = bun.Output; +const strings = bun.strings; +const transpiler = bun.transpiler; + +const CLI = bun.cli; +const Command = CLI.Command; diff --git a/test/cli/run/multi-run.test.ts b/test/cli/run/multi-run.test.ts new file mode 100644 index 0000000000..cf5979550a --- /dev/null +++ b/test/cli/run/multi-run.test.ts @@ -0,0 +1,2023 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// Helper: spawn bun with multi-run flags, returns { stdout, stderr, exitCode } +async function runMulti( + args: string[], + dir: string, + extraEnv?: Record, +): 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 = {}; + 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 ""`, + }, + }), + }); + 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 ""` }, + }), + }); + 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 ""`, + b: `${bunExe()} -e ""`, + c: `${bunExe()} -e ""`, + }, + }), + }); + 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 = await Bun.$`realpath ${String(dir)}`.text(); + expectPrefixed(r.stdout, "pwd", realDir.trim()); + 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 = {}; + 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("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 = {}; + 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>, + rootScripts?: Record, +) { + const files: Record = { + "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 = (await Bun.$`realpath ${String(dir)}`.text()).trim(); + 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); + expect(pkgALines.some(l => l.includes(`${realDir}/packages/pkg-a`))).toBe(true); + expect(pkgBLines.some(l => l.includes(`${realDir}/packages/pkg-b`))).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); + }); +});