diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index fc99c1771959..945530619b96 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -2528,11 +2528,11 @@ impl ExpectArrayContaining { /// (but only created for *custom* matchers, as built-ins have their own classes) // R-2 (host-fn re-entrancy): every JS-exposed method takes `&self`. The only // field, `flags`, is set once at construction (`create()`) and never written -// thereafter, so it stays a bare `Flags` (no `Cell` needed). Both host-fns -// call into user JS (`execute_impl` → `execute_custom_matcher`, `custom_print` -// → `matcher_fn.call`) which can re-enter on the same `m_ctx`; holding a -// `noalias` `&mut Self` across that call is Stacked-Borrows UB even with no -// field writes. The codegen shim emits `&*__this` for `&self` receivers. +// thereafter, so it stays a bare `Flags` (no `Cell` needed). The host-fn +// calls into user JS (`execute_impl` → `execute_custom_matcher`) which can +// re-enter on the same `m_ctx`; holding a `noalias` `&mut Self` across that +// call is Stacked-Borrows UB even with no field writes. The codegen shim +// emits `&*__this` for `&self` receivers. #[bun_jsc::JsClass(no_construct, no_constructor)] pub struct ExpectCustomAsymmetricMatcher { pub(crate) flags: Flags, @@ -2637,65 +2637,6 @@ impl ExpectCustomAsymmetricMatcher { let matched = Self::execute_impl(self, callframe.this(), global_this, received_value)?; Ok(JSValue::from(matched)) } - - fn maybe_clear(global_this: &JSGlobalObject, err: JsError, dont_throw: bool) -> crate::Result { - if dont_throw { - global_this.clear_exception(); - return Ok(false); - } - match err { - JsError::OutOfMemory => Err(crate::Error::Alloc(bun_alloc::AllocError)), - _ => Err(crate::Error::Unexpected), - } - } - - /// Calls a custom implementation (if provided) to stringify this asymmetric matcher, and returns true if it was provided and it succeed - pub(crate) fn custom_print( - &self, - this_value: JSValue, - global_this: &JSGlobalObject, - writer: &mut (impl bun_io::Write + ?Sized), - dont_throw: bool, - ) -> crate::Result { - let Some(matcher_fn) = expect_custom_asymmetric_matcher_js::matcher_fn_get_cached(this_value) else { return Ok(false) }; - let fn_value = match matcher_fn.get(global_this, "toAsymmetricMatcher") { - Ok(v) => v, - Err(e) => return Self::maybe_clear(global_this, e, dont_throw), - }; - if let Some(fn_value) = fn_value { - if fn_value.js_type().is_function() { - let Some(captured_args) = expect_custom_asymmetric_matcher_js::captured_args_get_cached(this_value) else { return Ok(false) }; - let args_len = match captured_args.get_length(global_this) { - Ok(n) => n, - Err(e) => return Self::maybe_clear(global_this, e, dont_throw), - }; - let mut args: Vec = Vec::with_capacity(args_len as usize); - let mut iter = match captured_args.array_iterator(global_this) { - Ok(it) => it, - Err(e) => return Self::maybe_clear(global_this, e, dont_throw), - }; - loop { - match iter.next() { - Ok(Some(arg)) => args.push(arg), - Ok(None) => break, - Err(e) => return Self::maybe_clear(global_this, e, dont_throw), - } - } - - let result = match matcher_fn.call(global_this, this_value, &args) { - Ok(r) => r, - Err(e) => return Self::maybe_clear(global_this, e, dont_throw), - }; - let s = match result.to_bun_string(global_this) { - Ok(s) => s, - Err(e) => return Self::maybe_clear(global_this, e, dont_throw), - }; - write!(writer, "{}", s)?; - } - } - Ok(false) - } - } /// Reference: `MatcherContext` in https://github.com/jestjs/jest/blob/main/packages/expect/src/types.ts diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 4ac1f89167d4..8e30c984c9c6 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -2888,37 +2888,26 @@ impl JestPrettyFormat { )?; *this.amf_quote_strings() = original_quote_strings; } else if let Some(instance) = value.as_class_ref::() { - let printed = expect::ExpectCustomAsymmetricMatcher::custom_print( - instance, value, this.amf_global_this(), &mut *writer.ctx, true, - ) - .expect("unreachable"); - if !printed { - // default print (non-overridden by user) - let flags = instance.flags; - let Some(args_value) = - expect_js::custom::captured_args_get_cached(value) - else { - return Ok(true); - }; - let Some(matcher_fn) = - expect_js::custom::matcher_fn_get_cached(value) - else { - return Ok(true); - }; - let matcher_name = matcher_fn.get_name(this.amf_global_this())?; - - Self::print_asymmetric_matcher_promise_prefix(flags, this, writer); - if flags.not() { - this.amf_add_for_new_line(b"not ".len()); - writer.write_all(b"not "); - } - this.amf_add_for_new_line(matcher_name.length() + 1); - writer.print(format_args!("{}", matcher_name)); - writer.write_all(b" "); - this.amf_print_as::( - bun_jsc::FormatTag::Array, &mut *writer.ctx, args_value, JSType::Array, - )?; + let flags = instance.flags; + let Some(args_value) = expect_js::custom::captured_args_get_cached(value) else { + return Ok(true); + }; + let Some(matcher_fn) = expect_js::custom::matcher_fn_get_cached(value) else { + return Ok(true); + }; + let matcher_name = matcher_fn.get_name(this.amf_global_this())?; + + Self::print_asymmetric_matcher_promise_prefix(flags, this, writer); + if flags.not() { + this.amf_add_for_new_line(b"not ".len()); + writer.write_all(b"not "); } + this.amf_add_for_new_line(matcher_name.length() + 1); + writer.print(format_args!("{}", matcher_name)); + writer.write_all(b" "); + this.amf_print_as::( + bun_jsc::FormatTag::Array, &mut *writer.ctx, args_value, JSType::Array, + )?; } else { return Ok(false); } diff --git a/test/js/bun/test/expect-extend-asymmetric-print.test.ts b/test/js/bun/test/expect-extend-asymmetric-print.test.ts new file mode 100644 index 000000000000..20bb929479d7 --- /dev/null +++ b/test/js/bun/test/expect-extend-asymmetric-print.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; + +// Like Jest, Bun ignores a `toAsymmetricMatcher` property on a matcher function +// and prints the custom asymmetric matcher in the default ` [args]` form. +test("ignores a toAsymmetricMatcher property on the matcher function", () => { + let hookCalls = 0; + function _toBeDivisibleByHooked(actual: unknown, divisor: number) { + return { pass: typeof actual === "number" && actual % divisor === 0, message: () => "" }; + } + _toBeDivisibleByHooked.toAsymmetricMatcher = () => { + hookCalls++; + return "DivisibleBy<3>"; + }; + let stringified: string | undefined; + expect.extend({ + _toBeDivisibleByHooked, + _stringifyActual(actual) { + stringified = this.utils.stringify(actual); + return { pass: true, message: () => "" }; + }, + }); + const anyExpect = expect as any; + + let message = ""; + try { + expect({ n: 7 }).toEqual({ n: anyExpect._toBeDivisibleByHooked(3) }); + } catch (err) { + message = Bun.stripANSI((err as Error).message); + } + expect(message).toContain('"n": _toBeDivisibleByHooked ['); + expect(message).not.toContain("[object Object]"); + expect(message).not.toContain("DivisibleBy<3>"); + + anyExpect(anyExpect.not._toBeDivisibleByHooked(3))._stringifyActual(); + expect(stringified).toStartWith("not _toBeDivisibleByHooked ["); + expect(stringified).not.toContain("[object Object]"); + expect(hookCalls).toBe(0); +});