From b57d4b3ea260f6f0431c9fda1d3d686406fd27ee Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:48:55 +0000 Subject: [PATCH 1/2] bun:test: do not call then() on what a callback with a done parameter returns A callback that takes `done` ends through `done()`. The runner no longer calls `then()` on an object that such a callback also returns. Since #44833 the runner followed a thenable that a `done` callback returned. That sent a supertest request twice: `done => request(app).get("/").expect(200, done)` failed with ".end() was called twice". Bun 1.4.4, Jest 30.5.2 and `node --test` 26.3.0 do not call `then()` there. --- docs/test/lifecycle.mdx | 2 +- src/runtime/test_runner/bun_test.rs | 3 +- test/js/bun/test/vitest-flavor.test.ts | 44 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/test/lifecycle.mdx b/docs/test/lifecycle.mdx index 6ff41b646ca0..b838d193b2f0 100644 --- a/docs/test/lifecycle.mdx +++ b/docs/test/lifecycle.mdx @@ -271,7 +271,7 @@ afterEach(() => { ## Async Lifecycle Hooks -All lifecycle hooks support async functions. Bun waits for the promise a hook or a test returns. It also waits for any other object with a `then()` method, such as the one React's `act()` returns. +All lifecycle hooks support async functions. Bun waits for the promise a hook or a test returns. It also waits for any other object with a `then()` method, such as the one React's `act()` returns. A hook or a test that takes a `done` parameter is the exception: Bun does not call `then()` on an object it returns. ```ts title="test.ts" icon="/icons/typescript.svg" import { beforeAll, afterAll, test, expect } from "bun:test"; diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index ece763c71f91..a6a289751f14 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -1389,7 +1389,8 @@ impl BunTest { args_slice, move || drop(on_stack), ) - .and_then(|returned| Self::promise_of_thenable(global_this, returned)) + // A callback that takes `done` ends through it: `then()` of what it also returns is not called. + .and_then(|returned| if cfg_done_parameter { Ok(returned) } else { Self::promise_of_thenable(global_this, returned) }) { Ok(v) => v, Err(_) => { diff --git a/test/js/bun/test/vitest-flavor.test.ts b/test/js/bun/test/vitest-flavor.test.ts index c343e3008134..4653cbdec700 100644 --- a/test/js/bun/test/vitest-flavor.test.ts +++ b/test/js/bun/test/vitest-flavor.test.ts @@ -2533,6 +2533,50 @@ describe.concurrent.each(["vitest", "bun:test", "@jest/globals"])("a thenable th }); }); +test.concurrent.each(["bun:test", "@jest/globals"])( + "then() of what a callback with a done parameter returns is not called: %s", + async module => { + const { log, results, errors, exitCode } = await runTests({ + "a.test.js": ` + import { test, describe, beforeEach } from "${module}"; + // The shape of a supertest request: end() sends it, and then() calls end() too. + const request = name => ({ + end(callback) { + if (this.sent) throw new Error(name + ": end() was called twice"); + this.sent = true; + setImmediate(() => { console.log(name + ": sent"); callback(); }); + return this; + }, + then(resolve, reject) { console.log(name + ": then()"); this.end(error => (error ? reject(error) : resolve())); }, + }); + const thenable = (name, then) => ({ then(resolve, reject) { console.log(name + ": then()"); then?.(resolve, reject); } }); + describe("a hook", () => { + beforeEach(done => request("beforeEach").end(done)); + test("a request", done => request("test").end(done)); + }); + test.each([["each"]])("test.each with a done parameter: %s", (name, done) => request(name).end(done)); + test("a thenable that rejects", done => { done(); return thenable("rejects", (resolve, reject) => reject(new Error("rejected"))); }); + test("a thenable that never settles", done => { done(); return thenable("never settles"); }, 10); + test("done() is never called", done => request("never sent"), 10); + test("done(error) is called later", done => { setImmediate(() => done(new Error("from done()"))); return request("never sent"); }); + `, + }); + expect({ log, results, errors, exitCode }).toEqual({ + log: ["beforeEach: sent", "test: sent", "each: sent"], + results: [ + "(pass) a hook > a request", + "(pass) test.each with a done parameter: each", + "(pass) a thenable that rejects", + "(pass) a thenable that never settles", + "(fail) done() is never called", + "(fail) done(error) is called later", + ], + errors: ["error: from done()"], + exitCode: 1, + }); + }, +); + test.concurrent("a function that a thenable from beforeEach or beforeAll resolves to is a teardown", async () => { const { log, exitCode } = await runTests({ "a.test.js": ` From fa960289b8bcfd1f868392acc2987d6e8a57dc92 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:24:30 +0000 Subject: [PATCH 2/2] test: give the row that must pass a 500 ms timeout --- test/js/bun/test/vitest-flavor.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/test/vitest-flavor.test.ts b/test/js/bun/test/vitest-flavor.test.ts index 4653cbdec700..b87032d7666a 100644 --- a/test/js/bun/test/vitest-flavor.test.ts +++ b/test/js/bun/test/vitest-flavor.test.ts @@ -2556,7 +2556,7 @@ test.concurrent.each(["bun:test", "@jest/globals"])( }); test.each([["each"]])("test.each with a done parameter: %s", (name, done) => request(name).end(done)); test("a thenable that rejects", done => { done(); return thenable("rejects", (resolve, reject) => reject(new Error("rejected"))); }); - test("a thenable that never settles", done => { done(); return thenable("never settles"); }, 10); + test("a thenable that never settles", done => { done(); return thenable("never settles"); }, 500); test("done() is never called", done => request("never sent"), 10); test("done(error) is called later", done => { setImmediate(() => done(new Error("from done()"))); return request("never sent"); }); `,