diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index 58d27cdba741..30355864c5ce 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -836,7 +836,9 @@ impl Expect { let prev_unhandled_pending_rejection_to_capture = vm.unhandled_pending_rejection_to_capture; vm.unhandled_pending_rejection_to_capture = Some(&raw mut return_value); vm.on_unhandled_rejection = VirtualMachine::on_quiet_unhandled_rejection_handler_capture_value; - return_value_from_function = match value.call(global_this, JSValue::UNDEFINED, &[]) { + let call_result = value.call(global_this, JSValue::UNDEFINED, &[]); + let threw_sync = call_result.is_err(); + return_value_from_function = match call_result { Ok(v) => v, Err(err) => global_this.take_exception(err), }; @@ -844,6 +846,7 @@ impl Expect { vm.global().handle_rejected_promises(); + let captured_rejection = !return_value.is_empty(); if return_value.is_empty() { return_value = return_value_from_function; } @@ -871,6 +874,12 @@ impl Expect { scope.apply(vm); + if !threw_sync && !captured_rejection { + // The function returned normally with a non-Promise value. A returned + // Error instance is not a throw (matches Jest and Vitest). + return Ok((None, return_value_from_function)); + } + Ok(( return_value.to_error().or_else(|| return_value_from_function.to_error()), return_value_from_function, diff --git a/test/js/bun/ffi/ffi-viewSource-non-object.test.ts b/test/js/bun/ffi/ffi-viewSource-non-object.test.ts index a875ab7e3499..fb6b3ce130b0 100644 --- a/test/js/bun/ffi/ffi-viewSource-non-object.test.ts +++ b/test/js/bun/ffi/ffi-viewSource-non-object.test.ts @@ -5,12 +5,12 @@ const isFFIUnavailable = isWindows && isArm64; describe.skipIf(isFFIUnavailable)("FFI viewSource", () => { test("rejects non-object symbol descriptor values", () => { - // These should throw a TypeError because each symbol descriptor - // must be an object like { args: [...], returns: "void" }. - // Previously, non-object values like numbers or strings would - // cause a debug assertion failure (crash) in generateSymbolForFunction. - expect(() => Bun.FFI.viewSource({ myFunc: 42 })).toThrow("Expected an object"); - expect(() => Bun.FFI.viewSource({ myFunc: "not_an_object" })).toThrow("Expected an object"); - expect(() => Bun.FFI.viewSource({ myFunc: true })).toThrow("Expected an object"); + // viewSource returns (rather than throws) a TypeError when a symbol descriptor is not an object. + // Non-object descriptors previously crashed with a debug assertion in generateSymbolForFunction. + for (const value of [42, "not_an_object", true]) { + const result = Bun.FFI.viewSource({ myFunc: value }); + expect(result).toBeInstanceOf(TypeError); + expect((result as TypeError).message).toContain("Expected an object"); + } }); }); diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index 114876e4d747..0b2221498608 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -949,6 +949,69 @@ describe("expect()", () => { } }); + test("toThrow does not treat a returned Error as thrown", () => { + // An Error that is *returned* (not thrown) must not satisfy toThrow(). + const returnsError = () => new TypeError("boom"); + const assertFails = (/** @type {() => void} */ fn) => + expect(fn).toThrow(/did not throw|didn't throw|to throw an error/); + + assertFails(() => expect(returnsError).toThrow()); + assertFails(() => expect(returnsError).toThrow("boom")); + assertFails(() => expect(returnsError).toThrow(/boom/)); + assertFails(() => expect(returnsError).toThrow(TypeError)); + assertFails(() => expect(returnsError).toThrow(new TypeError("boom"))); + assertFails(() => expect(returnsError).toThrow(expect.objectContaining({ name: "TypeError" }))); + assertFails(() => expect(returnsError).toThrow(expect.any(TypeError))); + assertFails(() => expect(returnsError).toThrowErrorMatchingSnapshot()); + if (!isVitest) { + assertFails(() => expect(returnsError).toThrowErrorMatchingInlineSnapshot(`"boom"`)); + } + + expect(returnsError).not.toThrow(); + expect(returnsError).not.toThrow("boom"); + expect(returnsError).not.toThrow(/boom/); + expect(returnsError).not.toThrow(TypeError); + expect(returnsError).not.toThrow(new TypeError("boom")); + + // Error subclass and plain object with a message property are still just return values + class MyError extends Error {} + assertFails(() => expect(() => new MyError("sub")).toThrow()); + expect(() => new MyError("sub")).not.toThrow(); + assertFails(() => expect(() => ({ message: "not an error" })).toThrow()); + expect(() => ({ message: "not an error" })).not.toThrow(); + + // Actually throwing still works for every expected-value shape + const throwsError = () => { + throw new TypeError("boom"); + }; + expect(throwsError).toThrow(); + expect(throwsError).toThrow("boom"); + expect(throwsError).toThrow(/boom/); + expect(throwsError).toThrow(TypeError); + expect(throwsError).toThrow(new TypeError("boom")); + expect(throwsError).toThrow(expect.objectContaining({ name: "TypeError" })); + expect(throwsError).toThrow(expect.any(TypeError)); + expect(() => expect(throwsError).not.toThrow()).toThrow(); + + // Throwing non-Error values still counts as throwing + for (const v of [42, "str", null, undefined, { a: 1 }, [1, 2]]) { + expect(() => { + throw v; + }).toThrow(); + } + + if (isBun) { + // Bun-only extension: a Promise-returning function is awaited. + // Returning an Error is still not a throw under .resolves. + expect(() => Promise.reject(new Error("rej"))).toThrow("rej"); + expect(async () => { + throw new Error("async"); + }).toThrow("async"); + expect(() => Promise.resolve(new Error("ok"))).not.toThrow(); + expect(async () => new TypeError("boom")).not.toThrow(); + } + }); + test("deepEquals derived strings and strings", () => { let a = new String("hello"); let b = "hello";