Compare commits

...

4 Commits

Author SHA1 Message Date
Claude Bot
082f71f8e7 test: set thenCalled inside async callback to verify settlement
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 13:08:11 +00:00
Claude Bot
0772f79aaf test: verify async resolving thenable also invokes .then
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 12:48:17 +00:00
Claude Bot
c3821ac72d fix: address review - avoid double .then read and improve error handling
- Read .then once, bind it to the original object, and place it on a
  wrapper object so JSPromise.resolve() uses the captured accessor
  instead of reading .then again (Promises/A+ spec §2.3.3.1)
- Handle errors explicitly: bun.handleOom for OutOfMemory, route
  JSError to onUncaughtException, clear JSTerminated properly
- Clean up RefData on .then registration failure
- Improve "resolving thenable" test to verify .then was actually invoked

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 12:42:35 +00:00
Claude Bot
0d6e1572f5 fix(test): handle non-native thenables returned from test callbacks
The test runner only checked for native JSPromise objects when a test
callback returned a value. Non-native thenables (objects with a callable
.then method, like supertest's Test class) were silently ignored, causing
tests to incorrectly pass when the thenable would reject.

Now wraps thenables in a native Promise via JSPromise.resolve(), which
follows the Promise resolution procedure to unwrap them properly.

Closes #27945

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 12:07:03 +00:00
2 changed files with 242 additions and 0 deletions

View File

@@ -701,6 +701,64 @@ pub const BunTest = struct {
return cfg_data;
},
}
} else if (result.isObject()) {
// Check for non-native thenables (objects with a callable .then method).
// This handles libraries like supertest that return custom thenable objects
// rather than native Promises. Per the Promises/A+ spec and Jest's behavior,
// any object with a .then() method should be awaited.
const then_fn = result.getPropertyValue(globalThis, "then") catch |err| switch (err) {
error.JSError => {
this.onUncaughtException(globalThis, globalThis.tryTakeException(), false, cfg_data);
return cfg_data;
},
error.OutOfMemory => bun.handleOom(error.OutOfMemory),
error.JSTerminated => {
globalThis.clearTerminationException();
return cfg_data;
},
};
if (then_fn) |tfn| {
if (tfn.isCell() and tfn.isCallable()) {
group.log("callTestCallback -> thenable: data {f}", .{cfg_data});
// Wrap the thenable in a native Promise by creating a wrapper object
// with the captured .then function bound to the original result.
// This avoids reading .then from the original object a second time
// (Promises/A+ spec §2.3.3.1) while preserving the correct `this`.
var then_name = bun.String.static("then");
const bound_then = tfn.bind(globalThis, result, &then_name, 2, &.{}) catch |err| switch (err) {
error.JSError => {
this.onUncaughtException(globalThis, globalThis.tryTakeException(), false, cfg_data);
return cfg_data;
},
error.OutOfMemory => bun.handleOom(error.OutOfMemory),
error.JSTerminated => {
globalThis.clearTerminationException();
return cfg_data;
},
};
const wrapper_obj = jsc.JSValue.createEmptyObject(globalThis, 1);
wrapper_obj.put(globalThis, "then", bound_then);
const wrapper_promise = jsc.JSPromise.create(globalThis);
wrapper_promise.resolve(globalThis, wrapper_obj) catch {
globalThis.clearTerminationException();
return cfg_data;
};
// The wrapper promise is still pending (thenable unwrapping is async).
// Register then/catch handlers to capture the eventual result.
const wrapper_value = wrapper_promise.toJS();
defer wrapper_value.ensureStillAlive();
const this_ref: *RefData = if (dcb_ref) |dcb_ref_value| dcb_ref_value.dupe() else ref(this_strong, cfg_data);
wrapper_value.then(globalThis, this_ref, bunTestThen, bunTestCatch) catch {
globalThis.clearTerminationException();
this_ref.deref();
return cfg_data;
};
return null;
}
}
}
}

View File

@@ -0,0 +1,184 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Test that the bun test runner correctly handles non-native thenables
// (objects with a .then method that are not native Promises).
// Previously, bun would only check for native JSPromise objects, causing
// thenable rejections to be silently ignored and tests to incorrectly pass.
test("rejecting thenable should fail the test", async () => {
using dir = tempDir("issue-27945", {
"thenable.test.js": `
const { test, expect } = require("bun:test");
test("rejecting thenable", () => {
return {
then(resolve, reject) {
reject(new Error("thenable rejected"));
}
};
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "thenable.test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("thenable rejected");
});
test("resolving thenable should pass the test and invoke .then", async () => {
using dir = tempDir("issue-27945", {
"thenable.test.js": `
const { test, expect, afterAll } = require("bun:test");
let thenCalled = false;
test("resolving thenable", () => {
return {
then(resolve, reject) {
thenCalled = true;
resolve("ok");
}
};
});
afterAll(() => {
expect(thenCalled).toBe(true);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "thenable.test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
});
test("async resolving thenable should pass the test", async () => {
using dir = tempDir("issue-27945", {
"thenable.test.js": `
const { test, expect, afterAll } = require("bun:test");
let thenCalled = false;
test("async resolving thenable", () => {
return {
then(resolve, reject) {
Promise.resolve().then(() => {
thenCalled = true;
resolve("ok");
});
}
};
});
afterAll(() => {
expect(thenCalled).toBe(true);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "thenable.test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
});
test("async rejecting thenable should fail the test", async () => {
using dir = tempDir("issue-27945", {
"thenable.test.js": `
const { test, expect } = require("bun:test");
test("async rejecting thenable", () => {
return {
then(resolve, reject) {
Promise.resolve().then(() => reject(new Error("async thenable rejected")));
}
};
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "thenable.test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("async thenable rejected");
});
test("class-based thenable that rejects should fail", async () => {
using dir = tempDir("issue-27945", {
"thenable.test.js": `
const { test, expect } = require("bun:test");
class CustomThenable {
then(resolve, reject) {
reject(new Error("custom thenable rejected"));
}
}
test("class-based thenable rejection", () => {
return new CustomThenable();
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "thenable.test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("custom thenable rejected");
});
test("native promise rejection still works", async () => {
using dir = tempDir("issue-27945", {
"thenable.test.js": `
const { test, expect } = require("bun:test");
test("native promise rejection", () => {
return Promise.reject(new Error("native promise rejected"));
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "thenable.test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("native promise rejected");
});