Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
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
7 changes: 4 additions & 3 deletions docs/guides/test/coverage-threshold.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ bun test --coverage
```

```txt
test.test.ts:
✓ math > add [0.71ms]
✓ math > multiply [0.03ms]
✓ test.test.ts:
✓ math
✓ add [0.71ms]
✓ multiply [0.03ms]
✓ random [0.13ms]
-------------|---------|---------|-------------------
File | % Funcs | % Lines | Uncovered Line #s
Expand Down
7 changes: 4 additions & 3 deletions docs/guides/test/coverage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ bun test --coverage

```txt

test.test.ts:
✓ math > add [0.71ms]
✓ math > multiply [0.03ms]
✓ test.test.ts:
✓ math
✓ add [0.71ms]
✓ multiply [0.03ms]
✓ random [0.13ms]
-------------|---------|---------|-------------------
File | % Funcs | % Lines | Uncovered Line #s
Expand Down
22 changes: 22 additions & 0 deletions docs/test/reporters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ test/package-json-lint.test.ts:
Ran 4 tests across 1 files. [0.66ms]
```

Tests inside a `describe` block are nested beneath their scope, which is printed
once. File and `describe` lines carry the worst status found beneath them, so a
failure is visible without reading the whole block:

```sh terminal icon="terminal"
✗ test/math.test.ts:
✗ math
✗ arithmetic
✓ add [0.12ms]
✗ subtract [0.04ms]
✓ is a module [0.03ms]
✓ top-level test [0.02ms]

3 pass
1 fail
Ran 4 tests across 1 files. [0.31ms]
```

Each file's results are buffered and printed when the file finishes, so a
`describe` block is never split apart — even with `test.concurrent` or
`--parallel`.

### Dots Reporter

The dots reporter shows `.` for passing tests and prints full error details for failures, useful for large test suites.
Expand Down
71 changes: 71 additions & 0 deletions src/bun_core/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1752,6 +1752,77 @@ pub mod io {
}
}

/// `io::Writer` sink that appends to an owned `Vec<u8>` instead of an fd.
///
/// Lets callers that must hand a `&mut io::Writer` to an existing formatter
/// (`VirtualMachine::print_errorlike_object`, which takes the vtable head
/// rather than `&mut dyn Write`) capture the render into memory.
#[repr(C)]
pub struct VecWriter {
/// Must stay first: `interface()` casts `*mut Self` to `*mut Writer`.
head: Writer,
buf: Vec<u8>,
}

unsafe fn vec_writer_write_all(w: *mut Writer, bytes: &[u8]) -> crate::CrateResult<()> {
// SAFETY: `w` was produced by `interface()` casting a `*mut VecWriter`,
// and `head` is the first `repr(C)` field, so the cast round-trips.
let this = unsafe { &mut *w.cast::<VecWriter>() };
this.buf.extend_from_slice(bytes);
Ok(())
}

unsafe fn vec_writer_flush(_: *mut Writer) -> crate::CrateResult<()> {
Ok(())
}

impl Default for VecWriter {
fn default() -> Self {
Self::new()
}
}

impl VecWriter {
pub const fn new() -> Self {
Self {
head: Writer {
write_all: vec_writer_write_all,
flush: vec_writer_flush,
},
buf: Vec::new(),
}
}

/// Vtable view. Same address as `self`; no two may be live at once.
#[inline]
pub fn interface(&mut self) -> &mut Writer {
// SAFETY: `head` is the first `repr(C)` field, so `*mut Self` and
// `*mut Writer` share an address, and the vtable fns cast back.
unsafe { &mut *core::ptr::from_mut::<Self>(self).cast::<Writer>() }
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[inline]
pub fn bytes(&self) -> &[u8] {
&self.buf
}

#[inline]
pub fn is_empty(&self) -> bool {
self.buf.is_empty()
}

/// Hand off the captured bytes, leaving the writer empty and reusable.
#[inline]
pub fn take(&mut self) -> Vec<u8> {
core::mem::take(&mut self.buf)
}

#[inline]
pub fn clear(&mut self) {
self.buf.clear();
}
}

// ════════════════════════════════════════════════════════════════════════
// trait Write — canonical byte-level write sink.
// Lives in `bun_core` (not `bun_io`) so leaf crates
Expand Down
5 changes: 4 additions & 1 deletion src/jsc/ConsoleObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,10 @@ fn message_with_type_and_level_(
// high-tier hook checks `Jest.runner` and calls `onBeforePrint()`; no-op
// when `bun test` isn't running or hooks aren't installed.
if let Some(hooks) = crate::virtual_machine::runtime_hooks() {
(hooks.console_on_before_print)();
(hooks.console_on_before_print)(matches!(
level,
MessageLevel::Warning | MessageLevel::Error
));
}

let mut print_length = len;
Expand Down
13 changes: 10 additions & 3 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,11 @@ pub struct VirtualMachine {
/// `run_error_handler`.
pub on_print_error_zig_exception: Option<fn(*mut c_void, &ZigException)>,
pub on_print_error_zig_exception_ctx: *mut c_void,
/// When set, `print_exception` renders into this writer instead of the
/// buffered stderr stream. Installed by `bun test` around
/// `run_error_handler` so a failure's diagnostics can be captured and
/// re-emitted later, attributed to the test that produced them.
pub error_writer_override: Option<NonNull<bun_core::io::Writer>>,
pub is_handling_uncaught_exception: bool,
pub exit_on_uncaught_exception: bool,

Expand Down Expand Up @@ -1730,9 +1735,11 @@ pub struct RuntimeHooks {
pub ipc_child_singleton_deinit: fn(),
/// `onBeforePrint()` for the `bun:test` runner, which lives in `bun_runtime`;
/// `console.log` calls this so the test reporter can flush its line state
/// before user output interleaves with it. No-op when `bun test` isn't
/// running.
pub console_on_before_print: fn(),
/// before user output interleaves with it, and label the output with the
/// test that produced it. `is_stderr` distinguishes `console.warn`/`error`
/// from the rest so the label lands on the same stream as the content.
/// No-op when `bun test` isn't running.
pub console_on_before_print: fn(is_stderr: bool),
/// `ConsoleObject.Formatter` runtime-type dispatch
/// over `Response`/`Request`/`Blob`/`S3Client`/`Archive`/
/// `BuildArtifact`/`FetchHeaders`/`Timer`/`Immediate`/`BuildMessage`/
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/test/ParallelRunner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@

pub use super::parallel::runner::run_as_coordinator;
pub use super::parallel::runner::run_as_worker;
pub use super::parallel::runner::worker_emit_failure_diagnostic;
pub use super::parallel::runner::worker_emit_test_done;
92 changes: 82 additions & 10 deletions src/runtime/cli/test/parallel/Coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use bun_sys::FdExt as _;

use super::frame::{self, Frame};
use super::worker::{PipeRole, Worker, WorkerPipe};
use crate::test_command::CommandLineReporter;
use crate::test_command::{self, CommandLineReporter, FileBlock};

// `Status` lives in `crate::api::bun::process`
// (not the lower-tier `bun_spawn` crate). Worker.exit_status is this type.
Expand Down Expand Up @@ -50,6 +50,11 @@ pub struct Coordinator<'a> {
/// from concurrent workers interleave; whenever the source file changes the
/// header is re-emitted so every line has visible context. None at start.
pub last_header_idx: Option<u32>,
/// Buffered blocks for the files currently in flight, keyed by file index.
/// Workers interleave, so a file's rows are held until its `FileDone` and
/// then rendered as one block — groups never split, and the file/describe
/// lines can carry an aggregate glyph.
pub blocks: Vec<(u32, FileBlock)>,
pub frame: Frame,
pub parallel_limit: u32,
pub scale_up_after_ms: i64,
Expand Down Expand Up @@ -267,6 +272,7 @@ impl<'a> Coordinator<'a> {
return;
}
self.bailed = true;
self.flush_all_blocks();
self.break_dots();
bun_core::pretty_error!(
"\nBailed out after {} failure{}<r>\n",
Expand Down Expand Up @@ -299,6 +305,49 @@ impl<'a> Coordinator<'a> {
)
}

/// Buffered block for `file_idx`, created on first use.
fn block_for(&mut self, file_idx: u32) -> &mut FileBlock {
if let Some(i) = self.blocks.iter().position(|(idx, _)| *idx == file_idx) {
return &mut self.blocks[i].1;
}
self.blocks.push((file_idx, FileBlock::default()));
&mut self.blocks.last_mut().unwrap().1
}

/// Render `file_idx`'s buffered block, led by its `path:` header carrying the
/// file's aggregate glyph.
fn flush_block(&mut self, file_idx: u32) {
let Some(i) = self.blocks.iter().position(|(idx, _)| *idx == file_idx) else {
return;
};
let (_, block) = self.blocks.remove(i);
if block.is_empty() {
return;
}
self.break_dots();
self.last_header_idx = Some(file_idx);
let colors = Output::enable_ansi_colors_stderr();
let glyph = test_command::fmt_file_glyph(block.file_status(), colors);
let _ = write!(
Output::error_writer(),
"\n{} {}:\n",
bstr::BStr::new(&glyph),
bstr::BStr::new(self.rel_path(file_idx))
);
block.render(Output::error_writer());
block.log_peak();
Output::flush();
}

/// Bail/crash paths exit without a `FileDone`; emit whatever each in-flight
/// file has produced so far rather than dropping it.
fn flush_all_blocks(&mut self) {
while let Some((idx, _)) = self.blocks.first() {
let idx = *idx;
self.flush_block(idx);
}
}

fn ensure_header(&mut self, file_idx: u32) {
if self.dots {
return;
Expand Down Expand Up @@ -326,9 +375,9 @@ impl<'a> Coordinator<'a> {
return;
}
self.break_dots();
if let Some(idx) = w.inflight {
self.ensure_header(idx);
}
// No file header here: `console.*` output carries its own
// `stdout | file > describe > test` label, written worker-side and
// relayed verbatim. Raw `process.stdout.write` stays unlabeled.
let _ = Output::error_writer().write_all(&w.captured);
if !strings::ends_with_char(&w.captured, b'\n') {
let _ = Output::error_writer().write_all(b"\n");
Expand All @@ -344,6 +393,8 @@ impl<'a> Coordinator<'a> {
}
frame::Kind::TestDone => {
let idx = rd.u32_();
let scope_path = rd.str();
let status = rd.u32_() as u8;
let formatted = rd.str();
if w.inflight != Some(idx) {
return;
Expand All @@ -352,9 +403,14 @@ impl<'a> Coordinator<'a> {
if formatted.is_empty() {
return; // e.g. pass under --only-failures
}
// dots-mode failures print a full line (writeTestStatusLine);
// dots themselves are unterminated.
let is_dot = self.dots && !strings::ends_with_char(formatted, b'\n');
if !self.dots {
let status = test_command::basic_result_from_u8(status);
let line = formatted.to_vec();
self.block_for(idx).push(scope_path, status, line);
return;
}
// dots-mode failures print a full line; dots are unterminated.
let is_dot = !strings::ends_with_char(formatted, b'\n');
if !is_dot {
self.break_dots();
self.ensure_header(idx);
Expand All @@ -381,6 +437,7 @@ impl<'a> Coordinator<'a> {
] = nums;

self.flush_captured(w);
self.flush_block(idx);

// A worker can write file_done and crash before the coordinator
// reads the frame; onWorkerExit() will already have called
Expand Down Expand Up @@ -423,16 +480,28 @@ impl<'a> Coordinator<'a> {
frame::Kind::RepeatBufs => {
// Unrolled because an array of disjoint &mut fields needs
// explicit splitting.
self.reporter
.failures_to_repeat_buf
.extend_from_slice(rd.str());
self.reporter
.skips_to_repeat_buf
.extend_from_slice(rd.str());
self.reporter
.todos_to_repeat_buf
.extend_from_slice(rd.str());
}
frame::Kind::FailureDiagnostic => {
let idx = rd.u32_();
let entry = rd.str();
if w.inflight != Some(idx) {
return;
}
self.flush_captured(w);
if !self.reporter.failure_report.push(entry) {
// Over the cap — print now rather than drop it.
self.break_dots();
let _enable_buffering = Output::enable_buffering_scope();
let _ = Output::error_writer_buffered().write_all(entry);
Output::flush();
}
}
frame::Kind::JunitFile | frame::Kind::CoverageFile => {
let path = rd.str();
if path.is_empty() {
Expand Down Expand Up @@ -533,6 +602,9 @@ impl<'a> Coordinator<'a> {
}

fn account_crash(&mut self, file_idx: u32, status: &SpawnStatus) {
// Whatever the worker managed to report before dying still deserves to
// be shown, grouped, above the crash line.
self.flush_block(file_idx);
self.break_dots();
let mut buf = [0u8; 32];
bun_core::pretty_error!(
Expand Down
12 changes: 10 additions & 2 deletions src/runtime/cli/test/parallel/Frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ pub enum Kind {
Ready,
/// u32 file_idx
FileStart,
/// u32 file_idx, str formatted_line (ANSI included; printed verbatim)
/// u32 file_idx, str scope_path (`\x1f`-separated describe names, outermost
/// first), u32 basic_status, str formatted_line (ANSI included; printed
/// verbatim). The coordinator buffers these into the file's block.
TestDone,
/// 9 × u32: file_idx, pass, fail, skip, todo, expectations, skipped_label, files, unhandled
FileDone,
/// 3 × str: failures, skips, todos (verbatim repeat-buffer bytes)
/// 2 × str: skips, todos (verbatim repeat-buffer bytes)
RepeatBufs,
/// str path
JunitFile,
Expand All @@ -25,6 +27,11 @@ pub enum Kind {
Run,
/// (empty)
Shutdown,
// worker → coordinator
/// u32 file_idx, str entry (rendered `(fail) file:line > scope > name`
/// header plus the diagnostic, ANSI included; appended verbatim to the
/// coordinator's report)
FailureDiagnostic,
}

impl TryFrom<u8> for Kind {
Expand All @@ -41,6 +48,7 @@ impl TryFrom<u8> for Kind {
6 => Kind::CoverageFile,
7 => Kind::Run,
8 => Kind::Shutdown,
9 => Kind::FailureDiagnostic,
_ => return Err(()),
})
}
Expand Down
Loading