Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions src/runtime/test_runner/bun_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,25 @@ impl BunTest {
}
}

/// Spec `Promise.resolve(value)` coercion, for callbacks that return a userland
/// thenable (`PromiseLike`) instead of a native promise: the runner must await it,
/// and only a real `JSPromise` can carry its completion reactions.
fn coerce_thenable(global_this: &JSGlobalObject, value: JSValue) -> JsResult<JSValue> {
if !value.is_object() || value.as_promise().is_some() {
return Ok(value);
}
// `resolve()` is the spec's ResolvePromise, which reads `then` exactly once:
// a thenable leaves the promise pending until the job it queued calls `then`,
// a `then` getter that throws rejects it, and anything else fulfills it here.
let promise = bun_jsc::JSPromise::create(global_this);
promise.resolve(global_this, value)?;
match promise.status() {
// Not a thenable. Keep the original value on the synchronous path.
PromiseStatus::Fulfilled => Ok(value),
PromiseStatus::Pending | PromiseStatus::Rejected => Ok(promise.to_js()),
}
}

/// if sync, the result is returned. if async, None is returned.
pub fn run_test_callback(
this_strong: &BunTestPtr,
Expand Down Expand Up @@ -1167,6 +1186,18 @@ impl BunTest {
}
};

// A returned thenable is an async callback too, so await it like a promise.
let result: JSValue = match Self::coerce_thenable(global_this, result) {
Ok(v) => v,
Err(_) => {
global_this.clear_termination_exception();
// SAFETY: re-derive after the `then` getter ran JS; no outer `&mut` was held across it.
unsafe { (*this).on_uncaught_exception(global_this, global_this.try_take_exception(), false, &cfg_data) };
bun_core::scoped_log!(bun_test_group, "callTestCallback -> thenable coercion threw");
JSValue::ZERO
}
};

done_callback.ensure_still_alive();

// Drain unhandled promise rejections.
Expand Down
150 changes: 149 additions & 1 deletion test/js/bun/test/test-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { spawn, spawnSync } from "bun";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test } from "bun:test";
import { copyFileSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "fs";
import { rm, writeFile } from "fs/promises";
import { bunEnv, bunExe, tempDirWithFiles, tmpdirSync } from "harness";
import { bunEnv, bunExe, normalizeBunSnapshot, tempDir, tempDirWithFiles, tmpdirSync } from "harness";
import { tmpdir } from "os";
import { dirname, join } from "path";

Expand Down Expand Up @@ -749,3 +749,151 @@ test("my-test", () => {
});
}
});

// A test or hook callback that returns a thenable (a userland `PromiseLike`, the
// kind produced by bluebird/q, older ORMs, or another realm) must be awaited like
// a native promise.
describe("thenable test callbacks", () => {
test("a returned thenable is awaited", async () => {
using dir = tempDir("thenable-await", {
"thenable.test.ts": `
import { beforeEach, expect, test } from "bun:test";

const order: string[] = [];
let thenReads = 0;

beforeEach(() => ({
then(resolve: () => void) {
queueMicrotask(() => {
order.push("beforeEach");
resolve();
});
},
}));

test("a resolving thenable is awaited", () => ({
// Reading \`then\` is observable, so the runner must read it exactly once,
// the way \`Promise.resolve()\` does.
get then() {
thenReads++;
return (resolve: () => void) => {
queueMicrotask(() => {
order.push("test");
resolve();
});
};
},
}));

test("the thenable settled before this test started", () => {
expect(order).toEqual(["beforeEach", "test", "beforeEach"]);
expect(thenReads).toBe(1);
});

// A non-thenable object is not a promise: the callback stays synchronous and
// the runner keeps waiting for the done callback. setImmediate does not run
// between two synchronous tests, so "done" is only recorded if it was awaited.
test("a non-thenable object does not complete the test", done => {
setImmediate(() => {
order.push("done");
done();
});
return { then: null };
});

test("the done callback ran before this test started", () => {
expect(order).toEqual(["beforeEach", "test", "beforeEach", "beforeEach", "done", "beforeEach"]);
});
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "test", "thenable.test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect({
stdout: normalizeBunSnapshot(stdout, String(dir)),
stderr: normalizeBunSnapshot(stderr, String(dir)),
exitCode,
}).toMatchInlineSnapshot(`
{
"exitCode": 0,
"stderr":
"thenable.test.ts:
(pass) a resolving thenable is awaited
(pass) the thenable settled before this test started
(pass) a non-thenable object does not complete the test
(pass) the done callback ran before this test started

4 pass
0 fail
3 expect() calls
Ran 4 tests across 1 file."
,
"stdout": "bun test <version> (<revision>)",
}
`);
});

test("a rejected thenable fails the test", async () => {
using dir = tempDir("thenable-reject", {
"thenable.test.ts": `
import { beforeEach, describe, test } from "bun:test";

test("a rejected thenable fails", () => ({
then(_resolve: () => void, reject: (err: unknown) => void) {
reject(new Error("rejected thenable"));
},
}));

test("a throwing then fails", () => ({
then(): void {
throw new Error("throwing then");
},
}));

test("a throwing then getter fails", () => ({
get then(): never {
throw new Error("throwing then getter");
},
}));

describe("rejected hook", () => {
beforeEach(() => ({
then(_resolve: () => void, reject: (err: unknown) => void) {
reject(new Error("rejected hook"));
},
}));

test("never runs", () => {
console.log("THIS TEST SHOULD NOT RUN");
});
});
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "test", "thenable.test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const err = normalizeBunSnapshot(stderr, String(dir));
expect(err).toContain("error: rejected thenable");
expect(err).toContain("error: throwing then\n");
expect(err).toContain("error: throwing then getter");
expect(err).toContain("error: rejected hook");
expect(err).toContain(" 0 pass");
expect(err).toContain(" 4 fail");
expect(stdout).not.toContain("THIS TEST SHOULD NOT RUN");
expect(exitCode).toBe(1);
});
});
Loading