diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 96455a9a6513..1d8ee3205247 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -454,7 +454,8 @@ fn message_with_type_and_level_( // Lock/unlock a mutex incase two JS threads are console.log'ing at the same // time. We do this the slightly annoying way to avoid assigning a pointer. let use_stderr = matches!(level, MessageLevel::Warning | MessageLevel::Error) - || message_type == MessageType::Assert; + || message_type == MessageType::Assert + || message_type == MessageType::Trace; let _stream_lock = ConsoleStreamLock::acquire(use_stderr); if message_type == MessageType::Clear { @@ -477,7 +478,9 @@ fn message_with_type_and_level_( return Ok(()); } - let enable_colors = if matches!(level, MessageLevel::Warning | MessageLevel::Error) { + let enable_colors = if matches!(level, MessageLevel::Warning | MessageLevel::Error) + || message_type == MessageType::Trace + { Output::enable_ansi_colors_stderr() } else { Output::enable_ansi_colors_stdout() @@ -496,7 +499,7 @@ fn message_with_type_and_level_( // long-lived `&mut ConsoleObject` across the re-derive in the empty-`Log` // arm below. let raw_writer: &mut bun_core::io::Writer = unsafe { - if matches!(level, MessageLevel::Warning | MessageLevel::Error) { + if matches!(level, MessageLevel::Warning | MessageLevel::Error) || message_type == MessageType::Trace { (*console).error_writer() } else { (*console).writer() @@ -578,6 +581,19 @@ fn message_with_type_and_level_( } } + // console.trace prints "Trace: " (or just "Trace" with no args), + // matching Node, with the stack trace appended below. The label goes through + // FormatOptions so it lands after the console.group indent rather than + // before it. + if message_type == MessageType::Trace { + if print_length > 0 { + print_options.prefix = b"Trace: "; + } else { + let _ = formatter::write_indent_n(u32::from(default_indent), writer); + let _ = writer.write_all(b"Trace\n"); + } + } + if print_length > 0 { format2( level, @@ -1298,6 +1314,9 @@ pub struct FormatOptions { pub single_line: bool, pub default_indent: u16, pub error_display_level: ErrorDisplayLevel, + /// Label emitted immediately after the indent, before the formatted values + /// (used by `console.trace` for its "Trace: " prefix). + pub prefix: &'static [u8], } impl Default for FormatOptions { @@ -1312,6 +1331,7 @@ impl Default for FormatOptions { single_line: false, default_indent: 0, error_display_level: ErrorDisplayLevel::Full, + prefix: b"", } } } @@ -1484,6 +1504,7 @@ pub fn format2( if fmt.write_indent(writer).is_err() { return Ok(()); } + let _ = writer.write_all(options.prefix); if matches!(tag.tag, TagPayload::String) { if options.enable_colors { @@ -1548,6 +1569,7 @@ pub fn format2( if fmt.write_indent(writer).is_err() { return Ok(()); } + let _ = writer.write_all(options.prefix); let mut any = false; if options.enable_colors { diff --git a/test/js/node/console/console.test.ts b/test/js/node/console/console.test.ts index 2913817f23c8..f69f7e58b90b 100644 --- a/test/js/node/console/console.test.ts +++ b/test/js/node/console/console.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { Console } from "node:console"; +import { bunEnv, bunExe } from "harness"; import { Writable } from "node:stream"; @@ -88,3 +89,52 @@ test("console._stderr", () => { configurable: true, }); }); + +// console.trace writes to stderr with a "Trace:" prefix, matching Node. +// https://github.com/oven-sh/bun/issues/19952 +describe("console.trace", () => { + async function run(code: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test("goes to stderr, not stdout", async () => { + const { stdout, stderr, exitCode } = await run(`console.trace("hi")`); + expect(stdout).toBe(""); + expect(stderr).toStartWith("Trace: hi\n"); + expect(stderr).toContain("at "); + expect(exitCode).toBe(0); + }); + + test("no arguments prints bare 'Trace'", async () => { + const { stdout, stderr, exitCode } = await run(`console.trace()`); + expect(stdout).toBe(""); + expect(stderr).toStartWith("Trace\n"); + expect(stderr).toContain("at "); + expect(exitCode).toBe(0); + }); + + test("applies format specifiers", async () => { + const { stdout, stderr, exitCode } = await run(`console.trace("x=%d", 5)`); + expect(stdout).toBe(""); + expect(stderr).toStartWith("Trace: x=5\n"); + expect(exitCode).toBe(0); + }); + + test("label goes after the console.group indent", async () => { + const { stdout, stderr, exitCode } = await run( + `console.group("G"); console.trace("x"); console.trace(); console.groupEnd(); console.trace("top");`, + ); + // The group indent precedes the label, and the bare header is indented too. + const headers = stderr.match(/^(?: Trace: x| Trace|Trace: top)$/gm); + expect(headers).toEqual([" Trace: x", " Trace", "Trace: top"]); + expect(stdout).toBe("G\n"); + expect(exitCode).toBe(0); + }); +});