Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 5 additions & 64 deletions src/runtime/test_runner/expect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
Resolved
#[bun_jsc::JsClass(no_construct, no_constructor)]
pub struct ExpectCustomAsymmetricMatcher {
pub(crate) flags: Flags,
Expand Down Expand Up @@ -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<bool> {
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<bool> {
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<JSValue> = 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
Comment thread
robobun marked this conversation as resolved.
Resolved
Expand Down
49 changes: 19 additions & 30 deletions src/runtime/test_runner/pretty_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2888,37 +2888,26 @@ impl JestPrettyFormat {
)?;
*this.amf_quote_strings() = original_quote_strings;
} else if let Some(instance) = value.as_class_ref::<expect::ExpectCustomAsymmetricMatcher>() {
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::<ENABLE_ANSI_COLORS>(
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::<ENABLE_ANSI_COLORS>(
bun_jsc::FormatTag::Array, &mut *writer.ctx, args_value, JSType::Array,
)?;
} else {
return Ok(false);
}
Expand Down
38 changes: 38 additions & 0 deletions test/js/bun/test/expect-extend-asymmetric-print.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<name> [args]` form.
test("ignores a toAsymmetricMatcher property on the matcher function", () => {

Check warning on line 5 in test/js/bun/test/expect-extend-asymmetric-print.test.ts

View check run for this annotation

Claude / Claude Code Review

Test added as new file instead of into existing expect-extend.test.js

This test belongs in `test/js/bun/test/expect-extend.test.js`, not a new file — test/CLAUDE.md:152 requires adding to the existing test file, and both the PR description ("Verified: test/js/bun/test/expect-extend.test.js (new test …)") and the robobun evidence gate (diff hotspot: `expect-extend.test.js | 41 ++++`) already claim it lives there. Fold this into `expect-extend.test.js` and delete this file; the test has no process-isolation requirement (it uses underscore-prefixed matcher names
Comment thread
robobun marked this conversation as resolved.
Resolved
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);
});
Loading