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..6c1075cc4f8d 100644 --- a/src/runtime/test_runner/expect_deferred.rs +++ b/src/runtime/test_runner/expect_deferred.rs @@ -544,6 +544,81 @@ 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, function)?; + } + Ok(answer) + } + + #[cold] + #[optimize(size)] + 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) { + "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()", + }; + // 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}{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, JSValue::UNDEFINED) { + 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..93eeb8c2833e 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -5369,6 +5369,96 @@ 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", () => { + const refused = (/** @type {string} */ returned) => + `asymmetricMatch() returned ${returned}. It has to return its answer synchronously`; + // 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() {} + } + 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: () => 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 [ + () => 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))); + } + } + }); + + 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..fbbeaad36a6f 100644 --- a/test/js/bun/test/jest-extended.test.js +++ b/test/js/bun/test/jest-extended.test.js @@ -255,6 +255,42 @@ describe("jest-extended", () => { expect(make).toThrow("Expected a function"); } }); + + 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`; + // 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"], + [() => rejected, "a promise"], + [() => new 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 }))); + + 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", + ), + ); + }); } // 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..75abba11920d 100644 --- a/test/js/bun/test/printing/dom-nodes.test.ts +++ b/test/js/bun/test/printing/dom-nodes.test.ts @@ -854,6 +854,33 @@ 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", () => { + const returning = (returned: unknown) => Object.assign(h("i"), { isEqualNode: () => returned }); + const refused = (returned: string) => + `isEqualNode() returned ${returned}. It has to return its answer synchronously`; + // 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"], + [rejected, "a promise"], + [new 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")); + }); + 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..e241ee3eb11e 100644 --- a/test/js/bun/test/vitest-apis.test.ts +++ b/test/js/bun/test/vitest-apis.test.ts @@ -1090,6 +1090,185 @@ 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", () => { + 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" })), + ]; + // 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() {} + } + const answers: [unknown, string][] = [ + [Promise.resolve(true), "a promise"], + [Promise.resolve(false), "a promise"], + [Promise.resolve(undefined), "a promise"], + [(async () => false)(), "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; + for (const assertion of assertions) { + const error = thrownBy(assertion) as Error; + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toBe(refused(described)); + } + } + }); + + 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("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({ + 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}"; + 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([ + "(fail) toEqual", + "(fail) not.toEqual", + "(fail) resolves.toEqual", + "(pass) toBe passes without a comparison by the testers", + "(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(3); + expect(stderr).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..71330b0dd947 100644 --- a/test/js/bun/test/vitest-flavor.test.ts +++ b/test/js/bun/test/vitest-flavor.test.ts @@ -2773,6 +2773,54 @@ 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", () => { + // 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": rejected, + "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))); + } + }); + `, + }); + 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": `