Repository navigation
Conversation
|
No actionable comments were generated in the recent review. WalkthroughMessage formatting and matcher error handling now preserve errors thrown by custom inspection methods. New tests cover matcher and parameterized-test calls, and documentation describes the behavior. ChangesCustom inspection error propagation
Possibly related issues
Merge Risk:
|
|
Updated 3:41 AM PT - Nov 9th, 2026
❌ @robobun, your commit 95b90b8 has 5 failures in
🧪 To try this PR locally: bunx bun-pr 48119That installs a local version of the PR into your bun-48119 --bun |
|
Status Reproduced on main (7ca0042, debug build) with one test file: import { test, expect } from "bun:test";
const v = {
a: 1,
[Symbol.for("nodejs.util.inspect.custom")]() {
throw new Error("zz-boom");
},
};
test("a failing matcher", () => {
expect(v).toBeNull();
});
test("an argument check", () => {
expect(v).toHaveLength(1);
});Main reports 16 of the 26 new tests in PR: #48119. Merge #48114 first. Rebased onto main 2596ac5. The diff did not change. CI (build 139162, head 95b90b8, finished) No red test comes from this PR.
|
|
On the two issues listed as possibly related: this PR leaves both as they are.
|
JSGlobalObject::throw and throw_type_error return the pending exception when a Display impl ran JS that threw while the message was formatted. They used to clear it and throw the cut message. create_*_error_instance still clears it, because it has to return a value. bun:test: every message a built-in matcher builds now follows one rule. If a value's inspect method throws while the value is printed, the matcher throws that error. Expect::throw_rendered no longer renders the message twice to check for this, and InvalidMatcherError prints the returned value before it builds its error. docs: state the rule under Matchers and at Bun.inspect.custom.
028d87a to
95b90b8
Compare
Merge order: after #48114, or
Bun.$throwsTypeError: No default valuefor an object with no prototype.Decision asked: this changes
JSGlobalObject::throwfor all of Bun. Only 20 test-runner call sites reach the new branch. Fallback: a throw helper inside the test runner.Problem
bun test, a value whose[Bun.inspect.custom]method throws gives two outcomes.expect(v).toBeNull()throws that error (bun test: Vitest compatibility #44833).expect(v).toHaveLength(1)throwsReceived value does not have a length property:and drops it.global.throw, andJSGlobalObject::error_message(src/jsc/JSGlobalObject.rs:744) clears the pending exception. No doc states the rule.Fix
throwandthrow_type_errorreturn the pending exception when formatting their message threw.create_*_error_instancestill clears it: it must return a value.Expect::throw_rendereddrops its own check and message copy.throw_invalid_matcher_errorprints the returned value first. Docs state the rule.test/js/bun/test/expect.test.js(16 of 26 new tests fail on main),jest-each.test.ts, and the suites in Notes.debug_assert!on the clear that stays.Background
error_messagehunk of bun:test: propagate throwing Proxy traps through strict deepEquals and error construction #34649.global.throwlines there can still print a value.Downsides
toThrow("length property")around such a value.expect(Buffer.prototype).toHaveBeenCalled()now throwsERR_INVALID_THISfromBuffer's inspect method, astoBeNull()does on main (console: do not call inspect.custom on a prototype object #41281)..textgrows 256 B. An invalidexpect.extendresult costs 1 more allocation.Notes
The question this answers
When a failing built-in matcher prints a value and code of that value throws, does the matcher report its own message (as before #44833) or the thrown error (as on main)? The thrown error. The check in
Expect::throw_renderedis a commented edit of #44833, andtest/js/bun/test/printing/dom-nodes.test.ts:732and:764pin it for DOM nodes. #40068 made the diff printers do the same in v1.4.1. This PR applies that rule to the messages that still dropped the error, and writes it down.Measured on other runners (Jest 30.5.2 and Vitest 5.1.0 on Node v26.3.0, one test file per row)
toBeNull()toBe(1)toBeNull()toBeNull()entries()throws ;toBeNull()toBeNull()[Getter][Getter]The nine
throw!rows of the new test block pass on Vitest 5.1.0. On Jest 30.5.2 all nine keep Jest's message, so the block skips them there.The 16 sites that now throw the value's error (each printed a message cut at the colon on main)
expect/toHaveLength.rs:31and:56,expect/toMatch.rs:21and:28,expect/toBeInstanceOf.rs:23expect.rs:3224(toHaveBeenCalled(),toHaveReturned()and the other mock matchers on a value that is not a mock),:3247(an entry ofmock.resultsthat is not a result)expect/toHaveBeenCalledBefore.rs:44,expect/toHaveBeenLastCalledWith.rs:37,expect/toHaveResolved.rs:56expect.rs:562,:578,:597(.resolvesand.rejectson a value that is not a promise, or on a promise settled the other way)expect.rs:1862(expect.assertions(v)),expect.rs:1602(an invalidexpect.extendresult),ScopeFunctions.rs:254(test.each(v))A scan with one row per site: 25 of 58 rows change, the other 33 are byte-identical. The inspect method is called the same number of times on all 34 rows of a call-count probe.
Call sites that can take the new branch
throwandthrow_type_errorcall sites passformat_args!. 268 pass a literal and take the unchanged arm.Displaythat runs JS: 20 insrc/runtime/test_runner, andshell/states/Cmd.rs:781andshell/Builtin.rs:736, which no value reaches.shell/shell_body.rs:486is the 23rd until Bun.$: name a rejected object asObject.prototype.toString()does #48114 lands.throw_type_errorcaller passes one.toThrowargument check that bun:test: reject a non-constructor function passed to toThrow() with an argument error #48031 adds is covered with no edit.Measurements (release builds of main 7ca0042 and of this branch, same build directory)
.text: 110,627,497 B to 110,627,753 B. The stripped binary has the same file size.throw_formatted+290 B (new),Expect::throw_rendered267 to 155,execute_custom_matcher+52,throw42 to 21,throw_type_error45 to 24.#[inline(never)]onthrow_rendered: without it the smaller function is inlined into 13 matchers and.textgrows 4,352 B.expect.assertions(-1), gdb single-step, 6 consecutive calls after 500 warm-ups, JIT off): 3,764 instructions and 486 conditional branches on main, 3,760 and 486 here.has_exception()compiles to an inlined load and compare. The new one runs only after a failed write.toBeNull()on{ a: 1 }: 9 malloc and 1 realloc on main, 8 malloc and 0 realloc heretoHaveLength(1)on{ a: 1 }: 9 malloc on bothexpect.extendresult: 10 malloc on main, 11 malloc and 1 realloc heretoBeNull,toHaveLength,toMatch,toBeandtoEqualare instruction-identical in both builds (108, 306, 300, 356 and 147 instructions).Node (v26.3.0, same value):
assert.match(v, /a/)andassert.ifError(v)throw the method's error after one call.util.inspect(v)andconsole.log(v)do too, in Node and in Bun.Not in this PR
create_*_error_instancekeeps its documented clear. After thethrow_invalid_matcher_errorchange no caller passes it aDisplaythat runs JS.throw_dom_exceptionandthrow_sys_error: no caller passes one.[Getter]and the matcher keeps its message (bun test: Vitest compatibility #44833). The diff printers and snapshots still do not call the inspect method.error_messagehunk madecreate_*_error_instancereturn an empty value, which Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021 removed. Its strictdeepEqualshunk is separate.Self-review
Bun.$message is its own PR (Bun.$: name a rejected object asObject.prototype.toString()does #48114), withObject.prototype.toString()in place of the C++ class name.Bun.inspect.custom(docs/runtime/utils.mdx,bun.d.ts).debug_assert!(!failed)before the clear inerror_message, and a lint forto_fmtinsidecreate_*_error_instance. Both are optional and change what a debug build does for every other caller.Suites (debug build)
expect.test.js(490 pass),mock-fn.test.js(848),spyMatchers.test.ts(150),vitest-apis.test.ts(151),jest-each.test.ts(36),printing/dom-nodes.test.ts(79),expect-extend.test.js(122),expect-toHaveReturnedWith.test.js(13)BUN_JSC_validateExceptionChecks=1: the new tests,dom-nodes.test.ts,expect-extend.test.js,expect-extend-matcher-utils-throw.test.ts,expect-symbol-toPrimitive-crash.test.tsexpect-deferred.test.tsandvitest-flavor.test.ts: all pass except subprocess tests that hit the 5 s timeout, which main's debug build also hits on the same machinetsc --noEmitintest/,test/integration/bun-types/bun-types.test.ts[human-review] gate passed · iteration 3 · 7 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 3
evidence per changed file