Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/runtime/cli/filter_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,24 @@ pub(crate) fn run_scripts_with_filter(

// SAFETY: Transpiler::init always sets `env` to the process-lifetime singleton.
let env_ptr: *mut bun_dotenv::Loader = this_transpiler.env;
// Child stdout/stderr are pipes, so isatty()-based color detection in the
// child reports false. When we are rendering with color, forward that
// decision via FORCE_COLOR so tools like Vite/Next/chalk keep their colors.
// Skip if the user already set FORCE_COLOR or NO_COLOR so explicit choices
// are preserved verbatim.
if Output::enable_ansi_colors_stdout() {
// SAFETY: env_ptr is the process-lifetime DotEnv loader.
let env = unsafe { &mut *env_ptr };
if env.map.get(b"FORCE_COLOR").is_none() && env.map.get(b"NO_COLOR").is_none() {
use bun_core::output::{ColorDepth, Source};
let depth: &[u8] = match Source::color_depth() {
ColorDepth::C16m => b"3",
ColorDepth::C256 => b"2",
_ => b"1",
};
let _ = env.map.put(b"FORCE_COLOR", depth);
}
}
let event_loop = MiniEventLoopMod::init_global(
// SAFETY: see above; `&'static mut` reborrow of the singleton for first-init only.
Some(unsafe { &mut *env_ptr }),
Expand Down
18 changes: 18 additions & 0 deletions src/runtime/cli/multi_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,24 @@ pub(crate) fn run(ctx: &mut Command::ContextData) -> Result<core::convert::Infal

// SAFETY: transpiler.env is a process-lifetime *mut Loader set in init.
let env_ptr: *mut DotEnvLoader = this_transpiler.env;
// Child stdout/stderr are pipes, so isatty()-based color detection in the
// child reports false. When we are rendering with color, forward that
// decision via FORCE_COLOR so downstream tools keep their colors. Skip if
// the user already set FORCE_COLOR or NO_COLOR so explicit choices are
// preserved verbatim.
if Output::enable_ansi_colors_stdout() {
// SAFETY: env_ptr is the process-lifetime DotEnv loader; no other borrow is live yet.
let env = unsafe { &mut *env_ptr };
if env.map.get(b"FORCE_COLOR").is_none() && env.map.get(b"NO_COLOR").is_none() {
use bun_core::output::{ColorDepth, Source};
let depth: &[u8] = match Source::color_depth() {
ColorDepth::C16m => b"3",
ColorDepth::C256 => b"2",
_ => b"1",
};
let _ = env.map.put(b"FORCE_COLOR", depth);
}
}
let event_loop = bun_event_loop::MiniEventLoop::init_global(
// SAFETY: env_ptr is the process-lifetime DotEnv loader; no other borrow of it is live yet.
Some(unsafe { &mut *env_ptr }),
Expand Down
123 changes: 123 additions & 0 deletions test/cli/run/filter-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,3 +662,126 @@
expect(exitCode).toBe(0);
});
});

// https://github.com/oven-sh/bun/issues/10346
// `bun --filter` pipes child stdout/stderr, so the child's isatty() is false and
// tools that gate color on it go monochrome. When the parent is attached to a
// color terminal the filter runner now forwards that decision via FORCE_COLOR.
describe("--filter forwards color to scripts", () => {
function colorFixture() {
return tempDirWithFiles("filter-color", {
packages: {
pkga: {
"probe.js": `process.stdout.write("RESULT " + JSON.stringify({
FORCE_COLOR: process.env.FORCE_COLOR ?? null,
NO_COLOR: process.env.NO_COLOR ?? null,
enableANSI: Bun.enableANSIColors,
}) + "\\n");`,
"package.json": JSON.stringify({
name: "pkga",
scripts: { probe: `${bunExe()} probe.js` },
}),
},
},
"package.json": JSON.stringify({ name: "ws", workspaces: ["packages/*"] }),
});
}

async function runOnPTY(dir: string, extraEnv: Record<string, string | undefined>) {
const decoder = new TextDecoder();
let output = "";
const done = Promise.withResolvers<void>();
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "--filter", "*", "probe"],
cwd: dir,
env: {
...bunEnv,
NO_COLOR: undefined,
FORCE_COLOR: undefined,
CI: undefined,
TERM: "xterm-256color",
COLORTERM: undefined,
...extraEnv,
},
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
terminal: {
cols: 200,
rows: 24,
data(_t, chunk: Uint8Array) {
output += decoder.decode(chunk, { stream: true });
if (output.includes("RESULT ") && output.includes("}")) done.resolve();
},
exit() {
done.resolve();
},
},
});
await done.promise;
Comment thread
robobun marked this conversation as resolved.
Resolved
await proc.exited;
output += decoder.decode();
const stripped = Bun.stripANSI(output).replace(/\r/g, "");
const m = stripped.match(/RESULT (\{[^}]*\})/);
if (!m) throw new Error("missing RESULT in terminal output: " + JSON.stringify(output));
return JSON.parse(m[1]);
}

test("sets FORCE_COLOR when parent stdout is a color TTY", async () => {
const dir = colorFixture();
const result = await runOnPTY(dir, {});
expect(result).toEqual({
FORCE_COLOR: "2",
NO_COLOR: null,
enableANSI: true,

Check warning on line 733 in test/cli/run/filter-workspace.test.ts

View check run for this annotation

Claude / Claude Code Review

Flaky test: color-depth assertion depends on ambient TMUX/TERM_PROGRAM

This assertion is not hermetic: `bunEnv` spreads `...process.env`, and `runOnPTY` clears `NO_COLOR`/`FORCE_COLOR`/`CI`/`COLORTERM` but not `TMUX` or `TERM_PROGRAM` — both of which `compute_color_depth()` checks *before* the `xterm-256` → `C256` branch and maps to `C16m`. A developer running the suite inside tmux, iTerm2, ghostty, WezTerm, or HyperTerm will see `FORCE_COLOR: "3"` here and the test fails. Add `TMUX: undefined, TERM_PROGRAM: undefined` to the `runOnPTY` env override.
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
});
});

test("sets FORCE_COLOR=3 for a truecolor terminal", async () => {
const dir = colorFixture();
const result = await runOnPTY(dir, { COLORTERM: "truecolor" });
expect(result).toEqual({
FORCE_COLOR: "3",
NO_COLOR: null,
enableANSI: true,
});
});

test("respects an explicit NO_COLOR", async () => {
const dir = colorFixture();
const result = await runOnPTY(dir, { NO_COLOR: "1" });
expect(result).toEqual({
FORCE_COLOR: null,
NO_COLOR: "1",
enableANSI: false,
});
});

test("respects an explicit FORCE_COLOR value", async () => {
const dir = colorFixture();
const result = await runOnPTY(dir, { FORCE_COLOR: "0" });
expect(result.FORCE_COLOR).toBe("0");
});

test("does not set FORCE_COLOR when parent stdout is not a TTY", async () => {
const dir = colorFixture();
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "--filter", "*", "probe"],
cwd: dir,
env: {
...bunEnv,
NO_COLOR: undefined,
FORCE_COLOR: undefined,
CI: undefined,
TERM: "xterm-256color",
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout] = await Promise.all([proc.stdout.text(), proc.exited]);

Check warning on line 778 in test/cli/run/filter-workspace.test.ts

View check run for this annotation

Claude / Claude Code Review

Subprocess test does not drain piped stderr or assert exit code

This test spawns with `stderr: "pipe"` but only awaits `Promise.all([proc.stdout.text(), proc.exited])` — stderr is never drained and the exit code is never asserted. Per REVIEW.md's subprocess-test convention, this should be `const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited])` followed by an `expect(exitCode).toBe(0)` after the output assertion (or drop `stderr: "pipe"` entirely if you don't need it).
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
const m = stdout.match(/RESULT (\{[^}]*\})/);
if (!m) throw new Error("missing RESULT in: " + JSON.stringify(stdout));
const result = JSON.parse(m[1]);
expect({ FORCE_COLOR: result.FORCE_COLOR, NO_COLOR: result.NO_COLOR }).toEqual({
FORCE_COLOR: null,
NO_COLOR: null,
});
});
});
Loading