From 8bd5b51434b8d60c4f5e70096c1f2d4567e654c6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:35:25 +0000 Subject: [PATCH 1/5] console: apply group indent to count/timeLog/timeEnd console.count, console.timeLog, and console.timeEnd wrote their output at column 0 regardless of the current console.group nesting level. The log/info/warn/error/dir/assert/trace paths already prefix each line with ConsoleObject.default_indent * 2 spaces via Formatter.write_indent; the count and time* host-call paths write directly and skipped it. Expose formatter::write_indent_n as pub(super) and call it with the current default_indent before each of these writes, matching Node. --- src/jsc/ConsoleObject.rs | 14 ++++++-- test/js/web/console/console-log.test.ts | 43 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 895848fcd305..cb2b2bd8a1ca 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -2800,7 +2800,7 @@ pub mod formatter { /// conflict with the `&self` borrow `Formatter::write_indent` takes. /// `self.indent` is a disjoint field read, so passing it by value here /// keeps the borrow checker happy. - fn write_indent_n(indent: u32, writer: &mut dyn bun_io::Write) -> bun_io::Result<()> { + pub(super) fn write_indent_n(indent: u32, writer: &mut dyn bun_io::Write) -> bun_io::Result<()> { let mut total_remain: u32 = indent; while total_remain > 0 { let written: u8 = total_remain.min(32) as u8; @@ -5785,7 +5785,9 @@ pub(crate) extern "C" fn Bun__ConsoleObject__count( } + 1; *counter.value_ptr = current; + let default_indent = this.default_indent; let writer = this.writer(); + let _ = formatter::write_indent_n(u32::from(default_indent), writer); if Output::enable_ansi_colors_stdout() { let _ = writeln!( writer, @@ -5856,7 +5858,7 @@ pub(crate) extern "C" fn Bun__ConsoleObject__time( #[crate::host_call] pub(crate) extern "C" fn Bun__ConsoleObject__timeEnd( _console: *mut ConsoleObject, - _global: &JSGlobalObject, + global_this: &JSGlobalObject, chars: *const u8, len: usize, ) { @@ -5873,6 +5875,10 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeEnd( return; }; let Some(value) = prev else { return }; + // SAFETY: top-level JS-thread host call ⇒ exclusive access to the + // set-once `VirtualMachine.console` box. + let default_indent = unsafe { vm_console_mut(global_this) }.default_indent; + let _ = formatter::write_indent_n(u32::from(default_indent), Output::error_writer()); // get the duration in microseconds, then display it in milliseconds Output::print_elapsed( (value.read() / bun_core::time::NS_PER_US) as f64 / bun_core::time::US_PER_MS as f64, @@ -5905,6 +5911,10 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeLog( let Some(Some(value)) = PENDING_TIME_LOGS.with_borrow(|m| m.get(&id).copied()) else { return; }; + // SAFETY: top-level JS-thread host call ⇒ exclusive access to the + // set-once `VirtualMachine.console` box. + let default_indent = unsafe { vm_console_mut(global) }.default_indent; + let _ = formatter::write_indent_n(u32::from(default_indent), Output::error_writer()); // get the duration in microseconds, then display it in milliseconds Output::print_elapsed( (value.read() / bun_core::time::NS_PER_US) as f64 / bun_core::time::US_PER_MS as f64, diff --git a/test/js/web/console/console-log.test.ts b/test/js/web/console/console-log.test.ts index 4356ae993262..8b2162e1948d 100644 --- a/test/js/web/console/console-log.test.ts +++ b/test/js/web/console/console-log.test.ts @@ -143,6 +143,49 @@ NamedError: console.error a named error `); }); +it.concurrent("console.group indents console.count and console.time/timeLog/timeEnd", async () => { + const src = ` + console.group("G1"); + console.count("cnt"); + console.count("cnt"); + console.group("G2"); + console.count("cnt"); + console.time("t"); + console.timeLog("t"); + console.timeEnd("t"); + console.groupEnd(); + console.count("cnt"); + console.groupEnd(); + console.count("cnt"); + console.time("u"); + console.timeEnd("u"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.replaceAll("\r\n", "\n")).toBe( + "G1\n" + // + " cnt: 1\n" + + " cnt: 2\n" + + " G2\n" + + " cnt: 3\n" + + " cnt: 4\n" + + "cnt: 5\n", + ); + + const stderrLines = stderr.replaceAll("\r\n", "\n").replace(/\n$/, "").split("\n"); + expect(stderrLines.length).toBe(3); + expect(stderrLines[0]).toMatch(/^ \[[\d.]+[mnµ]?s\] t$/); + expect(stderrLines[1]).toMatch(/^ \[[\d.]+[mnµ]?s\] t$/); + expect(stderrLines[2]).toMatch(/^\[[\d.]+[mnµ]?s\] u$/); + expect(exitCode).toBe(0); +}); + it("console.log with SharedArrayBuffer", () => { // console.log(x) === Bun.inspect(x) + "\n" written to stdout. expect(Bun.inspect(new ArrayBuffer(0))).toBe("ArrayBuffer(0) []"); From f1a98475deaeb05a5b2bc2c5eae966f84d5fb549 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:39 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- src/jsc/ConsoleObject.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index cb2b2bd8a1ca..8fbd23720093 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -2800,7 +2800,10 @@ pub mod formatter { /// conflict with the `&self` borrow `Formatter::write_indent` takes. /// `self.indent` is a disjoint field read, so passing it by value here /// keeps the borrow checker happy. - pub(super) fn write_indent_n(indent: u32, writer: &mut dyn bun_io::Write) -> bun_io::Result<()> { + pub(super) fn write_indent_n( + indent: u32, + writer: &mut dyn bun_io::Write, + ) -> bun_io::Result<()> { let mut total_remain: u32 = indent; while total_remain > 0 { let written: u8 = total_remain.min(32) as u8; From c68b37c0ab3ff6ce6318d62a8ee81c923fdecf5d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:57:51 +0000 Subject: [PATCH 3/5] console: also indent bare assert(false)/log() inside a group Same bug class as count/timeLog/timeEnd: the Assert-with-no-message early return and the print_length==0 fallthroughs wrote directly to the writer without the default_indent prefix. --- src/jsc/ConsoleObject.rs | 7 ++++++- test/js/web/console/console-log.test.ts | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 8fbd23720093..5670b3d7c357 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -425,7 +425,10 @@ fn message_with_type_and_level_( // SAFETY: no other borrow of the console is live in this // early-return arm (the deferred `_indent_guard` only holds the raw // pointer, not a reference). - let ew = unsafe { vm_console_mut(global) }.error_writer(); + let this = unsafe { vm_console_mut(global) }; + let default_indent = this.default_indent; + let ew = this.error_writer(); + let _ = formatter::write_indent_n(u32::from(default_indent), ew); let _ = ew.write_all(text.as_bytes()); let _ = ew.flush(); return Ok(()); @@ -545,9 +548,11 @@ fn message_with_type_and_level_( // the only later uses are in the mutually-exclusive `Trace` block, and // `message_type == Log` here. let w = unsafe { (*console).writer() }; + let _ = formatter::write_indent_n(u32::from(default_indent), w); let _ = w.write_all(b"\n"); let _ = w.flush(); } else if message_type != MessageType::Trace { + let _ = formatter::write_indent_n(u32::from(default_indent), writer); let _ = writer.write_all(b"undefined\n"); } diff --git a/test/js/web/console/console-log.test.ts b/test/js/web/console/console-log.test.ts index 8b2162e1948d..71243ecc204e 100644 --- a/test/js/web/console/console-log.test.ts +++ b/test/js/web/console/console-log.test.ts @@ -143,13 +143,15 @@ NamedError: console.error a named error `); }); -it.concurrent("console.group indents console.count and console.time/timeLog/timeEnd", async () => { +it.concurrent("console.group indents count/time/assert and the no-args log paths", async () => { const src = ` console.group("G1"); console.count("cnt"); console.count("cnt"); + console.log(); console.group("G2"); console.count("cnt"); + console.assert(false); console.time("t"); console.timeLog("t"); console.timeEnd("t"); @@ -157,6 +159,8 @@ it.concurrent("console.group indents console.count and console.time/timeLog/time console.count("cnt"); console.groupEnd(); console.count("cnt"); + console.log(); + console.assert(false); console.time("u"); console.timeEnd("u"); `; @@ -172,17 +176,21 @@ it.concurrent("console.group indents console.count and console.time/timeLog/time "G1\n" + // " cnt: 1\n" + " cnt: 2\n" + + " \n" + " G2\n" + " cnt: 3\n" + " cnt: 4\n" + - "cnt: 5\n", + "cnt: 5\n" + + "\n", ); const stderrLines = stderr.replaceAll("\r\n", "\n").replace(/\n$/, "").split("\n"); - expect(stderrLines.length).toBe(3); - expect(stderrLines[0]).toMatch(/^ \[[\d.]+[mnµ]?s\] t$/); + expect(stderrLines.length).toBe(5); + expect(stderrLines[0]).toBe(" Assertion failed"); expect(stderrLines[1]).toMatch(/^ \[[\d.]+[mnµ]?s\] t$/); - expect(stderrLines[2]).toMatch(/^\[[\d.]+[mnµ]?s\] u$/); + expect(stderrLines[2]).toMatch(/^ \[[\d.]+[mnµ]?s\] t$/); + expect(stderrLines[3]).toBe("Assertion failed"); + expect(stderrLines[4]).toMatch(/^\[[\d.]+[mnµ]?s\] u$/); expect(exitCode).toBe(0); }); From b1f79f01a1e45815371c7dd3b4131ad4420f2b9b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:52:02 +0000 Subject: [PATCH 4/5] ci: retrigger From 0ecfecacc43b0aa9c8b0a2bc961f3467aaa3dd3c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:58:35 +0000 Subject: [PATCH 5/5] console: route no-args warn/error blank line to stderr The print_length==0 Log arm re-derived (*console).writer() (always stdout) instead of using the level-selected writer, so bare console.warn()/console.error() wrote their newline to stdout. Use the already-selected writer so the indented blank line lands on stderr, matching Node. Test now covers info/warn/error with no args. --- src/jsc/ConsoleObject.rs | 15 ++++----------- test/js/web/console/console-log.test.ts | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 5670b3d7c357..6c95b66594d4 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -448,10 +448,7 @@ fn message_with_type_and_level_( let default_indent = unsafe { vm_console_mut(global) }.default_indent; // SAFETY: see [`vm_console`] — `console` points at the live boxed - // `ConsoleObject` for this VM; JS-thread-only. Kept as a raw deref (not - // `vm_console_mut`) so the resulting `writer` borrow does not pin a - // long-lived `&mut ConsoleObject` across the re-derive in the empty-`Log` - // arm below. + // `ConsoleObject` for this VM; JS-thread-only. let raw_writer: &mut bun_core::io::Writer = unsafe { if matches!(level, MessageLevel::Warning | MessageLevel::Error) { (*console).error_writer() @@ -544,13 +541,9 @@ fn message_with_type_and_level_( print_options, )?; } else if message_type == MessageType::Log { - // SAFETY: see [`vm_console`]. `writer` (above) is dead in this arm — - // the only later uses are in the mutually-exclusive `Trace` block, and - // `message_type == Log` here. - let w = unsafe { (*console).writer() }; - let _ = formatter::write_indent_n(u32::from(default_indent), w); - let _ = w.write_all(b"\n"); - let _ = w.flush(); + let _ = formatter::write_indent_n(u32::from(default_indent), writer); + let _ = writer.write_all(b"\n"); + let _ = writer.flush(); } else if message_type != MessageType::Trace { let _ = formatter::write_indent_n(u32::from(default_indent), writer); let _ = writer.write_all(b"undefined\n"); diff --git a/test/js/web/console/console-log.test.ts b/test/js/web/console/console-log.test.ts index 71243ecc204e..b4d7004adc77 100644 --- a/test/js/web/console/console-log.test.ts +++ b/test/js/web/console/console-log.test.ts @@ -149,6 +149,9 @@ it.concurrent("console.group indents count/time/assert and the no-args log paths console.count("cnt"); console.count("cnt"); console.log(); + console.info(); + console.warn(); + console.error(); console.group("G2"); console.count("cnt"); console.assert(false); @@ -177,6 +180,7 @@ it.concurrent("console.group indents count/time/assert and the no-args log paths " cnt: 1\n" + " cnt: 2\n" + " \n" + + " \n" + " G2\n" + " cnt: 3\n" + " cnt: 4\n" + @@ -185,12 +189,14 @@ it.concurrent("console.group indents count/time/assert and the no-args log paths ); const stderrLines = stderr.replaceAll("\r\n", "\n").replace(/\n$/, "").split("\n"); - expect(stderrLines.length).toBe(5); - expect(stderrLines[0]).toBe(" Assertion failed"); - expect(stderrLines[1]).toMatch(/^ \[[\d.]+[mnµ]?s\] t$/); - expect(stderrLines[2]).toMatch(/^ \[[\d.]+[mnµ]?s\] t$/); - expect(stderrLines[3]).toBe("Assertion failed"); - expect(stderrLines[4]).toMatch(/^\[[\d.]+[mnµ]?s\] u$/); + expect(stderrLines.length).toBe(7); + expect(stderrLines[0]).toBe(" "); + expect(stderrLines[1]).toBe(" "); + expect(stderrLines[2]).toBe(" Assertion failed"); + expect(stderrLines[3]).toMatch(/^ \[[\d.]+[mnµ]?s\] t$/); + expect(stderrLines[4]).toMatch(/^ \[[\d.]+[mnµ]?s\] t$/); + expect(stderrLines[5]).toBe("Assertion failed"); + expect(stderrLines[6]).toMatch(/^\[[\d.]+[mnµ]?s\] u$/); expect(exitCode).toBe(0); });