Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
4f5db6d225 fix: disable ANSI colors for files and pipes by default
This commit fixes the ANSI color detection logic in `Output.enable_ansi_colors_stdout` and `Output.enable_ansi_colors_stderr` to ensure colors are disabled when output is redirected to a file or unix pipe, unless `FORCE_COLOR` is explicitly set.

The previous implementation had a subtle bug: when `isColorTerminal()` returned true AND either stdout OR stderr was a TTY, it would set `enable_color = true`. This caused both stdout and stderr to have colors enabled, even if one of them was redirected to a pipe or file.

The new implementation:
1. `FORCE_COLOR` environment variable - always enables colors
2. `NO_COLOR` environment variable - always disables colors
3. Color terminal detection - enables colors only for the specific stream that is a TTY
4. Default - disables colors for files and pipes (non-TTY)

Added comprehensive tests to verify the behavior across different scenarios including syntax errors, runtime errors, test output, and install output.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:11:52 +00:00
2 changed files with 273 additions and 8 deletions

View File

@@ -395,17 +395,27 @@ pub const Source = struct {
stderr_descriptor_type = OutputStreamDescriptor.terminal;
}
var enable_color: ?bool = null;
// Determine if colors should be enabled
// Priority:
// 1. FORCE_COLOR environment variable - always enables colors
// 2. NO_COLOR environment variable - always disables colors
// 3. Color terminal detection AND TTY - enables colors only for TTYs
// 4. Default - disable colors for files and pipes (non-TTY)
if (isForceColor()) {
enable_color = true;
enable_ansi_colors_stdout = true;
enable_ansi_colors_stderr = true;
} else if (isNoColor()) {
enable_color = false;
} else if (isColorTerminal() and (is_stdout_tty or is_stderr_tty)) {
enable_color = true;
enable_ansi_colors_stdout = false;
enable_ansi_colors_stderr = false;
} else if (isColorTerminal()) {
// Only enable colors if the specific stream is a TTY
enable_ansi_colors_stdout = is_stdout_tty;
enable_ansi_colors_stderr = is_stderr_tty;
} else {
// Terminal doesn't support colors or not a TTY
enable_ansi_colors_stdout = false;
enable_ansi_colors_stderr = false;
}
enable_ansi_colors_stdout = enable_color orelse is_stdout_tty;
enable_ansi_colors_stderr = enable_color orelse is_stderr_tty;
}
stdout_stream = stdout;

View File

@@ -0,0 +1,255 @@
import { expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import { tmpdir } from "os";
import { join } from "path";
test("Bun's syntax errors should not have ANSI codes when stderr is piped", async () => {
const dir = tempDirWithFiles("ansi-colors-syntax-error", {
"test.ts": `const x = ;`, // Syntax error
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv, // No FORCE_COLOR or NO_COLOR set
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).not.toBe(0);
// Syntax error messages when piped should NOT contain ANSI escape codes
expect(stderr).not.toContain("\x1b[");
expect(stderr.length).toBeGreaterThan(0);
});
test("Bun's syntax errors should have ANSI codes when FORCE_COLOR is set", async () => {
const dir = tempDirWithFiles("ansi-colors-syntax-error-force", {
"test.ts": `const x = ;`, // Syntax error
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: { ...bunEnv, FORCE_COLOR: "1" },
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).not.toBe(0);
// Syntax error messages with FORCE_COLOR should contain ANSI escape codes
expect(stderr).toContain("\x1b[");
expect(stderr.length).toBeGreaterThan(0);
});
test("Bun test output should not have ANSI codes when stdout is piped", async () => {
const dir = tempDirWithFiles("ansi-colors-test", {
"test.test.ts": `
import { test, expect } from "bun:test";
test("sample test", () => {
expect(1).toBe(1);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "test.test.ts"],
env: bunEnv, // No FORCE_COLOR or NO_COLOR set
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
const output = stdout + stderr;
expect(exitCode).toBe(0);
// Test output when piped should NOT contain ANSI escape codes
expect(output).not.toContain("\x1b[");
});
test("Bun test output should have ANSI codes when FORCE_COLOR is set", async () => {
const dir = tempDirWithFiles("ansi-colors-test-force", {
"test.test.ts": `
import { test, expect } from "bun:test";
test("sample test", () => {
expect(1).toBe(1);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "test.test.ts"],
env: { ...bunEnv, FORCE_COLOR: "1" },
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
const output = stdout + stderr;
expect(exitCode).toBe(0);
// Test output with FORCE_COLOR should contain ANSI escape codes
expect(output).toContain("\x1b[");
});
test("Bun install output should not have ANSI codes when stdout is piped", async () => {
const dir = tempDirWithFiles("ansi-colors-install", {
"package.json": JSON.stringify({
name: "test",
dependencies: {
"is-number": "^7.0.0",
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv, // No FORCE_COLOR or NO_COLOR set
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
const output = stdout + stderr;
expect(exitCode).toBe(0);
// Install output when piped should NOT contain ANSI escape codes
expect(output).not.toContain("\x1b[");
});
test("Bun install output should have ANSI codes when FORCE_COLOR is set", async () => {
const dir = tempDirWithFiles("ansi-colors-install-force", {
"package.json": JSON.stringify({
name: "test",
dependencies: {
"is-number": "^7.0.0",
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: { ...bunEnv, FORCE_COLOR: "1" },
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
const output = stdout + stderr;
expect(exitCode).toBe(0);
// Install output with FORCE_COLOR should contain ANSI escape codes
expect(output).toContain("\x1b[");
});
test("ANSI colors should be disabled when stdout is redirected to a file", async () => {
const dir = tempDirWithFiles("ansi-colors-file", {
"test.ts": `console.log("Hello, world!");`,
});
const tempDir = mkdtempSync(join(tmpdir(), "bun-test-"));
const outputFile = join(tempDir, "output.txt");
try {
// Run Bun and redirect stdout to a file
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv, // No FORCE_COLOR or NO_COLOR set
cwd: dir,
stdout: Bun.file(outputFile),
stderr: "pipe",
});
const exitCode = await proc.exited;
const output = await Bun.file(outputFile).text();
expect(exitCode).toBe(0);
// Output should NOT contain ANSI escape codes
expect(output).not.toContain("\x1b[");
expect(output).toContain("Hello, world!");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("Runtime errors should not have ANSI codes when stderr is piped", async () => {
const dir = tempDirWithFiles("ansi-colors-runtime-error", {
"test.ts": `throw new Error("Test error");`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv, // No FORCE_COLOR or NO_COLOR set
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).not.toBe(0);
// Error messages when piped should NOT contain ANSI escape codes
expect(stderr).not.toContain("\x1b[");
expect(stderr).toContain("Test error");
});
test("Runtime errors should have ANSI codes when FORCE_COLOR is set", async () => {
const dir = tempDirWithFiles("ansi-colors-runtime-error-force", {
"test.ts": `throw new Error("Test error with colors");`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: { ...bunEnv, FORCE_COLOR: "1" },
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).not.toBe(0);
// Error messages with FORCE_COLOR should contain ANSI escape codes
expect(stderr).toContain("\x1b[");
expect(stderr).toContain("Test error with colors");
});