Skip to content

bun:test: stop expect() failure messages from consuming <...> spans in user data - #34343

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/7772a9e3/expect-angle-bracket-stripping
Jul 18, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/7772a9e3/expect-angle-bracket-stripping

Conversation

@robobun

@robobun robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

What

expect() failure messages were running the <tag> -> ANSI/strip rewrite over the fully rendered string (template + substituted user data). Any <...> span in a Received/Expected value was parsed as markup and consumed.

test("t", () => expect('<div class="active">Hello</div>').toBe('<div class="inactive">Hello</div>'));
// 1.4:    Expected: "Hello"  /  Received: "Hello"        <-- identical strings reported unequal
// 1.3.14: Expected: "<div class="inactive">Hello</div>"  /  Received: "<div class="active">Hello</div>"

Under FORCE_COLOR=1 it was worse: toContain("<i>") emitted a live \x1b[3m italic escape, interpreting user data as markup.

Affects toBe, toContain, toHaveProperty, toThrow, not.*, .resolves/.rejects, expect(v, label), .pass/.fail messages, and expect.extend custom-matcher messages.

Cause

JSGlobalObject::throw_pretty(args: Arguments<'_>) rendered the entire args (template literal + user-data substitutions) into one buffer, then ran pretty_fmt_rt over that buffer. The Zig baseline applied Output.prettyFmt to the comptime template and substituted args via createErrorInstance afterwards, so user data was never scanned.

Fix

Restore the template-first contract by moving the <tag> rewrite to compile time everywhere in the matcher failure path:

  • New expect_throw! macro (re-exported as throw! in the expect module) applies pretty_fmt! to the template literal at compile time for both colour branches, then substitutes positional args via format_args!. User data in the args is never scanned.
  • Expect::throw becomes throw_rendered, a pure concat sink that emits its input verbatim. get_signature caches the ANSI/stripped header pair so the failure path does no runtime markup work.
  • Every matcher failure site (~130 across 40+ files) converts from this.throw(g, sig, format_args!(tpl, ..)) to throw!(this, g, sig, tpl, ..).
  • JSGlobalObject::throw_pretty and the JSGlobalObjectTestExt::throw_pretty shim are deleted; the template-only callers move to a compile-time throw_pretty_static! macro or plain throw (shell/BunObject messages had no markup to begin with).
  • throw_pretty_matcher_error and CustomMatcherParamsFormatter now emit matcher_name/matcher_params/message verbatim, so a custom matcher's returned message string is no longer scanned for markup.
  • Dropped the pre-escaped \< / \> operator glyphs in toHaveReturned / OrderingRelation now that operands are substituted verbatim.

Verification

New test/js/bun/test/expect-failure-message-angle-brackets.test.ts spawns a child bun test and asserts, for both NO_COLOR and FORCE_COLOR:

  • <div ...>Hello</div> appears verbatim in the Expected/Received lines
  • toContain("<i>") shows the literal "<i>" (and under colours, does not emit "\x1b[3m")
  • <b>/<u>/<red>/<d>/<blue>/<magenta> in property paths, thrown Error messages, custom labels, .pass() messages, .resolves received values, and expect.extend messages all render verbatim
  • the > glyph in toBeGreaterThan failure output still renders
  • template markup (green/red around expected/received) still works

Fails on 1.4.0-canary; passes with this change. test/js/bun/test/expect.test.js (406 tests) passes unchanged.


[review] gate passed · iteration 0 · 45 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/expect-failure-message-angle-brackets.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (60254a39a)

test/js/bun/test/expect-failure-message-angle-brackets.test.ts:
65 |   test("colors disabled: angle-bracketed strings render verbatim", async () => {
66 |     const { stderr, exitCode } = await run({ NO_COLOR: "1", FORCE_COLOR: undefined });
67 | 
68 |     // toBe on HTML strings: Expected/Received must show the full string, not
69 |     // just the text between tags.
70 |     expect(stderr).toContain('Expected: "<div class="inactive">Hello</div>"');
                        ^
error: expect(received).toContain(expected)

Expected to contain: "Expected: \"Hello\""
Received: "\nangle.test.ts:\n1 | \n2 | import { test, expect } from \"bun:test\";\n3 | \n4 | test(\"toBe html\", () = {\n5 |   expec
... (truncated)

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

test/js/bun/test/expect-failure-message-angle-brackets.test.ts:
65 |   test("colors disabled: angle-bracketed strings render verbatim", async () => {
66 |     const { stderr, exitCode } = await run({ NO_COLOR: "1", FORCE_COLOR: undefined });
67 | 
68 |     // toBe on HTML strings: Expected/Received must show the full string, not
69 |     // just the text between tags.
70 |     expect(stderr).toContain('Expected: "<div class="inactive">Hello</div>"');
                        ^
error: expect(received).toContain(expected)

Expected to contain: "Expected: \"Hello\""
Received: "\nangle.test.ts:\n1 | \n2 | import { test, expect } from \"bun:test\";\n3 | \n4 | test(\"toBe html\", () = {\n5 |   expect('<div class=\"active\">Hello</div>').toBe('<div class=\"inactive\">Hello</div>');\n                                                ^\nerror: expect(received).toBe(expected)\n\nExpected: \"Hello\"\nReceived: \"Hello\"\n\n      at <anonymous> (/tmp/expect-angle_M54n1e/angle.test.ts:5:45)\n(fail) toBe html [1.09ms]\n3 | \n4 | test(\"toBe html\", () => {\n5 |   expect('<div class=\"active\">Hello</div>').toBe('<div class=\"inactive\">Hello</di
... (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/bun/test/expect-failure-message-angle-brackets.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (60254a39a)

test/js/bun/test/expect-failure-message-angle-brackets.test.ts:
(pass) expect() failure messages preserve <...> in user data > colors disabled: angle-bracketed strings render verbatim [893.89ms]
(pass) expect() failure messages preserve <...> in user data > colors enabled: user data is not interpreted as ANSI markup [842.06ms]

 2 pass
 0 fail
 22 expect() calls
Ran 2 tests across 1 file. [6.24s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     60254a39a8
  features     (none)

22 deps, 106 codegen, 1168 objects in 1773ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] gen bindgenv2
[2/1231] fetch zlib
[zlib] up to date
[3/1231] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[4/1231] fetch tinycc
[tinycc] up to date
[5/1230] fetch picohttpparser
[picohttpparser] up to date
[6/1230] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[7/1230] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[8/1230] gen ProcessBindingBuffer.lut.h
Generating /workspace/
... (truncated)
diff hotspot
src/jsc/JSGlobalObject.rs                          |  32 --
 src/jsc/bindgen_test.rs                            |   2 +-
 src/runtime/api/BunObject.rs                       |   8 +-
 src/runtime/shell/ParsedShellScript.rs             |   4 +-
 src/runtime/shell/shell_body.rs                    |   6 +-
 src/runtime/test_runner/expect.rs                  | 425 ++++++++++++---------
 src/runtime/test_runner/expect/toBe.rs             |  47 ++-
 src/runtime/test_runner/expect/toBeArrayOfSize.rs  |  11 +-
 src/runtime/test_runner/expect/toBeCloseTo.rs      |  41 +-
 src/runtime/test_runner/expect/toBeEmpty.rs        |  38 +-
 src/runtime/test_runner/expect/toBeEmptyObject.rs  |  11 +-
 src/runtime/test_runner/expect/toBeInstanceOf.rs   |  19 +-
 src/runtime/test_runner/expect/toBeObject.rs       |  20 +-
 src/runtime/test_runner/expect/toBeOneOf.rs        |  33 +-
 src/runtime/test_runner/expect/toBeTypeOf.rs       |  39 +-
 src/runtime/test_runner/expect/toBeValidDate.rs    |  11 +-
 src/runtime/test_runner/expect/toBeWithin.rs       |  40 +-
 src/runtime/test_runner/expect/toContain.rs        |  33 +-
 src/runtime/test_runner/expect/toContainEqual.rs   |  13 +-
 src/runtime/test_runner/expect/toEqual.rs          |   5 +-
 .../expect/toEqualIgnoringWhitespace.rs            |  19 +-
 src/runtime/test_runner/expect/toHaveBeenCalled.rs |  31 +-
 .../test_runner/expect/toHaveBeenCalledOnce.rs     |  31 +-
 .../test_runner/expect/toHaveBeenCalledTimes.rs    |  35 +-
 .../test_runner/expect/toHaveBeenCalledWith.rs     |  34 +-
 .../test_runner/expect/toHaveBeenLastCalledWith.rs |  21 +-
 .../test_runner/expect/toHaveBeenNthCalledWith.rs  |  30 +-
 .../test_runner/expect/toHaveLastReturnedWith.rs   |  43 +--
 src/runtime/test_runner/expect/toHaveLength.rs     |  15 +-
 .../test_runner/expect/toHaveNthReturnedWith.rs    |  54 ++-
 src/runtime/test_runner/expect/toHaveProperty.rs   |  45 +--
 src/runtime/test_runner/expect/toHaveReturned.rs   |  22 +-
 .../test_
... (truncated)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                               reads  edits  tests
src/jsc/JSGlobalObject.rs                              2      1      0
src/jsc/bindgen_test.rs                                1      1      0
src/runtime/api/BunObject.rs                           1      2      0
src/runtime/shell/ParsedShellScript.rs                 1      1      0
src/runtime/shell/shell_body.rs                        1      2      0
src/runtime/test_runner/expect.rs                     13     37      0
src/runtime/test_runner/expect/toBe.rs                 2      0      0
src/runtime/test_runner/expect/toBeArrayOfSize.rs      0      0      0
src/runtime/test_runner/expect/toBeCloseTo.rs          1      1      0
src/runtime/test_runner/expect/toBeEmpty.rs            2      0      0
src/runtime/test_runner/expect/toBeEmptyObject.rs      0      0      0
src/runtime/test_runner/expect/toBeInstanceOf.rs       0      0      0
src/runtime/test_runner/expect/toBeObject.rs           0      0      0
src/runtime/test_runner/expect/toBeOneOf.rs            0      0      0
src/runtime/test_runner/expect/toBeTypeOf.rs           0      0      0
src/runtime/test_runner/expect/toBeValidDate.rs        0      0      0
(+ 29 more files)

…not the rendered message

expect() failure messages were running the `<tag>` -> ANSI/strip rewrite
over the fully rendered string (template + substituted user data), so any
`<...>` span in a Received/Expected value was consumed as markup. Comparing
two different HTML strings printed two byte-identical strings as unequal;
under FORCE_COLOR a user's `<i>` became a live `\x1b[3m` italic escape.

The Zig baseline applied Output.prettyFmt to the comptime template and
substituted args afterwards, so user data was never scanned. Restore that
contract:

- Add `expect_throw!` which applies `pretty_fmt!` to the template literal at
  compile time (both colour branches) and substitutes positional args via
  `format_args!`. Re-exported as `throw!` inside the expect module.
- Replace `Expect::throw` with `throw_rendered`, a pure concat sink that
  never scans its input for markup.
- `get_signature` now caches the ANSI/stripped header pair so the failure
  path does no runtime markup work at all.
- Convert every matcher failure site (~130 call sites across 40+ files) from
  `this.throw(g, sig, format_args!(tpl, ..))` to `throw!(this, g, sig, tpl, ..)`.
- Delete `JSGlobalObject::throw_pretty` and the `JSGlobalObjectTestExt`
  shim; the handful of template-only callers move to the compile-time
  `throw_pretty_static!` or plain `throw` (shell/BunObject messages had no
  markup to begin with).
- `throw_pretty_matcher_error` and `CustomMatcherParamsFormatter` now emit
  matcher_name/matcher_params/message verbatim; the custom-matcher message
  returned from expect.extend is no longer scanned for markup.
- Drop the pre-escaped `\<`/`\>` glyphs in toHaveReturned/OrderingRelation
  now that operands go through as verbatim args.

Affects toBe, toContain, toHaveProperty, toThrow, not.*, expect.any, custom
labels, .pass/.fail messages, .resolves/.rejects, expect.extend messages.
@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:09 AM PT - Jul 16th, 2026

❌ @autofix-ci[bot], your commit 60254a3 has 1 failures in Build #73795 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34343

That installs a local version of the PR into your bun-34343 executable, so you can run:

bun-34343 --bun

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 5 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bcf86c08-9833-4e51-bdf1-02be7f20f34b

📥 Commits

Reviewing files that changed from the base of the PR and between 0ecd508 and 60254a3.

📒 Files selected for processing (45)
  • src/jsc/JSGlobalObject.rs
  • src/jsc/bindgen_test.rs
  • src/runtime/api/BunObject.rs
  • src/runtime/shell/ParsedShellScript.rs
  • src/runtime/shell/shell_body.rs
  • src/runtime/test_runner/expect.rs
  • src/runtime/test_runner/expect/toBe.rs
  • src/runtime/test_runner/expect/toBeArrayOfSize.rs
  • src/runtime/test_runner/expect/toBeCloseTo.rs
  • src/runtime/test_runner/expect/toBeEmpty.rs
  • src/runtime/test_runner/expect/toBeEmptyObject.rs
  • src/runtime/test_runner/expect/toBeInstanceOf.rs
  • src/runtime/test_runner/expect/toBeObject.rs
  • src/runtime/test_runner/expect/toBeOneOf.rs
  • src/runtime/test_runner/expect/toBeTypeOf.rs
  • src/runtime/test_runner/expect/toBeValidDate.rs
  • src/runtime/test_runner/expect/toBeWithin.rs
  • src/runtime/test_runner/expect/toContain.rs
  • src/runtime/test_runner/expect/toContainEqual.rs
  • src/runtime/test_runner/expect/toEqual.rs
  • src/runtime/test_runner/expect/toEqualIgnoringWhitespace.rs
  • src/runtime/test_runner/expect/toHaveBeenCalled.rs
  • src/runtime/test_runner/expect/toHaveBeenCalledOnce.rs
  • src/runtime/test_runner/expect/toHaveBeenCalledTimes.rs
  • src/runtime/test_runner/expect/toHaveBeenCalledWith.rs
  • src/runtime/test_runner/expect/toHaveBeenLastCalledWith.rs
  • src/runtime/test_runner/expect/toHaveBeenNthCalledWith.rs
  • src/runtime/test_runner/expect/toHaveLastReturnedWith.rs
  • src/runtime/test_runner/expect/toHaveLength.rs
  • src/runtime/test_runner/expect/toHaveNthReturnedWith.rs
  • src/runtime/test_runner/expect/toHaveProperty.rs
  • src/runtime/test_runner/expect/toHaveReturned.rs
  • src/runtime/test_runner/expect/toHaveReturnedWith.rs
  • src/runtime/test_runner/expect/toIncludeRepeated.rs
  • src/runtime/test_runner/expect/toMatch.rs
  • src/runtime/test_runner/expect/toMatchInlineSnapshot.rs
  • src/runtime/test_runner/expect/toMatchObject.rs
  • src/runtime/test_runner/expect/toMatchSnapshot.rs
  • src/runtime/test_runner/expect/toSatisfy.rs
  • src/runtime/test_runner/expect/toStrictEqual.rs
  • src/runtime/test_runner/expect/toThrow.rs
  • src/runtime/test_runner/expect/toThrowErrorMatchingInlineSnapshot.rs
  • src/runtime/test_runner/expect/toThrowErrorMatchingSnapshot.rs
  • src/runtime/test_runner/mod.rs
  • test/js/bun/test/expect-failure-message-angle-brackets.test.ts

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

@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.

I didn't find any bugs, but this is a 45-file change that introduces a new cross-cutting macro abstraction (expect_throw! / __expect_throw_dispatch! with the peel/pool ident-binding pattern) and deletes JSGlobalObject::throw_pretty, so it's worth a human look at the macro design and the incidental behavior changes.

Checked: pretty_fmt! accepts concat!(...) templates (existing usage in output.rs); create_error_instance already carries the mid-format clear_exception_except_termination handling that throw_pretty had; no stray callers of the removed throw_pretty/throw_fmt/Expect::throw remain; the \\< / \\>= escapes still in toBeCloseTo/toHaveBeenCalled templates are correct (template still goes through pretty_fmt!). Noted a few incidental message-content changes: toBeArrayOfSize/toContainEqual now emit the "Received:"/"Expected to contain:" prose that the old throw_fmt shim was silently dropping, and toBeEmpty/toThrow sites that previously called global.throw_pretty directly now route through throw_rendered and so respect expect(v, label) custom labels.

Extended reasoning...

Overview

Fixes expect() failure messages consuming <...> spans in user data by moving the <tag> → ANSI rewrite from a runtime pass over the rendered string to a compile-time pass over the template literal. Touches 45 files: a new expect_throw! macro in test_runner/mod.rs, deletion of JSGlobalObject::throw_pretty, a rewrite of get_signature to cache both colour variants, and ~130 mechanical call-site conversions across the expect/to*.rs matchers plus a handful of shell/BunObject callers.

Security risks

None. This is error-message formatting; no auth, crypto, network, or filesystem paths are touched. The one arguably security-adjacent aspect — user data being interpreted as ANSI escape markup under FORCE_COLOR=1 — is what the PR fixes.

Level of scrutiny

High. While the per-file conversions are mechanical, the core is a new #[macro_export] macro with a non-trivial recursive peel/pool pattern that binds each arg once via match ($sig, &($v), ..) to feed both colour branches without double-evaluation. CLAUDE.md flags "new cross-cutting abstractions need maintainer agreement before appearing inside a feature PR"; a maintainer should sign off on the macro shape, the crate-root #[macro_export] placement, and the deletion of the shared throw_pretty API.

Other factors

Several incidental behavior changes ride along: (1) toBeArrayOfSize and toContainEqual previously called throw_fmt with a template that was documented-ignored, so their failure messages were missing the "Received:" / "Expected to contain:" prose — now emitted correctly; (2) four toBeEmpty sites and several toThrow sites that called global.throw_pretty directly (bypassing custom_label) now go through throw_rendered and will show the user's expect(v, label) label instead of the default header; (3) the deleted __get_signature! const-macro had no remaining callers I could find. These all look like improvements but are user-visible message changes not covered by the new test. Combined with the scope, deferring to a human.

@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI build 73795: all failures are tagged [pre-existing] or [flaky] by the helper and none touch the expect path (net memleak/handle-leak/write-slow/mongodb, install registry/proxy/upgrade, es-module-lexer timeout, no-orphans, require-cache, password, cron, webview, 20144). The new expect-failure-message-angle-brackets.test.ts and the existing expect suite passed on every lane. Diff is green; ready for review.

@Jarred-Sumner
Jarred-Sumner merged commit f44d5fe into main Jul 18, 2026
78 of 79 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/7772a9e3/expect-angle-bracket-stripping branch July 18, 2026 00:16
robobun added a commit that referenced this pull request Jul 18, 2026
…e labels

The "Expected to contain:" / "Received:" labels were being dropped
from these matchers' failure messages (the format literal was passed
to a discarded parameter). #34343 fixed the underlying bug as part of
migrating all matchers to the throw! macro; these tests lock the
message shape so it can't regress again.
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