From 33d42aa52edbbccdb7e88f57b37a20d080f83d3c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:05:22 +0000 Subject: [PATCH 1/3] bun:test: throw when an equality tester, asymmetricMatch(), isEqualNode(), contains() or a toSatisfy predicate returns a promise A comparison took the return value of these five user functions by its truthiness. A promise is an object, so a promise of false counted as true and the test passed. Answerer::ask() calls the function and refuses a promise or another thenable with a TypeError, whatever the promise holds. It marks the promise as handled, so that its rejection does not fail the test that runs when it rejects. --- docs/test/writing-tests.mdx | 4 + packages/bun-types/test.d.ts | 6 + src/jsc/bindings/bindings.cpp | 31 +++- src/runtime/test_runner/expect.rs | 7 +- .../test_runner/expect/addEqualityTesters.rs | 3 +- src/runtime/test_runner/expect/toContain.rs | 3 +- src/runtime/test_runner/expect_deferred.rs | 68 ++++++++ test/js/bun/test/expect.test.js | 91 ++++++++++ test/js/bun/test/jest-extended.test.js | 29 ++++ test/js/bun/test/printing/dom-nodes.test.ts | 29 ++++ test/js/bun/test/vitest-apis.test.ts | 161 ++++++++++++++++++ test/js/bun/test/vitest-flavor.test.ts | 47 +++++ 12 files changed, 467 insertions(+), 12 deletions(-) diff --git a/docs/test/writing-tests.mdx b/docs/test/writing-tests.mdx index ff538ecad494..fbd94e50ea55 100644 --- a/docs/test/writing-tests.mdx +++ b/docs/test/writing-tests.mdx @@ -665,6 +665,8 @@ A tester registered in a test file applies until that file ends. A tester regist To compare nested values inside a tester, call `this.equals(a, b, customTesters)`. `customTesters` is the third argument the tester receives. +A tester returns its answer synchronously. If a tester returns a promise or another thenable, the matcher throws a `TypeError`. + ## Test State `expect.getState()` returns information about the running test. Custom matchers can read the properties below from `this`. @@ -873,6 +875,8 @@ test("the callback is a function", () => { }); ``` +The `predicate` of `expect.toSatisfy()` and an `asymmetricMatch()` method return their answer synchronously, like an [equality tester](#custom-equality). If one of them returns a promise or another thenable, the matcher throws a `TypeError`. + ## Best Practices ### Use Descriptive Test Names diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index 5802792ca63a..dc7027d2b9f3 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -1659,6 +1659,9 @@ declare module "bun:test" { * to the next tester, and then to the built-in rules. Testers registered by a test file last * until that file ends. Testers registered by a preload script apply to every file. * + * A tester returns its answer synchronously. If it returns a promise or another thenable, the + * matcher throws a `TypeError`. + * * @example * expect.addEqualityTesters([ * (a, b) => (a instanceof Volume && b instanceof Volume ? a.equals(b) : undefined), @@ -1839,6 +1842,9 @@ declare module "bun:test" { * `message` describes the predicate. It has no effect on the match. It is printed after the * predicate wherever the matcher is printed, such as in a failure message or a snapshot. * + * `predicate` returns its answer synchronously. If it returns a promise or another thenable, + * the matcher throws a `TypeError`. + * * @example * const isOdd = (n: number) => n % 2 === 1; * expect({ count: 3 }).toEqual({ count: expect.toSatisfy(isOdd) }); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 81597bb610cc..a72562066a3c 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -694,6 +694,29 @@ static NEVER_INLINE std::optional runEqualityTesters(JSGlobalObject* globa return verdict != 0; } +// Must match `Answerer` in expect_deferred.rs. +enum class ExpectAnswerer : uint8_t { + EqualityTester, + AsymmetricMatch, + IsEqualNode, + SatisfyPredicate, + Contains, +}; + +extern "C" void Expect__refuseThenable(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue answer, ExpectAnswerer who); + +// `Answerer::ask` of expect_deferred.rs: a promise that user code returns is no answer, so it throws. +static ALWAYS_INLINE bool askPredicate(JSGlobalObject* globalObject, ThrowScope& scope, ExpectAnswerer who, JSValue function, JSValue thisValue, const ArgList& arguments) +{ + JSValue answer = JSC::call(globalObject, function, JSC::getCallData(function), thisValue, arguments); + RETURN_IF_EXCEPTION(scope, false); + if (answer.isObject()) [[unlikely]] { + Expect__refuseThenable(globalObject, JSValue::encode(answer), who); + RETURN_IF_EXCEPTION(scope, false); + } + return answer.toBoolean(globalObject); +} + // Rules out most objects without a property lookup. static ALWAYS_INLINE bool mayHaveAsymmetricMatchMethod(JSGlobalObject* globalObject, JSValue value) { @@ -755,9 +778,7 @@ static NEVER_INLINE std::optional matchUserAsymmetricMatcher(JSGlobalObjec JSValue other = methodOfA ? b : a; MarkedArgumentBuffer arguments; arguments.append(other ? other : jsUndefined()); - JSValue matched = JSC::call(globalObject, method, JSC::getCallData(method), methodOfA ? a : b, arguments); - RETURN_IF_EXCEPTION(scope, false); - return matched.toBoolean(globalObject); + return askPredicate(globalObject, scope, ExpectAnswerer::AsymmetricMatch, method, methodOfA ? a : b, arguments); } // jsdom and happy-dom nodes are class instances, sometimes wrapped in a Proxy. @@ -854,9 +875,7 @@ static NEVER_INLINE std::optional domNodesDequal(JSGlobalObject* globalObj MarkedArgumentBuffer arguments; arguments.append(o2); - JSValue result = JSC::call(globalObject, isEqualNode, JSC::getCallData(isEqualNode), o1, arguments); - RETURN_IF_EXCEPTION(scope, std::nullopt); - return result.toBoolean(globalObject); + return askPredicate(globalObject, scope, ExpectAnswerer::IsEqualNode, isEqualNode, o1, arguments); } JSValue getIndexWithoutAccessors(JSGlobalObject* globalObject, JSObject* obj, uint64_t i) diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index f24aacfd29cb..fc1f6c43e547 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -17,7 +17,7 @@ use bun_ptr::RefPtr; use super::bun_test::{self}; use super::diff_format::DiffFormatter; -use self::expect_deferred::{Asked, ExpectDeferred, MatcherCall, Pass, Question}; +use self::expect_deferred::{Answerer, Asked, ExpectDeferred, MatcherCall, Pass, Question}; use super::execution::ExpectAssertions; use super::jest::Jest; use super::pretty_format::JestPrettyFormat; @@ -1504,9 +1504,8 @@ impl Expect { predicate: JsLocal<'_>, ) -> JsResult { let global_this = cx.global(); - let pass = predicate - .to_js() - .call(global_this, JSValue::UNDEFINED, &[received.to_js()])? + let pass = Answerer::SatisfyPredicate + .ask(global_this, predicate.to_js(), JSValue::UNDEFINED, &[received.to_js()])? .to_boolean(); let result = JSValue::create_empty_object(global_this, 1); result.put(global_this, b"pass", JSValue::from(pass)); diff --git a/src/runtime/test_runner/expect/addEqualityTesters.rs b/src/runtime/test_runner/expect/addEqualityTesters.rs index a93d275a52a0..a344a4f8f479 100644 --- a/src/runtime/test_runner/expect/addEqualityTesters.rs +++ b/src/runtime/test_runner/expect/addEqualityTesters.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSGlobalObject, JSValue, JsClass as _, JsResult}; use super::super::jest::{FileColumns as _, Jest}; use crate::generated_bindgen::expect::js_ExpectMatcherUtils as js; +use super::expect_deferred::Answerer; use super::{Expect, ExpectMatcherContext, ExpectMatcherUtils, Flags}; impl Expect { @@ -131,7 +132,7 @@ impl Expect { tester, )); } - let verdict = tester.call(global, context, &[a, b, testers])?; + let verdict = Answerer::EqualityTester.ask(global, tester, context, &[a, b, testers])?; if !verdict.is_undefined() { return Ok(Some(verdict.to_boolean())); } diff --git a/src/runtime/test_runner/expect/toContain.rs b/src/runtime/test_runner/expect/toContain.rs index ded0ab83eb69..00ec4da25fa9 100644 --- a/src/runtime/test_runner/expect/toContain.rs +++ b/src/runtime/test_runner/expect/toContain.rs @@ -5,6 +5,7 @@ use bun_jsc::{JSGlobalObject, JSValue, JsResult, VM}; use bun_core::strings; use super::Expect; +use super::expect_deferred::Answerer; use super::get_signature; use super::throw; @@ -68,7 +69,7 @@ impl Expect { ))); } let contains = value.get(global, "contains")?.unwrap_or(JSValue::UNDEFINED); - pass = contains.call(global, value, &[expected])?.to_boolean(); + pass = Answerer::Contains.ask(global, contains, value, &[expected])?.to_boolean(); } else if value.is_iterable(global)? { let mut expected_entry = ExpectedEntry { global, diff --git a/src/runtime/test_runner/expect_deferred.rs b/src/runtime/test_runner/expect_deferred.rs index 72f8e3c239bc..651cfa48ef60 100644 --- a/src/runtime/test_runner/expect_deferred.rs +++ b/src/runtime/test_runner/expect_deferred.rs @@ -544,6 +544,74 @@ impl Pass { } } +/// Must match `ExpectAnswerer` in bindings.cpp. +#[derive(Copy, Clone)] +pub(crate) enum Answerer { + EqualityTester, + AsymmetricMatch, + IsEqualNode, + SatisfyPredicate, + Contains, +} + +impl Answerer { + const ALL: [Answerer; 5] = [ + Answerer::EqualityTester, + Answerer::AsymmetricMatch, + Answerer::IsEqualNode, + Answerer::SatisfyPredicate, + Answerer::Contains, + ]; + + /// Calls a user function whose answer decides a comparison at once. A promise is no answer, so it throws. + #[inline] + pub(crate) fn ask( + self, + global: &JSGlobalObject, + function: JSValue, + this: JSValue, + arguments: &[JSValue], + ) -> JsResult { + let answer = function.call(global, this, arguments)?; + if answer.is_object() { + self.refuse_thenable(global, answer)?; + } + Ok(answer) + } + + #[cold] + #[optimize(size)] + fn refuse_thenable(self, global: &JSGlobalObject, answer: JSValue) -> JsResult<()> { + let returned = if let Some(promise) = answer.as_any_promise() { + // The error below reports it. As an unhandled rejection it would also fail whichever test runs then. + promise.set_handled(global.vm()); + "a promise" + } else if answer.get(global, "then")?.is_some_and(JSValue::is_callable) { + "a thenable" + } else { + return Ok(()); + }; + let who = match self { + Answerer::EqualityTester => "An equality tester", + Answerer::AsymmetricMatch => "asymmetricMatch()", + Answerer::IsEqualNode => "isEqualNode()", + Answerer::SatisfyPredicate => "The predicate of expect.toSatisfy()", + Answerer::Contains => "contains()", + }; + Err(global.throw_type_error(format_args!( + "{who} returned {returned}. It has to return its answer synchronously" + ))) + } + + /// Called by C++ deep equality with an object that `who` returned. Leaves an exception pending if it is refused. + #[unsafe(no_mangle)] + pub(crate) extern "C" fn Expect__refuseThenable(global: &JSGlobalObject, answer: JSValue, who: u8) { + if let Err(error) = Self::ALL[usize::from(who)].refuse_thenable(global, answer) { + bun_jsc::host_fn::host_call_error_value(global, error); + } + } +} + /// A matcher call waiting on a promise. [`Pass::wait_for`] throws it and `Expect::matcher_threw` catches it. pub(crate) struct ExpectDeferred { held: JsCell>, diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index db7fe78a3959..b4c849eb9e28 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -5369,6 +5369,97 @@ describe("expect()", () => { expect(() => expect({ n: 1 }).toMatchObject({ n: throws })).toThrow(new RangeError("thrown by the matcher")); }); + // Jest and Vitest take the promise itself for the answer, and don't call an async asymmetricMatch() at all. + test_skipIf(!isBun)("asymmetricMatch() that returns a promise or another thenable throws", async () => { + const refused = (/** @type {string} */ returned) => + `asymmetricMatch() returned ${returned}. It has to return its answer synchronously`; + const rejectsLater = Promise.withResolvers(); + class InheritsThen { + then() {} + } + const called = jest.fn(); + called(5); + const cases = /** @type {const} */ ([ + [{ asymmetricMatch: async () => true }, "a promise"], + [{ asymmetricMatch: async () => false }, "a promise"], + [{ asymmetricMatch: () => Promise.resolve(undefined) }, "a promise"], + [{ asymmetricMatch: () => Promise.reject(new Error("rejected before it was returned")) }, "a promise"], + [{ asymmetricMatch: () => rejectsLater.promise }, "a promise"], + [{ asymmetricMatch: () => ({ then() {} }) }, "a thenable"], + [{ asymmetricMatch: () => new InheritsThen() }, "a thenable"], + ]); + for (const [matcher, returned] of cases) { + for (const assertion of [ + () => expect(5).toEqual(matcher), + () => expect(matcher).toEqual(5), + () => expect([5]).not.toEqual([matcher]), + () => expect({ n: 5 }).toStrictEqual({ n: matcher }), + () => expect({ n: 5, other: 1 }).toMatchObject({ n: matcher }), + () => expect([5]).toContainEqual(matcher), + () => expect(called).toHaveBeenCalledWith(matcher), + () => expect({ n: 5, other: 1 }).toEqual(expect.objectContaining({ n: matcher })), + () => expect([5]).toEqual(expect.arrayContaining([matcher])), + ]) { + expect(assertion).toThrow(TypeError); + expect(assertion).toThrow(new TypeError(refused(returned))); + } + } + + // An unhandled rejection is reported once the microtasks have run, and fails the test that is running then. + rejectsLater.reject(new Error("rejected after the matcher threw")); + await new Promise(resolve => setImmediate(resolve)); + }); + + test_skipIf(!isBun)( + "a promise from asymmetricMatch() is refused on the re-run of a matcher that waited", + async () => { + expect.extend({ + async toPassLater() { + await 0; + return { pass: true, message: () => "" }; + }, + }); + const asymmetricMatch = jest.fn(async () => true); + const assertion = expect([1, 5]).toEqual([ANY(expect).toPassLater(), { asymmetricMatch }]); + expect(asymmetricMatch).toHaveBeenCalledTimes(0); + await expect(assertion).rejects.toThrow( + new TypeError("asymmetricMatch() returned a promise. It has to return its answer synchronously"), + ); + expect(asymmetricMatch).toHaveBeenCalledTimes(1); + }, + ); + + test_skipIf(!isBun)("an object from asymmetricMatch() is an answer unless its `then` is a function", () => { + const answering = (/** @type {unknown} */ answer) => ({ asymmetricMatch: () => answer }); + for (const answer of [{}, { then: true }, [], () => {}]) { + expect([5]).toEqual([answering(answer)]); + expect(() => expect([5]).not.toEqual([answering(answer)])).toThrow("toEqual"); + } + + const then = jest.fn(() => undefined); + expect([5]).toEqual([answering(Object.defineProperty({}, "then", { get: then }))]); + expect(then).toHaveBeenCalledTimes(1); + + const reason = new RangeError("then was read"); + const throwingGetter = { + get then() { + throw reason; + }, + }; + const throwingTrap = new Proxy( + {}, + { + get() { + throw reason; + }, + }, + ); + for (const answer of [throwingGetter, throwingTrap]) { + expect(() => expect([5]).toEqual([answering(answer)])).toThrow(reason); + expect(() => expect({ n: 5 }).not.toMatchObject({ n: answering(answer) })).toThrow(reason); + } + }); + test("two matchers are compared as plain objects", () => { const never = () => false; expect({ asymmetricMatch: never }).toEqual({ asymmetricMatch: never }); diff --git a/test/js/bun/test/jest-extended.test.js b/test/js/bun/test/jest-extended.test.js index 740fcc8cba05..9f0f6c8eacb8 100644 --- a/test/js/bun/test/jest-extended.test.js +++ b/test/js/bun/test/jest-extended.test.js @@ -255,6 +255,35 @@ describe("jest-extended", () => { expect(make).toThrow("Expected a function"); } }); + + test("expect.toSatisfy() throws if its predicate returns a promise or another thenable", async () => { + const refused = returned => + `The predicate of expect.toSatisfy() returned ${returned}. It has to return its answer synchronously`; + const rejectsLater = Promise.withResolvers(); + for (const [predicate, returned] of [ + [async () => true, "a promise"], + [async () => false, "a promise"], + [() => Promise.reject(new Error("rejected before it was returned")), "a promise"], + [() => rejectsLater.promise, "a promise"], + [() => ({ then() {} }), "a thenable"], + ]) { + for (const assertion of [ + () => expect(7).toEqual(expect.toSatisfy(predicate)), + () => expect(7).not.toEqual(expect.toSatisfy(predicate)), + () => expect({ a: 7 }).toEqual({ a: expect.not.toSatisfy(predicate) }), + () => expect([7]).toContainEqual(expect.toSatisfy(predicate)), + () => expect.toSatisfy(predicate).asymmetricMatch(7), + ]) { + expect(assertion).toThrow(TypeError); + expect(assertion).toThrow(new TypeError(refused(returned))); + } + } + expect(7).toEqual(expect.toSatisfy(() => ({ then: true }))); + + // An unhandled rejection is reported once the microtasks have run, and fails the test that is running then. + rejectsLater.reject(new Error("rejected after the matcher threw")); + await new Promise(resolve => setImmediate(resolve)); + }); } // Array diff --git a/test/js/bun/test/printing/dom-nodes.test.ts b/test/js/bun/test/printing/dom-nodes.test.ts index dfafbfc7a083..089bcf3a5f61 100644 --- a/test/js/bun/test/printing/dom-nodes.test.ts +++ b/test/js/bun/test/printing/dom-nodes.test.ts @@ -854,6 +854,35 @@ describe("equality", () => { for (const falsy of [0, "", null, undefined]) expect(returning(falsy)).not.toEqual(h("i")); }); + test("isEqualNode() that returns a promise or another thenable throws", async () => { + const returning = (returned: unknown) => Object.assign(h("i"), { isEqualNode: () => returned }); + const refused = (returned: string) => + `isEqualNode() returned ${returned}. It has to return its answer synchronously`; + const rejectsLater = Promise.withResolvers(); + for (const [returned, described] of [ + [Promise.resolve(true), "a promise"], + [Promise.resolve(false), "a promise"], + [Promise.reject(new Error("rejected before it was returned")), "a promise"], + [rejectsLater.promise, "a promise"], + [{ then() {} }, "a thenable"], + ] as const) { + for (const assertion of [ + () => expect(returning(returned)).toEqual(h("i")), + () => expect(returning(returned)).not.toEqual(h("i")), + () => expect(returning(returned)).toStrictEqual(h("i")), + () => expect({ n: returning(returned) }).toMatchObject({ n: h("i") }), + ]) { + expect(assertion).toThrow(TypeError); + expect(assertion).toThrow(new TypeError(refused(described))); + } + } + expect(returning({ then: true })).toEqual(h("b")); + + // An unhandled rejection is reported once the microtasks have run, and fails the test that is running then. + rejectsLater.reject(new Error("rejected after the matcher threw")); + await new Promise(resolve => setImmediate(resolve)); + }); + test("toMatchObject uses isEqualNode() for two nodes and properties for a plain pattern", () => { expect({ n: h("i", { a: "1" }), x: 1 }).toMatchObject({ n: h("i", { a: "1" }) }); expect({ n: h("i", { a: "1" }), x: 1 }).not.toMatchObject({ n: h("i", { a: "2" }) }); diff --git a/test/js/bun/test/vitest-apis.test.ts b/test/js/bun/test/vitest-apis.test.ts index 1e8221e52ca1..b1ae69e07351 100644 --- a/test/js/bun/test/vitest-apis.test.ts +++ b/test/js/bun/test/vitest-apis.test.ts @@ -1090,6 +1090,167 @@ describe("expect.addEqualityTesters", () => { ); }); + const refused = (returned: string) => + `An equality tester returned ${returned}. It has to return its answer synchronously`; + function thrownBy(fn: () => unknown): unknown { + try { + fn(); + } catch (error) { + return error; + } + throw new Error("expected the function to throw"); + } + + test("a tester that returns a promise or another thenable throws, whatever the promise holds", async () => { + let answer: unknown; + const sentinel = "vitest-apis-promise"; + expect.addEqualityTesters([(a, b) => (a === sentinel || b === sentinel ? (answer as boolean) : undefined)]); + const fn = mock(); + fn(sentinel); + const assertions = [ + () => expect(sentinel).toEqual("x"), + () => expect(sentinel).not.toEqual("x"), + () => expect(sentinel).toEqual(sentinel), + () => expect({ deep: [sentinel] }).toStrictEqual({ deep: ["x"] }), + () => expect({ deep: sentinel, extra: 1 }).toMatchObject({ deep: "x" }), + () => expect([sentinel]).toContainEqual("x"), + () => expect(fn).toHaveBeenCalledWith("x"), + () => expect({ deep: sentinel }).toEqual(expect.objectContaining({ deep: "x" })), + ]; + const rejectsLater = Promise.withResolvers(); + class InheritsThen { + then() {} + } + const answers: [unknown, string][] = [ + [Promise.resolve(true), "a promise"], + [Promise.resolve(false), "a promise"], + [Promise.resolve(undefined), "a promise"], + [(async () => false)(), "a promise"], + [Promise.reject(new Error("rejected before the tester returned it")), "a promise"], + [rejectsLater.promise, "a promise"], + [{ then() {} }, "a thenable"], + [new InheritsThen(), "a thenable"], + ]; + for (const [returned, described] of answers) { + answer = returned; + for (const assertion of assertions) { + const error = thrownBy(assertion) as Error; + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toBe(refused(described)); + } + } + + // An unhandled rejection is reported once the microtasks have run, and fails the test that is running then. + rejectsLater.reject(new Error("rejected after the matcher threw")); + await new Promise(resolve => setImmediate(resolve)); + }); + + test("an object that a tester returns is an answer unless its `then` is a function, and `then` is read once", () => { + let answer: unknown; + const sentinel = "vitest-apis-then"; + expect.addEqualityTesters([(a, b) => (a === sentinel || b === sentinel ? (answer as boolean) : undefined)]); + + for (answer of [{}, { then: true }, [], () => {}]) { + expect(sentinel).toEqual("x"); + expect(() => expect(sentinel).not.toEqual("x")).toThrow("toEqual"); + } + + const then = mock(() => undefined); + answer = Object.defineProperty({}, "then", { get: then }); + expect(sentinel).toEqual("x"); + expect(then).toHaveBeenCalledTimes(1); + + const reason = new RangeError("then was read"); + const throwingGetter = { + get then() { + throw reason; + }, + }; + const throwingTrap = new Proxy( + {}, + { + get() { + throw reason; + }, + }, + ); + for (answer of [throwingGetter, throwingTrap]) { + expect(thrownBy(() => expect(sentinel).toEqual("x"))).toBe(reason); + expect(thrownBy(() => expect({ deep: sentinel }).not.toMatchObject({ deep: "x" }))).toBe(reason); + } + }); + + test("this.equals refuses a promise from the testers it is given", () => { + const messages: string[] = []; + expect.extend({ + toProbeAsyncTesters(received, expected) { + for (const tester of [async () => false, async () => true, () => new Promise(() => {})]) { + messages.push(messageOf(() => this.equals(received, expected, [tester as any]))); + } + return { pass: true, message: () => "" }; + }, + }); + (expect(1) as any).toProbeAsyncTesters(1); + expect(messages).toEqual(Array(3).fill(refused("a promise"))); + }); + + test("a promise from a tester is refused on the re-run of a matcher that waited", async () => { + expect.extend({ + async toPassLater() { + await 0; + return { pass: true, message: () => "" }; + }, + }); + const sentinel = "vitest-apis-rerun"; + let calls = 0; + expect.addEqualityTesters([ + (a, b) => (a === sentinel || b === sentinel ? (calls++, Promise.resolve(true) as any) : undefined), + ]); + + const matcher = expect([1, sentinel]).toEqual([(expect as any).toPassLater(), "x"]) as unknown as Promise; + expect(calls).toBe(0); + const error = await matcher.then( + () => undefined, + (error: Error) => error, + ); + expect(error).toBeInstanceOf(TypeError); + expect(error!.message).toBe(refused("a promise")); + expect(calls).toBe(1); + }); + + test.concurrent.each(["bun:test", "vitest"])( + "a registered async tester fails each comparison in %s", + async module => { + const { results, stderr, exitCode } = await runTests({ + "answers.test.ts": ` + import { expect, test } from "${module}"; + expect.addEqualityTesters([async () => false]); + test("toEqual", () => { expect({ a: 1 }).toEqual({ a: 2 }); }); + test("not.toEqual", () => { expect({ a: 1 }).not.toEqual({ a: 2 }); }); + test("resolves.toEqual", async () => { await expect(Promise.resolve({ a: 1 })).resolves.toEqual({ a: 1 }); }); + test("toBe passes without a comparison by the testers", () => { expect(1).toBe(1); }); + `, + "rejects.test.ts": ` + import { expect, test } from "${module}"; + expect.addEqualityTesters([async () => { await 0; throw new Error("rejected later"); }]); + test("a tester that rejects later", () => { expect({ a: 1 }).toEqual({ a: 1 }); }); + test("its rejection is not reported", async () => { await new Promise(resolve => setImmediate(resolve)); }); + `, + }); + expect(results).toEqual([ + "(fail) toEqual", + "(fail) not.toEqual", + "(fail) resolves.toEqual", + "(pass) toBe passes without a comparison by the testers", + "(fail) a tester that rejects later", + "(pass) its rejection is not reported", + ]); + expect(stderr.split("TypeError: " + refused("a promise")).length - 1).toBe(4); + expect(stderr).not.toContain("error: rejected later"); + expect(exitCode).toBe(1); + }, + ); + test("this.equals uses only the testers it is given", () => { class Box { constructor(public content: unknown) {} diff --git a/test/js/bun/test/vitest-flavor.test.ts b/test/js/bun/test/vitest-flavor.test.ts index c343e3008134..93d8cbea4b8c 100644 --- a/test/js/bun/test/vitest-flavor.test.ts +++ b/test/js/bun/test/vitest-flavor.test.ts @@ -2773,6 +2773,53 @@ describe.concurrent("behavior depends on the module a function is imported from" }); }); + test("toContain() on a DOM node throws if contains() returns a promise or another thenable", async () => { + const { log, exitCode } = await runTests({ + "a.test.js": ` + import { expect, test } from "vitest"; + globalThis.Node = class Node { + constructor(answer) { + this.answer = answer; + } + contains() { + return this.answer; + } + }; + const outcome = fn => { + try { fn(); return "passes"; } catch (error) { + return error instanceof TypeError ? error.message : "fails"; + } + }; + test("answers", async () => { + const answers = { + "a fulfilled promise": Promise.resolve(false), + "a rejected promise": Promise.reject(new Error("rejected before it was returned")), + "a pending promise": new Promise(() => {}), + "a thenable": { then() {} }, + "an object": { then: true }, + }; + for (const [what, answer] of Object.entries(answers)) { + const node = new Node(answer); + console.log(what, "|", outcome(() => expect(node).toContain(node)), "|", outcome(() => expect(node).not.toContain(node))); + } + // An unhandled rejection is reported once the microtasks have run, and fails the test that is running then. + await new Promise(resolve => setImmediate(resolve)); + }); + `, + }); + const refused = (returned: string) => `contains() returned ${returned}. It has to return its answer synchronously`; + expect({ log, exitCode }).toEqual({ + log: [ + ...["a fulfilled promise", "a rejected promise", "a pending promise"].map( + what => `${what} | ${refused("a promise")} | ${refused("a promise")}`, + ), + `a thenable | ${refused("a thenable")} | ${refused("a thenable")}`, + "an object | passes | fails", + ], + exitCode: 0, + }); + }); + test(".resolves.toThrow() on a promise that resolved to an error", async () => { const { log, exitCode } = await runTests({ "a.test.js": ` From 1f4a3c1d7d9b85fe6d48b084eaa1c5a601fa10c8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:23:57 +0000 Subject: [PATCH 2/3] bun:test: leave a promise that a matcher refuses to its owner Answerer::ask marked the promise as handled before it threw. A promise that the value under test owns could then reject later with no report. The matcher no longer touches the promise, so an unhandled rejection of it is reported as before. Also test an answer that is a function with a then() method. --- src/runtime/test_runner/expect_deferred.rs | 4 +-- test/js/bun/test/expect.test.js | 15 +++++---- test/js/bun/test/jest-extended.test.js | 14 ++++----- test/js/bun/test/printing/dom-nodes.test.ts | 14 ++++----- test/js/bun/test/vitest-apis.test.ts | 35 ++++++++++++--------- test/js/bun/test/vitest-flavor.test.ts | 9 +++--- 6 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/runtime/test_runner/expect_deferred.rs b/src/runtime/test_runner/expect_deferred.rs index 651cfa48ef60..d011c4b28e1a 100644 --- a/src/runtime/test_runner/expect_deferred.rs +++ b/src/runtime/test_runner/expect_deferred.rs @@ -582,9 +582,7 @@ impl Answerer { #[cold] #[optimize(size)] fn refuse_thenable(self, global: &JSGlobalObject, answer: JSValue) -> JsResult<()> { - let returned = if let Some(promise) = answer.as_any_promise() { - // The error below reports it. As an unhandled rejection it would also fail whichever test runs then. - promise.set_handled(global.vm()); + let returned = if answer.as_any_promise().is_some() { "a promise" } else if answer.get(global, "then")?.is_some_and(JSValue::is_callable) { "a thenable" diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index b4c849eb9e28..93eeb8c2833e 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -5370,10 +5370,12 @@ describe("expect()", () => { }); // Jest and Vitest take the promise itself for the answer, and don't call an async asymmetricMatch() at all. - test_skipIf(!isBun)("asymmetricMatch() that returns a promise or another thenable throws", async () => { + test_skipIf(!isBun)("asymmetricMatch() that returns a promise or another thenable throws", () => { const refused = (/** @type {string} */ returned) => `asymmetricMatch() returned ${returned}. It has to return its answer synchronously`; - const rejectsLater = Promise.withResolvers(); + // The matcher leaves a promise to its owner, so this test handles the rejection. + const rejected = Promise.reject(new Error("rejected before it was returned")); + rejected.catch(() => {}); class InheritsThen { then() {} } @@ -5383,10 +5385,11 @@ describe("expect()", () => { [{ asymmetricMatch: async () => true }, "a promise"], [{ asymmetricMatch: async () => false }, "a promise"], [{ asymmetricMatch: () => Promise.resolve(undefined) }, "a promise"], - [{ asymmetricMatch: () => Promise.reject(new Error("rejected before it was returned")) }, "a promise"], - [{ asymmetricMatch: () => rejectsLater.promise }, "a promise"], + [{ asymmetricMatch: () => rejected }, "a promise"], + [{ asymmetricMatch: () => new Promise(() => {}) }, "a promise"], [{ asymmetricMatch: () => ({ then() {} }) }, "a thenable"], [{ asymmetricMatch: () => new InheritsThen() }, "a thenable"], + [{ asymmetricMatch: () => Object.assign(() => {}, { then() {} }) }, "a thenable"], ]); for (const [matcher, returned] of cases) { for (const assertion of [ @@ -5404,10 +5407,6 @@ describe("expect()", () => { expect(assertion).toThrow(new TypeError(refused(returned))); } } - - // An unhandled rejection is reported once the microtasks have run, and fails the test that is running then. - rejectsLater.reject(new Error("rejected after the matcher threw")); - await new Promise(resolve => setImmediate(resolve)); }); test_skipIf(!isBun)( diff --git a/test/js/bun/test/jest-extended.test.js b/test/js/bun/test/jest-extended.test.js index 9f0f6c8eacb8..3e46158a425c 100644 --- a/test/js/bun/test/jest-extended.test.js +++ b/test/js/bun/test/jest-extended.test.js @@ -256,15 +256,17 @@ describe("jest-extended", () => { } }); - test("expect.toSatisfy() throws if its predicate returns a promise or another thenable", async () => { + test("expect.toSatisfy() throws if its predicate returns a promise or another thenable", () => { const refused = returned => `The predicate of expect.toSatisfy() returned ${returned}. It has to return its answer synchronously`; - const rejectsLater = Promise.withResolvers(); + // The matcher leaves a promise to its owner, so this test handles the rejection. + const rejected = Promise.reject(new Error("rejected before it was returned")); + rejected.catch(() => {}); for (const [predicate, returned] of [ [async () => true, "a promise"], [async () => false, "a promise"], - [() => Promise.reject(new Error("rejected before it was returned")), "a promise"], - [() => rejectsLater.promise, "a promise"], + [() => rejected, "a promise"], + [() => new Promise(() => {}), "a promise"], [() => ({ then() {} }), "a thenable"], ]) { for (const assertion of [ @@ -279,10 +281,6 @@ describe("jest-extended", () => { } } expect(7).toEqual(expect.toSatisfy(() => ({ then: true }))); - - // An unhandled rejection is reported once the microtasks have run, and fails the test that is running then. - rejectsLater.reject(new Error("rejected after the matcher threw")); - await new Promise(resolve => setImmediate(resolve)); }); } diff --git a/test/js/bun/test/printing/dom-nodes.test.ts b/test/js/bun/test/printing/dom-nodes.test.ts index 089bcf3a5f61..75abba11920d 100644 --- a/test/js/bun/test/printing/dom-nodes.test.ts +++ b/test/js/bun/test/printing/dom-nodes.test.ts @@ -854,16 +854,18 @@ describe("equality", () => { for (const falsy of [0, "", null, undefined]) expect(returning(falsy)).not.toEqual(h("i")); }); - test("isEqualNode() that returns a promise or another thenable throws", async () => { + test("isEqualNode() that returns a promise or another thenable throws", () => { const returning = (returned: unknown) => Object.assign(h("i"), { isEqualNode: () => returned }); const refused = (returned: string) => `isEqualNode() returned ${returned}. It has to return its answer synchronously`; - const rejectsLater = Promise.withResolvers(); + // The matcher leaves a promise to its owner, so this test handles the rejection. + const rejected = Promise.reject(new Error("rejected before it was returned")); + rejected.catch(() => {}); for (const [returned, described] of [ [Promise.resolve(true), "a promise"], [Promise.resolve(false), "a promise"], - [Promise.reject(new Error("rejected before it was returned")), "a promise"], - [rejectsLater.promise, "a promise"], + [rejected, "a promise"], + [new Promise(() => {}), "a promise"], [{ then() {} }, "a thenable"], ] as const) { for (const assertion of [ @@ -877,10 +879,6 @@ describe("equality", () => { } } expect(returning({ then: true })).toEqual(h("b")); - - // An unhandled rejection is reported once the microtasks have run, and fails the test that is running then. - rejectsLater.reject(new Error("rejected after the matcher threw")); - await new Promise(resolve => setImmediate(resolve)); }); test("toMatchObject uses isEqualNode() for two nodes and properties for a plain pattern", () => { diff --git a/test/js/bun/test/vitest-apis.test.ts b/test/js/bun/test/vitest-apis.test.ts index b1ae69e07351..460e38a8a94e 100644 --- a/test/js/bun/test/vitest-apis.test.ts +++ b/test/js/bun/test/vitest-apis.test.ts @@ -1101,7 +1101,7 @@ describe("expect.addEqualityTesters", () => { throw new Error("expected the function to throw"); } - test("a tester that returns a promise or another thenable throws, whatever the promise holds", async () => { + test("a tester that returns a promise or another thenable throws, whatever the promise holds", () => { let answer: unknown; const sentinel = "vitest-apis-promise"; expect.addEqualityTesters([(a, b) => (a === sentinel || b === sentinel ? (answer as boolean) : undefined)]); @@ -1117,7 +1117,9 @@ describe("expect.addEqualityTesters", () => { () => expect(fn).toHaveBeenCalledWith("x"), () => expect({ deep: sentinel }).toEqual(expect.objectContaining({ deep: "x" })), ]; - const rejectsLater = Promise.withResolvers(); + // The matcher leaves a promise to its owner, so this test handles the rejection. + const rejected = Promise.reject(new Error("rejected before the tester returned it")); + rejected.catch(() => {}); class InheritsThen { then() {} } @@ -1126,10 +1128,11 @@ describe("expect.addEqualityTesters", () => { [Promise.resolve(false), "a promise"], [Promise.resolve(undefined), "a promise"], [(async () => false)(), "a promise"], - [Promise.reject(new Error("rejected before the tester returned it")), "a promise"], - [rejectsLater.promise, "a promise"], + [rejected, "a promise"], + [new Promise(() => {}), "a promise"], [{ then() {} }, "a thenable"], [new InheritsThen(), "a thenable"], + [Object.assign(() => {}, { then() {} }), "a thenable"], ]; for (const [returned, described] of answers) { answer = returned; @@ -1139,10 +1142,6 @@ describe("expect.addEqualityTesters", () => { expect(error.message).toBe(refused(described)); } } - - // An unhandled rejection is reported once the microtasks have run, and fails the test that is running then. - rejectsLater.reject(new Error("rejected after the matcher threw")); - await new Promise(resolve => setImmediate(resolve)); }); test("an object that a tester returns is an answer unless its `then` is a function, and `then` is read once", () => { @@ -1232,9 +1231,15 @@ describe("expect.addEqualityTesters", () => { `, "rejects.test.ts": ` import { expect, test } from "${module}"; - expect.addEqualityTesters([async () => { await 0; throw new Error("rejected later"); }]); - test("a tester that rejects later", () => { expect({ a: 1 }).toEqual({ a: 1 }); }); - test("its rejection is not reported", async () => { await new Promise(resolve => setImmediate(resolve)); }); + const shared = Promise.withResolvers(); + expect.addEqualityTesters([() => shared.promise]); + test("the matcher refuses a promise that it does not own", () => { + expect(() => expect(1).toEqual(1)).toThrow(TypeError); + }); + test("a later rejection of that promise is still reported", async () => { + shared.reject(new Error("rejected later")); + await new Promise(resolve => setImmediate(resolve)); + }); `, }); expect(results).toEqual([ @@ -1242,11 +1247,11 @@ describe("expect.addEqualityTesters", () => { "(fail) not.toEqual", "(fail) resolves.toEqual", "(pass) toBe passes without a comparison by the testers", - "(fail) a tester that rejects later", - "(pass) its rejection is not reported", + "(pass) the matcher refuses a promise that it does not own", + "(fail) a later rejection of that promise is still reported", ]); - expect(stderr.split("TypeError: " + refused("a promise")).length - 1).toBe(4); - expect(stderr).not.toContain("error: rejected later"); + expect(stderr.split("TypeError: " + refused("a promise")).length - 1).toBe(3); + expect(stderr).toContain("error: rejected later"); expect(exitCode).toBe(1); }, ); diff --git a/test/js/bun/test/vitest-flavor.test.ts b/test/js/bun/test/vitest-flavor.test.ts index 93d8cbea4b8c..71330b0dd947 100644 --- a/test/js/bun/test/vitest-flavor.test.ts +++ b/test/js/bun/test/vitest-flavor.test.ts @@ -2790,10 +2790,13 @@ describe.concurrent("behavior depends on the module a function is imported from" return error instanceof TypeError ? error.message : "fails"; } }; - test("answers", async () => { + test("answers", () => { + // The matcher leaves a promise to its owner, so this test handles the rejection. + const rejected = Promise.reject(new Error("rejected before it was returned")); + rejected.catch(() => {}); const answers = { "a fulfilled promise": Promise.resolve(false), - "a rejected promise": Promise.reject(new Error("rejected before it was returned")), + "a rejected promise": rejected, "a pending promise": new Promise(() => {}), "a thenable": { then() {} }, "an object": { then: true }, @@ -2802,8 +2805,6 @@ describe.concurrent("behavior depends on the module a function is imported from" const node = new Node(answer); console.log(what, "|", outcome(() => expect(node).toContain(node)), "|", outcome(() => expect(node).not.toContain(node))); } - // An unhandled rejection is reported once the microtasks have run, and fails the test that is running then. - await new Promise(resolve => setImmediate(resolve)); }); `, }); From 4b90d1ca4237660017ded749885e8dba60772ea1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:57:40 +0000 Subject: [PATCH 3/3] bun:test: name the tester or the predicate that returned a promise With several equality testers, the TypeError did not say which one returned the promise. The message now has the name of the function when it has one: An equality tester, `areVolumesEqual`, returned a promise. --- src/runtime/test_runner/expect_deferred.rs | 17 +++++++++++++---- test/js/bun/test/jest-extended.test.js | 9 +++++++++ test/js/bun/test/vitest-apis.test.ts | 13 +++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/runtime/test_runner/expect_deferred.rs b/src/runtime/test_runner/expect_deferred.rs index d011c4b28e1a..6c1075cc4f8d 100644 --- a/src/runtime/test_runner/expect_deferred.rs +++ b/src/runtime/test_runner/expect_deferred.rs @@ -574,14 +574,14 @@ impl Answerer { ) -> JsResult { let answer = function.call(global, this, arguments)?; if answer.is_object() { - self.refuse_thenable(global, answer)?; + self.refuse_thenable(global, answer, function)?; } Ok(answer) } #[cold] #[optimize(size)] - fn refuse_thenable(self, global: &JSGlobalObject, answer: JSValue) -> JsResult<()> { + fn refuse_thenable(self, global: &JSGlobalObject, answer: JSValue, function: JSValue) -> JsResult<()> { let returned = if answer.as_any_promise().is_some() { "a promise" } else if answer.get(global, "then")?.is_some_and(JSValue::is_callable) { @@ -596,15 +596,24 @@ impl Answerer { Answerer::SatisfyPredicate => "The predicate of expect.toSatisfy()", Answerer::Contains => "contains()", }; + // The other three are methods, which `who` names. + let name = match self { + Answerer::EqualityTester | Answerer::SatisfyPredicate => function.get(global, "name")?, + _ => None, + }; + let named = match name.filter(|name| name.is_string() && name.to_boolean()) { + Some(name) => format!(", `{}`,", name.to_bun_string(global)?), + None => String::new(), + }; Err(global.throw_type_error(format_args!( - "{who} returned {returned}. It has to return its answer synchronously" + "{who}{named} returned {returned}. It has to return its answer synchronously" ))) } /// Called by C++ deep equality with an object that `who` returned. Leaves an exception pending if it is refused. #[unsafe(no_mangle)] pub(crate) extern "C" fn Expect__refuseThenable(global: &JSGlobalObject, answer: JSValue, who: u8) { - if let Err(error) = Self::ALL[usize::from(who)].refuse_thenable(global, answer) { + if let Err(error) = Self::ALL[usize::from(who)].refuse_thenable(global, answer, JSValue::UNDEFINED) { bun_jsc::host_fn::host_call_error_value(global, error); } } diff --git a/test/js/bun/test/jest-extended.test.js b/test/js/bun/test/jest-extended.test.js index 3e46158a425c..fbbeaad36a6f 100644 --- a/test/js/bun/test/jest-extended.test.js +++ b/test/js/bun/test/jest-extended.test.js @@ -281,6 +281,15 @@ describe("jest-extended", () => { } } expect(7).toEqual(expect.toSatisfy(() => ({ then: true }))); + + async function isOdd(n) { + return n % 2 === 1; + } + expect(() => expect(7).toEqual(expect.toSatisfy(isOdd))).toThrow( + new TypeError( + "The predicate of expect.toSatisfy(), `isOdd`, returned a promise. It has to return its answer synchronously", + ), + ); }); } diff --git a/test/js/bun/test/vitest-apis.test.ts b/test/js/bun/test/vitest-apis.test.ts index 460e38a8a94e..e241ee3eb11e 100644 --- a/test/js/bun/test/vitest-apis.test.ts +++ b/test/js/bun/test/vitest-apis.test.ts @@ -1179,6 +1179,19 @@ describe("expect.addEqualityTesters", () => { } }); + test("the error names a tester that has a name", () => { + const sentinel = "vitest-apis-named"; + function returnsAPromise(a: unknown, b: unknown) { + return a === sentinel || b === sentinel ? (Promise.resolve(true) as any) : undefined; + } + expect.addEqualityTesters([returnsAPromise]); + const error = thrownBy(() => expect(sentinel).toEqual("x")) as Error; + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toBe( + "An equality tester, `returnsAPromise`, returned a promise. It has to return its answer synchronously", + ); + }); + test("this.equals refuses a promise from the testers it is given", () => { const messages: string[] = []; expect.extend({