diff --git a/packages/bun-inspector-protocol/test/inspector/websocket.test.ts b/packages/bun-inspector-protocol/test/inspector/websocket.test.ts index 2514728cbc3..88f6eb9c7ea 100644 --- a/packages/bun-inspector-protocol/test/inspector/websocket.test.ts +++ b/packages/bun-inspector-protocol/test/inspector/websocket.test.ts @@ -7,69 +7,69 @@ let server: Server; let url: URL; describe("WebSocketInspector", () => { - test("fails without a URL", () => { + test("fails without a URL", async () => { const ws = new WebSocketInspector(); const fn = mock(error => { expect(error).toBeInstanceOf(Error); }); ws.on("Inspector.error", fn); - expect(ws.start()).resolves.toBeFalse(); + await expect(ws.start()).resolves.toBeFalse(); expect(fn).toHaveBeenCalled(); }); - test("fails with invalid URL", () => { + test("fails with invalid URL", async () => { const ws = new WebSocketInspector("notaurl"); const fn = mock(error => { expect(error).toBeInstanceOf(Error); }); ws.on("Inspector.error", fn); - expect(ws.start()).resolves.toBeFalse(); + await expect(ws.start()).resolves.toBeFalse(); expect(fn).toHaveBeenCalled(); }); - test("fails with valid URL but no server", () => { + test("fails with valid URL but no server", async () => { const ws = new WebSocketInspector("ws://localhost:0/doesnotexist/"); const fn = mock(error => { expect(error).toBeInstanceOf(Error); }); ws.on("Inspector.error", fn); - expect(ws.start()).resolves.toBeFalse(); + await expect(ws.start()).resolves.toBeFalse(); expect(fn).toHaveBeenCalled(); }); - test("fails with invalid upgrade response", () => { + test("fails with invalid upgrade response", async () => { const ws = new WebSocketInspector(new URL("/", url)); const fn = mock(error => { expect(error).toBeInstanceOf(Error); }); ws.on("Inspector.error", fn); - expect(ws.start()).resolves.toBeFalse(); + await expect(ws.start()).resolves.toBeFalse(); expect(fn).toHaveBeenCalled(); }); - test("can connect to a server", () => { + test("can connect to a server", async () => { const ws = new WebSocketInspector(url); const fn = mock(() => { expect(ws.closed).toBe(false); }); ws.on("Inspector.connected", fn); - expect(ws.start()).resolves.toBeTrue(); + await expect(ws.start()).resolves.toBeTrue(); expect(fn).toHaveBeenCalled(); ws.close(); }); - test("can disconnect from a server", () => { + test("can disconnect from a server", async () => { const ws = new WebSocketInspector(url); const fn = mock(() => { expect(ws.closed).toBeTrue(); }); ws.on("Inspector.disconnected", fn); - expect(ws.start()).resolves.toBeTrue(); + await expect(ws.start()).resolves.toBeTrue(); ws.close(); expect(fn).toHaveBeenCalled(); }); - test("can connect to a server multiple times", () => { + test("can connect to a server multiple times", async () => { const ws = new WebSocketInspector(url); const fn0 = mock(() => { expect(ws.closed).toBeFalse(); @@ -80,14 +80,14 @@ describe("WebSocketInspector", () => { }); ws.on("Inspector.disconnected", fn1); for (let i = 0; i < 3; i++) { - expect(ws.start()).resolves.toBeTrue(); + await expect(ws.start()).resolves.toBeTrue(); ws.close(); } expect(fn0).toHaveBeenCalledTimes(3); expect(fn1).toHaveBeenCalledTimes(3); }); - test("can send a request", () => { + test("can send a request", async () => { const ws = new WebSocketInspector(url); const fn0 = mock(request => { expect(request).toStrictEqual({ @@ -108,14 +108,14 @@ describe("WebSocketInspector", () => { }); }); ws.on("Inspector.response", fn1); - expect(ws.start()).resolves.toBeTrue(); - expect(ws.send("Debugger.setPauseOnAssertions", { enabled: true })).resolves.toMatchObject({ ok: true }); + await expect(ws.start()).resolves.toBeTrue(); + await expect(ws.send("Debugger.setPauseOnAssertions", { enabled: true })).resolves.toMatchObject({ ok: true }); expect(fn0).toHaveBeenCalled(); expect(fn1).toHaveBeenCalled(); ws.close(); }); - test("can send a request before connecting", () => { + test("can send a request before connecting", async () => { const ws = new WebSocketInspector(url); const fn0 = mock(request => { expect(request).toStrictEqual({ @@ -136,14 +136,14 @@ describe("WebSocketInspector", () => { }); ws.on("Inspector.response", fn1); const request = ws.send("Runtime.enable"); - expect(ws.start()).resolves.toBe(true); - expect(request).resolves.toMatchObject({ ok: true }); + await expect(ws.start()).resolves.toBe(true); + await expect(request).resolves.toMatchObject({ ok: true }); expect(fn0).toHaveBeenCalledTimes(2); expect(fn1).toHaveBeenCalled(); ws.close(); }); - test("can receive an event", () => { + test("can receive an event", async () => { const ws = new WebSocketInspector(url); const fn = mock(event => { expect(event).toStrictEqual({ @@ -154,8 +154,8 @@ describe("WebSocketInspector", () => { }); }); ws.on("Inspector.event", fn); - expect(ws.start()).resolves.toBeTrue(); - expect(ws.send("Debugger.enable")).resolves.toMatchObject({ ok: true }); + await expect(ws.start()).resolves.toBeTrue(); + await expect(ws.send("Debugger.enable")).resolves.toMatchObject({ ok: true }); expect(fn).toHaveBeenCalled(); ws.close(); }); diff --git a/src/jsc/CallFrame.rs b/src/jsc/CallFrame.rs index faf1707c51b..0609f55fe7e 100644 --- a/src/jsc/CallFrame.rs +++ b/src/jsc/CallFrame.rs @@ -259,6 +259,7 @@ impl Arguments { } } +#[derive(Clone, Copy)] pub struct CallerSrcLoc { pub str: bun_core::String, pub line: c_uint, diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 456898e72aa..30b9accca77 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3872,6 +3872,10 @@ GlobalObject::PromiseFunctions GlobalObject::promiseHandlerID(Zig::FFIFunction h return GlobalObject::PromiseFunctions::Bun__HTTPRequestContextDebugH3__onResolve; } else if (handler == Bun__HTTPRequestContextDebugH3__onResolveStream) { return GlobalObject::PromiseFunctions::Bun__HTTPRequestContextDebugH3__onResolveStream; + } else if (handler == Bun__Expect__PendingMatcher__onResolve) { + return GlobalObject::PromiseFunctions::Bun__Expect__PendingMatcher__onResolve; + } else if (handler == Bun__Expect__PendingMatcher__onReject) { + return GlobalObject::PromiseFunctions::Bun__Expect__PendingMatcher__onReject; } else { RELEASE_ASSERT_NOT_REACHED(); } diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index eae5d1cc5a6..2a2f1ea5ee9 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -412,8 +412,10 @@ class GlobalObject : public Bun::GlobalScope { Bun__HTTPRequestContextDebugH3__onRejectStream, Bun__HTTPRequestContextDebugH3__onResolve, Bun__HTTPRequestContextDebugH3__onResolveStream, + Bun__Expect__PendingMatcher__onResolve, + Bun__Expect__PendingMatcher__onReject, }; - static constexpr size_t promiseFunctionsSize = 42; + static constexpr size_t promiseFunctionsSize = 44; static PromiseFunctions promiseHandlerID(SYSV_ABI EncodedJSValue (*handler)(JSC::JSGlobalObject* arg0, JSC::CallFrame* arg1)); diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index c85e7379065..97a073600cb 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -787,6 +787,8 @@ BUN_DECLARE_HOST_FUNCTION(Bun__TestScope__Describe2__bunTestThen); BUN_DECLARE_HOST_FUNCTION(Bun__TestScope__Describe2__bunTestCatch); BUN_DECLARE_HOST_FUNCTION(Bun__CronJob__onPromiseResolve); BUN_DECLARE_HOST_FUNCTION(Bun__CronJob__onPromiseReject); +BUN_DECLARE_HOST_FUNCTION(Bun__Expect__PendingMatcher__onResolve); +BUN_DECLARE_HOST_FUNCTION(Bun__Expect__PendingMatcher__onReject); #endif diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index e1e607847f0..f63effb3a23 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -1381,7 +1381,7 @@ pub use self::js_object::{ExternColumnIdentifier, ExternColumnIdentifierValue, J // ────────────────────────────────────────────────────────────────────────── #[path = "CallFrame.rs"] pub mod call_frame; -pub use self::call_frame::{ArgumentsSlice, CallFrame}; +pub use self::call_frame::{ArgumentsSlice, CallFrame, CallerSrcLoc}; /// Lives here (not in `bun_sys_jsc`) because the orphan /// rule requires either the trait or the type to be local; `FromJsEnum` is. diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index bcfd3b587de..7ec207ffed5 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -40,6 +40,41 @@ pub struct Expect { pub flags: Cell, pub parent: Option, pub custom_label: bun_core::String, + /// Set to true while re-invoking a matcher from a `.resolves`/`.rejects` + /// `.then()` callback so we don't try to defer again. + pub is_async_rerun: Cell, + /// Set by `increment_expect_call_counter()` so a deferred re-run doesn't + /// count the same expectation twice. Reset by `post_match()` so multiple + /// matchers on the same `expect()` each count; `PendingMatcher` snapshots + /// it at defer time and restores it before the re-run. + pub counted_expect_call: Cell, + /// The caller's source location for the currently-executing deferred + /// re-run, reconstructed from the context array's `SLOT_SRCLOC_*` slots. + /// Written by `PendingMatcher::rerun()` for the duration of the + /// re-invoked matcher call and restored afterwards. `inline_snapshot()` + /// reads it (gated on `is_async_rerun`) because the user's frame is no + /// longer on the stack. The string is owned by `rerun()`'s scope, not by + /// `Expect`. + pub async_rerun_srcloc: Cell>, +} + +/// Result of `Expect::get_value()`: either the (promise-unwrapped) value the +/// matcher should operate on, or a deferred promise that the matcher must +/// return to its caller because `.resolves`/`.rejects` was used on a +/// still-pending input promise. +pub enum MaybeDeferred { + Value(JSValue), + /// The input promise is still pending. A `PendingMatcher` has been + /// attached via `.then()` to re-invoke the matcher once it settles; the + /// matcher must return this promise to its caller now. + Deferred(JSValue), +} + +/// Result of `Expect::matcher_prelude()`. +pub enum MatcherStart<'a> { + Ready(PostMatchGuard<'a>, JSValue, bool), + /// See [`MaybeDeferred::Deferred`]. + Deferred(JSValue), } @@ -192,6 +227,10 @@ impl Expect { } pub fn increment_expect_call_counter(&self) { + if self.counted_expect_call.get() { + return; + } + self.counted_expect_call.set(true); let Some(parent) = self.parent.as_ref() else { return }; // not in bun:test let Some(buntest_strong) = parent.bun_test() else { return }; // the test file this expect() call was for is no longer let buntest = buntest_strong.get(); @@ -357,15 +396,23 @@ impl Expect { Ok(this_value) } + /// Retrieves the captured value passed to `expect(...)`, processing + /// `.resolves`/`.rejects` if set. + /// + /// Returns `MaybeDeferred::Deferred` when the captured value is a + /// still-pending promise. In that case the matcher is deferred: a + /// `.then()` is attached that re-invokes the matcher once the promise + /// settles, and the matcher must immediately return the deferred promise. pub fn get_value( &self, global_this: &JSGlobalObject, this_value: JSValue, + callframe: &CallFrame, // Every caller passes a string literal, so accept `&str` // (BStr::new below takes `AsRef<[u8]>`, so no copy). matcher_name: &str, matcher_params_fmt: &'static str, - ) -> JsResult { + ) -> JsResult { let Some(value) = super::expect::js::captured_value_get_cached(this_value) else { return Err(global_this.throw2( "Internal error: the expect(value) was garbage collected but it should not have been!", @@ -374,6 +421,10 @@ impl Expect { }; value.ensure_still_alive(); + if let Some(deferred) = self.maybe_defer_matcher(global_this, this_value, callframe, value)? { + return Ok(MaybeDeferred::Deferred(deferred)); + } + #[allow(clippy::disallowed_methods)] // template is a runtime parameter let matcher_params = Output::pretty_fmt_rt(matcher_params_fmt, Output::enable_ansi_colors_stderr()); Self::process_promise( @@ -385,6 +436,48 @@ impl Expect { matcher_params, false, ) + .map(MaybeDeferred::Value) + } + + /// If `.resolves`/`.rejects` is set and `value` is a still-pending + /// promise, sets up a `.then()` callback to re-invoke the current matcher + /// once the promise settles, and returns the deferred-result promise for + /// the caller to await. Returns `None` otherwise. + /// + /// This replaces the old synchronous `wait_for_promise()` which would + /// hang forever if the promise could only be resolved by JavaScript + /// sitting above the matcher on the call stack (#14950). + fn maybe_defer_matcher( + &self, + global_this: &JSGlobalObject, + this_value: JSValue, + callframe: &CallFrame, + value: JSValue, + ) -> JsResult> { + if self.flags.get().promise() == Promise::None { + return Ok(None); + } + if self.is_async_rerun.get() { + return Ok(None); + } + let Some(promise) = value.as_any_promise() else { + return Ok(None); + }; + if promise.status() != js_promise::Status::Pending { + return Ok(None); + } + + promise.set_handled(global_this.vm()); + + let deferred = PendingMatcher::create( + global_this, + this_value, + callframe, + value, + self.counted_expect_call.get(), + self.flags.get(), + )?; + Ok(Some(deferred)) } /// Processes the async flags (resolves/rejects), waiting for the async value if needed. @@ -651,6 +744,9 @@ impl Expect { flags: Cell::new(Flags::default()), custom_label, parent: active_execution_entry_ref, + is_async_rerun: Cell::new(false), + counted_expect_call: Cell::new(false), + async_rerun_srcloc: Cell::new(None), }; // `JsClass::to_js` boxes `self` and hands the pointer to `${T}__create`. let expect_js_value = expect.to_js(global_this); @@ -1075,11 +1171,20 @@ impl Expect { let buntest = buntest_strong.get(); // 1. find the src loc of the snapshot - let srcloc = call_frame.get_caller_src_loc(global_this); + // When re-running from a deferred `.resolves`/`.rejects` + // `.then()` callback, the user's frame is no longer on the + // stack; `PendingMatcher::rerun()` lends its captured location + // via `async_rerun_srcloc` for the duration of the call. + let (srcloc, owns_srcloc) = match (this.is_async_rerun.get(), this.async_rerun_srcloc.get()) { + (true, Some(loc)) => (loc, false), + _ => (call_frame.get_caller_src_loc(global_this), true), + }; // bun_core::String is Copy // with no Drop, so wrap in the RAII guard to release the +1 on - // every exit path (including the early returns below). - let _srcloc_str_guard = bun_core::OwnedString::new(srcloc.str); + // every exit path (including the early returns below). When the + // srcloc is borrowed (the deferred-rerun path), + // `PendingMatcher::rerun()`'s scope owns the deref. + let _srcloc_str_guard = owns_srcloc.then(|| bun_core::OwnedString::new(srcloc.str)); let file_id = buntest.file_id; // MultiArrayList::get requires MultiArrayElement (derive pending); // use the column accessor which already compiles in jest.rs. @@ -1535,9 +1640,6 @@ impl Expect { /// and we can known which case it is based on if the `callFrame.this()` value is an instance of Expect // extern shim emitted by `#[bun_jsc::JsClass]` codegen (TypeClass__construct/__call); bare `#[host_fn]` cannot target an associated fn without a receiver. pub fn apply_custom_matcher(global_this: &JSGlobalObject, call_frame: &CallFrame) -> JsResult { - // SAFETY: bun_vm() returns the live VM pointer for this global. - let _gc = global_this.bun_vm().as_mut().auto_gc_on_drop(); - // retrieve the user-provided matcher function (matcher_fn) let func: JSValue = call_frame.callee(); let matcher_fn: JSValue = get_custom_matcher_fn(func, global_this).unwrap_or(JSValue::UNDEFINED); @@ -1553,6 +1655,8 @@ impl Expect { let this_value: JSValue = call_frame.this(); let Some(expect_ptr) = Expect::from_js(this_value) else { // if no Expect instance, assume it is a static call (`expect.myMatcher()`), so create an ExpectCustomAsymmetricMatcher instance + // SAFETY: bun_vm() returns the live VM pointer for this global. + let _gc = global_this.bun_vm().as_mut().auto_gc_on_drop(); return ExpectCustomAsymmetricMatcher::create(global_this, call_frame, matcher_fn); }; // SAFETY: from_js returned a non-null live m_ctx pointer owned by the JS wrapper. @@ -1560,6 +1664,7 @@ impl Expect { // thenable's user-defined `then`), which can call another matcher on this same // `expect()` chain; aliased `&Expect` is sound, aliased `&mut Expect` is not. let expect = unsafe { &*expect_ptr }; + let expect = expect.post_match_guard(global_this); // if we got an Expect instance, then it's a non-static call (`expect().myMatcher`), // so now execute the symmetric matching @@ -1579,6 +1684,9 @@ impl Expect { "Internal consistency error: failed to retrieve the captured value" ))); }; + if let Some(deferred) = expect.maybe_defer_matcher(global_this, this_value, call_frame, value)? { + return Ok(deferred); + } value = Self::process_promise( expect.custom_label.clone(), expect.flags.get(), @@ -1709,6 +1817,10 @@ impl Expect { } pub fn post_match(&self, global_this: &JSGlobalObject) { + // Reset so the *next* matcher on this `expect()` instance counts + // independently. `PendingMatcher` snapshots the pre-reset value so + // a deferred rerun observes the state at the point of defer. + self.counted_expect_call.set(false); global_this.bun_vm().auto_garbage_collect(); } @@ -1729,22 +1841,28 @@ impl Expect { /// [`Self::mock_prologue`], for matchers that need the received value but /// are NOT a pure unary predicate and NOT a mock-function matcher. /// - /// Returns `(guard, received_value, not)`. The guard derefs to `&Expect` - /// and runs `post_match` on drop; `not` is `flags.not()` snapshotted once. - /// Callers that don't need `not` until later destructure as `(this, v, _)`. + /// Returns [`MatcherStart::Ready(guard, received_value, not)`] in the + /// common case — the guard derefs to `&Expect` and runs `post_match` on + /// drop, and `not` is `flags.not()` snapshotted once — or + /// [`MatcherStart::Deferred`] when `.resolves`/`.rejects` was used on a + /// still-pending promise. Callers unwrap with [`ready_matcher!`]. #[inline] pub fn matcher_prelude<'a>( &'a self, global: &'a JSGlobalObject, this_value: JSValue, + callframe: &CallFrame, matcher_name: &str, matcher_params: &'static str, - ) -> JsResult<(PostMatchGuard<'a>, JSValue, bool)> { + ) -> JsResult> { let this = self.post_match_guard(global); - let value = this.get_value(global, this_value, matcher_name, matcher_params)?; + let value = match this.get_value(global, this_value, callframe, matcher_name, matcher_params)? { + MaybeDeferred::Value(v) => v, + MaybeDeferred::Deferred(p) => return Ok(MatcherStart::Deferred(p)), + }; this.increment_expect_call_counter(); let not = this.flags.get().not(); - Ok((this, value, not)) + Ok(MatcherStart::Ready(this, value, not)) } // extern shim emitted by `#[bun_jsc::JsClass]` codegen (TypeClass__construct/__call); bare `#[host_fn]` cannot target an associated fn without a receiver. @@ -1791,6 +1909,251 @@ impl Drop for PostMatchGuard<'_> { } } +/// State for a `.resolves`/`.rejects` matcher call whose promise hasn't +/// settled yet. When it does, we re-invoke the matcher and resolve/reject +/// the deferred promise with the outcome. +/// +/// The state is stored in a GC-managed JS array passed as the promise +/// reaction's context (`then_with_value`), NOT in a native allocation: +/// if the input promise never settles (e.g. an un-awaited +/// `expect(new Promise(()=>{})).resolves...`), the reaction — and with it +/// this array and everything it references — is simply garbage-collected. +/// Nothing leaks and LeakSanitizer stays quiet. +pub struct PendingMatcher; + +impl PendingMatcher { + /// Slots of the context array built in [`Self::create`]. + /// The `Expect` JS instance (also keeps capturedValue alive). + const SLOT_EXPECT_THIS: u32 = 0; + /// The native matcher function being called (e.g. `toBe`). + const SLOT_MATCHER_FN: u32 = 1; + /// JSArray of the arguments the matcher was called with. + const SLOT_MATCHER_ARGS: u32 = 2; + /// The pending promise returned to the caller of the matcher. + const SLOT_DEFERRED: u32 = 3; + /// Whether `increment_expect_call_counter()` had already run by the + /// time we deferred (boolean). Restored on re-run so the counter is + /// bumped exactly once regardless of whether this matcher calls it + /// before or after `get_value()`. + const SLOT_WAS_COUNTED: u32 = 4; + /// The `Expect.flags` bits at the time we deferred (number). `.not` / + /// `.resolves` / `.rejects` mutate flags in place on the shared + /// instance, so a later matcher call on the same `expect()` could flip + /// them before this re-run fires; restore so the re-run observes the + /// flags the user wrote for *this* matcher call. + const SLOT_FLAGS: u32 = 5; + /// Caller source location captured while the user's frame is still on + /// the stack, split into string/line/column slots. `inline_snapshot()` + /// needs it on the re-run to know which line to write the snapshot + /// back to. Stored per-deferral (not on the shared `Expect`) so two + /// deferred inline-snapshot matchers on the same `expect()` instance + /// each write to their own call site. + const SLOT_SRCLOC_STR: u32 = 6; + const SLOT_SRCLOC_LINE: u32 = 7; + const SLOT_SRCLOC_COLUMN: u32 = 8; + const SLOT_COUNT: usize = 9; + + fn create( + global_this: &JSGlobalObject, + this_value: JSValue, + callframe: &CallFrame, + promise_value: JSValue, + was_counted: bool, + flags: Flags, + ) -> JsResult { + let args = callframe.arguments(); + let args_array = JSValue::create_empty_array(global_this, args.len())?; + for (i, arg) in args.iter().enumerate() { + args_array.put_index(global_this, i as u32, *arg)?; + } + + let deferred_value = js_promise::JSPromise::create(global_this).to_js(); + + // `get_caller_src_loc` returns the path with a +1 ref; convert it to + // a JS string for GC-managed storage and release the native ref on + // scope exit. + let srcloc = callframe.get_caller_src_loc(global_this); + let srcloc_str_guard = bun_core::OwnedString::new(srcloc.str); + let srcloc_str_js = srcloc_str_guard.get().to_js(global_this)?; + + let ctx = JSValue::create_empty_array(global_this, Self::SLOT_COUNT)?; + ctx.put_index(global_this, Self::SLOT_EXPECT_THIS, this_value)?; + ctx.put_index(global_this, Self::SLOT_MATCHER_FN, callframe.callee())?; + ctx.put_index(global_this, Self::SLOT_MATCHER_ARGS, args_array)?; + ctx.put_index(global_this, Self::SLOT_DEFERRED, deferred_value)?; + ctx.put_index(global_this, Self::SLOT_WAS_COUNTED, JSValue::js_boolean(was_counted))?; + ctx.put_index( + global_this, + Self::SLOT_FLAGS, + JSValue::js_number_from_int32(flags.0 as i32), + )?; + ctx.put_index(global_this, Self::SLOT_SRCLOC_STR, srcloc_str_js)?; + ctx.put_index( + global_this, + Self::SLOT_SRCLOC_LINE, + JSValue::js_number_from_int32(srcloc.line as i32), + )?; + ctx.put_index( + global_this, + Self::SLOT_SRCLOC_COLUMN, + JSValue::js_number_from_int32(srcloc.column as i32), + )?; + + promise_value.then_with_value( + global_this, + ctx, + Bun__Expect__PendingMatcher__onResolve, + Bun__Expect__PendingMatcher__onReject, + ); + + Ok(deferred_value) + } + + fn on_settle(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let args = callframe.arguments_old::<2>(); + let ctx = args.ptr[1]; + if ctx.is_empty_or_undefined_or_null() || !ctx.is_cell() { + return Ok(JSValue::UNDEFINED); + } + + // Read the deferred before anything else fallible: every remaining + // slot read and the re-run itself happen inside `rerun()`, whose + // error feeds the reject arm below instead of returning early and + // leaving the deferred pending forever. + let deferred_value = ctx.get_index(global_this, Self::SLOT_DEFERRED)?; + + let result = Self::rerun(global_this, ctx); + + if let Some(deferred) = deferred_value.as_promise() { + // SAFETY: `deferred_value` was created by `JSPromise::create` in + // `create()` and is kept alive by the ctx array (rooted by this + // frame's arguments) for the duration of this call. + let deferred = unsafe { &mut *deferred }; + match result { + Ok(value) => { + let _ = deferred.resolve(global_this, value); + } + Err(_) => { + let exception = global_this + .try_take_exception() + .unwrap_or(JSValue::UNDEFINED); + let _ = deferred.reject(global_this, Ok(exception)); + } + } + } + Ok(JSValue::UNDEFINED) + } + + /// Reads the re-run inputs back out of the context array and re-invokes + /// the matcher. Fallible end to end: `on_settle()` routes any error from + /// here, including the slot reads, into the deferred's reject arm. + fn rerun(global_this: &JSGlobalObject, ctx: JSValue) -> JsResult { + let expect_this = ctx.get_index(global_this, Self::SLOT_EXPECT_THIS)?; + let matcher_fn = ctx.get_index(global_this, Self::SLOT_MATCHER_FN)?; + let args_array = ctx.get_index(global_this, Self::SLOT_MATCHER_ARGS)?; + let was_counted = ctx.get_index(global_this, Self::SLOT_WAS_COUNTED)?.to_boolean(); + let flags = Flags(ctx.get_index(global_this, Self::SLOT_FLAGS)?.to_int32() as u8); + + // Rebuild the caller srcloc. The bun String is owned here (+1 from + // `to_bun_string`) and released when `_srcloc_str_guard` drops, after + // the matcher call below has finished using it via + // `async_rerun_srcloc`. + let srcloc_str_js = ctx.get_index(global_this, Self::SLOT_SRCLOC_STR)?; + let _srcloc_str_guard = + bun_core::OwnedString::new(srcloc_str_js.to_bun_string(global_this)?); + let srcloc = bun_jsc::CallerSrcLoc { + str: _srcloc_str_guard.get(), + line: ctx.get_index(global_this, Self::SLOT_SRCLOC_LINE)?.to_int32() as core::ffi::c_uint, + column: ctx.get_index(global_this, Self::SLOT_SRCLOC_COLUMN)?.to_int32() + as core::ffi::c_uint, + }; + + // Extract the original arguments back out of the array. Most + // built-in matchers take 0-2 arguments, but `toHaveBeenCalledWith` + // and `expect.extend()` custom matchers are variadic. + let args_len = args_array.get_length(global_this)? as usize; + let mut args_buf: Vec = Vec::with_capacity(args_len); + for i in 0..args_len { + args_buf.push(args_array.get_index(global_this, i as u32)?); + } + + // Mark the re-run so we don't try to defer a second time. The + // captured promise is now settled, so `process_promise` will extract + // its result synchronously. + // + // `is_async_rerun`, `flags`, and `async_rerun_srcloc` are + // saved/restored (not hard-reset) because a sibling deferred matcher + // on the same input promise can rerun *inside* `matcher_fn.call()` + // when the matcher re-enters the event loop (e.g. `toThrow` → + // `get_value_as_to_throw` → `wait_for_promise` → `tick` drains the + // microtask queue), and the inner restore must not clobber the + // outer's state. `counted_expect_call` is restored to its value at + // the point of defer so the counter is bumped exactly once. + struct RerunGuard { + expect: *mut Expect, + saved_is_rerun: bool, + saved_flags: Flags, + saved_srcloc: Option, + } + impl Drop for RerunGuard { + fn drop(&mut self) { + // SAFETY: `expect` came from `Expect::from_js` on a value + // held live by the ctx array in `on_settle()` (rooted by + // that frame's arguments) for the duration of this scope. + unsafe { + (*self.expect).is_async_rerun.set(self.saved_is_rerun); + (*self.expect).flags.set(self.saved_flags); + (*self.expect).async_rerun_srcloc.set(self.saved_srcloc); + } + } + } + let _guard = Expect::from_js(expect_this).map(|expect| { + // SAFETY: `expect_this` is held live by the ctx array in `on_settle()`. + let e = unsafe { &*expect }; + let guard = RerunGuard { + expect, + saved_is_rerun: e.is_async_rerun.get(), + saved_flags: e.flags.get(), + saved_srcloc: e.async_rerun_srcloc.get(), + }; + e.is_async_rerun.set(true); + e.counted_expect_call.set(was_counted); + e.flags.set(flags); + e.async_rerun_srcloc.set(Some(srcloc)); + guard + }); + + matcher_fn.call(global_this, expect_this, &args_buf) + } +} + +// `ZigGlobalObject::promiseHandlerID` (C++) compares the fn-ptr passed to +// `JSValue::then` against these by identity, so the Rust thunk MUST be the +// exported symbol itself — see the comment on `Bun__TestScope__Describe2__*` +// in `bun_test.rs`. +bun_jsc::jsc_host_abi! { + #[unsafe(no_mangle)] + pub unsafe fn Bun__Expect__PendingMatcher__onResolve( + global: *mut JSGlobalObject, + frame: *mut CallFrame, + ) -> JSValue { + // SAFETY: JSC passes non-null live pointers for both. + let (global, frame) = unsafe { (&*global, &*frame) }; + bun_jsc::host_fn::to_js_host_fn_result(global, PendingMatcher::on_settle(global, frame)) + } +} +bun_jsc::jsc_host_abi! { + #[unsafe(no_mangle)] + pub unsafe fn Bun__Expect__PendingMatcher__onReject( + global: *mut JSGlobalObject, + frame: *mut CallFrame, + ) -> JSValue { + // SAFETY: JSC passes non-null live pointers for both. + let (global, frame) = unsafe { (&*global, &*frame) }; + bun_jsc::host_fn::to_js_host_fn_result(global, PendingMatcher::on_settle(global, frame)) + } +} + pub struct CustomMatcherParamsFormatter<'a> { pub colors: bool, pub global_this: &'a JSGlobalObject, @@ -2014,7 +2377,7 @@ impl Expect { matcher_name: &'static str, pred: impl FnOnce(JSValue) -> bool, ) -> JsResult { - let (this, value, not) = self.matcher_prelude(global, frame.this(), matcher_name, "")?; + let (this, value, not) = crate::ready_matcher!(self.matcher_prelude(global, frame.this(), frame, matcher_name, "")?); if pred(value) != not { return Ok(JSValue::UNDEFINED); } @@ -2065,7 +2428,7 @@ impl Expect { ))); } - let value = this.get_value(global, frame.this(), matcher_name, "expected")?; + let value = crate::ready_value!(this.get_value(global, frame.this(), frame, matcher_name, "expected")?); this.increment_expect_call_counter(); let mut pass = value.is_string(); @@ -2194,7 +2557,7 @@ impl Expect { } expected.ensure_still_alive(); - let value = this.get_value(global, this_value, matcher_name, "expected")?; + let value = crate::ready_value!(this.get_value(global, this_value, frame, matcher_name, "expected")?); if matches!(expected_array, ExpectedArray::AfterValue) && !expected.js_type().is_array() { return Err(global.throw_invalid_argument_type(matcher_name, "expected", "array")); } @@ -3050,18 +3413,24 @@ pub mod mock { /// `.rejects`), bumps the assertion counter, fetches the requested /// mock-backed array, and emits the kind-appropriate "not a mock" error. /// - /// Returns the [`PostMatchGuard`] (so `post_match` runs when the caller - /// drops it), the `mock.calls` / `mock.results` JSArray, and the raw - /// received value (some matchers print it again on later error paths). + /// Returns [`MockStart::Ready(guard, arr, value)`] — the + /// [`PostMatchGuard`] (so `post_match` runs when the caller drops it), + /// the `mock.calls` / `mock.results` JSArray, and the raw received + /// value (some matchers print it again on later error paths) — or + /// [`MockStart::Deferred`] when `.resolves`/`.rejects` was used on a + /// still-pending promise. Callers unwrap with [`ready_mock!`]. pub fn mock_prologue<'a>( &'a self, global: &'a JSGlobalObject, - this_value: JSValue, + frame: &CallFrame, matcher_name: &'static str, matcher_params: &'static str, kind: MockKind, - ) -> JsResult<(PostMatchGuard<'a>, JSValue, JSValue)> { - let (this, value, _) = self.matcher_prelude(global, this_value, matcher_name, matcher_params)?; + ) -> JsResult> { + let (this, value, _) = match self.matcher_prelude(global, frame.this(), frame, matcher_name, matcher_params)? { + MatcherStart::Ready(t, v, n) => (t, v, n), + MatcherStart::Deferred(p) => return Ok(MockStart::Deferred(p)), + }; let arr = match kind { MockKind::Calls | MockKind::CallsWithSig => JSMockFunction__getCalls(global, value)?, MockKind::Returns => JSMockFunction__getReturns(global, value)?, @@ -3085,10 +3454,17 @@ pub mod mock { )), }); } - Ok((this, arr, value)) + Ok(MockStart::Ready(this, arr, value)) } } + /// Result of `Expect::mock_prologue()`. + pub enum MockStart<'a> { + Ready(PostMatchGuard<'a>, JSValue, JSValue), + /// See [`MaybeDeferred::Deferred`]. + Deferred(JSValue), + } + pub(crate) fn jest_mock_return_object_type(global_this: &JSGlobalObject, value: JSValue) -> JsResult { if let Some(type_string) = value.fast_get(global_this, bun_jsc::BuiltinName::Type)? { if type_string.is_string() { diff --git a/src/runtime/test_runner/expect/toBe.rs b/src/runtime/test_runner/expect/toBe.rs index 3e257b363d4..f6abd5cc680 100644 --- a/src/runtime/test_runner/expect/toBe.rs +++ b/src/runtime/test_runner/expect/toBe.rs @@ -11,8 +11,7 @@ impl Expect { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - let (this, left, not) = - self.matcher_prelude(global_this, callframe.this(), "toBe", "expected")?; + let (this, left, not) = crate::ready_matcher!(self.matcher_prelude(global_this, callframe.this(), callframe, "toBe", "expected")?); let arguments_ = callframe.arguments_old::<2>(); let arguments = arguments_.slice(); diff --git a/src/runtime/test_runner/expect/toBeArrayOfSize.rs b/src/runtime/test_runner/expect/toBeArrayOfSize.rs index 464e5bdd543..9b883956f16 100644 --- a/src/runtime/test_runner/expect/toBeArrayOfSize.rs +++ b/src/runtime/test_runner/expect/toBeArrayOfSize.rs @@ -21,7 +21,7 @@ pub(crate) fn to_be_array_of_size( return Err(global.throw_invalid_arguments(format_args!("toBeArrayOfSize() requires 1 argument"))); } - let value: JSValue = this.get_value(global, this_value, "toBeArrayOfSize", "")?; + let value: JSValue = crate::ready_value!(this.get_value(global, this_value, frame, "toBeArrayOfSize", "")?); let size = arguments[0]; size.ensure_still_alive(); diff --git a/src/runtime/test_runner/expect/toBeCloseTo.rs b/src/runtime/test_runner/expect/toBeCloseTo.rs index ba133753732..dce89c4ede7 100644 --- a/src/runtime/test_runner/expect/toBeCloseTo.rs +++ b/src/runtime/test_runner/expect/toBeCloseTo.rs @@ -40,7 +40,7 @@ impl Expect { } let received_: JSValue = - this.get_value(global, this_value, "toBeCloseTo", "expected, precision")?; + crate::ready_value!(this.get_value(global, this_value, call_frame, "toBeCloseTo", "expected, precision")?); if !received_.is_number() { return Err(global.throw_invalid_argument_type("expect", "received", "number")); } diff --git a/src/runtime/test_runner/expect/toBeEmpty.rs b/src/runtime/test_runner/expect/toBeEmpty.rs index aaf15206b21..e9dec3f9ebf 100644 --- a/src/runtime/test_runner/expect/toBeEmpty.rs +++ b/src/runtime/test_runner/expect/toBeEmpty.rs @@ -11,7 +11,7 @@ pub(crate) fn to_be_empty( global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - let (_this, value, not) = this.matcher_prelude(global, frame.this(), "toBeEmpty", "")?; + let (_this, value, not) = crate::ready_matcher!(this.matcher_prelude(global, frame.this(), frame, "toBeEmpty", "")?); let mut pass; let mut formatter = super::make_formatter(global); // `defer formatter.deinit()` — handled by Drop. diff --git a/src/runtime/test_runner/expect/toBeEmptyObject.rs b/src/runtime/test_runner/expect/toBeEmptyObject.rs index ac1177a3f72..92bc84e2d11 100644 --- a/src/runtime/test_runner/expect/toBeEmptyObject.rs +++ b/src/runtime/test_runner/expect/toBeEmptyObject.rs @@ -9,7 +9,7 @@ pub(crate) fn to_be_empty_object( call_frame: &CallFrame, ) -> JsResult { let this_value = call_frame.this(); - let (this, value, not) = this.matcher_prelude(global, this_value, "toBeEmptyObject", "")?; + let (this, value, not) = crate::ready_matcher!(this.matcher_prelude(global, this_value, call_frame, "toBeEmptyObject", "")?); let mut pass = value.is_object_empty(global)?; if not { diff --git a/src/runtime/test_runner/expect/toBeInstanceOf.rs b/src/runtime/test_runner/expect/toBeInstanceOf.rs index 02f9d2f211f..0059949770e 100644 --- a/src/runtime/test_runner/expect/toBeInstanceOf.rs +++ b/src/runtime/test_runner/expect/toBeInstanceOf.rs @@ -38,7 +38,7 @@ pub(crate) fn to_be_instance_of( expected_value.ensure_still_alive(); let value: JSValue = - this.get_value(global, this_value, "toBeInstanceOf", "expected")?; + crate::ready_value!(this.get_value(global, this_value, frame, "toBeInstanceOf", "expected")?); let not = this.flags.get().not(); let mut pass = value.is_instance_of(global, expected_value)?; diff --git a/src/runtime/test_runner/expect/toBeObject.rs b/src/runtime/test_runner/expect/toBeObject.rs index 66aaeba27f9..08fe3249a54 100644 --- a/src/runtime/test_runner/expect/toBeObject.rs +++ b/src/runtime/test_runner/expect/toBeObject.rs @@ -15,7 +15,7 @@ impl Expect { // (incl. `?` early-returns) is covered without a raw `*mut Expect`. let result = (|| -> JsResult { let this_value = frame.this(); - let value: JSValue = this.get_value(global, this_value, "toBeObject", "")?; + let value: JSValue = crate::ready_value!(this.get_value(global, this_value, frame, "toBeObject", "")?); this.increment_expect_call_counter(); diff --git a/src/runtime/test_runner/expect/toBeOneOf.rs b/src/runtime/test_runner/expect/toBeOneOf.rs index a9689d2e455..b32d0383e63 100644 --- a/src/runtime/test_runner/expect/toBeOneOf.rs +++ b/src/runtime/test_runner/expect/toBeOneOf.rs @@ -36,8 +36,7 @@ pub(crate) fn to_be_one_of( global_this: &JSGlobalObject, call_frame: &CallFrame, ) -> JsResult { - let (this, expected, not) = - this.matcher_prelude(global_this, call_frame.this(), "toBeOneOf", "expected")?; + let (this, expected, not) = crate::ready_matcher!(this.matcher_prelude(global_this, call_frame.this(), call_frame, "toBeOneOf", "expected")?); let arguments_ = call_frame.arguments_old::<1>(); let arguments = arguments_.slice(); diff --git a/src/runtime/test_runner/expect/toBeTypeOf.rs b/src/runtime/test_runner/expect/toBeTypeOf.rs index 6726efd7695..8270b9b8163 100644 --- a/src/runtime/test_runner/expect/toBeTypeOf.rs +++ b/src/runtime/test_runner/expect/toBeTypeOf.rs @@ -20,7 +20,7 @@ pub(crate) fn to_be_type_of( global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - let (this, value, not) = this.matcher_prelude(global, frame.this(), "toBeTypeOf", "")?; + let (this, value, not) = crate::ready_matcher!(this.matcher_prelude(global, frame.this(), frame, "toBeTypeOf", "")?); let _arguments = frame.arguments_old::<1>(); let arguments = _arguments.slice(); diff --git a/src/runtime/test_runner/expect/toBeValidDate.rs b/src/runtime/test_runner/expect/toBeValidDate.rs index c9491e79594..a92074c00bc 100644 --- a/src/runtime/test_runner/expect/toBeValidDate.rs +++ b/src/runtime/test_runner/expect/toBeValidDate.rs @@ -12,7 +12,7 @@ pub(crate) fn to_be_valid_date( frame: &CallFrame, ) -> JsResult { let this_value = frame.this(); - let (this, value, not) = this.matcher_prelude(global, this_value, "toBeValidDate", "")?; + let (this, value, not) = crate::ready_matcher!(this.matcher_prelude(global, this_value, frame, "toBeValidDate", "")?); let mut pass = value.is_date() && !value.get_unix_timestamp().is_nan(); if not { pass = !pass; diff --git a/src/runtime/test_runner/expect/toBeWithin.rs b/src/runtime/test_runner/expect/toBeWithin.rs index 3de2c0c4170..5f9945d9543 100644 --- a/src/runtime/test_runner/expect/toBeWithin.rs +++ b/src/runtime/test_runner/expect/toBeWithin.rs @@ -9,12 +9,13 @@ impl Expect { global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - let (this, value, not) = self.matcher_prelude( + let (this, value, not) = crate::ready_matcher!(self.matcher_prelude( global, frame.this(), + frame, "toBeWithin", "start, end", - )?; + )?); let _arguments = frame.arguments_old::<2>(); let arguments = _arguments.slice(); diff --git a/src/runtime/test_runner/expect/toContain.rs b/src/runtime/test_runner/expect/toContain.rs index ea9926fe4c8..2805c178470 100644 --- a/src/runtime/test_runner/expect/toContain.rs +++ b/src/runtime/test_runner/expect/toContain.rs @@ -13,8 +13,7 @@ impl Expect { global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - let (this, value, not) = - self.matcher_prelude(global, frame.this(), "toContain", "expected")?; + let (this, value, not) = crate::ready_matcher!(self.matcher_prelude(global, frame.this(), frame, "toContain", "expected")?); let arguments_ = frame.arguments_old::<1>(); let arguments = arguments_.slice(); diff --git a/src/runtime/test_runner/expect/toContainEqual.rs b/src/runtime/test_runner/expect/toContainEqual.rs index c4384459e52..595426b3e87 100644 --- a/src/runtime/test_runner/expect/toContainEqual.rs +++ b/src/runtime/test_runner/expect/toContainEqual.rs @@ -36,8 +36,7 @@ pub(crate) fn to_contain_equal( frame: &CallFrame, ) -> JsResult { let this_value = frame.this(); - let (this, value, not) = - this.matcher_prelude(global, this_value, "toContainEqual", "expected")?; + let (this, value, not) = crate::ready_matcher!(this.matcher_prelude(global, this_value, frame, "toContainEqual", "expected")?); let arguments_ = frame.arguments_old::<1>(); let arguments = arguments_.slice(); diff --git a/src/runtime/test_runner/expect/toEqual.rs b/src/runtime/test_runner/expect/toEqual.rs index 8cc083a3f7c..26ce0f9b082 100644 --- a/src/runtime/test_runner/expect/toEqual.rs +++ b/src/runtime/test_runner/expect/toEqual.rs @@ -10,8 +10,7 @@ impl Expect { global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - let (this, value, not) = - self.matcher_prelude(global, frame.this(), "toEqual", "expected")?; + let (this, value, not) = crate::ready_matcher!(self.matcher_prelude(global, frame.this(), frame, "toEqual", "expected")?); let _arguments = frame.arguments_old::<1>(); let arguments: &[JSValue] = _arguments.slice(); diff --git a/src/runtime/test_runner/expect/toEqualIgnoringWhitespace.rs b/src/runtime/test_runner/expect/toEqualIgnoringWhitespace.rs index c8d3ecafb46..2416eda95e4 100644 --- a/src/runtime/test_runner/expect/toEqualIgnoringWhitespace.rs +++ b/src/runtime/test_runner/expect/toEqualIgnoringWhitespace.rs @@ -15,8 +15,7 @@ pub(crate) fn to_equal_ignoring_whitespace( global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - let (this, value, not) = - this.matcher_prelude(global, frame.this(), "toEqualIgnoringWhitespace", "expected")?; + let (this, value, not) = crate::ready_matcher!(this.matcher_prelude(global, frame.this(), frame, "toEqualIgnoringWhitespace", "expected")?); let arguments_ = frame.arguments_old::<1>(); let arguments: &[JSValue] = arguments_.slice(); diff --git a/src/runtime/test_runner/expect/toHaveBeenCalled.rs b/src/runtime/test_runner/expect/toHaveBeenCalled.rs index ddebfb26eab..f1399a77adc 100644 --- a/src/runtime/test_runner/expect/toHaveBeenCalled.rs +++ b/src/runtime/test_runner/expect/toHaveBeenCalled.rs @@ -7,8 +7,7 @@ pub(crate) fn to_have_been_called( frame: &CallFrame, ) -> JsResult { bun_jsc::mark_binding!(); - let (this, calls, _value) = - this.mock_prologue(global, frame.this(), "toHaveBeenCalled", "", super::mock::MockKind::Calls)?; + let (this, calls, _value) = crate::ready_mock!(this.mock_prologue(global, frame, "toHaveBeenCalled", "", super::mock::MockKind::Calls)?); // arg-check after prologue: counter bump + post_match still fire on bad-arity. if !frame.arguments_as_array::<1>()[0].is_undefined() { return Err(global.throw_invalid_arguments(format_args!( diff --git a/src/runtime/test_runner/expect/toHaveBeenCalledOnce.rs b/src/runtime/test_runner/expect/toHaveBeenCalledOnce.rs index 1d1f99e6b15..be8b99dac99 100644 --- a/src/runtime/test_runner/expect/toHaveBeenCalledOnce.rs +++ b/src/runtime/test_runner/expect/toHaveBeenCalledOnce.rs @@ -8,13 +8,9 @@ pub(crate) fn to_have_been_called_once( frame: &CallFrame, ) -> JsResult { bun_jsc::mark_binding!(); - let (this, calls, _value) = this.mock_prologue( - global, - frame.this(), - "toHaveBeenCalledOnce", - "expected", - super::mock::MockKind::Calls, - )?; + let (this, calls, _value) = crate::ready_mock!(this.mock_prologue( + global, frame, "toHaveBeenCalledOnce", "expected", super::mock::MockKind::Calls, + )?); let calls_length = calls.get_length(global)?; let mut pass = calls_length == 1; diff --git a/src/runtime/test_runner/expect/toHaveBeenCalledTimes.rs b/src/runtime/test_runner/expect/toHaveBeenCalledTimes.rs index c903e69ae19..802e0797055 100644 --- a/src/runtime/test_runner/expect/toHaveBeenCalledTimes.rs +++ b/src/runtime/test_runner/expect/toHaveBeenCalledTimes.rs @@ -9,13 +9,9 @@ pub(crate) fn to_have_been_called_times( ) -> JsResult { let arguments_ = frame.arguments_old::<1>(); let arguments: &[JSValue] = arguments_.slice(); - let (this, calls, _value) = this.mock_prologue( - global, - frame.this(), - "toHaveBeenCalledTimes", - "expected", - super::mock::MockKind::Calls, - )?; + let (this, calls, _value) = crate::ready_mock!(this.mock_prologue( + global, frame, "toHaveBeenCalledTimes", "expected", super::mock::MockKind::Calls, + )?); if arguments.len() < 1 || !arguments[0].is_uint32_as_any_int() { return Err(global.throw_invalid_arguments(format_args!( diff --git a/src/runtime/test_runner/expect/toHaveBeenCalledWith.rs b/src/runtime/test_runner/expect/toHaveBeenCalledWith.rs index defb78cb26f..140aacf14dc 100644 --- a/src/runtime/test_runner/expect/toHaveBeenCalledWith.rs +++ b/src/runtime/test_runner/expect/toHaveBeenCalledWith.rs @@ -11,13 +11,9 @@ pub(crate) fn to_have_been_called_with( ) -> JsResult { bun_jsc::mark_binding!(); let arguments = frame.arguments(); - let (this, calls, _value) = this.mock_prologue( - global, - frame.this(), - "toHaveBeenCalledWith", - "...expected", - mock::MockKind::CallsWithSig, - )?; + let (this, calls, _value) = crate::ready_mock!(this.mock_prologue( + global, frame, "toHaveBeenCalledWith", "...expected", mock::MockKind::CallsWithSig, + )?); let mut pass = false; diff --git a/src/runtime/test_runner/expect/toHaveBeenLastCalledWith.rs b/src/runtime/test_runner/expect/toHaveBeenLastCalledWith.rs index 735c24befba..32385102f7e 100644 --- a/src/runtime/test_runner/expect/toHaveBeenLastCalledWith.rs +++ b/src/runtime/test_runner/expect/toHaveBeenLastCalledWith.rs @@ -10,13 +10,9 @@ pub(crate) fn to_have_been_last_called_with( ) -> JsResult { bun_jsc::mark_binding!(); let arguments = frame.arguments(); - let (this, calls, value) = this.mock_prologue( - global, - frame.this(), - "toHaveBeenLastCalledWith", - "...expected", - super::mock::MockKind::CallsWithSig, - )?; + let (this, calls, value) = crate::ready_mock!(this.mock_prologue( + global, frame, "toHaveBeenLastCalledWith", "...expected", super::mock::MockKind::CallsWithSig, + )?); let total_calls: u32 = calls.get_length(global)? as u32; let mut last_call_value: JSValue = JSValue::ZERO; diff --git a/src/runtime/test_runner/expect/toHaveBeenNthCalledWith.rs b/src/runtime/test_runner/expect/toHaveBeenNthCalledWith.rs index fe270de99f3..6e799cf2d65 100644 --- a/src/runtime/test_runner/expect/toHaveBeenNthCalledWith.rs +++ b/src/runtime/test_runner/expect/toHaveBeenNthCalledWith.rs @@ -8,13 +8,9 @@ pub(crate) fn to_have_been_nth_called_with( frame: &CallFrame, ) -> JsResult { let arguments = frame.arguments(); - let (this, calls, _value) = this.mock_prologue( - global, - frame.this(), - "toHaveBeenNthCalledWith", - "n, ...expected", - super::mock::MockKind::CallsWithSig, - )?; + let (this, calls, _value) = crate::ready_mock!(this.mock_prologue( + global, frame, "toHaveBeenNthCalledWith", "n, ...expected", super::mock::MockKind::CallsWithSig, + )?); if arguments.is_empty() || !arguments[0].is_any_int() { return Err(global.throw_invalid_arguments(format_args!( diff --git a/src/runtime/test_runner/expect/toHaveLastReturnedWith.rs b/src/runtime/test_runner/expect/toHaveLastReturnedWith.rs index 750bd4b81ee..4b4c079d11c 100644 --- a/src/runtime/test_runner/expect/toHaveLastReturnedWith.rs +++ b/src/runtime/test_runner/expect/toHaveLastReturnedWith.rs @@ -12,13 +12,9 @@ pub(crate) fn to_have_last_returned_with( ) -> JsResult { bun_jsc::mark_binding!(); let expected = callframe.arguments_as_array::<1>()[0]; - let (this, returns, _value) = this.mock_prologue( - global_this, - callframe.this(), - "toHaveBeenLastReturnedWith", - "expected", - super::mock::MockKind::Returns, - )?; + let (this, returns, _value) = crate::ready_mock!(this.mock_prologue( + global_this, callframe, "toHaveBeenLastReturnedWith", "expected", super::mock::MockKind::Returns, + )?); let calls_count = u32::try_from(returns.get_length(global_this)?).unwrap(); let mut pass = false; diff --git a/src/runtime/test_runner/expect/toHaveLength.rs b/src/runtime/test_runner/expect/toHaveLength.rs index feedb825882..be6f4cdea4f 100644 --- a/src/runtime/test_runner/expect/toHaveLength.rs +++ b/src/runtime/test_runner/expect/toHaveLength.rs @@ -8,8 +8,7 @@ pub(crate) fn to_have_length( global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - let (this, value, not) = - this.matcher_prelude(global, frame.this(), "toHaveLength", "expected")?; + let (this, value, not) = crate::ready_matcher!(this.matcher_prelude(global, frame.this(), frame, "toHaveLength", "expected")?); let arguments_ = frame.arguments_old::<1>(); let arguments = arguments_.slice(); diff --git a/src/runtime/test_runner/expect/toHaveNthReturnedWith.rs b/src/runtime/test_runner/expect/toHaveNthReturnedWith.rs index 8367a6e9c85..1995a7e798f 100644 --- a/src/runtime/test_runner/expect/toHaveNthReturnedWith.rs +++ b/src/runtime/test_runner/expect/toHaveNthReturnedWith.rs @@ -10,13 +10,9 @@ pub(crate) fn to_have_nth_returned_with( ) -> JsResult { bun_jsc::mark_binding!(); let [nth_arg, expected] = frame.arguments_as_array::<2>(); - let (this, returns, _value) = this.mock_prologue( - global, - frame.this(), - "toHaveNthReturnedWith", - "n, expected", - super::mock::MockKind::Returns, - )?; + let (this, returns, _value) = crate::ready_mock!(this.mock_prologue( + global, frame, "toHaveNthReturnedWith", "n, expected", super::mock::MockKind::Returns, + )?); // Validate n is a number if !nth_arg.is_any_int() { diff --git a/src/runtime/test_runner/expect/toHaveProperty.rs b/src/runtime/test_runner/expect/toHaveProperty.rs index c7888375793..443c7120a41 100644 --- a/src/runtime/test_runner/expect/toHaveProperty.rs +++ b/src/runtime/test_runner/expect/toHaveProperty.rs @@ -31,12 +31,13 @@ pub(crate) fn to_have_property( ev.ensure_still_alive(); } - let value: JSValue = this.get_value( + let value: JSValue = crate::ready_value!(this.get_value( global, this_value, + frame, "toHaveProperty", "path, value", - )?; + )?); if !expected_property_path.is_string() && !expected_property_path.is_iterable(global)? { return Err(global.throw(format_args!("Expected path must be a string or an array"))); diff --git a/src/runtime/test_runner/expect/toHaveReturned.rs b/src/runtime/test_runner/expect/toHaveReturned.rs index a5ea4977318..a7c98f7b368 100644 --- a/src/runtime/test_runner/expect/toHaveReturned.rs +++ b/src/runtime/test_runner/expect/toHaveReturned.rs @@ -29,13 +29,13 @@ fn to_have_returned_times_fn( ) -> JsResult { bun_jsc::mark_binding!(); let arguments = callframe.arguments(); - let (this, returns_arr, _value) = this.mock_prologue( + let (this, returns_arr, _value) = crate::ready_mock!(this.mock_prologue( global, - callframe.this(), + callframe, mode.tag_name(), "expected", mock::MockKind::Returns, - )?; + )?); let mut returns = returns_arr.array_iterator(global)?; let expected_success_count: i32 = if mode == Mode::ToHaveReturned { diff --git a/src/runtime/test_runner/expect/toHaveReturnedWith.rs b/src/runtime/test_runner/expect/toHaveReturnedWith.rs index d6bcd036583..d94b605f8b6 100644 --- a/src/runtime/test_runner/expect/toHaveReturnedWith.rs +++ b/src/runtime/test_runner/expect/toHaveReturnedWith.rs @@ -10,13 +10,9 @@ pub(crate) fn to_have_returned_with( frame: &CallFrame, ) -> JsResult { let expected = frame.arguments_as_array::<1>()[0]; - let (this, returns, _value) = this.mock_prologue( - global, - frame.this(), - "toHaveReturnedWith", - "expected", - mock::MockKind::Returns, - )?; + let (this, returns, _value) = crate::ready_mock!(this.mock_prologue( + global, frame, "toHaveReturnedWith", "expected", mock::MockKind::Returns, + )?); let calls_count = u32::try_from(returns.get_length(global)?).unwrap(); let mut pass = false; diff --git a/src/runtime/test_runner/expect/toMatch.rs b/src/runtime/test_runner/expect/toMatch.rs index c7beace723e..8df7aed0e7a 100644 --- a/src/runtime/test_runner/expect/toMatch.rs +++ b/src/runtime/test_runner/expect/toMatch.rs @@ -8,8 +8,7 @@ pub(crate) fn to_match( global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - let (this, value, not) = - this.matcher_prelude(global, frame.this(), "toMatch", "expected")?; + let (this, value, not) = crate::ready_matcher!(this.matcher_prelude(global, frame.this(), frame, "toMatch", "expected")?); let arguments: &[JSValue] = frame.arguments(); diff --git a/src/runtime/test_runner/expect/toMatchInlineSnapshot.rs b/src/runtime/test_runner/expect/toMatchInlineSnapshot.rs index 1bf737fd5e6..4f6dc3f23c7 100644 --- a/src/runtime/test_runner/expect/toMatchInlineSnapshot.rs +++ b/src/runtime/test_runner/expect/toMatchInlineSnapshot.rs @@ -81,12 +81,13 @@ pub(crate) fn to_match_inline_snapshot( let expected_slice: Option<&[u8]> = if has_expected { Some(expected.slice()) } else { None }; - let value = this.get_value( + let value = crate::ready_value!(this.get_value( global, this_value, + frame, "toMatchInlineSnapshot", "properties, hint", - )?; + )?); Expect::inline_snapshot( &**this, global, diff --git a/src/runtime/test_runner/expect/toMatchObject.rs b/src/runtime/test_runner/expect/toMatchObject.rs index 411363d53c2..290f27fd236 100644 --- a/src/runtime/test_runner/expect/toMatchObject.rs +++ b/src/runtime/test_runner/expect/toMatchObject.rs @@ -7,8 +7,7 @@ pub(crate) fn to_match_object( global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - let (this, received_object, not) = - this.matcher_prelude(global, frame.this(), "toMatchObject", "expected")?; + let (this, received_object, not) = crate::ready_matcher!(this.matcher_prelude(global, frame.this(), frame, "toMatchObject", "expected")?); let args_buf = frame.arguments_old::<1>(); let args = args_buf.slice(); diff --git a/src/runtime/test_runner/expect/toMatchSnapshot.rs b/src/runtime/test_runner/expect/toMatchSnapshot.rs index cae5037fada..d74ab708c1b 100644 --- a/src/runtime/test_runner/expect/toMatchSnapshot.rs +++ b/src/runtime/test_runner/expect/toMatchSnapshot.rs @@ -90,12 +90,13 @@ pub(crate) fn to_match_snapshot( let hint = hint_string.to_slice(); // `hint` cleanup handled by Drop. - let value: JSValue = this.get_value( + let value: JSValue = crate::ready_value!(this.get_value( global, this_value, + frame, "toMatchSnapshot", "properties, hint", - )?; + )?); Expect::snapshot(&**this, global, value, property_matchers, hint.slice(), "toMatchSnapshot") } diff --git a/src/runtime/test_runner/expect/toStrictEqual.rs b/src/runtime/test_runner/expect/toStrictEqual.rs index 9c246be1022..d373e750e04 100644 --- a/src/runtime/test_runner/expect/toStrictEqual.rs +++ b/src/runtime/test_runner/expect/toStrictEqual.rs @@ -10,8 +10,7 @@ impl Expect { global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - let (this, value, not) = - self.matcher_prelude(global, frame.this(), "toStrictEqual", "expected")?; + let (this, value, not) = crate::ready_matcher!(self.matcher_prelude(global, frame.this(), frame, "toStrictEqual", "expected")?); let _arguments = frame.arguments_old::<1>(); let arguments: &[JSValue] = _arguments.slice(); diff --git a/src/runtime/test_runner/expect/toThrow.rs b/src/runtime/test_runner/expect/toThrow.rs index db44625079a..ed199ed8d8c 100644 --- a/src/runtime/test_runner/expect/toThrow.rs +++ b/src/runtime/test_runner/expect/toThrow.rs @@ -60,7 +60,7 @@ pub(crate) fn to_throw( let (result_, return_value_from_function) = this.get_value_as_to_throw( global, - this.get_value(global, this_value, "toThrow", "expected")?, + crate::ready_value!(this.get_value(global, this_value, frame, "toThrow", "expected")?), )?; let did_throw = result_.is_some(); diff --git a/src/runtime/test_runner/expect/toThrowErrorMatchingInlineSnapshot.rs b/src/runtime/test_runner/expect/toThrowErrorMatchingInlineSnapshot.rs index 4849a213f37..f2788219969 100644 --- a/src/runtime/test_runner/expect/toThrowErrorMatchingInlineSnapshot.rs +++ b/src/runtime/test_runner/expect/toThrowErrorMatchingInlineSnapshot.rs @@ -59,12 +59,13 @@ pub(crate) fn to_throw_error_matching_inline_snapshot( // reshaped for borrowck — hoist get_value out so the two &mut self // receivers don't overlap. - let received = this.get_value( + let received = crate::ready_value!(this.get_value( global, this_value, + frame, "toThrowErrorMatchingInlineSnapshot", "properties, hint", - )?; + )?); let Some(value) = this.fn_to_err_string_or_undefined(global, received)? else { let signature = Expect::get_signature("toThrowErrorMatchingInlineSnapshot", "", false); return this.throw( diff --git a/src/runtime/test_runner/expect/toThrowErrorMatchingSnapshot.rs b/src/runtime/test_runner/expect/toThrowErrorMatchingSnapshot.rs index a89d4c59025..a30611a8195 100644 --- a/src/runtime/test_runner/expect/toThrowErrorMatchingSnapshot.rs +++ b/src/runtime/test_runner/expect/toThrowErrorMatchingSnapshot.rs @@ -70,12 +70,13 @@ pub(crate) fn to_throw_error_matching_snapshot( let Some(value): Option = this.fn_to_err_string_or_undefined( global, - this.get_value( - global, - this_value, - "toThrowErrorMatchingSnapshot", - "properties, hint", - )?, + crate::ready_value!(this.get_value( + global, + this_value, + frame, + "toThrowErrorMatchingSnapshot", + "properties, hint", + )?), )? else { let signature = get_signature("toThrowErrorMatchingSnapshot", "", false); diff --git a/src/runtime/test_runner/mod.rs b/src/runtime/test_runner/mod.rs index 5b16e24b857..efa124293fb 100644 --- a/src/runtime/test_runner/mod.rs +++ b/src/runtime/test_runner/mod.rs @@ -48,6 +48,45 @@ macro_rules! unary_predicate_matcher { }; } +/// Unwrap a [`expect_core::MatcherStart`] or early-return the deferred +/// promise. Used at the top of every matcher body that goes through +/// `matcher_prelude` so a `.resolves`/`.rejects` matcher on a still-pending +/// promise returns the deferred promise to its caller instead of blocking +/// the event loop (#14950). +#[macro_export] +macro_rules! ready_matcher { + ($start:expr) => { + match $start { + $crate::test_runner::expect_core::MatcherStart::Ready(t, v, n) => (t, v, n), + $crate::test_runner::expect_core::MatcherStart::Deferred(p) => return Ok(p), + } + }; +} + +/// Unwrap a [`expect_core::MaybeDeferred`] or early-return the deferred +/// promise. See [`ready_matcher!`]. +#[macro_export] +macro_rules! ready_value { + ($v:expr) => { + match $v { + $crate::test_runner::expect_core::MaybeDeferred::Value(v) => v, + $crate::test_runner::expect_core::MaybeDeferred::Deferred(p) => return Ok(p), + } + }; +} + +/// Unwrap a [`expect_core::mock::MockStart`] or early-return the deferred +/// promise. See [`ready_matcher!`]. +#[macro_export] +macro_rules! ready_mock { + ($start:expr) => { + match $start { + $crate::test_runner::expect_core::mock::MockStart::Ready(t, a, v) => (t, a, v), + $crate::test_runner::expect_core::mock::MockStart::Deferred(p) => return Ok(p), + } + }; +} + cfg_jsc! { #[path = "bun_test.rs"] pub mod bun_test; #[path = "Collection.rs"] pub mod collection; @@ -433,7 +472,7 @@ pub mod expect { other_value.ensure_still_alive(); let value: JSValue = - this.get_value(global, this_value, name, "expected")?; + crate::ready_value!(this.get_value(global, this_value, frame, name, "expected")?); if (!value.is_number() && !value.is_big_int()) || (!other_value.is_number() && !other_value.is_big_int()) diff --git a/test/bake/fixtures/deinitialization/test.ts b/test/bake/fixtures/deinitialization/test.ts index b6b41533a15..506769990fa 100644 --- a/test/bake/fixtures/deinitialization/test.ts +++ b/test/bake/fixtures/deinitialization/test.ts @@ -58,7 +58,7 @@ async function run({ closeActiveConnections = false, sendAnyRequests = true, web if (sendAnyRequests) { if (closeActiveConnections) { - expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("closed unexpectedly"); + await expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("closed unexpectedly"); } else { const response = await fetch(server.url.origin, { keepalive: false }); expect(response.status).toBe(200); @@ -68,7 +68,7 @@ async function run({ closeActiveConnections = false, sendAnyRequests = true, web } // Server is closed - expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("Unable to connect"); + await expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("Unable to connect"); } await main(); diff --git a/test/cli/inspect/inspect.test.ts b/test/cli/inspect/inspect.test.ts index 41315f70310..dc3a9a50926 100644 --- a/test/cli/inspect/inspect.test.ts +++ b/test/cli/inspect/inspect.test.ts @@ -263,7 +263,7 @@ describe("websocket", () => { }).toMatchObject(expected); const webSocket = new WebSocket(url); - expect( + await expect( new Promise((resolve, reject) => { webSocket.addEventListener("open", () => resolve()); webSocket.addEventListener("error", cause => reject(new Error("WebSocket error", { cause }))); @@ -272,7 +272,7 @@ describe("websocket", () => { ).resolves.toBeUndefined(); webSocket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "1 + 1" } })); - expect( + await expect( new Promise(resolve => { webSocket.addEventListener("message", ({ data }) => { resolve(JSON.parse(data.toString())); diff --git a/test/cli/install/bun-pm-version.test.ts b/test/cli/install/bun-pm-version.test.ts index 1021f4df3ea..4e96dc6a410 100644 --- a/test/cli/install/bun-pm-version.test.ts +++ b/test/cli/install/bun-pm-version.test.ts @@ -717,8 +717,8 @@ describe.concurrent("bun pm version", () => { stdout: "ignore", }).exited; - expect(Bun.file(join(testDir2, "event.log")).exists()).resolves.toBe(true); - expect(Bun.file(join(testDir2, "script.log")).exists()).resolves.toBe(true); + await expect(Bun.file(join(testDir2, "event.log")).exists()).resolves.toBe(true); + await expect(Bun.file(join(testDir2, "script.log")).exists()).resolves.toBe(true); const eventContent = await Bun.file(join(testDir2, "event.log")).text(); const scriptContent = await Bun.file(join(testDir2, "script.log")).text(); @@ -777,8 +777,8 @@ describe.concurrent("bun pm version", () => { stdout: "ignore", }).exited; - expect(Bun.file(join(testDir4, "version-output.txt")).exists()).resolves.toBe(true); - expect(Bun.file(join(testDir4, "build")).exists()).resolves.toBe(false); + await expect(Bun.file(join(testDir4, "version-output.txt")).exists()).resolves.toBe(true); + await expect(Bun.file(join(testDir4, "build")).exists()).resolves.toBe(false); const content = await Bun.file(join(testDir4, "version-output.txt")).text(); expect(content.trim()).toBe("built"); diff --git a/test/cli/install/migration/yarn-lock-migration.test.ts b/test/cli/install/migration/yarn-lock-migration.test.ts index da3fbd786ef..99a2ed7063c 100644 --- a/test/cli/install/migration/yarn-lock-migration.test.ts +++ b/test/cli/install/migration/yarn-lock-migration.test.ts @@ -1366,8 +1366,8 @@ describe("bun pm migrate for existing yarn.lock", () => { stdin: "ignore", }); - expect(migrateResult.exited).resolves.toBe(0); - expect(Bun.file(join(tempDir, "bun.lock")).exists()).resolves.toBe(true); + await expect(migrateResult.exited).resolves.toBe(0); + await expect(Bun.file(join(tempDir, "bun.lock")).exists()).resolves.toBe(true); const bunLockContent = await Bun.file(join(tempDir, "bun.lock")).text(); expect(bunLockContent).toMatchSnapshot(folder); diff --git a/test/cli/install/minimum-release-age.test.ts b/test/cli/install/minimum-release-age.test.ts index d90cf6fb835..304c9c9e85a 100644 --- a/test/cli/install/minimum-release-age.test.ts +++ b/test/cli/install/minimum-release-age.test.ts @@ -1787,7 +1787,7 @@ registry = "${mockRegistryUrl}"`, stderr: "pipe", }); - expect(proc.exited).resolves.toBe(0); + await expect(proc.exited).resolves.toBe(0); const lockfile = await Bun.file(`${dir}/bun.lock`).text(); // Scoped package should be filtered (1.5.0 not 2.0.0) diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 76396616814..6475315d850 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -118,7 +118,7 @@ test("raise ignoring panic handler does not trigger the panic handler", async () /// Wait two seconds for a slow http request, or continue immediately once the request is heard. await Promise.race([resolve_handler.promise, Bun.sleep(2000)]); - expect(proc.exited).resolves.not.toBe(0); + await expect(proc.exited).resolves.not.toBe(0); expect(sent).toBe(false); }); diff --git a/test/js/bun/dns/resolve-dns.test.ts b/test/js/bun/dns/resolve-dns.test.ts index c78284e8621..30354578723 100644 --- a/test/js/bun/dns/resolve-dns.test.ts +++ b/test/js/bun/dns/resolve-dns.test.ts @@ -96,17 +96,17 @@ describe("dns", () => { } }); }); - test.each(invalidHostnames)("%s", hostname => { + test.each(invalidHostnames)("%s", async hostname => { // @ts-expect-error - expect(dns.lookup(hostname, { backend })).rejects.toMatchObject({ + await expect(dns.lookup(hostname, { backend })).rejects.toMatchObject({ code: "DNS_ENOTFOUND", name: "DNSException", }); }); - test.each(malformedHostnames)("'%s'", hostname => { + test.each(malformedHostnames)("'%s'", async hostname => { // @ts-expect-error - expect(dns.lookup(hostname, { backend })).rejects.toMatchObject({ + await expect(dns.lookup(hostname, { backend })).rejects.toMatchObject({ code: expect.stringMatching(/^DNS_ENOTFOUND|DNS_ESERVFAIL|DNS_ENOTIMP$/), name: "DNSException", }); diff --git a/test/js/bun/http/bun-serve-routes.test.ts b/test/js/bun/http/bun-serve-routes.test.ts index e3f70535d6a..6c3151260c8 100644 --- a/test/js/bun/http/bun-serve-routes.test.ts +++ b/test/js/bun/http/bun-serve-routes.test.ts @@ -621,7 +621,7 @@ it("don't crash on server.fetch()", async () => { routes: { "/test": () => new Response("test") }, }); - expect(server.fetch("/test")).rejects.toThrow("fetch() requires the server to have a fetch handler"); + await expect(server.fetch("/test")).rejects.toThrow("fetch() requires the server to have a fetch handler"); }); it("route precedence for any routes", async () => { diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 96478976974..2ef426ce223 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -369,7 +369,7 @@ test("non-TLS origin redirect through HTTPS proxy forwards every hop through the }); test("unsupported protocol", async () => { - expect( + await expect( fetch("https://httpbin.org/get", { proxy: "ftp://asdf.com", }), diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index b31e07eeeaa..914eb542cdc 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -489,7 +489,7 @@ describe("streaming", () => { const response = await fetch(url); expect(response.status).toBe(402); expect(response.headers.get("X-Hey")).toBe("123"); - expect(response.text()).resolves.toBe(""); + await expect(response.text()).resolves.toBe(""); subprocess.kill(); }); @@ -514,7 +514,7 @@ describe("streaming", () => { const response = await fetch(url); expect(response.status).toBe(402); expect(response.headers.get("X-Hey")).toBe("123"); - expect(response.text()).resolves.toBe(""); + await expect(response.text()).resolves.toBe(""); subprocess.kill(); }); @@ -1617,7 +1617,7 @@ it("should support promise returned from error", async () => { { const resp = await fetch(new URL("async-fulfilled", url)); expect(resp.status).toBe(200); - expect(resp.text()).resolves.toBe("Async fulfilled"); + await expect(resp.text()).resolves.toBe("Async fulfilled"); } { @@ -1628,7 +1628,7 @@ it("should support promise returned from error", async () => { { const resp = await fetch(new URL("async-pending", url)); expect(resp.status).toBe(200); - expect(resp.text()).resolves.toBe("Async pending"); + await expect(resp.text()).resolves.toBe("Async pending"); } { @@ -1754,7 +1754,7 @@ it.concurrent("should work with dispose keyword", async () => { url = server.url; expect((await fetch(url)).status).toBe(200); } - expect(fetch(url)).rejects.toThrow(); + await expect(fetch(url)).rejects.toThrow(); }); // prettier-ignore @@ -2026,9 +2026,9 @@ it.concurrent( const res = await fetch(new URL(pathname, server.url.origin)); expect(res.status).toBe(200); if (success) { - expect(res.text()).resolves.toBe("Hello, World!"); + await expect(res.text()).resolves.toBe("Hello, World!"); } else { - expect(res.text()).rejects.toThrow(/The socket connection was closed unexpectedly./); + await expect(res.text()).rejects.toThrow(/The socket connection was closed unexpectedly./); } } await Promise.all([testTimeout("/ok", true), testTimeout("/timeout", false)]); @@ -2124,7 +2124,7 @@ it.concurrent( expect(server.timeout).toBeFunction(); const res = await fetch(new URL("/long-timeout", server.url.origin)); expect(res.status).toBe(200); - expect(res.text()).resolves.toBe("Hello, World!"); + await expect(res.text()).resolves.toBe("Hello, World!"); }, 20_000, ); diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 46fe4783d66..5346cf10ad3 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -314,24 +314,24 @@ it.todo("import override to bun:test", async () => { expect(await import("#bun_test")).toBeDefined(); }); -it.if(isWindows)("directory cache key computation", () => { - expect(import(`${process.cwd()}\\\\doesnotexist.ts`)).rejects.toThrow(); - expect(import(`${process.cwd()}\\\\\\doesnotexist.ts`)).rejects.toThrow(); - expect(import(`\\\\Test\\\\doesnotexist.ts\\` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\\\doesnotexist.ts\\\\\\` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\\\\\doesnotexist.ts` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\\\\\\\doesnotexist.ts` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\doesnotexist.ts` as any)).rejects.toThrow(); - expect(import(`\\\\\\Test\\doesnotexist.ts` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\\\\\doesnotexist.ts\\` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\\\\\\\doesnotexist.ts\\` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\doesnotexist.ts\\` as any)).rejects.toThrow(); - expect(import(`\\\\\\Test\\doesnotexist.ts\\` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\\\\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\\\\\\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); - expect(import(`\\\\Test\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); - expect(import(`\\\\\\Test\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); +it.if(isWindows)("directory cache key computation", async () => { + await expect(import(`${process.cwd()}\\\\doesnotexist.ts`)).rejects.toThrow(); + await expect(import(`${process.cwd()}\\\\\\doesnotexist.ts`)).rejects.toThrow(); + await expect(import(`\\\\Test\\\\doesnotexist.ts\\` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\\\doesnotexist.ts\\\\\\` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\\\\\doesnotexist.ts` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\\\\\\\doesnotexist.ts` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\doesnotexist.ts` as any)).rejects.toThrow(); + await expect(import(`\\\\\\Test\\doesnotexist.ts` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\\\\\doesnotexist.ts\\` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\\\\\\\doesnotexist.ts\\` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\doesnotexist.ts\\` as any)).rejects.toThrow(); + await expect(import(`\\\\\\Test\\doesnotexist.ts\\` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\\\\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\\\\\\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); + await expect(import(`\\\\Test\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); + await expect(import(`\\\\\\Test\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); }); describe("NODE_PATH test", () => { diff --git a/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts b/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts index dbebd6c854b..193c6cb9d68 100644 --- a/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts +++ b/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts @@ -34,6 +34,7 @@ test.skipIf(!isLinux)( const N = 20; const exits: Array> = []; let nested = false; + let deferred: unknown; for (let i = 0; i < N; i++) { const { promise, resolve } = Promise.withResolvers(); @@ -52,9 +53,18 @@ test.skipIf(!isLinux)( // sibling pidfd events queued after this one in the outer batch // are dropped. With EPOLLONESHOT those pidfds are now disarmed in // the kernel with no re-arm path. + // + // Note: since #30595, `.resolves` on a still-pending promise no + // longer calls `waitForPromise`, so this line no longer forces + // the synchronous nested tick that originally triggered the bug. + // The test still validates that every child fires onExit (the + // level-triggered pidfd registration makes that robust regardless + // of whether the nested-tick drop path is exercised). The deferred + // matcher is awaited below so the assertion completes within this + // test rather than racing process teardown. if (!nested) { nested = true; - expect(Bun.sleep(1)).resolves.toBe(undefined); + deferred = expect(Bun.sleep(1)).resolves.toBe(undefined); } resolve(); }, @@ -65,5 +75,6 @@ test.skipIf(!isLinux)( // whose events were dropped never fire onExit and this await hangs until // the test's own 5s timeout — there is no other wake source. await Promise.all(exits); + await deferred; }, ); diff --git a/test/js/bun/test/expect-resolves-pending.test.ts b/test/js/bun/test/expect-resolves-pending.test.ts new file mode 100644 index 00000000000..293b6b4e9a6 --- /dev/null +++ b/test/js/bun/test/expect-resolves-pending.test.ts @@ -0,0 +1,324 @@ +// https://github.com/oven-sh/bun/issues/14950 +// +// `expect(promise).resolves.()` used to synchronously spin the +// event loop (`waitForPromise`) until the promise settled. If the only +// thing that could settle it was JS still sitting above the matcher on +// the call stack, the test hung at 100% CPU — not even the test-level +// timeout could interrupt it. +// +// All of the hang-prone cases are exercised in a subprocess so that this +// file fails (rather than hangs) on a build without the fix. + +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +async function runFixture(name: string, source: string, extraEnv: Record = {}) { + using dir = tempDir(`expect-resolves-${name}`, { "sub.test.js": source }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "sub.test.js"], + env: { ...bunEnv, ...extraEnv }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + // On a build without the fix the subprocess hangs, so bound the wait. + const timedOut = await Promise.race([proc.exited.then(() => false), Bun.sleep(20_000).then(() => true)]); + if (timedOut) { + proc.kill("SIGKILL"); + await proc.exited; + } + + const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]); + return { out: stdout + stderr, exitCode: proc.exitCode, timedOut }; +} + +describe.concurrent("expect().resolves / .rejects on a still-pending promise", () => { + // Exact reproduction from #14950, plus the .rejects mirror and several + // matcher variants. + test("does not hang when the promise is settled after the matcher call", async () => { + const { out, exitCode, timedOut } = await runFixture( + "pass", + /* js */ ` + import { test, expect } from "bun:test"; + + test("resolves.toBe, resolved after matcher (no await)", () => { + let resolve; + expect(new Promise(r => (resolve = r))).resolves.toBe(25); + resolve(25); + }); + + test("rejects.toBe, rejected after matcher (no await)", () => { + let reject; + expect(new Promise((_, r) => (reject = r))).rejects.toBe("err"); + reject("err"); + }); + + test("resolves.toBe awaited", async () => { + let resolve; + const assertion = expect(new Promise(r => (resolve = r))).resolves.toBe(42); + if (!(assertion instanceof Promise)) { + throw new Error("expected .resolves matcher to return a Promise for a pending input"); + } + resolve(42); + await assertion; + }); + + test("rejects.toThrow awaited", async () => { + let reject; + const assertion = expect(new Promise((_, r) => (reject = r))).rejects.toThrow("boom"); + reject(new Error("boom")); + await assertion; + }); + + test("resolves.not.toBe awaited", async () => { + let resolve; + const assertion = expect(new Promise(r => (resolve = r))).resolves.not.toBe(100); + resolve(99); + await assertion; + }); + + test("resolves.toEqual awaited", async () => { + let resolve; + const assertion = expect(new Promise(r => (resolve = r))).resolves.toEqual({ a: 1 }); + resolve({ a: 1 }); + await assertion; + }); + + test("resolves resolved in a later task", async () => { + let resolve; + const prom = new Promise(r => (resolve = r)); + setImmediate(() => resolve(7)); + await expect(prom).resolves.toBe(7); + }); + + expect.extend({ + toBeFoo(received) { + return { + pass: received === "foo", + message: () => "expected " + received + ' to be "foo"', + }; + }, + }); + + test("custom matcher via expect.extend", async () => { + let resolve; + const assertion = expect(new Promise(r => (resolve = r))).resolves.toBeFoo(); + resolve("foo"); + await assertion; + }); + `, + ); + + expect(timedOut).toBe(false); + expect(out).toContain("8 pass"); + expect(out).toContain("0 fail"); + expect(out).not.toContain("timed out"); + expect(exitCode).toBe(0); + }, 40_000); + + test("a failing deferred assertion still fails the test", async () => { + const { out, exitCode, timedOut } = await runFixture( + "fail", + /* js */ ` + import { test, expect } from "bun:test"; + + test("resolves.toBe mismatch (awaited)", async () => { + let resolve; + const assertion = expect(new Promise(r => (resolve = r))).resolves.toBe(25); + resolve(99); + await assertion; + }); + + test("rejects on a promise that resolves (awaited)", async () => { + let resolve; + const assertion = expect(new Promise(r => (resolve = r))).rejects.toBe(25); + resolve(25); + await assertion; + }); + + test("resolves.toBe mismatch (not awaited)", () => { + let resolve; + expect(new Promise(r => (resolve = r))).resolves.toBe(25); + resolve(99); + }); + `, + ); + + expect(timedOut).toBe(false); + expect(out).toContain("0 pass"); + expect(out).toContain("3 fail"); + expect(out).toContain("Expected: 25"); + expect(out).toContain("Received: 99"); + expect(out).toContain("Expected promise that rejects"); + expect(out).not.toContain("timed out"); + expect(exitCode).toBe(1); + }, 40_000); + + // Matchers are inconsistent about whether they call + // `incrementExpectCallCounter()` before or after `getValue()`. Both + // orderings must count exactly once through the deferred path. + test("expect.assertions counts a deferred matcher exactly once", async () => { + const { out, exitCode, timedOut } = await runFixture( + "assertions", + /* js */ ` + import { test, expect } from "bun:test"; + + expect.extend({ + toBeBar(received) { + return { pass: received === "bar", message: () => "expected bar" }; + }, + }); + + test("toBe: increments before getValue", async () => { + expect.assertions(1); + let resolve; + const assertion = expect(new Promise(r => (resolve = r))).resolves.toBe(5); + resolve(5); + await assertion; + }); + + test("toBeTruthy: increments after getValue", async () => { + expect.assertions(1); + let resolve; + const assertion = expect(new Promise(r => (resolve = r))).resolves.toBeTruthy(); + resolve("x"); + await assertion; + }); + + test("custom matcher: increments after maybeDeferMatcher", async () => { + expect.assertions(1); + let resolve; + const assertion = expect(new Promise(r => (resolve = r))).resolves.toBeBar(); + resolve("bar"); + await assertion; + }); + + // The counted_expect_call flag is per-Expect-instance; each + // matcher call on a reused instance must still count, including + // when the first is a custom matcher (applyCustomMatcher path). + test("multiple matchers on the same expect() each count", () => { + expect.assertions(2); + const e = expect(5); + e.toBe(5); + e.toBeGreaterThan(0); + }); + + test("custom then built-in on the same expect() each count", () => { + expect.assertions(2); + const e = expect("bar"); + e.toBeBar(); + e.toBe("bar"); + }); + `, + ); + + expect(timedOut).toBe(false); + expect(out).toContain("5 pass"); + expect(out).toContain("0 fail"); + expect(exitCode).toBe(0); + }, 40_000); + + // On the deferred re-run the user's frame is gone from the stack, so + // `inlineSnapshot()` has to use the source location captured on the + // first call. Only the write path reads the source location, so this + // exercises it by creating a new snapshot (hence `CI: "false"`). + // + // The second test reuses the same `expect()` instance for two deferred + // snapshots. Each must write to its own call site — the srcloc is + // stored per-PendingMatcher, not on the shared `Expect`. + test("toMatchInlineSnapshot writes from the deferred re-run", async () => { + const { out, exitCode, timedOut } = await runFixture( + "inline-snapshot", + /* js */ ` + import { test, expect } from "bun:test"; + + test("pending", async () => { + await expect(Bun.sleep(1).then(() => ({ a: 1 }))).resolves.toMatchInlineSnapshot(); + }); + + test("two on the same expect()", async () => { + let resolve; + const e = expect(new Promise(r => (resolve = r))); + const first = e.resolves.toMatchInlineSnapshot(); + const second = e.resolves.toMatchInlineSnapshot(); + resolve("x"); + await Promise.all([first, second]); + }); + `, + { CI: "false" }, + ); + + expect(timedOut).toBe(false); + expect(out).toContain("2 pass"); + expect(out).toContain("0 fail"); + expect(out).toContain("+3 added"); + expect(out).not.toContain("must be called from the test file"); + expect(exitCode).toBe(0); + }, 40_000); + + // https://github.com/oven-sh/bun/issues/25181 + // With the blocking `waitForPromise()`, each concurrent test's + // `.resolves` serialized the whole group. Assert overlap directly + // via an in-flight counter rather than wall-clock timing. + test("does not serialize test.concurrent tests", async () => { + const { out, exitCode, timedOut } = await runFixture( + "concurrent", + /* js */ ` + import { test, expect, afterAll } from "bun:test"; + + let inFlight = 0; + let maxInFlight = 0; + + async function slow() { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise(r => setTimeout(r, 500)); + inFlight--; + return { ok: true }; + } + + test.concurrent.each([...Array(10).keys()])("concurrent %i", async () => { + await expect(slow()).resolves.toEqual({ ok: true }); + }); + + afterAll(() => { + console.log("MAX_INFLIGHT=" + maxInFlight); + }); + `, + ); + + expect(timedOut).toBe(false); + expect(out).toContain("10 pass"); + expect(out).toContain("0 fail"); + // If `.resolves` blocks, each test runs to completion before the + // next starts and maxInFlight stays at 1. With the deferred path + // the tests overlap up to the runner's concurrency cap (5 on ASAN, + // 20 otherwise). + const maxInFlight = Number(out.match(/MAX_INFLIGHT=(\d+)/)?.[1]); + expect(maxInFlight).toBeGreaterThan(1); + expect(exitCode).toBe(0); + }, 40_000); +}); + +// Already-settled promises took the synchronous path before and after the +// fix, so these are safe to run inline. +describe("expect().resolves / .rejects on an already-settled promise", () => { + test("resolves.toBe", async () => { + await expect(Promise.resolve(99)).resolves.toBe(99); + }); + + test("rejects.toBe", async () => { + await expect(Promise.reject(99)).rejects.toBe(99); + }); + + test("resolves on a rejected promise throws", async () => { + let caught: unknown; + try { + await expect(Promise.reject(1)).resolves.toBe(1); + } catch (e) { + caught = e; + } + expect(String(caught)).toContain("Expected promise that resolves"); + }); +}); diff --git a/test/js/node/events/event-emitter.test.ts b/test/js/node/events/event-emitter.test.ts index aae4efd5156..04964c04fd8 100644 --- a/test/js/node/events/event-emitter.test.ts +++ b/test/js/node/events/event-emitter.test.ts @@ -859,7 +859,7 @@ test("addAbortListener", async () => { const mocked = mock(); EventEmitter.addAbortListener(controller.signal, mocked); controller.abort(); - expect(promise).rejects.toThrow("aborted"); + await expect(promise).rejects.toThrow("aborted"); expect(mocked).toHaveBeenCalled(); }); @@ -872,7 +872,7 @@ test("using addAbortListener", async () => { using aborty = EventEmitter.addAbortListener(controller.signal, mocked); } controller.abort(); - expect(promise).rejects.toThrow("aborted"); + await expect(promise).rejects.toThrow("aborted"); expect(mocked).not.toHaveBeenCalled(); }); diff --git a/test/js/node/fs/glob.test.ts b/test/js/node/fs/glob.test.ts index 7544a36ea6e..87469c76b3d 100644 --- a/test/js/node/fs/glob.test.ts +++ b/test/js/node/fs/glob.test.ts @@ -212,23 +212,25 @@ describe("fs.promises.glob", () => { it("can filter out files", async () => { const exclude = (path: string) => path.endsWith(".js"); const expected = isWindows ? ["a\\bar.txt"] : ["a/bar.txt"]; - expect(Array.fromAsync(fs.promises.glob("a/*", { cwd: tmp, exclude }))).resolves.toStrictEqual(expected); + await expect(Array.fromAsync(fs.promises.glob("a/*", { cwd: tmp, exclude }))).resolves.toStrictEqual(expected); }); it("can filter out files (2)", async () => { const exclude = ["**/*.js"]; const expected = isWindows ? ["a\\bar.txt"] : ["a/bar.txt"]; - expect(Array.fromAsync(fs.promises.glob("a/*", { cwd: tmp, exclude }))).resolves.toStrictEqual(expected); + await expect(Array.fromAsync(fs.promises.glob("a/*", { cwd: tmp, exclude }))).resolves.toStrictEqual(expected); const exclude2 = ["folder.test/another-folder"]; const expected2 = isWindows ? ["folder.test\\file.txt"] : ["folder.test/file.txt"]; - expect( + await expect( Array.fromAsync(fs.promises.glob("folder.test/**/*", { cwd: tmp, exclude: exclude2 })), ).resolves.toStrictEqual(expected2); }); it("supports arrays of patterns", async () => { const expected = isWindows ? ["a\\bar.txt", "a\\baz.js"] : ["a/bar.txt", "a/baz.js"]; - expect(Array.fromAsync(fs.promises.glob(["a/bar.txt", "a/baz.js"], { cwd: tmp }))).resolves.toStrictEqual(expected); + await expect(Array.fromAsync(fs.promises.glob(["a/bar.txt", "a/baz.js"], { cwd: tmp }))).resolves.toStrictEqual( + expected, + ); }); }); // diff --git a/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index 51d65271e28..f447d121a63 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -494,7 +494,7 @@ describe("fs.watch", () => { reject("timeout"); }, 3000); }); - expect(promise).resolves.toBe("change"); + await expect(promise).resolves.toBe("change"); }); // on windows 0o200 will be readable (match nodejs behavior) @@ -681,7 +681,7 @@ describe("fs.promises.watch", () => { clearInterval(interval); } })(); - expect(promise).resolves.toBe("rename"); + await expect(promise).resolves.toBe("rename"); }); test("should work with symlink dir", async () => { @@ -709,7 +709,7 @@ describe("fs.promises.watch", () => { clearInterval(interval); } })(); - expect(promise).resolves.toBe("rename"); + await expect(promise).resolves.toBe("rename"); }); test("should work with symlink", async () => { @@ -732,7 +732,7 @@ describe("fs.promises.watch", () => { clearInterval(interval); } })(); - expect(promise).resolves.toBe("change"); + await expect(promise).resolves.toBe("change"); }); }); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 555c205c6fc..3cb2ee32759 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -443,9 +443,9 @@ describe("getHeapSnapshot", () => { }); }); - test("returns a rejected promise if the worker is not running", () => { + test("returns a rejected promise if the worker is not running", async () => { const worker = new Worker("", { eval: true }); - expect(worker.getHeapSnapshot()).rejects.toMatchObject({ + await expect(worker.getHeapSnapshot()).rejects.toMatchObject({ name: "Error", code: "ERR_WORKER_NOT_RUNNING", message: "Worker instance not running", diff --git a/test/js/third_party/stripe/stripe.test.ts b/test/js/third_party/stripe/stripe.test.ts index 17fc728a4dc..7ae56973850 100644 --- a/test/js/third_party/stripe/stripe.test.ts +++ b/test/js/third_party/stripe/stripe.test.ts @@ -9,7 +9,7 @@ describe.skipIf(!stripeCredentials)("stripe", () => { const stripe = new Stripe(accessToken); test("should be able to query a charge", async () => { - expect(stripe.charges.retrieve(chargeId, { stripeAccount: accountId })).rejects.toThrow( + await expect(stripe.charges.retrieve(chargeId, { stripeAccount: accountId })).rejects.toThrow( `No such charge: '${chargeId}'`, ); }); diff --git a/test/js/valkey/valkey.test.ts b/test/js/valkey/valkey.test.ts index 6f0364d6ca3..3024be11485 100644 --- a/test/js/valkey/valkey.test.ts +++ b/test/js/valkey/valkey.test.ts @@ -6300,7 +6300,7 @@ for (const connectionType of [ConnectionType.TLS, ConnectionType.TCP]) { const subscriber = await ctx.newSubscriberClient(connectionType); await subscriber.subscribe(channel, () => {}); await subscriber.unsubscribe(channel); - expect(ctx.redis.set(testKey(), testValue())).resolves.toEqual("OK"); + await expect(ctx.redis.set(testKey(), testValue())).resolves.toEqual("OK"); }); test("subscribing to a channel receives messages", async () => { diff --git a/test/js/web/fetch/blob-write.test.ts b/test/js/web/fetch/blob-write.test.ts index 9fc61c0a110..16453750f6b 100644 --- a/test/js/web/fetch/blob-write.test.ts +++ b/test/js/web/fetch/blob-write.test.ts @@ -32,14 +32,14 @@ test("blob.delete() throws for data-backed blob", () => { test("Bun.file(path).unlink() does not throw", async () => { const dir = tempDirWithFiles("bun-unlink", { a: "Hello, world!" }); const file = Bun.file(path.join(dir, "a")); - expect(file.unlink()).resolves.toBeUndefined(); + await expect(file.unlink()).resolves.toBeUndefined(); expect(await Bun.file(path.join(dir, "a")).exists()).toBe(false); }); test("Bun.file(path).delete() does not throw", async () => { const dir = tempDirWithFiles("bun-unlink", { a: "Hello, world!" }); const file = Bun.file(path.join(dir, "a")); - expect(file.delete()).resolves.toBeUndefined(); + await expect(file.delete()).resolves.toBeUndefined(); expect(await Bun.file(path.join(dir, "a")).exists()).toBe(false); }); diff --git a/test/js/web/fetch/client-fetch.test.ts b/test/js/web/fetch/client-fetch.test.ts index a76e35d912d..643822a5af5 100644 --- a/test/js/web/fetch/client-fetch.test.ts +++ b/test/js/web/fetch/client-fetch.test.ts @@ -13,8 +13,8 @@ test("function signature", () => { }); test("args validation", async () => { - expect(fetch()).rejects.toThrow(TypeError); - expect(fetch("ftp://unsupported")).rejects.toThrow(TypeError); + await expect(fetch()).rejects.toThrow(TypeError); + await expect(fetch("ftp://unsupported")).rejects.toThrow(TypeError); }); test("request json", async () => { @@ -69,7 +69,7 @@ test("pre aborted with readable request body", async () => { const ac = new AbortController(); ac.abort(); - expect( + await expect( fetch(`http://localhost:${server.address().port}`, { signal: ac.signal, method: "POST", @@ -101,7 +101,7 @@ test("pre aborted with closed readable request body", async () => { }, }); - expect( + await expect( fetch(`http://localhost:${server.address().port}`, { signal: ac.signal, method: "POST", @@ -117,7 +117,9 @@ test("unsupported formData 1", async () => { res.end(); }).listen(0); await once(server, "listening"); - expect(fetch(`http://localhost:${server.address().port}`).then(res => res.formData())).rejects.toThrow(TypeError); + await expect(fetch(`http://localhost:${server.address().port}`).then(res => res.formData())).rejects.toThrow( + TypeError, + ); }); test("multipart formdata not base64", async () => { @@ -212,7 +214,7 @@ test("busboy emit error", async () => { await listen(0); const res = await fetch(`http://localhost:${server.address().port}`); - expect(res.formData()).rejects.toThrow("FormData parse error missing final boundary"); + await expect(res.formData()).rejects.toThrow("FormData parse error missing final boundary"); }); // https://github.com/nodejs/undici/issues/2244 @@ -268,7 +270,7 @@ test("locked blob body", async () => { const res = await fetch(`http://localhost:${server.address().port}`); const reader = res.body.getReader(); - expect(res.blob()).rejects.toThrow("ReadableStream is locked"); + await expect(res.blob()).rejects.toThrow("ReadableStream is locked"); reader.cancel(); }); @@ -280,7 +282,7 @@ test("disturbed blob body", async () => { const res = await fetch(`http://localhost:${server.address().port}`); await res.blob(); - expect(res.blob()).rejects.toThrow("Body already used"); + await expect(res.blob()).rejects.toThrow("Body already used"); }); test("redirect with body", async () => { @@ -447,7 +449,7 @@ test("error on redirect", async () => { }).listen(0); await once(server, "listening"); - expect( + await expect( fetch(`http://localhost:${server.address().port}`, { redirect: "error", }), diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index b5bbca34e6e..37ea04b8696 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -85,7 +85,7 @@ describe("fetch data urls", () => { var blob = await res.blob(); expect(blob.size).toBe(13); expect(blob.type).toBe("text/plain;charset=utf-8"); - expect(blob.text()).resolves.toBe("Hello, World!"); + await expect(blob.text()).resolves.toBe("Hello, World!"); }); it("percent encoded (invalid)", async () => { var url = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3"; @@ -103,7 +103,7 @@ describe("fetch data urls", () => { var blob = await res.blob(); expect(blob.size).toBe(13); expect(blob.type).toBe("text/plain;charset=utf-8"); - expect(blob.text()).resolves.toBe("Hello, World!"); + await expect(blob.text()).resolves.toBe("Hello, World!"); url = "data:,helloworld!"; res = await fetch(url); @@ -114,7 +114,7 @@ describe("fetch data urls", () => { blob = await res.blob(); expect(blob.size).toBe(11); expect(blob.type).toBe("text/plain;charset=utf-8"); - expect(blob.text()).resolves.toBe("helloworld!"); + await expect(blob.text()).resolves.toBe("helloworld!"); }); it("unstrict parsing of invalid URL characters", async () => { var url = "data:application/json,{%7B%7D}"; @@ -126,7 +126,7 @@ describe("fetch data urls", () => { var blob = await res.blob(); expect(blob.size).toBe(4); expect(blob.type).toBe("application/json;charset=utf-8"); - expect(blob.text()).resolves.toBe("{{}}"); + await expect(blob.text()).resolves.toBe("{{}}"); }); it("unstrict parsing of double percent characters", async () => { var url = "data:application/json,{%%7B%7D%%}%%"; @@ -138,7 +138,7 @@ describe("fetch data urls", () => { var blob = await res.blob(); expect(blob.size).toBe(9); expect(blob.type).toBe("application/json;charset=utf-8"); - expect(blob.text()).resolves.toBe("{%{}%%}%%"); + await expect(blob.text()).resolves.toBe("{%{}%%}%%"); }); it("data url (invalid)", async () => { var url = "data:Hello%2C%20World!"; @@ -157,7 +157,7 @@ describe("fetch data urls", () => { var blob = await res.blob(); expect(blob.size).toBe(4); expect(blob.type).toBe("text/plain;charset=utf-8"); - expect(blob.text()).resolves.toBe("😀"); + await expect(blob.text()).resolves.toBe("😀"); }); it("should work with Request", async () => { var req = new Request("data:,Hello%2C%20World!"); @@ -169,7 +169,7 @@ describe("fetch data urls", () => { var blob = await res.blob(); expect(blob.size).toBe(13); expect(blob.type).toBe("text/plain;charset=utf-8"); - expect(blob.text()).resolves.toBe("Hello, World!"); + await expect(blob.text()).resolves.toBe("Hello, World!"); req = new Request("data:,😀"); res = await fetch(req); @@ -180,7 +180,7 @@ describe("fetch data urls", () => { blob = await res.blob(); expect(blob.size).toBe(4); expect(blob.type).toBe("text/plain;charset=utf-8"); - expect(blob.text()).resolves.toBe("😀"); + await expect(blob.text()).resolves.toBe("😀"); }); it("should work with Request (invalid)", async () => { var req = new Request("data:Hello%2C%20World!"); @@ -1947,7 +1947,7 @@ describe("maxRedirects", () => { }); it("rejects once the chain exceeds maxRedirects", async () => { - expect(fetch(`${server.url}hop/0`, { maxRedirects: 2 })).rejects.toThrow("redirected too many times"); + await expect(fetch(`${server.url}hop/0`, { maxRedirects: 2 })).rejects.toThrow("redirected too many times"); }); it("follows the chain when maxRedirects is large enough", async () => { @@ -2135,13 +2135,13 @@ describe("http/1.1 response body length", () => { it("should read text until socket closed", async () => { const response = await fetch(`http://${getHost()}/text`); expect(response.status).toBe(200); - expect(response.text()).resolves.toBe("Hello, World!"); + await expect(response.text()).resolves.toBe("Hello, World!"); }); it("should read json until socket closed", async () => { const response = await fetch(`http://${getHost()}/json`); expect(response.status).toBe(200); - expect(response.json()).resolves.toEqual({ "hello": "World" }); + await expect(response.json()).resolves.toEqual({ "hello": "World" }); }); it("should disable keep-alive", async () => { @@ -2151,38 +2151,38 @@ describe("http/1.1 response body length", () => { // the 1st http response body + the full 2nd http response as text const response = await fetch(`http://${getHost()}/keepalive/bad`); expect(response.status).toBe(200); - expect(response.text()).resolves.toHaveLength(95); + await expect(response.text()).resolves.toHaveLength(95); }); }); it("should support keep-alive", async () => { const response = await fetch(`http://${getHost()}/keepalive`); expect(response.status).toBe(200); - expect(response.text()).resolves.toBe("Hello, World!"); + await expect(response.text()).resolves.toBe("Hello, World!"); }); it("should support transfer-encoding: chunked", async () => { const response = await fetch(`http://${getHost()}/chunked`); expect(response.status).toBe(200); - expect(response.text()).resolves.toBe("Hello, World!"); + await expect(response.text()).resolves.toBe("Hello, World!"); }); it("should support non-zero content-length", async () => { const response = await fetch(`http://${getHost()}/non-empty`); expect(response.status).toBe(200); - expect(response.text()).resolves.toBe("Hello, World!"); + await expect(response.text()).resolves.toBe("Hello, World!"); }); it("should support content-length: 0", async () => { const response = await fetch(`http://${getHost()}/empty`); expect(response.status).toBe(200); - expect(response.arrayBuffer()).resolves.toHaveLength(0); + await expect(response.arrayBuffer()).resolves.toHaveLength(0); }); it.todoIf(isBroken)("should ignore body on HEAD", async () => { const response = await fetch(`http://${getHost()}/text`, { method: "HEAD" }); expect(response.status).toBe(200); - expect(response.arrayBuffer()).resolves.toHaveLength(0); + await expect(response.arrayBuffer()).resolves.toHaveLength(0); }); }); describe("fetch Response life cycle", () => { diff --git a/test/js/web/fetch/form-data-boundary-crash.test.ts b/test/js/web/fetch/form-data-boundary-crash.test.ts index 7f7230ef0cc..8caa8950066 100644 --- a/test/js/web/fetch/form-data-boundary-crash.test.ts +++ b/test/js/web/fetch/form-data-boundary-crash.test.ts @@ -8,7 +8,7 @@ test('Response.formData() rejects on boundary=" (lone double-quote)', async () = const response = new Response("body", { headers: { "content-type": 'multipart/form-data; boundary="' }, }); - expect(response.formData()).rejects.toThrow(); + await expect(response.formData()).rejects.toThrow(); }); test('Request.formData() rejects on boundary=" (lone double-quote)', async () => { @@ -17,24 +17,24 @@ test('Request.formData() rejects on boundary=" (lone double-quote)', async () => body: "body", headers: { "content-type": 'multipart/form-data; boundary="' }, }); - expect(request.formData()).rejects.toThrow(); + await expect(request.formData()).rejects.toThrow(); }); test('Blob.formData() rejects on boundary=" (lone double-quote)', async () => { const blob = new Blob(["body"], { type: 'multipart/form-data; boundary="' }); - expect(blob.formData()).rejects.toThrow(); + await expect(blob.formData()).rejects.toThrow(); }); test('Response.formData() rejects on boundary="abc (unclosed double-quote)', async () => { const response = new Response("body", { headers: { "content-type": 'multipart/form-data; boundary="abc' }, }); - expect(response.formData()).rejects.toThrow(); + await expect(response.formData()).rejects.toThrow(); }); test('Response.formData() rejects on boundary="; (lone double-quote before semicolon)', async () => { const response = new Response("body", { headers: { "content-type": 'multipart/form-data; boundary="; charset=utf-8' }, }); - expect(response.formData()).rejects.toThrow(); + await expect(response.formData()).rejects.toThrow(); }); diff --git a/test/js/web/fetch/wasm-streaming.test.ts b/test/js/web/fetch/wasm-streaming.test.ts index d0a53ebed83..2dd699cba08 100644 --- a/test/js/web/fetch/wasm-streaming.test.ts +++ b/test/js/web/fetch/wasm-streaming.test.ts @@ -48,18 +48,18 @@ const responseFromStream = (pull: (controller: ReadableStreamDefaultController { test("compiles a non-streaming Response", async () => { const response = await fetch(simpleWasmUri); - expect(WebAssembly.compileStreaming(response)).resolves.toBeInstanceOf(WebAssembly.Module); + await expect(WebAssembly.compileStreaming(response)).resolves.toBeInstanceOf(WebAssembly.Module); }); test("compiles a resolved Promise to a non-streaming Response", async () => { const promise = Promise.resolve(await fetch(simpleWasmUri)); - expect(WebAssembly.compileStreaming(promise)).resolves.toBeInstanceOf(WebAssembly.Module); + await expect(WebAssembly.compileStreaming(promise)).resolves.toBeInstanceOf(WebAssembly.Module); }); test("compiles a pending Promise to a non-streaming Response", async () => { const response = await fetch(simpleWasmUri); const promise = Bun.sleep(100).then(() => response); - expect(WebAssembly.compileStreaming(promise)).resolves.toBeInstanceOf(WebAssembly.Module); + await expect(WebAssembly.compileStreaming(promise)).resolves.toBeInstanceOf(WebAssembly.Module); }); // Errors: @@ -67,20 +67,20 @@ describe("WebAssembly.compileStreaming", () => { test("doesn't compile a rejected Promise", async () => { const error = new Error("sudden explosion"); const promise = Promise.reject(error); - expect(WebAssembly.compileStreaming(promise)).rejects.toBe(error); + await expect(WebAssembly.compileStreaming(promise)).rejects.toBe(error); }); test("doesn't compile a non-Response", async () => { const nonResponse = Buffer.from("not a Response"); // @ts-expect-error nonResponse is not a Response - expect(WebAssembly.compileStreaming(nonResponse)).rejects.toThrow( + await expect(WebAssembly.compileStreaming(nonResponse)).rejects.toThrow( `The "source" argument must be an instance of Response or an Promise resolving to Response. Received an instance of Buffer`, ); }); test("doesn't compile a response with the wrong MIME type", async () => { const response = await fetch("data:image/png;base64," + simpleWasm); - expect(WebAssembly.compileStreaming(response)).rejects.toThrow( + await expect(WebAssembly.compileStreaming(response)).rejects.toThrow( "WebAssembly response has unsupported MIME type 'image/png'", ); }); @@ -93,7 +93,7 @@ describe("WebAssembly.compileStreaming", () => { status: 418, }); - expect(WebAssembly.compileStreaming(response)).rejects.toThrow("WebAssembly response has status code 418"); + await expect(WebAssembly.compileStreaming(response)).rejects.toThrow("WebAssembly response has status code 418"); }); test("doesn't compile a used streaming response", async () => { @@ -108,7 +108,9 @@ describe("WebAssembly.compileStreaming", () => { for await (const _ of response.body); // Consume the stream ok(response.bodyUsed); - expect(WebAssembly.compileStreaming(response)).rejects.toThrow("WebAssembly response body has already been used"); + await expect(WebAssembly.compileStreaming(response)).rejects.toThrow( + "WebAssembly response body has already been used", + ); }); test("doesn't compile a streaming response that throws while streaming", async () => { @@ -120,7 +122,7 @@ describe("WebAssembly.compileStreaming", () => { i++; }); - expect(WebAssembly.compileStreaming(response)).rejects.toBe(error); + await expect(WebAssembly.compileStreaming(response)).rejects.toBe(error); }); test("doesn't compile a streaming response that yields neither ArrayBuffer nor ArrayBufferView", async () => { @@ -128,7 +130,7 @@ describe("WebAssembly.compileStreaming", () => { controller.enqueue("something random"); }); - expect(WebAssembly.compileStreaming(response)).rejects.toThrow( + await expect(WebAssembly.compileStreaming(response)).rejects.toThrow( "chunk must be an ArrayBufferView or an ArrayBuffer", ); }); @@ -140,7 +142,7 @@ describe("WebAssembly.compileStreaming", () => { controller.enqueue(array); }); - expect(WebAssembly.compileStreaming(response)).rejects.toThrow( + await expect(WebAssembly.compileStreaming(response)).rejects.toThrow( "Underlying ArrayBuffer has been detached from the view or out-of-bounds", ); }); @@ -152,14 +154,14 @@ describe("WebAssembly.compileStreaming", () => { controller.enqueue(buffer); }); - expect(WebAssembly.compileStreaming(response)).rejects.toThrow( + await expect(WebAssembly.compileStreaming(response)).rejects.toThrow( "Underlying ArrayBuffer has been detached from the view or out-of-bounds", ); }); test("doesn't compile a response that isn't valid WebAssembly", async () => { const response = await fetch("data:application/wasm,This is not actually Wasm"); - expect(WebAssembly.compileStreaming(response)).rejects.toBeInstanceOf(WebAssembly.CompileError); + await expect(WebAssembly.compileStreaming(response)).rejects.toBeInstanceOf(WebAssembly.CompileError); }); }); @@ -180,18 +182,18 @@ describe("WebAssembly.instantiateStreaming", () => { test("instantiates a non-streaming response", async () => { const response = await fetch(simpleWasmUri); - expect(instantiateAndGetExports(response, imports)).resolves.toHaveProperty("div"); + await expect(instantiateAndGetExports(response, imports)).resolves.toHaveProperty("div"); }); test("instantiates a non-streaming response, without an import object", async () => { const response = await fetch(simplerWasmUri); - expect(instantiateAndGetExports(response)).resolves.toHaveProperty("add"); + await expect(instantiateAndGetExports(response)).resolves.toHaveProperty("add"); }); test("instantiates a pending Promise to a non-streaming response", async () => { const response = await fetch(simpleWasmUri); const promise = Bun.sleep(100).then(() => response); - expect(instantiateAndGetExports(promise, imports)).resolves.toHaveProperty("div"); + await expect(instantiateAndGetExports(promise, imports)).resolves.toHaveProperty("div"); }); test("instantiates a Bun.file() response", async () => { @@ -199,7 +201,7 @@ describe("WebAssembly.instantiateStreaming", () => { await Bun.write(path, Buffer.from(simpleWasm, "base64")); const response = new Response(Bun.file(path)); - expect(instantiateAndGetExports(response, imports)).resolves.toHaveProperty("div"); + await expect(instantiateAndGetExports(response, imports)).resolves.toHaveProperty("div"); }); test("instantiates a ReadableStream response", async () => { @@ -215,7 +217,7 @@ describe("WebAssembly.instantiateStreaming", () => { if (i >= buffer.length) controller.close(); }); - expect(instantiateAndGetExports(response, imports)).resolves.toHaveProperty("div"); + await expect(instantiateAndGetExports(response, imports)).resolves.toHaveProperty("div"); }); test("instantiates a string response", async () => { @@ -225,15 +227,17 @@ describe("WebAssembly.instantiateStreaming", () => { }, }); - expect(instantiateAndGetExports(response)).resolves.toHaveProperty("foo"); + await expect(instantiateAndGetExports(response)).resolves.toHaveProperty("foo"); }); // Errors: test("doesn't instantiate a response without the correct import object", async () => { const response = await fetch(simpleWasmUri); - expect(instantiateAndGetExports(response)).rejects.toThrow( - "can't make WebAssembly.Instance because there is no imports Object and the WebAssembly.Module requires imports", + // JSC produces a different message depending on whether this module + // was previously compiled in the same process, so accept either. + await expect(instantiateAndGetExports(response)).rejects.toThrow( + /there is no imports Object and the WebAssembly\.Module requires imports|import env:reciprocal must be an object/, ); }); }); diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 5256b7502f1..24bae7bb270 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -787,13 +787,13 @@ it("ReadableStream rejects pending reads when the lock is released", async () => let read = reader.read(); reader.releaseLock(); - expect(read).rejects.toThrow( + await expect(read).rejects.toThrow( expect.objectContaining({ name: "AbortError", code: "ERR_STREAM_RELEASE_LOCK", }), ); - expect(reader.closed).rejects.toThrow( + await expect(reader.closed).rejects.toThrow( expect.objectContaining({ name: "AbortError", code: "ERR_STREAM_RELEASE_LOCK", diff --git a/test/js/web/workers/worker_blob.test.ts b/test/js/web/workers/worker_blob.test.ts index aaf58ae0ea3..efe85577ac9 100644 --- a/test/js/web/workers/worker_blob.test.ts +++ b/test/js/web/workers/worker_blob.test.ts @@ -60,7 +60,7 @@ test("Worker from a blob errors on invalid blob", async () => { const { promise, reject } = Promise.withResolvers(); const worker = new Worker("blob:i dont exist!"); worker.addEventListener("error", e => reject(e.message)); - expect(promise).rejects.toBe('BuildMessage: ModuleNotFound resolving "blob:i dont exist!" (entry point)'); + await expect(promise).rejects.toBe('BuildMessage: ModuleNotFound resolving "blob:i dont exist!" (entry point)'); }); test("Revoking an object URL after a Worker is created before it loads should throw an error", async () => { diff --git a/test/regression/issue/06443.test.ts b/test/regression/issue/06443.test.ts index 418d8636974..4c218849060 100644 --- a/test/regression/issue/06443.test.ts +++ b/test/regression/issue/06443.test.ts @@ -31,7 +31,7 @@ describe("Bun.serve()", () => { const proto = options.tls ? "https" : "http"; const target = `${proto}://localhost:${server.port}/`; const response = await fetch(target, { tls: { rejectUnauthorized: false } }); - expect(response.text()).resolves.toMatch(url); + await expect(response.text()).resolves.toMatch(url); } finally { server.stop(true); }