Skip to content

bun test --reporter=junit: fix broken XML, double-escaped classname, and empty <failure> - #34975

Merged
Jarred-Sumner merged 9 commits into
mainfrom
farm/820d63c5/junit-xml-fixes
Jul 23, 2026
Merged

Jarred-Sumner merged 9 commits into
mainfrom
farm/820d63c5/junit-xml-fixes

Conversation

@robobun

@robobun robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes three problems with the JUnit XML reporter that, together, made the report unparseable and stripped of any useful failure information.

Repro

cat > x.test.js <<'JS'
describe('suite <a> & "b"', () => {
  test("plain fail", () => { throw new Error("boom: important message"); });
  test("ctrl \x00nul\x1besc", () => { throw new Error("x"); });
});
JS
bun test --reporter=junit --reporter-outfile=r.xml
python3 -c "import xml.etree.ElementTree as ET; ET.parse('r.xml')"
# xml.etree.ElementTree.ParseError: not well-formed (invalid token): line 8, column 36

1. Control characters in test names produce malformed XML

escape_xml wrote &#N; for every C0 control byte but neither flushed the pending run nor advanced last, so the raw byte was also emitted, and the references appeared at the start of the string instead of in place. Since bytes 0x00..=0x08 / 0x0B / 0x0C / 0x0E..=0x1F are not legal XML 1.0 Chars even as numeric references, the fix passes TAB/LF/CR through and drops every other C0 byte.

Before: name="&#0;&#27;ctrl ^@nul^[esc" (parse error).
After: name="ctrl nulesc" (parses).

2. classname is double-escaped

The describe-scope names were XML-escaped while being joined with " &gt; ", then escaped again inside write_test_case, so describe('suite <a> & "b"') produced classname="suite &amp;lt;a&amp;gt; &amp;amp; &amp;quot;b&amp;quot;". The inner <testsuite name=""> was only escaped once, so the two disagreed. The join now concatenates the raw names with a raw " > " separator and leaves the single escape to write_test_case.

3. <failure> carries no message, stack, or correct type

Every thrown error was reported as <failure type="AssertionError" /> with no message or stack. on_uncaught_exception now records the error's name, message, and a colourless print_errorlike_object rendering on the JunitReporter; write_test_case emits them as the type and message attributes and the element body:

<failure type="Error" message="boom: important message">1 | describe(...
   ...
error: boom: important message
      at &lt;anonymous&gt; (x.test.js:3:71)
</failure>

TypeError, RangeError, etc. now report their real name in type. Timeouts also gain message="test timed out".

How did you verify your code works?

New tests in test/js/junit-reporter/junit.test.js:

  • produces well-formed XML when test names contain control characters: asserts the raw bytes contain no illegal C0 characters, no &#0;/&#27;, the report parses with a strict XML parser, and TAB/LF survive.
  • escapes the classname attribute exactly once: asserts no &amp;lt; / &amp;amp; etc. appear and the decoded classname round-trips to the original describe titles.
  • includes the error type, message and stack in <failure>: asserts type/message and body text for a plain Error, a TypeError, and an expect().toBe() failure, and that no ANSI escapes leak into the report.

All three fail against main and pass with this change. The existing junit reporter tests and the --parallel --reporter=junit tests still pass.


[review] gate passed · iteration 5 · 6 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/junit-reporter/junit.test.js
ninja: Entering directory `/workspace/bun/build/debug'
[1/184] gen bindgenv2
[2/184] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[3/184] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 240 extern-C blocks audited
[4/184] gen cpp.rs (cppbind)
[5/184] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute
... (truncated)

release without fix: 6 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/junit-reporter/junit.test.js:
(pass) junit reporter > should generate valid junit xml for passing tests %s [20.52ms]
(pass) junit reporter > should generate valid junit xml for passing tests %s [15.13ms]
/tmp/junit-comprehensive_5eGMij
253 |       stderr: "pipe",
254 |     });
255 |     await proc1.exited;
256 | 
257 |     const xmlContent1 = await file(junitPath1).text();
258 |     expect(filterJunitXmlOutput(xmlContent1)).toMatchSnapshot();
                                                    ^
error: expect(received).toMatchSnapshot(expected)

@@ -2,26 +2,26 @@
  "
  
    
      
        
-         
+         
        
        
-         
+         
        
        
-         
+         
        
        
-         
+         
            
          
        
        
        
-         AssertionError: expect(received).toBe(expected)&#10;&#10;Expected: 3&#10;Received: 2&#10;&#10;      at comprehensive.test.js:31:27&#10;
+         
        
        
          
        
        

- Expected  - 5
+ Received  + 5

      at <anonymous> (/workspace/bun/test/js/junit-reporter/junit.test.js:258:47)
(fail) junit reporter > mor
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/junit-reporter/junit.test.js
bun test v1.4.0 (a93ab8bcc)

test/js/junit-reporter/junit.test.js:
(pass) junit reporter > should generate valid junit xml for passing tests %s [796.77ms]
(pass) junit reporter > should generate valid junit xml for passing tests %s [576.89ms]
/tmp/junit-comprehensive_Py8zho
(pass) junit reporter > more scenarios [1138.97ms]
(pass) junit reporter > should report only the final result for a retried test [652.04ms]
(pass) junit reporter > produces well-formed XML when test names contain control characters [531.82ms]
(pass) junit reporter > escapes the classname attribute exactly once [467.97ms]
(pass) junit reporter > keeps the test body's error in <failure> when afterEach also throws [456.76ms]
(pass) junit reporter > includes the error type, message and stack in <failure> [525.69ms]

 8 pass
 0 fail
 2 snapshots, 135 expect() calls
Ran 8 tests across 1 file. [8.06s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 882ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/142] gen bindgenv2
[2/142] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[3/142] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[4/142] gen cpp.rs (cppbind)
[5/142] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/ap
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs                          |  10 ++
 src/runtime/cli/test_command.rs                    | 181 +++++++++++++++++++--
 src/runtime/test_runner/Execution.rs               |  18 ++
 src/runtime/test_runner/bun_test.rs                |  27 ++-
 .../__snapshots__/junit.test.js.snap               |  18 +-
 test/js/junit-reporter/junit.test.js               | 172 +++++++++++++++++++-
 6 files changed, 401 insertions(+), 25 deletions(-)

gate history · 6 passed · 1 rejected · iteration 5

evidence per changed file
file                                                     reads  edits  tests
src/jsc/VirtualMachine.rs                                    8      2      0
src/runtime/cli/test_command.rs                             12     14      0
src/runtime/test_runner/Execution.rs                         4      3      0
src/runtime/test_runner/bun_test.rs                          6      8      0
test/js/junit-reporter/__snapshots__/junit.test.js.snap      0      0      0
test/js/junit-reporter/junit.test.js                         7     16      0

…and empty <failure>

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 &#0;
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 <a> & "b"' rendered as '&amp;lt;a&amp;gt;' in the
report. The join now concatenates the raw names with a raw ' > '
separator and leaves the single escape to write_test_case.

<failure> for a thrown error was always emitted as
  <failure type="AssertionError" />
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.
@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Jul 22nd, 2026

@robobun, your commit a93ab8b is building: #78333

@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Adds a VirtualMachine callback for observing printed ZigException values, connects it to JUnit failure capture, serializes exception details into JUnit XML, clears stale retry state, and adds coverage for retries, XML controls, classname escaping, cleanup errors, and stack output.

JUnit failure reporting

Layer / File(s) Summary
VirtualMachine error hook
src/jsc/VirtualMachine.rs
Adds an optional callback invoked during side-effecting exception printing.
JUnit failure capture and serialization
src/runtime/cli/test_command.rs
Captures exception metadata and stack frames, emits detailed failures, and updates XML escaping, timeout messages, and classname handling.
Test runner integration and validation
src/runtime/test_runner/bun_test.rs, src/runtime/test_runner/Execution.rs, test/js/junit-reporter/junit.test.js
Temporarily installs the JUnit callback, clears captured failures between attempts, and validates retry, XML, escaping, cleanup-error, and failure-output behavior.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main JUnit reporter fixes and is concise enough for history scanning.
Description check ✅ Passed The description matches the template and includes both the PR purpose and verification steps.

Comment @coderabbitai help to get the list of available commands.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs the exception formatter twice. Can we instead wire writing the log through run_error_handler, like we already do for GitHub Actions and similar?

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Good call. I'll switch to the GitHub Actions pattern: add a fn-ptr slot on VirtualMachine that print_error_instance_body calls with the already-populated &ZigException (right next to the debugger / GitHub annotation hooks, gated on allow_side_effects). on_uncaught_exception installs it around the existing run_error_handler call, and the junit reporter builds the <failure> body from the remapped name/message/stack frames. No second to_zig_exception / print_errorlike_object.

robobun added 2 commits July 22, 2026 07:16
…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 <failure> body is built from the remapped name/message/stack frames,
matching the standard Error.stack shape CI ingesters expect.
Comment thread test/js/junit-reporter/__snapshots__/junit.test.js.snap Outdated
Comment thread src/runtime/cli/test_command.rs
robobun added 2 commits July 22, 2026 07:43
…y 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.
Comment thread test/js/junit-reporter/junit.test.js Outdated
escape_xml drops ESC, so a colour leak would surface as bare [Nm
sequences; the previous assertion could never fail.
@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI build 77656 on 49cf823: test/js/junit-reporter/junit.test.js passes on every lane. The remaining reds are unrelated to this diff:

  • test/js/bun/http/bun-server.test.ts (darwin-14 aarch64): Bun.serve HTTP test, nothing in this diff touches the server path.
  • bun-install-registry, test-http-server-connections-checking-leak, node-http-uaf, spawn.test.ts, fetch-backpressure, no-orphans, 20144: all flaky, passed on retry.

Ready for another look.

Comment thread src/runtime/cli/test_command.rs
Comment thread src/runtime/test_runner/bun_test.rs Outdated
…boundary

escape_xml now emits &#9;/&#10;/&#13; 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 <failure> 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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

49cf823 addresses both remaining points — TAB/LF/CR now emit numeric references, and the last_failure clear moved to the retry/repeat boundary in advance_sequence with the new afterEach test covering the within-attempt accumulation. No new findings this round. Leaving the final call to a maintainer given the new VM-level on_print_error_zig_exception hook and the acknowledged test.concurrent interleaving deferral.

What was reviewed:

  • escape_xml C0 handling against XML 1.0 §2.2/§3.3.3; verified the flush/advance now brackets both new arms.
  • discard_junit_failure placement — retry and repeat both clear, write_test_case also clears at the tail so a passing repeat after a failing attempt won't carry stale state into a later failure.
  • Hook install/uninstall around run_error_handler is symmetric on both branches; junit_ctx points into the Box<JunitReporter> heap so the intermediate reporter.as_ref() in on_before_print doesn't invalidate it.
  • Snapshot now uses cwd-relative forward-slash frame paths, no temp-dir substitution.
Extended reasoning...

Overview

Three-part fix to the JUnit XML reporter: (1) escape_xml now flushes+advances around C0 bytes, dropping illegal ones and emitting &#9;/&#10;/&#13; for TAB/LF/CR; (2) describe-scope classname joining no longer pre-escapes before write_test_case's own escape; (3) a new VirtualMachine::on_print_error_zig_exception fn-ptr hook lets on_uncaught_exception capture the already-remapped ZigException name/message/stack into JunitReporter::last_failure, which write_test_case then serialises into <failure type= message=>body</failure>. Supporting changes: discard_junit_failure at retry/repeat boundaries, cwd-relative forward-slash frame paths, timeout message=.

Security risks

None. Output-only reporter path; no untrusted-input parsing, no auth/crypto/network. The new VM hook is a plain fn-ptr + ctx set/cleared around a single synchronous call, gated on the junit reporter being active.

Level of scrutiny

Medium-high. The XML escaping and classname changes are mechanical and well-covered. The failure-capture plumbing is more involved: it adds two fields to VirtualMachine, threads a raw *mut JunitReporter through a VM callback, and coordinates state across on_uncaught_exception → print_error_instance_body → write_test_case with retry/repeat/afterEach lifecycles. This went through three review rounds with a substantive fix each time (Windows path snapshot, retry accumulation → afterEach clobber → boundary clear), which is a signal the state machine is subtle.

Other factors

  • Four new targeted tests plus a strengthened retry assertion and a snapshot update; author confirmed all pass on every CI lane.
  • The VM hook mirrors the existing GitHub Actions annotation / debugger lifecycle-reporter pattern at the same call site (allow_side_effects guard), so it's not a novel mechanism — but adding fields to VirtualMachine is the kind of surface a maintainer should ack.
  • The test.concurrent same-microtask interleaving is explicitly deferred; the fallback is the pre-PR bare <failure type="Error" />, so not a regression, but worth a maintainer nod that the follow-up scope is acceptable.
  • The record_failure_cb unsafe cast is sound (ctx is set to &mut JunitReporter for the duration of one synchronous run_error_handler, single-threaded, cleared afterward; the pointer targets the Box heap so on_before_print's shared reporter borrow doesn't invalidate it), but it's the kind of raw-ptr threading a human should glance at.

Comment thread test/js/junit-reporter/__snapshots__/junit.test.js.snap Outdated
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix clippy

…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 <failure> 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).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/junit-reporter/junit.test.js`:
- Around line 552-554: Extend the JUnit report test around the existing ANSI
assertion with fixture messages containing parameterized SGR such as ESC[1;31m
and non-SGR CSI such as ESC[2K sequences. Assert the generated xmlContent
excludes their post-escape residue, while retaining the existing simple SGR
coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f3215b39-ff36-4b2d-bd06-898d41101d6b

📥 Commits

Reviewing files that changed from the base of the PR and between 186318a and 202a194.

⛔ Files ignored due to path filters (1)
  • test/js/junit-reporter/__snapshots__/junit.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • src/runtime/cli/test_command.rs
  • src/runtime/test_runner/Execution.rs
  • src/runtime/test_runner/bun_test.rs
  • test/js/junit-reporter/junit.test.js

Comment thread test/js/junit-reporter/junit.test.js Outdated
@robobun
robobun force-pushed the farm/820d63c5/junit-xml-fixes branch from 5d7ad8b to a93ab8b Compare July 23, 2026 01:54
@Jarred-Sumner
Jarred-Sumner merged commit a1e6a45 into main Jul 23, 2026
50 of 51 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/820d63c5/junit-xml-fixes branch July 23, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants