From 0ae0101f78b92116297fc7f093bbe209fab193ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:19:22 +0000 Subject: [PATCH 1/9] bun test --reporter=junit: fix broken XML, double-escaped classname, and empty escape_xml previously wrote a numeric reference for every C0 control character but neither flushed the pending run nor advanced last, so the raw byte was also emitted and the reference appeared in the wrong position. The result was not well-formed XML 1.0 (and a literal � would not be legal anyway). escape_xml now passes TAB/LF/CR through and drops every other C0 control character. The describe-scope names used for classname were XML-escaped while being joined and then escaped again inside write_test_case, so a describe title like 'suite & "b"' rendered as '&lt;a&gt;' in the report. The join now concatenates the raw names with a raw ' > ' separator and leaves the single escape to write_test_case. for a thrown error was always emitted as with no message or stack. on_uncaught_exception now records the error name, message, and a colourless rendering on the JunitReporter; write_test_case emits them as the failure's type and message attributes and text body. Timeouts also get a message attribute. --- src/jsc/ConsoleObject.rs | 6 +- src/runtime/cli/test_command.rs | 107 +++++++++++-- src/runtime/test_runner/bun_test.rs | 10 ++ .../__snapshots__/junit.test.js.snap | 35 +++-- test/js/junit-reporter/junit.test.js | 141 +++++++++++++++++- 5 files changed, 269 insertions(+), 30 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 96dae5137792..59f7ac8a8131 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1162,13 +1162,13 @@ impl<'a> TablePrinter<'a> { /// recover `&mut Self` from the `*mut io::Writer` they receive (same pattern as /// `Output::QuietWriterAdapter::new_interface`). #[repr(C)] -pub(crate) struct DynWriteAdapter<'a> { +pub struct DynWriteAdapter<'a> { head: bun_core::io::Writer, inner: &'a mut dyn bun_io::Write, } impl<'a> DynWriteAdapter<'a> { - pub(crate) fn new(inner: &'a mut dyn bun_io::Write) -> Self { + pub fn new(inner: &'a mut dyn bun_io::Write) -> Self { Self { head: bun_core::io::Writer { write_all: Self::thunk_write_all, @@ -1180,7 +1180,7 @@ impl<'a> DynWriteAdapter<'a> { /// Reborrow as the `io::Writer` head. #[inline] - pub(crate) fn interface(&mut self) -> &mut bun_core::io::Writer { + pub fn interface(&mut self) -> &mut bun_core::io::Writer { // SAFETY: `head` is the first `#[repr(C)]` field, so `&mut self.head` // and `&mut *self as *mut io::Writer` are the same address; the thunks // below cast back to `*mut Self`. diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 5c5c1d61f558..c565dce5308b 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -157,9 +157,16 @@ pub(crate) fn escape_xml(str_: &[u8], writer: &mut impl bun_io::Write) -> crate: writer.write_all(bun_core::strings::xml_escape_entity(c).unwrap())?; last = i + 1; } + b'\t' | b'\n' | b'\r' => { + // Valid XML 1.0 Char; pass through as-is. + } 0..=0x1f => { - // Escape all control characters - write!(writer, "&#{};", c)?; + // Any other C0 control character is not a valid XML 1.0 Char and + // cannot be represented even as a numeric reference, so drop it. + if i > last { + writer.write_all(&str_[last..i])?; + } + last = i + 1; } _ => {} } @@ -211,6 +218,13 @@ pub fn write_test_status_line( // `&mut io::Writer`; the previous local `err_w`/`out_w` wrappers were no-op // reborrows. Call sites use the `Output` accessors directly. +#[derive(Default)] +pub struct JunitFailure { + pub name: Vec, + pub message: Vec, + pub body: Vec, +} + // Remaining TODOs: // - Add stdout/stderr to the JUnit report // - Add timestamp field to the JUnit report @@ -227,6 +241,10 @@ pub struct JunitReporter { pub suite_stack: Vec, pub current_depth: u32, + /// Error captured by `on_uncaught_exception` for the currently-failing + /// test; consumed by `write_test_case` on the next `Result::Fail`. + pub last_failure: Option, + pub hostname_value: Option>, } @@ -301,6 +319,46 @@ impl JunitReporter { // `pub const new = bun.TrivialNew(JunitReporter);` → Box::new + /// Capture the thrown value's name/message plus a plain-text (no ANSI) + /// rendering so the next `write_test_case` can populate ``. + pub fn record_failure(&mut self, global_this: &jsc::JSGlobalObject, exception: jsc::JSValue) { + let vm = global_this.bun_vm().as_mut(); + let (value, exc_ref): (jsc::JSValue, Option<&jsc::Exception>) = + match exception.as_exception(vm.jsc_vm) { + // SAFETY: `as_exception` returned a live JSC-heap `Exception`; + // read-only for the duration of this call. + Some(exc) => unsafe { ((*exc).value(), Some(&*exc)) }, + None => (exception, None), + }; + + let mut failure = JunitFailure::default(); + + { + let mut holder = jsc::zig_exception::Holder::init(); + let zig_exc = holder.zig_exception(); + value.to_zig_exception(global_this, zig_exc); + failure.name = zig_exc.name.to_utf8_bytes(); + failure.message = zig_exc.message.to_utf8_bytes(); + holder.deinit(vm); + } + + { + let mut adapter = jsc::console_object::DynWriteAdapter::new(&mut failure.body); + let mut formatter = jsc::console_object::Formatter::new(global_this); + vm.print_errorlike_object( + value, + exc_ref, + None, + &mut formatter, + adapter.interface(), + false, + false, + ); + } + + self.last_failure = Some(failure); + } + fn generate_properties_list(&mut self) -> crate::Result<()> { struct PropertiesList<'a> { ci: &'a [u8], @@ -583,16 +641,34 @@ impl JunitReporter { let last = self.suite_stack.len() - 1; self.suite_stack[last].metrics.failures += 1; } - // TODO: add the failure message - // if (failure_message) |msg| { - // try this.contents.appendSlice(bun.default_allocator, " message=\""); - // try escapeXml(msg, this.contents.writer(bun.default_allocator)); - // try this.contents.appendSlice(bun.default_allocator, "\""); - // } self.contents.extend_from_slice(b">\n"); self.contents.extend_from_slice(indent); - self.contents - .extend_from_slice(b" \n"); + let failure = self.last_failure.take(); + let type_name: &[u8] = failure + .as_ref() + .map(|f| f.name.as_slice()) + .filter(|n| !n.is_empty()) + .unwrap_or(b"Error"); + self.contents.extend_from_slice(b" { + self.contents.extend_from_slice(b">"); + escape_xml(&f.body, &mut self.contents)?; + self.contents.extend_from_slice(b"\n"); + } + None => { + self.contents.extend_from_slice(b" />\n"); + } + } self.contents.extend_from_slice(indent); self.contents.extend_from_slice(b"\n"); } @@ -686,13 +762,15 @@ impl JunitReporter { } self.contents.extend_from_slice(b">\n"); self.contents.extend_from_slice(indent); - self.contents - .extend_from_slice(b" \n"); + self.contents.extend_from_slice( + b" \n", + ); self.contents.extend_from_slice(indent); self.contents.extend_from_slice(b"\n"); } R::Pending => unreachable!(), } + self.last_failure = None; Ok(()) } @@ -1187,10 +1265,11 @@ impl CommandLineReporter { if let Some(name) = unsafe { (*scope).base.name.as_deref() } { if !name.is_empty() { if initial_length != concatenated_describe_scopes.len() { - concatenated_describe_scopes.extend_from_slice(b" > "); + concatenated_describe_scopes.extend_from_slice(b" > "); } - escape_xml(name, &mut concatenated_describe_scopes).expect("oom"); + // write_test_case escapes class_name once; do not pre-escape here. + concatenated_describe_scopes.extend_from_slice(name); } } } diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index e4662e7f65e0..ddffe4edbc8b 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -1312,6 +1312,16 @@ impl BunTest { return; // the exception should not be visible (eg m_terminationException) }; + if handle_status == HandleUncaughtExceptionResult::ShowHandledError { + if let Some(reporter) = self.reporter { + // SAFETY: `BunTest.reporter` carries write provenance from `enter_file`'s + // `&mut`; single-threaded test runner, no other borrow live here. + if let Some(junit) = unsafe { (*reporter.as_ptr()).reporters.junit.as_mut() } { + junit.record_failure(global_this, exception); + } + } + } + self.bun_test_root.on_before_print(); if matches!( handle_status, diff --git a/test/js/junit-reporter/__snapshots__/junit.test.js.snap b/test/js/junit-reporter/__snapshots__/junit.test.js.snap index 37d2c7d40ffe..44547917d1d7 100644 --- a/test/js/junit-reporter/__snapshots__/junit.test.js.snap +++ b/test/js/junit-reporter/__snapshots__/junit.test.js.snap @@ -6,22 +6,39 @@ exports[`junit reporter more scenarios 1`] = ` - + - + - + - + - + 26 | test("basic passing test", () => { +27 | expect(1 + 1).toBe(2); +28 | }); +29 | +30 | test("basic failing test", () => { +31 | expect(1 + 1).toBe(3); + ^ +error: expect(received).toBe(expected) + +Expected: 3 +Received: 2 + + at <anonymous> (/comprehensive.test.js:31:27) + @@ -61,22 +78,22 @@ exports[`junit reporter more scenarios 2`] = ` - + - + - + - + diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index b800ea22d388..6a3e0107ac1d 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -1,6 +1,7 @@ import { file, spawn } from "bun"; import { describe, expect, it } from "bun:test"; import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { join } from "node:path"; const xml2js = require("xml2js"); @@ -254,7 +255,7 @@ describe("junit reporter", () => { await proc1.exited; const xmlContent1 = await file(junitPath1).text(); - expect(filterJunitXmlOutput(xmlContent1)).toMatchSnapshot(); + expect(filterJunitXmlOutput(xmlContent1, tmpDir)).toMatchSnapshot(); const result1 = await new Promise((resolve, reject) => { xml2js.parseString(xmlContent1, (err, result) => { if (err) reject(err); @@ -282,7 +283,7 @@ describe("junit reporter", () => { await proc2.exited; const xmlContent2 = await file(junitPath2).text(); - expect(filterJunitXmlOutput(xmlContent2)).toMatchSnapshot(); + expect(filterJunitXmlOutput(xmlContent2, tmpDir)).toMatchSnapshot(); const result2 = await new Promise((resolve, reject) => { xml2js.parseString(xmlContent2, (err, result) => { if (err) reject(err); @@ -384,8 +385,140 @@ describe("junit reporter", () => { expect(proc.exitCode).toBe(1); }); + + it("produces well-formed XML when test names contain control characters", async () => { + const tmpDir = tempDirWithFiles("junit-ctrl", { + "package.json": "{}", + "ctrl.test.js": + 'import { test } from "bun:test";\n' + + 'test("ctrl \\x00nul\\x1besc\\x07bell", () => { throw new Error("x"); });\n' + + 'test("keeps\\twhitespace\\nfine", () => {});\n', + }); + + const junitPath = join(tmpDir, "junit.xml"); + await using proc = spawn([bunExe(), "test", "--reporter=junit", "--reporter-outfile", junitPath], { + cwd: tmpDir, + env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const xmlBytes = await file(junitPath).bytes(); + // Raw control characters (other than TAB/LF/CR) are not well-formed XML 1.0 + // and must not appear in the output at all. + for (const c of [0x00, 0x07, 0x1b]) { + expect(xmlBytes).not.toContain(c); + } + + const xmlContent = new TextDecoder().decode(xmlBytes); + // � /  are also illegal as numeric references in XML 1.0. + expect(xmlContent).not.toMatch(/&#(0|7|27);/); + + // The report must parse as XML. + const result = await new Promise((resolve, reject) => { + xml2js.parseString(xmlContent, { strict: true }, (err, r) => (err ? reject(err) : resolve(r))); + }); + const testcases = result.testsuites.testsuite[0].testcase; + expect(testcases[0].$.name).toBe("ctrl nulescbell"); + // TAB and LF are valid XML Chars and should pass through untouched. + expect(testcases[1].$.name).toBe("keeps\twhitespace\nfine"); + expect(exitCode).toBe(1); + }); + + it("escapes the classname attribute exactly once", async () => { + const tmpDir = tempDirWithFiles("junit-escape", { + "package.json": "{}", + "escape.test.js": ` + import { describe, test } from "bun:test"; + describe("suite & \\"b\\"", () => { + describe("inner > stuff", () => { + test("t", () => {}); + }); + }); + `, + }); + + const junitPath = join(tmpDir, "junit.xml"); + await using proc = spawn([bunExe(), "test", "--reporter=junit", "--reporter-outfile", junitPath], { + cwd: tmpDir, + env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const xmlContent = await file(junitPath).text(); + // Double-escaping would produce &lt; / &amp; etc. + expect(xmlContent).not.toContain("&lt;"); + expect(xmlContent).not.toContain("&amp;"); + expect(xmlContent).not.toContain("&gt;"); + expect(xmlContent).not.toContain("&quot;"); + + const result = await new Promise((resolve, reject) => { + xml2js.parseString(xmlContent, { strict: true }, (err, r) => (err ? reject(err) : resolve(r))); + }); + const fileSuite = result.testsuites.testsuite[0]; + const outer = fileSuite.testsuite[0]; + const inner = outer.testsuite[0]; + const tc = inner.testcase[0]; + // The classname should decode back to the original describe names joined by " > ". + expect(outer.$.name).toBe('suite & "b"'); + expect(tc.$.classname).toBe('inner > stuff > suite & "b"'); + expect(exitCode).toBe(0); + }); + + it("includes the error type, message and stack in ", async () => { + const tmpDir = tempDirWithFiles("junit-failure", { + "package.json": "{}", + "fail.test.js": ` + import { test, expect } from "bun:test"; + test("thrown error", () => { throw new Error("boom: the important message"); }); + test("type error", () => { null.foo; }); + test("assertion", () => { expect(1).toBe(2); }); + `, + }); + + const junitPath = join(tmpDir, "junit.xml"); + await using proc = spawn([bunExe(), "test", "--reporter=junit", "--reporter-outfile", junitPath], { + cwd: tmpDir, + env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const xmlContent = await file(junitPath).text(); + const result = await new Promise((resolve, reject) => { + xml2js.parseString(xmlContent, { strict: true }, (err, r) => (err ? reject(err) : resolve(r))); + }); + const testcases = result.testsuites.testsuite[0].testcase; + expect(testcases).toHaveLength(3); + + const [thrown, typeErr, assertion] = testcases; + + expect(thrown.failure[0].$.type).toBe("Error"); + expect(thrown.failure[0].$.message).toContain("boom: the important message"); + expect(thrown.failure[0]._).toContain("boom: the important message"); + expect(thrown.failure[0]._).toContain("fail.test.js:3"); + + expect(typeErr.failure[0].$.type).toBe("TypeError"); + expect(typeErr.failure[0].$.message).toContain("null is not an object"); + expect(typeErr.failure[0]._).toContain("fail.test.js:4"); + + expect(assertion.failure[0].$.message).toContain("expect(received).toBe(expected)"); + expect(assertion.failure[0]._).toContain("Expected: 2"); + expect(assertion.failure[0]._).toContain("Received: 1"); + expect(assertion.failure[0]._).toContain("fail.test.js:5"); + + // No ANSI escape sequences should leak into the report. + expect(xmlContent).not.toContain("\x1b["); + expect(exitCode).toBe(1); + }); }); -function filterJunitXmlOutput(xmlContent) { - return xmlContent.replaceAll(/ (time|hostname)=".*?"/g, ""); +function filterJunitXmlOutput(xmlContent, dir) { + let out = xmlContent.replaceAll(/ (time|hostname)=".*?"/g, ""); + if (dir) out = out.replaceAll(String(dir), ""); + return out; } From ed15c445bc314667baa56ec8cbcc3d2b3a7ee077 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:16:41 +0000 Subject: [PATCH 2/9] junit: capture failure via on_print_error_zig_exception hook instead of re-formatting Addresses review feedback: the previous approach ran to_zig_exception and print_errorlike_object a second time for every failed test. Now the stderr render path populates a single ZigException and the junit reporter reads from it via a fn-ptr slot on VirtualMachine (same lifecycle as the GitHub Actions annotation hook and the debugger lifecycle reporter). The body is built from the remapped name/message/stack frames, matching the standard Error.stack shape CI ingesters expect. --- src/jsc/ConsoleObject.rs | 6 +- src/jsc/VirtualMachine.rs | 10 ++ src/runtime/cli/test_command.rs | 91 +++++++++++-------- src/runtime/test_runner/bun_test.rs | 33 +++++-- .../__snapshots__/junit.test.js.snap | 11 +-- 5 files changed, 94 insertions(+), 57 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 59f7ac8a8131..96dae5137792 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1162,13 +1162,13 @@ impl<'a> TablePrinter<'a> { /// recover `&mut Self` from the `*mut io::Writer` they receive (same pattern as /// `Output::QuietWriterAdapter::new_interface`). #[repr(C)] -pub struct DynWriteAdapter<'a> { +pub(crate) struct DynWriteAdapter<'a> { head: bun_core::io::Writer, inner: &'a mut dyn bun_io::Write, } impl<'a> DynWriteAdapter<'a> { - pub fn new(inner: &'a mut dyn bun_io::Write) -> Self { + pub(crate) fn new(inner: &'a mut dyn bun_io::Write) -> Self { Self { head: bun_core::io::Writer { write_all: Self::thunk_write_all, @@ -1180,7 +1180,7 @@ impl<'a> DynWriteAdapter<'a> { /// Reborrow as the `io::Writer` head. #[inline] - pub fn interface(&mut self) -> &mut bun_core::io::Writer { + pub(crate) fn interface(&mut self) -> &mut bun_core::io::Writer { // SAFETY: `head` is the first `#[repr(C)]` field, so `&mut self.head` // and `&mut *self as *mut io::Writer` are the same address; the thunks // below cast back to `*mut Self`. diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 5d715e84b68f..a04bad12f1c6 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -300,6 +300,13 @@ pub struct VirtualMachine { pub on_unhandled_rejection_ctx: Option<*mut c_void>, pub on_unhandled_rejection_exception_list: Option>, pub unhandled_error_counter: usize, + /// When set, `print_error_instance_body` calls this with the remapped + /// `ZigException` (same lifecycle as the GitHub Actions annotation hook), + /// so observers can read name/message/stack without re-running the + /// formatter. Installed by `bun test --reporter=junit` around + /// `run_error_handler`. + pub on_print_error_zig_exception: Option, + pub on_print_error_zig_exception_ctx: *mut c_void, pub is_handling_uncaught_exception: bool, pub exit_on_uncaught_exception: bool, @@ -5711,6 +5718,9 @@ impl VirtualMachine { if let Some(debugger) = self.debugger.as_deref_mut() { debugger.lifecycle_reporter_agent.report_error(exception); } + if let Some(cb) = self.on_print_error_zig_exception { + cb(self.on_print_error_zig_exception_ctx, exception); + } } // Defer the GitHub-annotation print to scope exit. diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index c565dce5308b..9fe7cb675fce 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -319,44 +319,63 @@ impl JunitReporter { // `pub const new = bun.TrivialNew(JunitReporter);` → Box::new - /// Capture the thrown value's name/message plus a plain-text (no ANSI) - /// rendering so the next `write_test_case` can populate ``. - pub fn record_failure(&mut self, global_this: &jsc::JSGlobalObject, exception: jsc::JSValue) { - let vm = global_this.bun_vm().as_mut(); - let (value, exc_ref): (jsc::JSValue, Option<&jsc::Exception>) = - match exception.as_exception(vm.jsc_vm) { - // SAFETY: `as_exception` returned a live JSC-heap `Exception`; - // read-only for the duration of this call. - Some(exc) => unsafe { ((*exc).value(), Some(&*exc)) }, - None => (exception, None), - }; - - let mut failure = JunitFailure::default(); - - { - let mut holder = jsc::zig_exception::Holder::init(); - let zig_exc = holder.zig_exception(); - value.to_zig_exception(global_this, zig_exc); - failure.name = zig_exc.name.to_utf8_bytes(); - failure.message = zig_exc.message.to_utf8_bytes(); - holder.deinit(vm); - } - - { - let mut adapter = jsc::console_object::DynWriteAdapter::new(&mut failure.body); - let mut formatter = jsc::console_object::Formatter::new(global_this); - vm.print_errorlike_object( - value, - exc_ref, - None, - &mut formatter, - adapter.interface(), - false, - false, - ); + /// Capture name/message/stack from the `ZigException` that + /// `print_error_instance_body` has already populated, so the next + /// `write_test_case` can emit a useful `` without re-running + /// the exception formatter. + pub fn record_failure(&mut self, exception: &jsc::ZigException) { + let failure = self.last_failure.get_or_insert_default(); + if failure.name.is_empty() { + failure.name = exception.name.to_utf8_bytes(); + } + if failure.message.is_empty() { + failure.message = exception.message.to_utf8_bytes(); + } + + let body = &mut failure.body; + if !body.is_empty() { + body.push(b'\n'); + } + let name = exception.name.to_utf8(); + let message = exception.message.to_utf8(); + match (name.slice().is_empty(), message.slice().is_empty()) { + (true, true) => body.extend_from_slice(b"error"), + (true, false) => body.extend_from_slice(message.slice()), + (false, true) => body.extend_from_slice(name.slice()), + (false, false) => { + body.extend_from_slice(name.slice()); + body.extend_from_slice(b": "); + body.extend_from_slice(message.slice()); + } + } + body.push(b'\n'); + let dir = FileSystem::instance().top_level_dir; + for frame in exception.stack.frames() { + let source_url = frame.source_url.to_utf8(); + let file = resolve_path::relative(dir, source_url.slice()); + let func = frame.function_name.to_utf8(); + if file.is_empty() && func.slice().is_empty() { + continue; + } + body.extend_from_slice(b" at "); + let name_fmt = frame.name_formatter(false); + let url_fmt = frame.source_url_formatter(file, None, false, false); + if func.slice().is_empty() { + let _ = write!(body, "{}", url_fmt); + } else { + let _ = write!(body, "{} ({})", name_fmt, url_fmt); + } + body.push(b'\n'); } + } - self.last_failure = Some(failure); + /// VirtualMachine::on_print_error_zig_exception thunk. + pub fn record_failure_cb(ctx: *mut core::ffi::c_void, exception: &jsc::ZigException) { + // SAFETY: `ctx` was set to `&mut JunitReporter` by `on_uncaught_exception` + // for the duration of a single `run_error_handler` call; single-threaded, + // no other borrow of the reporter is live across that call. + let this = unsafe { &mut *ctx.cast::() }; + this.record_failure(exception); } fn generate_properties_list(&mut self) -> crate::Result<()> { diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index ddffe4edbc8b..b5cf01a16165 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -1312,15 +1312,20 @@ impl BunTest { return; // the exception should not be visible (eg m_terminationException) }; - if handle_status == HandleUncaughtExceptionResult::ShowHandledError { - if let Some(reporter) = self.reporter { - // SAFETY: `BunTest.reporter` carries write provenance from `enter_file`'s - // `&mut`; single-threaded test runner, no other borrow live here. - if let Some(junit) = unsafe { (*reporter.as_ptr()).reporters.junit.as_mut() } { - junit.record_failure(global_this, exception); - } + let junit_ctx: *mut core::ffi::c_void = 'ctx: { + if handle_status != HandleUncaughtExceptionResult::ShowHandledError { + break 'ctx core::ptr::null_mut(); } - } + let Some(reporter) = self.reporter else { + break 'ctx core::ptr::null_mut(); + }; + // SAFETY: `BunTest.reporter` carries write provenance from `enter_file`'s + // `&mut`; single-threaded test runner, no other borrow live here. + match unsafe { (*reporter.as_ptr()).reporters.junit.as_deref_mut() } { + Some(junit) => (junit as *mut crate::cli::test_command::JunitReporter).cast(), + None => core::ptr::null_mut(), + } + }; self.bun_test_root.on_before_print(); if matches!( @@ -1340,7 +1345,17 @@ impl BunTest { Output::flush(); } - global_this.bun_vm().as_mut().run_error_handler(exception, None); + let vm = global_this.bun_vm().as_mut(); + if !junit_ctx.is_null() { + vm.on_print_error_zig_exception = + Some(crate::cli::test_command::JunitReporter::record_failure_cb); + vm.on_print_error_zig_exception_ctx = junit_ctx; + } + vm.run_error_handler(exception, None); + if !junit_ctx.is_null() { + vm.on_print_error_zig_exception = None; + vm.on_print_error_zig_exception_ctx = core::ptr::null_mut(); + } if matches!( handle_status, diff --git a/test/js/junit-reporter/__snapshots__/junit.test.js.snap b/test/js/junit-reporter/__snapshots__/junit.test.js.snap index 44547917d1d7..8049f63156c4 100644 --- a/test/js/junit-reporter/__snapshots__/junit.test.js.snap +++ b/test/js/junit-reporter/__snapshots__/junit.test.js.snap @@ -25,19 +25,12 @@ exports[`junit reporter more scenarios 1`] = ` Expected: 3 Received: 2 -">26 | test("basic passing test", () => { -27 | expect(1 + 1).toBe(2); -28 | }); -29 | -30 | test("basic failing test", () => { -31 | expect(1 + 1).toBe(3); - ^ -error: expect(received).toBe(expected) +">Error: expect(received).toBe(expected) Expected: 3 Received: 2 - at <anonymous> (/comprehensive.test.js:31:27) + at /comprehensive.test.js:31:27 From a5d0e7dd7b3c419d4ca6bf4c142bdb0c27a735e8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:31:36 +0000 Subject: [PATCH 3/9] test(junit): normalize Windows path separator in snapshot filter --- test/js/junit-reporter/junit.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index 6a3e0107ac1d..9f977c823af2 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -519,6 +519,11 @@ describe("junit reporter", () => { function filterJunitXmlOutput(xmlContent, dir) { let out = xmlContent.replaceAll(/ (time|hostname)=".*?"/g, ""); - if (dir) out = out.replaceAll(String(dir), ""); + if (dir) { + out = out.replaceAll(String(dir), ""); + out = out.replaceAll(String(dir).replaceAll("\\", "/"), ""); + } + // bodies render stack-frame paths with the platform separator. + out = out.replaceAll(/()[\\\/]/g, "$1/"); return out; } From 186318a3977033aa2b770c4e11c0659ecb4ba4db Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:43:18 +0000 Subject: [PATCH 4/9] junit: emit cwd-relative forward-slash frame paths; report final retry attempt record_failure was routing the computed relative path through SourceURLFormatter as root_path, which that formatter ignores on the no-colour no-origin path, so the raw absolute source_url was written instead. Emit the relative path (with forward slashes on Windows) and line:col directly so the failure body is platform-stable and needs no temp-dir substitution in the snapshot. Also clear last_failure at the start of each top-level on_uncaught_exception so an exhausted retry reports the final attempt rather than accumulating every attempt's stack under the first attempt's message. --- src/runtime/cli/test_command.rs | 26 ++++++++++++++----- src/runtime/test_runner/bun_test.rs | 7 ++++- .../__snapshots__/junit.test.js.snap | 2 +- test/js/junit-reporter/junit.test.js | 20 ++++++-------- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 9fe7cb675fce..d5b241053961 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -358,12 +358,26 @@ impl JunitReporter { continue; } body.extend_from_slice(b" at "); - let name_fmt = frame.name_formatter(false); - let url_fmt = frame.source_url_formatter(file, None, false, false); - if func.slice().is_empty() { - let _ = write!(body, "{}", url_fmt); - } else { - let _ = write!(body, "{} ({})", name_fmt, url_fmt); + if !func.slice().is_empty() { + let _ = write!(body, "{} (", frame.name_formatter(false)); + } + let file_start = body.len(); + body.extend_from_slice(file); + if cfg!(windows) { + for b in &mut body[file_start..] { + if *b == b'\\' { + *b = b'/'; + } + } + } + let pos = frame.position; + if pos.line.is_valid() && pos.column.is_valid() { + let _ = write!(body, ":{}:{}", pos.line.one_based(), pos.column.one_based()); + } else if pos.line.is_valid() { + let _ = write!(body, ":{}", pos.line.one_based()); + } + if !func.slice().is_empty() { + body.push(b')'); } body.push(b'\n'); } diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index b5cf01a16165..22ff6f08bcd0 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -1322,7 +1322,12 @@ impl BunTest { // SAFETY: `BunTest.reporter` carries write provenance from `enter_file`'s // `&mut`; single-threaded test runner, no other borrow live here. match unsafe { (*reporter.as_ptr()).reporters.junit.as_deref_mut() } { - Some(junit) => (junit as *mut crate::cli::test_command::JunitReporter).cast(), + Some(junit) => { + // Each top-level exception starts a fresh capture so a retried + // test reports the final attempt rather than accumulating. + junit.last_failure = None; + (junit as *mut crate::cli::test_command::JunitReporter).cast() + } None => core::ptr::null_mut(), } }; diff --git a/test/js/junit-reporter/__snapshots__/junit.test.js.snap b/test/js/junit-reporter/__snapshots__/junit.test.js.snap index 8049f63156c4..0b5d16b4138b 100644 --- a/test/js/junit-reporter/__snapshots__/junit.test.js.snap +++ b/test/js/junit-reporter/__snapshots__/junit.test.js.snap @@ -30,7 +30,7 @@ Received: 2 Expected: 3 Received: 2 - at /comprehensive.test.js:31:27 + at comprehensive.test.js:31:27 diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index 9f977c823af2..2ae9577cad4c 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -255,7 +255,7 @@ describe("junit reporter", () => { await proc1.exited; const xmlContent1 = await file(junitPath1).text(); - expect(filterJunitXmlOutput(xmlContent1, tmpDir)).toMatchSnapshot(); + expect(filterJunitXmlOutput(xmlContent1)).toMatchSnapshot(); const result1 = await new Promise((resolve, reject) => { xml2js.parseString(xmlContent1, (err, result) => { if (err) reject(err); @@ -283,7 +283,7 @@ describe("junit reporter", () => { await proc2.exited; const xmlContent2 = await file(junitPath2).text(); - expect(filterJunitXmlOutput(xmlContent2, tmpDir)).toMatchSnapshot(); + expect(filterJunitXmlOutput(xmlContent2)).toMatchSnapshot(); const result2 = await new Promise((resolve, reject) => { xml2js.parseString(xmlContent2, (err, result) => { if (err) reject(err); @@ -366,12 +366,15 @@ describe("junit reporter", () => { expect(flakyEntries).toHaveLength(1); expect(flakyEntries[0][0]).not.toContain("]*name="exhausted test"[^/]*(?:\/>|>[\s\S]*?<\/testcase>)/g), ]; expect(exhaustedEntries).toHaveLength(1); expect(exhaustedEntries[0][0]).toContain(" { }); }); -function filterJunitXmlOutput(xmlContent, dir) { - let out = xmlContent.replaceAll(/ (time|hostname)=".*?"/g, ""); - if (dir) { - out = out.replaceAll(String(dir), ""); - out = out.replaceAll(String(dir).replaceAll("\\", "/"), ""); - } - // bodies render stack-frame paths with the platform separator. - out = out.replaceAll(/()[\\\/]/g, "$1/"); - return out; +function filterJunitXmlOutput(xmlContent) { + return xmlContent.replaceAll(/ (time|hostname)=".*?"/g, ""); } From cb0c7dd10b7c35e01272f511bb5671ac786dfced Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:55:15 +0000 Subject: [PATCH 5/9] clippy: use ptr::from_mut instead of reference cast --- src/runtime/test_runner/bun_test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index 22ff6f08bcd0..9ac358895c3e 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -1326,7 +1326,7 @@ impl BunTest { // Each top-level exception starts a fresh capture so a retried // test reports the final attempt rather than accumulating. junit.last_failure = None; - (junit as *mut crate::cli::test_command::JunitReporter).cast() + core::ptr::from_mut(junit).cast() } None => core::ptr::null_mut(), } From 1ebba8943fad4735308b55db0aef56aa42f27073 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:02:45 +0000 Subject: [PATCH 6/9] test(junit): assert on SGR residue instead of raw ESC byte escape_xml drops ESC, so a colour leak would surface as bare [Nm sequences; the previous assertion could never fail. --- test/js/junit-reporter/junit.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index 2ae9577cad4c..6632b718c628 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -514,8 +514,9 @@ describe("junit reporter", () => { expect(assertion.failure[0]._).toContain("Received: 1"); expect(assertion.failure[0]._).toContain("fail.test.js:5"); - // No ANSI escape sequences should leak into the report. - expect(xmlContent).not.toContain("\x1b["); + // No ANSI escape sequences should leak into the report. escape_xml drops + // the ESC byte, so a colour leak would surface as bare SGR residue. + expect(xmlContent).not.toMatch(/\[\d+m/); expect(exitCode).toBe(1); }); }); From 49cf823454ce37436c954b28acaad219eb4f5a29 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:35:49 +0000 Subject: [PATCH 7/9] junit: encode TAB/LF/CR as numeric refs; clear last_failure at retry boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit escape_xml now emits / / instead of the literal bytes so attribute values survive XML 1.0 §3.3.3 attribute-value normalisation in compliant parsers. Moved the last_failure reset from on_uncaught_exception to the retry/repeat boundary in advance_sequence: a test body that throws followed by a throwing afterEach (or an unhandled rejection drained in the same attempt) now keeps the primary failure as the type/message and appends the secondary error to the body, while an exhausted retry still reports only the final attempt. New test covers the afterEach case. --- src/runtime/cli/test_command.rs | 8 ++++- src/runtime/test_runner/Execution.rs | 17 ++++++++++ src/runtime/test_runner/bun_test.rs | 7 +--- .../__snapshots__/junit.test.js.snap | 12 +------ test/js/junit-reporter/junit.test.js | 32 +++++++++++++++++++ 5 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index d5b241053961..28af77575d45 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -158,7 +158,13 @@ pub(crate) fn escape_xml(str_: &[u8], writer: &mut impl bun_io::Write) -> crate: last = i + 1; } b'\t' | b'\n' | b'\r' => { - // Valid XML 1.0 Char; pass through as-is. + // Valid XML 1.0 Char. Emit as a numeric reference so the literal + // byte survives attribute-value normalisation (XML 1.0 §3.3.3). + if i > last { + writer.write_all(&str_[last..i])?; + } + write!(writer, "&#{};", c)?; + last = i + 1; } 0..=0x1f => { // Any other C0 control character is not a valid XML 1.0 Char and diff --git a/src/runtime/test_runner/Execution.rs b/src/runtime/test_runner/Execution.rs index 22e7d946b01e..376f7f35a57f 100644 --- a/src/runtime/test_runner/Execution.rs +++ b/src/runtime/test_runner/Execution.rs @@ -537,6 +537,7 @@ impl Execution { // Handle retry logic: if test failed and we have retries remaining, retry it if test_failed && sequence.remaining_retry_count > 0 { sequence.remaining_retry_count -= 1; + Execution::discard_junit_failure(buntest); Execution::reset_sequence(sequence); return; } @@ -544,6 +545,7 @@ impl Execution { // Handle repeat logic: if test passed and we have repeats remaining, repeat it if test_passed && sequence.remaining_repeat_count > 0 { sequence.remaining_repeat_count -= 1; + Execution::discard_junit_failure(buntest); Execution::reset_sequence(sequence); return; } @@ -701,6 +703,21 @@ impl Execution { } } + /// Drop any captured junit failure so the next retry/repeat starts fresh. + /// Kept out of `reset_sequence` so within-attempt errors (e.g. a throwing + /// afterEach after the test body already threw) accumulate instead of + /// clobbering the primary failure. + fn discard_junit_failure(buntest: NonNull) { + // SAFETY: `buntest` points at the live per-file BunTest; `reporter` is a + // `NonNull` with write provenance (see BunTest docs); + // single-threaded test runner, no other borrow live here. + if let Some(reporter) = unsafe { (*buntest.as_ptr()).reporter } { + if let Some(junit) = unsafe { (*reporter.as_ptr()).reporters.junit.as_deref_mut() } { + junit.last_failure = None; + } + } + } + pub fn reset_sequence(sequence: &mut ExecutionSequence) { debug_assert!(!sequence.executing); { diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index 9ac358895c3e..824565b09b83 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -1322,12 +1322,7 @@ impl BunTest { // SAFETY: `BunTest.reporter` carries write provenance from `enter_file`'s // `&mut`; single-threaded test runner, no other borrow live here. match unsafe { (*reporter.as_ptr()).reporters.junit.as_deref_mut() } { - Some(junit) => { - // Each top-level exception starts a fresh capture so a retried - // test reports the final attempt rather than accumulating. - junit.last_failure = None; - core::ptr::from_mut(junit).cast() - } + Some(junit) => core::ptr::from_mut(junit).cast(), None => core::ptr::null_mut(), } }; diff --git a/test/js/junit-reporter/__snapshots__/junit.test.js.snap b/test/js/junit-reporter/__snapshots__/junit.test.js.snap index 0b5d16b4138b..c632d3927cc7 100644 --- a/test/js/junit-reporter/__snapshots__/junit.test.js.snap +++ b/test/js/junit-reporter/__snapshots__/junit.test.js.snap @@ -21,17 +21,7 @@ exports[`junit reporter more scenarios 1`] = ` - Error: expect(received).toBe(expected) - -Expected: 3 -Received: 2 - - at comprehensive.test.js:31:27 - + Error: expect(received).toBe(expected) Expected: 3 Received: 2 at comprehensive.test.js:31:27 diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index 6632b718c628..b23b1e00f25d 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -471,6 +471,38 @@ describe("junit reporter", () => { expect(exitCode).toBe(0); }); + it("keeps the test body's error in when afterEach also throws", async () => { + const tmpDir = tempDirWithFiles("junit-aftereach", { + "package.json": "{}", + "after.test.js": ` + import { test, afterEach } from "bun:test"; + afterEach(() => { throw new Error("cleanup broke"); }); + test("t", () => { throw new Error("actual test failure"); }); + `, + }); + + const junitPath = join(tmpDir, "junit.xml"); + await using proc = spawn([bunExe(), "test", "--reporter=junit", "--reporter-outfile", junitPath], { + cwd: tmpDir, + env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const xmlContent = await file(junitPath).text(); + const result = await new Promise((resolve, reject) => { + xml2js.parseString(xmlContent, { strict: true }, (err, r) => (err ? reject(err) : resolve(r))); + }); + const tc = result.testsuites.testsuite[0].testcase[0]; + // The primary failure (from the test body) should win type/message; + // the afterEach error should be appended to the body. + expect(tc.failure[0].$.message).toContain("actual test failure"); + expect(tc.failure[0]._).toContain("actual test failure"); + expect(tc.failure[0]._).toContain("cleanup broke"); + expect(exitCode).toBe(1); + }); + it("includes the error type, message and stack in ", async () => { const tmpDir = tempDirWithFiles("junit-failure", { "package.json": "{}", From 202a194eb2c7dfefb487d21dc1c9da486c156a4e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:48:54 +0000 Subject: [PATCH 8/9] junit: report expect() failures as type="AssertionError"; strip ANSI from captured message record_failure now strips CSI sequences from the error message before storing it, so a matcher message built with colours does not surface as bare [Nm residue in the report. When the stripped message begins with 'expect(' and the error name is the generic 'Error', the type is reported as AssertionError. Also splits the SAFETY comment in discard_junit_failure so each unsafe block has its own (clippy::undocumented_unsafe_blocks). --- src/runtime/cli/test_command.rs | 53 +++++++++++++++---- src/runtime/test_runner/Execution.rs | 7 +-- .../__snapshots__/junit.test.js.snap | 2 +- test/js/junit-reporter/junit.test.js | 5 +- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 28af77575d45..af9d60f8bf7d 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -231,6 +231,26 @@ pub struct JunitFailure { pub body: Vec, } +/// Append `input` to `out`, dropping CSI sequences (`ESC '[' ... final`), so a +/// matcher message built with colour does not reach the report as SGR residue. +fn push_stripping_ansi(out: &mut Vec, input: &[u8]) { + let mut i = 0; + while i < input.len() { + if input[i] == 0x1b && i + 1 < input.len() && input[i + 1] == b'[' { + i += 2; + while i < input.len() && !(0x40..=0x7e).contains(&input[i]) { + i += 1; + } + if i < input.len() { + i += 1; + } + continue; + } + out.push(input[i]); + i += 1; + } +} + // Remaining TODOs: // - Add stdout/stderr to the JUnit report // - Add timestamp field to the JUnit report @@ -331,27 +351,42 @@ impl JunitReporter { /// the exception formatter. pub fn record_failure(&mut self, exception: &jsc::ZigException) { let failure = self.last_failure.get_or_insert_default(); + let name = exception.name.to_utf8(); + let raw_message = exception.message.to_utf8(); + let mut message = Vec::with_capacity(raw_message.slice().len()); + push_stripping_ansi(&mut message, raw_message.slice()); + + let is_assertion = strings::has_prefix_comptime(&message, b"expect(") + && (name.slice().is_empty() || strings::eql(name.slice(), b"Error")); + if failure.name.is_empty() { - failure.name = exception.name.to_utf8_bytes(); + if is_assertion { + failure.name.extend_from_slice(b"AssertionError"); + } else { + failure.name.extend_from_slice(name.slice()); + } } if failure.message.is_empty() { - failure.message = exception.message.to_utf8_bytes(); + failure.message.extend_from_slice(&message); } let body = &mut failure.body; if !body.is_empty() { body.push(b'\n'); } - let name = exception.name.to_utf8(); - let message = exception.message.to_utf8(); - match (name.slice().is_empty(), message.slice().is_empty()) { + let header: &[u8] = if is_assertion { + b"AssertionError" + } else { + name.slice() + }; + match (header.is_empty(), message.is_empty()) { (true, true) => body.extend_from_slice(b"error"), - (true, false) => body.extend_from_slice(message.slice()), - (false, true) => body.extend_from_slice(name.slice()), + (true, false) => body.extend_from_slice(&message), + (false, true) => body.extend_from_slice(header), (false, false) => { - body.extend_from_slice(name.slice()); + body.extend_from_slice(header); body.extend_from_slice(b": "); - body.extend_from_slice(message.slice()); + body.extend_from_slice(&message); } } body.push(b'\n'); diff --git a/src/runtime/test_runner/Execution.rs b/src/runtime/test_runner/Execution.rs index 376f7f35a57f..f470785ec90f 100644 --- a/src/runtime/test_runner/Execution.rs +++ b/src/runtime/test_runner/Execution.rs @@ -708,10 +708,11 @@ impl Execution { /// afterEach after the test body already threw) accumulate instead of /// clobbering the primary failure. fn discard_junit_failure(buntest: NonNull) { - // SAFETY: `buntest` points at the live per-file BunTest; `reporter` is a - // `NonNull` with write provenance (see BunTest docs); - // single-threaded test runner, no other borrow live here. + // SAFETY: `buntest` points at the live per-file BunTest; single-threaded + // test runner, no other borrow live here. if let Some(reporter) = unsafe { (*buntest.as_ptr()).reporter } { + // SAFETY: `reporter` is a `NonNull` with write + // provenance (see BunTest docs); single-threaded, no other borrow. if let Some(junit) = unsafe { (*reporter.as_ptr()).reporters.junit.as_deref_mut() } { junit.last_failure = None; } diff --git a/test/js/junit-reporter/__snapshots__/junit.test.js.snap b/test/js/junit-reporter/__snapshots__/junit.test.js.snap index c632d3927cc7..37deafad5917 100644 --- a/test/js/junit-reporter/__snapshots__/junit.test.js.snap +++ b/test/js/junit-reporter/__snapshots__/junit.test.js.snap @@ -21,7 +21,7 @@ exports[`junit reporter more scenarios 1`] = ` - Error: expect(received).toBe(expected) Expected: 3 Received: 2 at comprehensive.test.js:31:27 + AssertionError: expect(received).toBe(expected) Expected: 3 Received: 2 at comprehensive.test.js:31:27 diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index b23b1e00f25d..1b56f732ea7b 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -517,7 +517,9 @@ describe("junit reporter", () => { const junitPath = join(tmpDir, "junit.xml"); await using proc = spawn([bunExe(), "test", "--reporter=junit", "--reporter-outfile", junitPath], { cwd: tmpDir, - env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" }, + // FORCE_COLOR so the matcher builds a coloured message, to exercise the + // ANSI-strip path. + env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1", FORCE_COLOR: "1" }, stdout: "pipe", stderr: "pipe", }); @@ -541,6 +543,7 @@ describe("junit reporter", () => { expect(typeErr.failure[0].$.message).toContain("null is not an object"); expect(typeErr.failure[0]._).toContain("fail.test.js:4"); + expect(assertion.failure[0].$.type).toBe("AssertionError"); expect(assertion.failure[0].$.message).toContain("expect(received).toBe(expected)"); expect(assertion.failure[0]._).toContain("Expected: 2"); expect(assertion.failure[0]._).toContain("Received: 1"); From a93ab8bcc0bb5805618a63ed23ed5b7fa2dabffb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:54:04 +0000 Subject: [PATCH 9/9] test(junit): broaden CSI-residue regex to cover parameterised SGR and non-SGR finals --- test/js/junit-reporter/junit.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index 1b56f732ea7b..c2e0f278a83f 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -550,8 +550,8 @@ describe("junit reporter", () => { expect(assertion.failure[0]._).toContain("fail.test.js:5"); // No ANSI escape sequences should leak into the report. escape_xml drops - // the ESC byte, so a colour leak would surface as bare SGR residue. - expect(xmlContent).not.toMatch(/\[\d+m/); + // the ESC byte, so a leak would surface as bare CSI residue. + expect(xmlContent).not.toMatch(/\[[\d;]*[A-HJKSTfm]/); expect(exitCode).toBe(1); }); });