Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
738982f
bun:test: await a thenable or lazy Promise subclass returned from a t…
robobun Aug 31, 2026
603a03d
bun:test: gate the awaitable on PromiseResolve, not on the then watch…
robobun Aug 31, 2026
674cb28
bun:test: shorten the awaitable comments
robobun Aug 31, 2026
afa2a9f
bun:test: trim the awaitable doc comment
robobun Aug 31, 2026
e05f55a
bun:test: one-line doc for JSPromise::awaitable
robobun Aug 31, 2026
6b1f88d
bun:test: assert the PromiseResolve result is non-null without an exc…
robobun Aug 31, 2026
3c0af86
Merge branch 'main' into robobun/871123bb/test-callback-thenable
robobun Oct 16, 2026
7cc1b52
bun:test: wait on a returned thenable or Promise subclass through its…
robobun Oct 16, 2026
8aebfc9
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 16, 2026
d1d9d65
bun:test: one line for each comment of the awaitable change
robobun Oct 16, 2026
642e339
Merge branch 'main' into robobun/871123bb/test-callback-thenable
robobun Oct 23, 2026
c915a83
Merge branch 'main' into robobun/871123bb/test-callback-thenable
robobun Oct 23, 2026
8816fec
Merge branch 'main' into robobun/871123bb/test-callback-thenable
robobun Nov 1, 2026
5f8148b
bun:test: wait on a returned Promise subclass through its own then()
robobun Nov 1, 2026
28585bc
bun:test: do not call then() on what a callback with a done parameter…
robobun Nov 2, 2026
45e459b
Merge branch 'robobun/871123bb/done-callback-no-then' into robobun/87…
robobun Nov 2, 2026
3d8bd2c
test: keep one done test, with a Promise subclass row
robobun Nov 2, 2026
bf1c3ff
Merge branch 'robobun/871123bb/done-callback-no-then' into robobun/87…
robobun Nov 2, 2026
b9a1bfc
bun:test: .resolves and .rejects wait on a settled Promise subclass t…
robobun Nov 2, 2026
6cebe7f
test: the two failing matchers assert their message and the then() calls
robobun Nov 2, 2026
4378abd
test: an un-awaited matcher and expect.poll() wait on a settled Promi…
robobun Nov 2, 2026
bd96563
bun:test: say which settled promise .resolves checks at once, and pin…
robobun Nov 2, 2026
5e05545
test: the turn of then() has a thenable row and a Promise subclass row
robobun Nov 2, 2026
a17b12a
Merge branch 'robobun/871123bb/thenable-before-drain' into robobun/87…
robobun Nov 2, 2026
69b9008
Merge branch 'robobun/871123bb/await-through-engine' into robobun/871…
robobun Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/test/writing-tests.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -566,13 +566,13 @@ test("promises", async () => {

If you don't `await` a matcher, the test still waits for it before ending, and a failure still fails that test. Outside a test, the file waits for the matcher, and a failure is reported as an error between tests. Code after the matcher doesn't wait for it, so `await` the matcher if that code closes something the promise needs.

In a `"bun:test"` test, a promise that has already settled is checked right away, and a failed matcher throws instead of returning a rejected promise. In a `"vitest"` test, the matcher always returns a promise.
In a `"bun:test"` test, a settled promise that the built-in `Promise` made is checked right away, and a failed matcher throws instead of returning a rejected promise. A `Promise` subclass, or a promise from another realm such as `node:vm`, is awaited through its `then()`, so its matcher returns a promise. In a `"vitest"` test, the matcher always returns a promise.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

These matchers also return a promise:

- `toThrow()` on a function that returns a pending promise
- A custom matcher that returns a promise
- A matcher that compares a pending promise with `expect.resolvesTo` or `expect.rejectsTo`
- A matcher that compares a pending promise or a `Promise` subclass with `expect.resolvesTo` or `expect.rejectsTo`

Don't change the values being compared while one of these matchers waits. If they change, the matcher fails.

Expand Down
10 changes: 2 additions & 8 deletions src/runtime/test_runner/bun_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1524,15 +1524,9 @@ impl BunTest {
Some(cfg_data)
}

/// A promise that follows `returned` if it's a thenable other than a promise. Otherwise `returned`.
/// The promise of `awaited_promise`, or `returned` itself when `await` does not wait on it.
pub(crate) fn promise_of_thenable(global_this: &JSGlobalObject, returned: JSValue) -> JsResult<JSValue> {
if !returned.is_object() || returned.as_promise().is_some() {
return Ok(returned);
}
// Reads `then` once. Only a value without a callable `then` fulfills the promise right away.
let promise = bun_jsc::JSPromise::create(global_this);
promise.resolve(global_this, returned)?;
Ok(if promise.status() == PromiseStatus::Fulfilled { returned } else { promise.to_js() })
Ok(Self::awaited_promise(global_this, returned)?.unwrap_or(returned))
}

/// What `await returned` waits on: a native promise as is, any other thenable through a new promise that follows its `then()`. `None`: `await` gives `returned` itself.
Expand Down
19 changes: 9 additions & 10 deletions src/runtime/test_runner/expect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,18 +512,17 @@ impl Expect {

/// A promise that settles the way `await value` would, or `None` if `value` isn't thenable.
#[optimize(size)]
#[inline(never)]
fn promise_to_await(global_this: &JSGlobalObject, value: JSValue) -> JsResult<Option<bun_jsc::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));
}
let vm = global_this.vm();
if let Some(received) = value.as_any_promise() {
received.set_handled(vm);
}
let promise = bun_test::BunTest::promise_of_thenable(global_this, value)?.as_any_promise();
if let Some(promise) = promise {
promise.set_handled(vm);
}
let adopted = bun_jsc::JSPromise::create(global_this);
adopted.set_handled();
adopted.resolve(global_this, value)?;
// A thenable's `then` only runs in a microtask, so `adopted` is already fulfilled only if `value` isn't thenable.
Ok((adopted.status() != js_promise::Status::Fulfilled).then_some(bun_jsc::AnyPromise::Normal(adopted)))
Ok(promise)
}
Comment thread
robobun marked this conversation as resolved.
Resolved

fn received_promise(global_this: &JSGlobalObject, received: JSValue) -> JsResult<Option<bun_jsc::AnyPromise>> {
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/test_runner/expect_deferred.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1124,7 +1124,7 @@ impl Expect {
global,
promise.map_or(JSValue::NULL, AnyPromise::as_value),
);
// bun:test checks a settled promise right away and throws, as it always has. Vitest always returns a promise.
// bun:test checks a settled native promise right away and throws, as it always has. Vitest always returns a promise.
let always_defer = self.is_vitest();
if let Some(promise) =
promise.filter(|promise| always_defer || promise.status() == Status::Pending)
Expand Down
107 changes: 106 additions & 1 deletion test/js/bun/test/expect-deferred.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,91 @@ describe.concurrent(".resolves and .rejects", () => {
expect(exitCode).toBe(0);
});

test("a settled Promise subclass is not checked at once: the matcher waits on its then()", async () => {
const { stdout, results, exitCode } = await runTests(`
import { runInNewContext } from "node:vm";
// The shape of the openai SDK's APIPromise: the promise is fulfilled with null at once, and each then() does the work.
class Settled extends Promise {
#work;
constructor(work) { super(resolve => resolve(null)); this.#work = work; }
then(onFulfilled, onRejected) { return this.#work().then(onFulfilled, onRejected); }
}
test("not awaited, and then() resolves", () => {
const matcher = expect(new Settled(() => later("parsed"))).resolves.toBe("parsed");
console.log("returned", matcher);
matcher.then(() => console.log("checked"));
});
test("not awaited, and then() rejects", () => {
expect(new Settled(() => laterReject(new Error("HTTP 500")))).resolves.toBeNull();
});
test("a subclass that inherits then(), and a promise of another realm", async () => {
class Plain extends Promise {}
const matchers = [expect(Plain.resolve(1)).resolves.toBe(1), expect(runInNewContext("Promise.resolve(1)")).resolves.toBe(1)];
console.log("returned", ...matchers);
await Promise.all(matchers);
});
test("expect.poll()", async () => {
let polls = 0;
// The third poll gives a plain value, so a build that reads the state of the promise ends this test too.
await expect.poll(() => (++polls < 3 ? new Settled(() => later("polled")) : "polled"), { timeout: 60_000, interval: 1 }).toBe("polled");
console.log("polls:", polls);
});
test("next", () => {});
`);
expect({ stdout, results, exitCode }).toEqual({
stdout: [
"returned Promise { <pending> }",
"checked",
"returned Promise { <pending> } Promise { <pending> }",
"polls: 1",
],
results: [
"(pass) not awaited, and then() resolves",
"(fail) not awaited, and then() rejects",
"(pass) a subclass that inherits then(), and a promise of another realm",
"(pass) expect.poll()",
"(pass) next",
],
exitCode: 1,
});
});

test("a matcher waits on a pending native promise itself, with no then() call", async () => {
const { stdout, results, exitCode } = await runTests(`
test("a replaced Promise.prototype.then is not called", async () => {
const { then } = Promise.prototype;
const pending = Array.from({ length: 5 }, () => Promise.withResolvers());
const calls = new Map(pending.map(({ promise }) => [promise, 0]));
let matchers;
Promise.prototype.then = function (...reactions) {
if (calls.has(this)) calls.set(this, calls.get(this) + 1);
return then.apply(this, reactions);
};
try {
matchers = [
expect(pending[0].promise).resolves.toBe(0),
expect(pending[1].promise).rejects.toBe(1),
expect(() => pending[2].promise).not.toThrow(),
expect({ x: pending[3].promise }).toEqual({ x: expect.resolvesTo.anything() }),
expect.poll(() => pending[4].promise, { timeout: 60_000, interval: 1 }).toBe(4),
];
pending.forEach(({ resolve, reject }, index) => (index === 1 ? reject : resolve)(index));
// After one turn of the event loop every reaction of the five promises has run.
await new Promise(resolve => setImmediate(resolve));
} finally {
Promise.prototype.then = then;
}
await Promise.all(matchers);
console.log("then() calls:", ...calls.values());
});
`);
expect({ stdout, results, exitCode }).toEqual({
stdout: ["then() calls: 0 0 0 0 0"],
results: ["(pass) a replaced Promise.prototype.then is not called"],
exitCode: 0,
});
});

// Expected values measured on Vitest 5.0.3.
test('in a "vitest" test, a settled promise still gives a promise', async () => {
const { stdout, results, exitCode } = await run(["test"], {
Expand Down Expand Up @@ -1238,6 +1323,21 @@ describe.concurrent("matchers that have to wait for a promise", () => {
await expect([{ then }, later(1, 5)]).toEqual([expect.resolvesTo.stringContaining("thenable"), expect.resolvesTo.any(Number)]);
expect(then).toHaveBeenCalledTimes(1);
});
test("a settled Promise subclass is awaited through then(), once", async () => {
class Settled extends Promise {
thenCalls = 0;
#work;
constructor(work) { super(resolve => resolve(null)); this.#work = work; }
then(onFulfilled, onRejected) { this.thenCalls++; return this.#work().then(onFulfilled, onRejected); }
}
const resolved = new Settled(() => later("parsed"));
const rejected = new Settled(() => laterReject(new Error("HTTP 500")));
const matcher = expect({ a: resolved, b: [rejected] }).toEqual({
a: expect.resolvesTo.stringContaining("pars"),
b: [expect.rejectsTo.any(Error)],
});
console.log(matcher instanceof Promise, await matcher, resolved.thenCalls, rejected.thenCalls);
});
test("not awaited", () => {
expect(later("one")).toEqual(expect.resolvesTo.stringContaining("one"));
});
Expand All @@ -1252,10 +1352,11 @@ describe.concurrent("matchers that have to wait for a promise", () => {
}, 50);
`;
const { stdout, stderr, results, exitCode, signalCode } = await runTests(source);
expect(stdout).toEqual(["true undefined", "undefined"]);
expect(stdout).toEqual(["true undefined", "undefined", "true undefined 1 1"]);
expect(results).toEqual([
"(pass) passes",
"(pass) calls then() once",
"(pass) a settled Promise subclass is awaited through then(), once",
"(pass) not awaited",
"(fail) fails",
"(fail) fails, not awaited",
Expand Down Expand Up @@ -1639,6 +1740,8 @@ describe.concurrent("matchers that have to wait for a promise", () => {
expect({ a: 1 }).toEqualWithThisEquals({ a: expect.toBeOddAtOnce() });
expect({ a: 1 }).toEqualWithThisEquals({ a: expect.toBeOdd() });
expect({ a: later(1) }).toEqualWithThisEquals({ a: expect.resolvesTo.anything() });
expect({ a: Promise.resolve(1) }).toEqualWithThisEquals({ a: expect.resolvesTo.anything() });
expect({ a: (class extends Promise {}).resolve(1) }).toEqualWithThisEquals({ a: expect.resolvesTo.anything() });
});
`);
const cannotWait =
Expand All @@ -1649,6 +1752,8 @@ describe.concurrent("matchers that have to wait for a promise", () => {
"this.equals(): true",
"this.equals(): " + cannotWait,
"this.equals(): " + cannotWait,
"this.equals(): true",
"this.equals(): " + cannotWait,
]);
expect(exitCode).toBe(0);
});
Expand Down
42 changes: 42 additions & 0 deletions test/js/bun/test/expect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,48 @@ describe("expect()", () => {
expect(promise.thenCalls).toBe(1);
});

// The shape of the openai SDK's APIPromise: the promise is fulfilled with null at once, and each then() does the work.
test("resolves and rejects call then() of a Promise subclass that is already settled", async () => {
class Settled extends Promise {
thenCalls = 0;
#work;
constructor(/** @type {() => Promise<unknown>} */ work) {
super(resolve => resolve(null));
this.#work = work;
}
then(onFulfilled, onRejected) {
this.thenCalls++;
return this.#work().then(onFulfilled, onRejected);
}
}
const error = new Error("HTTP 500");
const resolved = new Settled(() => Promise.resolve("parsed"));
const rejected = new Settled(() => Promise.reject(error));
await expect(resolved).resolves.toBe("parsed");
await expect(rejected).rejects.toBe(error);
expect([resolved.thenCalls, rejected.thenCalls]).toEqual([1, 1]);

if (isBun) {
const notResolved = new Settled(() => Promise.reject(error));
const notRejected = new Settled(() => Promise.resolve("parsed"));
await expectFailure(() => expect(notResolved).resolves.toBeNull()).toThrow("Received promise that rejected");
await expectFailure(() => expect(notRejected).rejects.toBe(error)).toThrow("Received promise that resolved");
expect([notResolved.thenCalls, notRejected.thenCalls]).toEqual([1, 1]);
}
});

// The matcher is the first reaction of the promise, as in Jest and Vitest.
test("resolves and rejects compare before a reaction that is attached after the matcher call", async () => {
for (const outcome of ["resolves", "rejects"]) {
const { promise, resolve, reject } = Promise.withResolvers();
const matcher = expect(promise)[outcome].toEqual({ checked: false });
const later = (/** @type {{ checked: boolean }} */ value) => void (value.checked = true);
promise.then(later, later);
(outcome === "resolves" ? resolve : reject)({ checked: false });
await matcher;
}
});

test("can call without an argument", () => {
expect().toBe(undefined);
});
Expand Down
Loading
Loading