Compare commits

...

4 Commits

Author SHA1 Message Date
autofix-ci[bot]
6c0917bcd1 [autofix.ci] apply automated fixes 2025-09-07 08:41:52 +00:00
Claude Bot
c359f0ac95 Improve --stream test to verify immediate output
- Switch from spawnSync to Bun.spawn to read output incrementally
- Add delays between console.log calls to simulate real workloads
- Verify that output arrives before process exits (proving streaming)
- Add assertion that no elision UI text appears in stream mode

This addresses review feedback to properly test the 'immediate' nature
of the streaming functionality.
2025-09-07 08:34:55 +00:00
autofix-ci[bot]
1f024302c6 [autofix.ci] apply automated fixes 2025-09-07 08:10:01 +00:00
Claude Bot
690ca26414 Add --stream flag for workspace script execution
Implements a --stream flag for `bun run` that streams logs immediately without
buffering or eliding when running scripts across workspace packages. This is
similar to pnpm's --stream flag and addresses user feedback about log display
in workspace environments.

Changes:
- Add --stream flag to CLI parameters for both run and auto commands
- Implement streaming output in filter_run.zig that bypasses buffering
- Add validation to prevent using --stream with --elide-lines
- Add comprehensive test coverage for the new flag
- Update documentation in filter.md and run.md

The flag provides immediate, unbuffered output which is useful for:
- Real-time monitoring of long-running processes
- CI/CD environments where immediate feedback is important
- Debugging scripts that may hang or have timing issues

Fixes the issue where workspace script output was being buffered and elided
by default, making it difficult to see logs in real-time.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 08:06:37 +00:00
6 changed files with 308 additions and 2 deletions

View File

@@ -71,6 +71,33 @@ bun --filter '*' dev
Both commands will be run in parallel, and you will see a nice terminal UI showing their respective outputs:
![Terminal Output](https://github.com/oven-sh/bun/assets/48869301/2a103e42-9921-4c33-948f-a1ad6e6bac71)
### Output options
By default, when running scripts with `--filter` in a terminal, Bun displays a formatted UI that elides long output to keep the display manageable. You can control this behavior with these flags:
#### `--elide-lines <number>`
Controls how many lines of output are shown for each package (default: 10). Set to 0 to show all output:
```bash
# Show only 5 lines of output per package
bun --filter '*' --elide-lines 5 test
# Show all output (no eliding)
bun --filter '*' --elide-lines 0 test
```
#### `--stream`
Stream logs immediately without buffering or eliding. This is useful when you want to see real-time output from all packages, similar to pnpm's `--stream` flag:
```bash
# Stream all output immediately
bun --filter '*' --stream dev
```
Note: `--stream` and `--elide-lines` cannot be used together. When `--stream` is enabled, output is written directly to stdout without package name prefixes or formatting.
### Running scripts in workspaces
Filters respect your [workspace configuration](https://bun.com/docs/install/workspaces): If you have a `package.json` file that specifies which packages are part of the workspace,

View File

@@ -164,6 +164,11 @@ bun run --filter 'ba*' <script>
will execute `<script>` in both `bar` and `baz`, but not in `foo`.
When using `--filter`, you can control the output display with:
- `--elide-lines <number>`: Limit the number of output lines shown per package (default: 10, use 0 for no limit)
- `--stream`: Stream output immediately without buffering or formatting (similar to pnpm's `--stream`)
Find more details in the docs page for [filter](https://bun.com/docs/cli/filter#running-scripts-with-filter).
## `bun run -` to pipe code from stdin

View File

@@ -419,6 +419,7 @@ pub const Command = struct {
env_behavior: api.DotEnvBehavior = .disable,
env_prefix: []const u8 = "",
elide_lines: ?usize = null,
stream_logs: bool = false,
// Compile options
compile: bool = false,
compile_target: Cli.CompileTarget = .{},

View File

@@ -123,6 +123,7 @@ pub const auto_only_params = [_]ParamType{
// clap.parseParam("--all") catch unreachable,
clap.parseParam("--silent Don't print the script command") catch unreachable,
clap.parseParam("--elide-lines <NUMBER> Number of lines of script output shown when using --filter (default: 10). Set to 0 to show all lines.") catch unreachable,
clap.parseParam("--stream Stream logs immediately without buffering or eliding. Similar to pnpm's --stream flag") catch unreachable,
clap.parseParam("-v, --version Print version and exit") catch unreachable,
clap.parseParam("--revision Print version with revision and exit") catch unreachable,
} ++ auto_or_run_params;
@@ -131,6 +132,7 @@ pub const auto_params = auto_only_params ++ runtime_params_ ++ transpiler_params
pub const run_only_params = [_]ParamType{
clap.parseParam("--silent Don't print the script command") catch unreachable,
clap.parseParam("--elide-lines <NUMBER> Number of lines of script output shown when using --filter (default: 10). Set to 0 to show all lines.") catch unreachable,
clap.parseParam("--stream Stream logs immediately without buffering or eliding. Similar to pnpm's --stream flag") catch unreachable,
} ++ auto_or_run_params;
pub const run_params = run_only_params ++ runtime_params_ ++ transpiler_params_ ++ base_params_;
@@ -399,6 +401,8 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
};
}
}
ctx.bundler_options.stream_logs = args.flag("--stream");
}
if (cmd == .TestCommand) {
@@ -1141,6 +1145,8 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
}
}
ctx.bundler_options.stream_logs = args.flag("--stream");
if (opts.define) |define| {
if (define.keys.len > 0)
bun.jsc.RuntimeTranspilerCache.is_disabled = true;

View File

@@ -146,6 +146,7 @@ const State = struct {
draw_buf: std.ArrayList(u8) = std.ArrayList(u8).init(bun.default_allocator),
last_lines_written: usize = 0,
pretty_output: bool,
stream_logs: bool,
shell_bin: [:0]const u8,
aborted: bool = false,
env: *bun.DotEnv.Loader,
@@ -160,7 +161,11 @@ const State = struct {
};
fn readChunk(this: *This, handle: *ProcessHandle, chunk: []const u8) !void {
if (this.pretty_output) {
if (this.stream_logs) {
// In stream mode, output immediately without buffering
const stdout = std.io.getStdOut();
stdout.writeAll(chunk) catch {};
} else if (this.pretty_output) {
bun.handleOom(handle.buffer.appendSlice(chunk));
this.redraw(false) catch {};
} else {
@@ -202,7 +207,10 @@ const State = struct {
}
}
}
if (this.pretty_output) {
if (this.stream_logs) {
// In stream mode, don't do any special exit handling for output
// The output has already been streamed
} else if (this.pretty_output) {
this.redraw(false) catch {};
} else {
this.draw_buf.clearRetainingCapacity();
@@ -542,10 +550,22 @@ pub fn runScriptsWithFilter(ctx: Command.Context) !noreturn {
.handles = try ctx.allocator.alloc(ProcessHandle, scripts.items.len),
.event_loop = event_loop,
.pretty_output = if (Environment.isWindows) windowsIsTerminal() and Output.enable_ansi_colors_stdout else Output.enable_ansi_colors_stdout,
.stream_logs = ctx.bundler_options.stream_logs,
.shell_bin = shell_bin,
.env = this_transpiler.env,
};
// Check if both --stream and --elide-lines are used
if (state.stream_logs and ctx.bundler_options.elide_lines != null) {
Output.prettyErrorln("<r><red>error<r>: --stream and --elide-lines cannot be used together", .{});
Global.exit(1);
}
// When --stream is set, disable pretty output
if (state.stream_logs) {
state.pretty_output = false;
}
// Check if elide-lines is used in a non-terminal environment
if (ctx.bundler_options.elide_lines != null and !state.pretty_output) {
Output.prettyErrorln("<r><red>error<r>: --elide-lines is only supported in terminal environments", .{});

View File

@@ -506,4 +506,251 @@ describe("bun", () => {
win32ExpectedError: /--elide-lines is only supported in terminal environments/,
});
});
describe("--stream flag", () => {
test("streams output immediately without buffering", async () => {
const dir = tempDirWithFiles("testworkspace-stream", {
packages: {
pkg1: {
"index.js": `console.log('pkg1-line1'); await new Promise(r => setTimeout(r, 100)); console.log('pkg1-line2'); await new Promise(r => setTimeout(r, 100)); console.log('pkg1-line3');`,
"package.json": JSON.stringify({
name: "pkg1",
scripts: {
test: `${bunExe()} run index.js`,
},
}),
},
pkg2: {
"index.js": `console.log('pkg2-line1'); await new Promise(r => setTimeout(r, 100)); console.log('pkg2-line2'); await new Promise(r => setTimeout(r, 100)); console.log('pkg2-line3');`,
"package.json": JSON.stringify({
name: "pkg2",
scripts: {
test: `${bunExe()} run index.js`,
},
}),
},
},
"package.json": JSON.stringify({
name: "ws",
workspaces: ["packages/*"],
}),
});
const proc = Bun.spawn({
cwd: dir,
cmd: [bunExe(), "run", "--filter", "*", "--stream", "test"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const td = new TextDecoder();
const reader = proc.stdout.getReader();
let output = "";
// Prove we get a chunk before the process exits (streaming)
let firstChunk: Uint8Array | null = null;
const first = await Promise.race([
proc.exited.then(() => "exit"),
(async () => {
const r = await reader.read();
if (!r.done && r.value) {
firstChunk = r.value;
output += td.decode(r.value);
return "chunk";
}
return "done";
})(),
]);
expect(first).toBe("chunk");
// Drain remaining output
while (true) {
const { value, done } = await reader.read();
if (done) break;
output += td.decode(value);
}
const exitCode = await proc.exited;
// In stream mode, output should not have package name prefixes
expect(output).toMatch(/pkg1-line1/);
expect(output).toMatch(/pkg1-line2/);
expect(output).toMatch(/pkg1-line3/);
expect(output).toMatch(/pkg2-line1/);
expect(output).toMatch(/pkg2-line2/);
expect(output).toMatch(/pkg2-line3/);
// Should not have the package name prefixes that appear in non-stream mode
expect(output).not.toMatch(/pkg1 test:/);
expect(output).not.toMatch(/pkg2 test:/);
// No elision UI in stream mode
expect(output).not.toMatch(/lines elided/);
expect(exitCode).toBe(0);
});
test("--stream and --elide-lines cannot be used together", () => {
const dir = tempDirWithFiles("testworkspace-stream-conflict", {
packages: {
pkg1: {
"package.json": JSON.stringify({
name: "pkg1",
scripts: {
test: "echo test",
},
}),
},
},
"package.json": JSON.stringify({
name: "ws",
workspaces: ["packages/*"],
}),
});
const { exitCode, stderr } = spawnSync({
cwd: dir,
cmd: [bunExe(), "run", "--filter", "*", "--stream", "--elide-lines", "5", "test"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(stderr.toString()).toMatch(/--stream and --elide-lines cannot be used together/);
expect(exitCode).not.toBe(0);
});
test("--stream works with dependency ordering", () => {
const dir = tempDirWithFiles("testworkspace-stream-deps", {
dep0: {
"index.js": `await Bun.write('out.txt', 'dep0-complete'); console.log('dep0-output');`,
"package.json": JSON.stringify({
name: "dep0",
scripts: {
script: `${bunExe()} run index.js`,
},
}),
},
dep1: {
"index.js": `const content = await Bun.file("../dep0/out.txt").text(); console.log('dep1-output: ' + content);`,
"package.json": JSON.stringify({
name: "dep1",
dependencies: {
dep0: "*",
},
scripts: {
script: `${bunExe()} run index.js`,
},
}),
},
});
const { exitCode, stdout } = spawnSync({
cwd: dir,
cmd: [bunExe(), "run", "--filter", "*", "--stream", "script"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = stdout.toString();
// Verify that dep0 runs before dep1 and outputs are streamed
expect(output).toMatch(/dep0-output/);
expect(output).toMatch(/dep1-output: dep0-complete/);
expect(exitCode).toBe(0);
});
test("--stream works with pre and post scripts", () => {
const dir = tempDirWithFiles("testworkspace-stream-lifecycle", {
pkg1: {
"package.json": JSON.stringify({
name: "pkg1",
scripts: {
prescript: "echo pre-script",
script: "echo main-script",
postscript: "echo post-script",
},
}),
},
});
const { exitCode, stdout } = spawnSync({
cwd: dir,
cmd: [bunExe(), "run", "--filter", "*", "--stream", "script"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = stdout.toString();
// All outputs should be streamed in order
expect(output).toMatch(/pre-script[\s\S]*main-script[\s\S]*post-script/);
expect(exitCode).toBe(0);
});
test("--stream handles errors correctly", () => {
const dir = tempDirWithFiles("testworkspace-stream-error", {
pkg1: {
"package.json": JSON.stringify({
name: "pkg1",
scripts: {
test: "echo before-error && exit 42",
},
}),
},
pkg2: {
"package.json": JSON.stringify({
name: "pkg2",
scripts: {
test: "echo pkg2-runs",
},
}),
},
});
const { exitCode, stdout } = spawnSync({
cwd: dir,
cmd: [bunExe(), "run", "--filter", "*", "--stream", "test"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = stdout.toString();
// Both should run even if one fails
expect(output).toMatch(/before-error/);
expect(output).toMatch(/pkg2-runs/);
// Should exit with the error code from the failed script
expect(exitCode).toBe(42);
});
test("--stream works with auto command", () => {
const dir = tempDirWithFiles("testworkspace-stream-auto", {
packages: {
pkg1: {
"package.json": JSON.stringify({
name: "pkg1",
scripts: {
test: "echo auto-stream-test",
},
}),
},
},
"package.json": JSON.stringify({
name: "ws",
workspaces: ["packages/*"],
}),
});
const { exitCode, stdout } = spawnSync({
cwd: dir,
cmd: [bunExe(), "--filter", "*", "--stream", "test"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = stdout.toString();
expect(output).toMatch(/auto-stream-test/);
expect(exitCode).toBe(0);
});
});
});