Skip to content
10 changes: 7 additions & 3 deletions packages/bun-types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,18 +920,22 @@ declare module "bun:test" {
not: Matchers<unknown>;

/**
* Expects the value to be a promise that resolves.
* Expects the value to be a promise (or any thenable) that resolves.
* A function is called first, and the matcher waits on what it returns.
*
* @example
* expect(Promise.resolve(1)).resolves.toBe(1);
* expect(async () => 1).resolves.toBe(1);
*/
resolves: Matchers<Awaited<T>>;
resolves: Matchers<Awaited<T extends (...args: any[]) => infer R ? R : T>>;

/**
* Expects the value to be a promise that rejects.
* Expects the value to be a promise (or any thenable) that rejects.
* A function is called first, and the matcher waits on what it returns.
*
* @example
* expect(Promise.reject("error")).rejects.toBe("error");
* expect(async () => { throw new Error("error"); }).rejects.toThrow("error");
*/
rejects: Matchers<unknown>;

Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ AsymmetricMatcherResult matchAsymmetricMatcher(JSGlobalObject* globalObject, JSV
{
ExpectFlags flags = ExpectFlags();
AsymmetricMatcherResult result = matchAsymmetricMatcherAndGetFlags(globalObject, matcherProp, otherProp, throwScope, flags);
RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL);
if (result != AsymmetricMatcherResult::NOT_MATCHER && (flags & FLAG_NOT)) {
result = (result == AsymmetricMatcherResult::PASS) ? AsymmetricMatcherResult::FAIL : AsymmetricMatcherResult::PASS;
}
Expand Down
51 changes: 42 additions & 9 deletions src/runtime/test_runner/expect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use core::fmt;
use bun_core::Output;
use bun_jsc::bun_string_jsc;
use bun_jsc::{
CallFrame, JSGlobalObject, JSValue, JsError, JsResult,
AnyPromise, CallFrame, JSGlobalObject, JSValue, JsError, JsResult,
ConsoleObject, JSFunction, JSPropertyIterator, JSString,
};
use bun_jsc::{JsClass as _, StringJsc as _};
Expand Down Expand Up @@ -471,6 +471,28 @@ impl Expect {
}
}

/// The promise `wait_for_promise` polls for `value`'s outcome, or `None` for a non-thenable.
/// A pending promise or a thenable is adopted through `resolve`, which calls its own `then()`.
fn thenable_to_wait_for(global_this: &JSGlobalObject, value: JSValue) -> JsResult<Option<AnyPromise>> {
if let Some(promise) = value.as_any_promise() {
promise.set_handled(global_this.vm());
if promise.status() != js_promise::Status::Pending {
return Ok(Some(promise));
}
} else if !value.is_object() {
return Ok(None);
}
let adopter = js_promise::JSPromise::create(global_this);
adopter.set_handled();
adopter.resolve(global_this, value)?;
// A thenable's `then()` runs in a queued job, so the adopter is still pending here. An
// object without a callable `then` fulfilled it at once and is not a promise.
if adopter.status() == js_promise::Status::Fulfilled {
return Ok(None);
}
Ok(Some(AnyPromise::Normal(core::ptr::from_mut(adopter))))
}

/// Processes the async flags (resolves/rejects), waiting for the async value if needed.
/// If no flags, returns the original value
/// If either flag is set, waits for the result, and returns either it as a JSValue, or null if the expectation failed (in which case if silent is false, also throws a js exception)
Expand All @@ -485,9 +507,14 @@ impl Expect {
) -> JsResult<JSValue> {
match flags.promise() {
resolution @ (Promise::Resolves | Promise::Rejects) => {
if let Some(promise) = value.as_any_promise() {
// Jest calls a function receiver and waits on what it returns.
let received = if value.is_callable() {
value.call(global_this, JSValue::UNDEFINED, &[])?
} else {
value
};
if let Some(promise) = Self::thenable_to_wait_for(global_this, received)? {
let vm = global_this.vm();
promise.set_handled(vm);

// SAFETY: bun_vm() returns the live thread-local VirtualMachine.
global_this
Expand All @@ -507,7 +534,7 @@ impl Expect {
global_this, custom_label, matcher_name, matcher_params, flags,
"Expected promise that rejects",
"Received promise that resolved: ",
value.to_fmt(&mut formatter),
new_value.to_fmt(&mut formatter),
));
}
return Err(JsError::Thrown);
Expand All @@ -523,7 +550,7 @@ impl Expect {
global_this, custom_label, matcher_name, matcher_params, flags,
"Expected promise that resolves",
"Received promise that rejected: ",
value.to_fmt(&mut formatter),
new_value.to_fmt(&mut formatter),
));
}
return Err(JsError::Thrown);
Expand All @@ -540,7 +567,7 @@ impl Expect {
let mut formatter = ConsoleObject::Formatter::new(global_this).with_quote_strings(true);
return Err(Self::throw_promise_matcher_error(
global_this, custom_label, matcher_name, matcher_params, flags,
"Expected promise",
"Expected a promise or a function returning a promise",
"Received: ",
value.to_fmt(&mut formatter),
));
Expand Down Expand Up @@ -868,7 +895,14 @@ impl Expect {
return_value = return_value_from_function;
}

if let Some(promise) = return_value.as_any_promise() {
let promise = match Self::thenable_to_wait_for(global_this, return_value) {
Ok(promise) => promise,
Err(err) => {
scope.apply(vm);
return Err(err);
}
};
if let Some(promise) = promise {
let waited = vm.wait_for_promise(promise);
scope.apply(vm);
waited.map_err(|stopped| stopped.throw(global_this))?;
Expand Down Expand Up @@ -1437,9 +1471,8 @@ impl Expect {
// call the custom matcher implementation
let mut result = matcher_fn.call(global_this, matcher_context_jsvalue, args)?;
// support for async matcher results
if let Some(promise) = result.as_any_promise() {
if let Some(promise) = Self::thenable_to_wait_for(global_this, result)? {
let vm = global_this.vm();
promise.set_handled(vm);

// SAFETY: bun_vm() returns the live thread-local VirtualMachine.
global_this
Expand Down
19 changes: 19 additions & 0 deletions test/integration/bun-types/fixture/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@ describe("bun:test", () => {
expect(undefined).toBeUndefined();
expect(undefined).not.toBeDefined();
});

test("resolves / rejects", async () => {
await expect(Promise.resolve(1)).resolves.toBe(1);
await expect({
then(resolve: (value: number) => void) {
resolve(1);
},
}).resolves.toBe(1);
await expect(() => Promise.resolve(1)).resolves.toBe(1);
await expect(async () => "a").resolves.toBe("a");
expectType(expect(Promise.resolve(1)).resolves).is<Matchers<number>>();
expectType(expect(() => Promise.resolve(1)).resolves).is<Matchers<number>>();
expectType(expect(async () => "a").resolves).is<Matchers<string>>();

await expect(Promise.reject(new Error("Bad"))).rejects.toThrow("Bad");
await expect(async () => {
throw new Error("Bad");
}).rejects.toThrow("Bad");
});
});

test.each([1, 2, 3])("test.each", a => {
Expand Down
75 changes: 75 additions & 0 deletions test/js/bun/test/bun-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,78 @@ test("toBeWithin() with missing or non-number arguments fails the test without c
expect(stderr).toContain("4 fail");
expect(exitCode).toBe(1);
});

// https://github.com/oven-sh/bun/issues/40949
// https://github.com/oven-sh/bun/issues/14670
// `.resolves` / `.rejects` used to wait on the internal promise state without
// calling the value's own then(). A Promise subclass that starts its work in
// then() (Bun.SQL's Query, Bun.$'s ShellPromise) never settled, and the test
// timeout could not interrupt the wait. The child is killed by `timeout` if
// that comes back.
test("expect(lazyPromiseSubclass).rejects settles instead of hanging the run", async () => {
using dir = tempDir("expect-lazy-then", {
"lazy.test.ts": `
import { expect, test } from "bun:test";
import { $, SQL } from "bun";

class LazyQuery extends Promise<never> {
started = false;
#reject!: (e: Error) => void;
constructor() {
let reject!: (e: Error) => void;
super((_, rej) => {
reject = rej;
});
this.#reject = reject;
}
then(onFulfilled?: any, onRejected?: any): any {
if (!this.started) {
this.started = true;
queueMicrotask(() => this.#reject(new Error("boom")));
}
return super.then(onFulfilled, onRejected);
}
static get [Symbol.species]() {
return Promise;
}
}

test("expect(lazy).rejects settles", async () => {
await expect(new LazyQuery()).rejects.toThrow("boom");
});

test("Bun.$ ShellPromise", async () => {
$.throws(true);
await expect($\`exit 7\`.quiet()).rejects.toThrow(/exit code 7/);
await expect($\`echo hi\`.quiet()).resolves.toMatchObject({ exitCode: 0 });
expect(() => $\`exit 7\`.quiet()).toThrow(/exit code 7/);
});

test("Bun.SQL Query", async () => {
await using sql = new SQL("sqlite://:memory:");
await sql\`CREATE TABLE t (a INTEGER)\`;
await sql\`INSERT INTO t VALUES (1)\`;
await expect(sql\`SELECT a FROM t\`).resolves.toEqual([{ a: 1 }]);
await expect(sql\`SELECT a FROM t\`.values()).resolves.toEqual([[1]]);
await expect(sql\`SELECT * FROM no_such_table\`).rejects.toThrow(/no such table/);
expect(() => sql\`SELECT * FROM no_such_table\`).toThrow(/no such table/);
});
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "test", "lazy.test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
timeout: 10_000,
});

const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

// A hang is reported as the timeout kill, not as missing output.
expect(proc.signalCode).toBeNull();
expect(stderr).toContain("3 pass");
expect(exitCode).toBe(0);
});
Loading
Loading