Repository navigation
Conversation
The runner only took its async path when the callback's return value downcast to a native JSPromise, so a returned thenable (a userland PromiseLike) was dropped and the test was recorded as a finished synchronous test without `then` ever being called. Run the spec's Promise.resolve() coercion on the return value when it is a thenable, so the runner awaits it and surfaces its rejection.
|
Warning Review limit reached
Next review available in: 7 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughAdds a ChangesThenable Support in Bun Test Runner
Sequence Diagram(s)sequenceDiagram
participant TestCallback
participant run_test_callback
participant coerce_thenable
participant JSPromise
TestCallback->>run_test_callback: return value
run_test_callback->>coerce_thenable: coerce(value)
coerce_thenable->>coerce_thenable: check for callable then
alt is thenable
coerce_thenable->>JSPromise: create and resolve with value
JSPromise-->>run_test_callback: native JSPromise
else not thenable
coerce_thenable-->>run_test_callback: original value
end
alt coercion throws
run_test_callback->>run_test_callback: clear exception, on_uncaught_exception, log
run_test_callback-->>run_test_callback: JSValue::ZERO
end
Compact metadata: 4 ranges changed across 2 files (+174/-0 lines). Related issues: None found in provided context. Related PRs: None found in provided context. Suggested labels: test-runner, bun-test Suggested reviewers: None specified. Poem A rabbit hopped through Promise land, 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 9:08 AM PT - Jul 5th, 2026
❌ @robobun, your commit 7ec6067 has some failures in 🧪 To try this PR locally: bunx bun-pr 33372That installs a local version of the PR into your bun-33372 --bun |
There was a problem hiding this comment.
I didn't find any bugs, but this changes run_test_callback — the path every test/hook goes through — and adds an observable .then lookup on every returned object plus a new done+thenable interaction, so it's worth a human look before merging.
Extended reasoning...
Overview
This PR makes bun:test await userland thenables (PromiseLike) returned from test and hook callbacks, matching Jest and node --test. It adds a ~15-line coerce_thenable helper in src/runtime/test_runner/bun_test.rs that wraps a returned non-promise object with a callable .then in a native JSPromise via the spec's ResolvePromise, then reuses the existing promise path in run_test_callback. A new test/js/bun/test/thenable.test.ts spawns fixtures covering resolving/rejecting thenables, throwing then, a throwing then getter, a rejected beforeEach thenable, and confirms non-thenables still wait on done().
Security risks
None identified. The change reads a .then property on values already returned from user callbacks running in the same VM; there's no new privilege boundary, untrusted-input parsing, or resource exposure.
Level of scrutiny
Medium-high. run_test_callback is the core path every test and hook callback flows through, so a subtle regression here would affect every bun test user. The change is small and mirrors the surrounding error-handling pattern exactly, but it introduces two observable behavior changes: (1) every object returned from a callback now gets a .then property access it didn't get before (observable through getters/proxies, and the getter is invoked twice — once by the callable check and once by ResolvePromise), and (2) a callback that both takes done and returns a thenable now waits on both via the dcb_ref refcount, where previously the thenable was ignored. Both are arguably correctness fixes that align with Jest, but they're the kind of semantic shift a maintainer should sign off on.
Other factors
The tests are thorough and follow the harness conventions well (subprocess spawn, ordering assertions instead of timers, snapshot on the pass fixture, targeted toContain on the fail fixture). The JSValue::ZERO path (callback threw) is safe because !value.is_object() short-circuits. Error handling in the coercion branch mirrors the callback-error branch immediately above it. CI was still building at the time of review, so there's no green-build confirmation yet.
|
Both flagged behaviors are intentional. Detail on each, in case it helps the human pass: The The read goes through Handing the value to A single read is possible: resolve a fresh promise unconditionally and infer thenable-ness from its status, since pending means a thenable job was queued and fulfilled means it was not. It costs a throwaway promise on every object return and makes the non-thenable path less obvious, so I kept the explicit check. Happy to switch if a maintainer prefers it.
|
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/test_runner/bun_test.rs`:
- Around line 1123-1132: The `then` accessor in `bun_test.rs` is being observed
twice: once via `value.get_if_property_exists(global_this, "then")` and again
indirectly by `promise.resolve(global_this, value)`, which can re-run promise
assimilation side effects. Update the logic around the `then` handling path to
reuse the cached lookup result or a one-lookup helper instead of triggering a
second property access, and add a test in the `bun_test`/promise assimilation
path that verifies the getter is called only once.
In `@test/js/bun/test/thenable.test.ts`:
- Around line 1-142: The new thenable coverage should be added to the existing
bun:test runner integration tests instead of introducing a separate file. Move
the cases from the current thenable test module into the appropriate existing
suite that exercises test/hook execution in bun:test, keeping the assertions
around returned thenables, rejected thenables, throwing then, and throwing then
getters together for discoverability. Use the existing bun:test integration
entrypoint that already covers runner behavior so these scenarios live alongside
related test execution cases.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 32a87cab-331e-4106-b83a-9b734038b193
📒 Files selected for processing (2)
src/runtime/test_runner/bun_test.rstest/js/bun/test/thenable.test.ts
There was a problem hiding this comment.
No bugs found, but this changes the core test-runner callback path (every test/hook flows through it) and adds an observable .then read on returned objects, plus there's an open design alternative you flagged — worth a maintainer's eyes.
Extended reasoning...
Overview
This PR makes bun test await userland thenables (PromiseLike) returned from test/hook callbacks, matching jest and node --test. It adds a coerce_thenable helper in src/runtime/test_runner/bun_test.rs that wraps a returned thenable in a real JSPromise via ResolvePromise, then inserts a call to it in run_test_callback between the callback invocation and the existing promise-handling path. A new spawned-fixture test file covers resolve/reject/throwing-then/throwing-getter/hook cases and pins the non-thenable + done path.
Security risks
Low. The new .then property read on returned objects can invoke user getters/Proxy traps, but this is inside the test runner where user code is already being executed with full trust. No auth, crypto, or sandbox boundaries are involved.
Level of scrutiny
High. run_test_callback is the single funnel every test and hook callback in every bun test run passes through. The change:
- Adds a new observable side effect (reading
.thenon any object a callback returns), which could interact with Proxy return values or side-effecting getters in existing suites. - Sits amid dense
unsafere-entrancy handling (raw*mut BunTestderefs,UnsafeCellaliasing contracts) — the new call runs user JS between two such reborrows. - Subtly changes
done+ returned-object semantics (now waits for both if the object is a thenable).
Other factors
The author explicitly offers a design alternative (unconditional Promise.resolve to avoid the double .then read) and says "Happy to switch if a maintainer prefers it" — that's a design call a human should make. The PR is well-tested and well-argued, but it is neither mechanical nor obvious, so I'm not auto-approving.
ResolvePromise already fulfills a non-thenable synchronously, so the promise's status after resolve() says whether the value was a thenable. Use that instead of a separate `then` lookup, which made a `then` getter run twice. Move the thenable cases into the existing test-test.test.ts suite.
|
Took the review's advice over my earlier comment:
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()),
}Pending means the thenable job was queued, rejected means the Verified against a getter call count, which is now part of the fixture:
Also moved both cases out of the new file and into Fail-before and pass-after, with |
There was a problem hiding this comment.
I didn't find any bugs, but this changes the hot path of run_test_callback (every test/hook return now allocates a throwaway JSPromise and observably reads .then on returned objects), so a maintainer should confirm the approach and the perf/behavior tradeoff.
Extended reasoning...
Overview
Adds coerce_thenable to BunTest::run_test_callback so that a test or hook callback returning a userland thenable (not a native JSPromise) is awaited instead of silently dropped. The helper creates a fresh JSPromise, calls the spec's ResolvePromise on the return value, and infers thenable-ness from the resulting status (Pending/Rejected → treat as async; Fulfilled → keep the original value on the sync path). Comprehensive integration tests are added to test/js/bun/test/test-test.test.ts covering resolving thenables, rejecting thenables, throwing then, throwing then getters, hooks, and the non-thenable-object-plus-done case.
Security risks
None. This is test-runner control flow; no untrusted-input parsing, auth, crypto, or filesystem paths are touched.
Level of scrutiny
Medium-high. run_test_callback is executed for every single test and hook callback in every bun test invocation. The change is small (~30 native lines) and the error-handling mirrors the existing callback-threw path exactly, but it introduces two observable behavior changes that a maintainer should sign off on:
- Every callback that returns a non-promise object now has
.thenread on it (viaResolvePromise), where previously it was ignored. This is spec-correct and matches jest/node:test, but it's a user-visible semantic change. - Every such object return now allocates a throwaway
JSPromise. The author explicitly flagged this cost in the thread and switched to it after CodeRabbit feedback about double.thenreads — a maintainer should confirm the tradeoff is acceptable.
Other factors
- The implementation looks correct:
JSValue::ZEROfrom the earlier error path short-circuits onis_object(); the promise local is stack-rooted; theErrbranch mirrors the neighboring exception handler. - Test coverage is thorough and follows repo conventions (
tempDir,normalizeBunSnapshot, concurrent pipe drain, exit-code asserted last, single-then-read pinned). - The CodeRabbit "double lookup" concern was addressed in 7ec6067; the remaining "move tests into existing file" suggestion was also addressed (tests are now in
test-test.test.ts, not a new file). - CI build #68565 was still in progress at review time.
|
Two notes for whoever picks this up. What the coercion actually costsThe review above reads as if every test/hook return now allocates a promise and reads if !value.is_object() || value.as_promise().is_some() {
return Ok(value);
}
The plain-object row reads CIBuild 68565 finished 285 passed, 1 failed. The one red job is Same failure, same agent ( I already pushed one empty commit to re-roll and landed on the same agent, so I am not going to push another. The job needs a retry from someone with buildkite write access. |
|
Superseded by #41008. That PR coerces a returned thenable the same way and also covers a pending |
|
Closing: superseded by #41008. |
Repro
bun testreports(pass), andthen()is never called at all.node --testand jest fail this test. Any assertion, rejection, or async work inside the thenable silently never happens.The same applies to hooks: a
beforeEachreturning a rejected thenable is ignored and the test runs anyway.Cause
BunTest::run_test_callbackonly takes the async path when the callback's return value downcasts to a nativeJSPromise:A thenable is not a
JSPromise, so it falls through to the "synchronous test finished" path and the value is dropped. Registering the runner's completion reactions on it is not possible either:JSC__JSValue___thenis a no-op for anything that isn't aJSPromise.Fix
Coerce the return value with the spec's
Promise.resolve()when it is a thenable that isn't already a promise, then let the existing promise path await it. Non-thenables are returned unchanged, so a callback that returns a plain object (or takes adoneparameter) keeps taking the synchronous path.JSPromise::resolverunsResolvePromise, so athenthat throws, athengetter that throws, and a double-resolve are all handled per spec.Verification
test/js/bun/test/thenable.test.tsspawnsbun teston two fixtures:beforeEachmust settle before the next test starts (ordering assertion, no timers), and a non-thenable object must still wait fordone()then, a throwingthengetter, and a rejectedbeforeEachthenable must each fail, and the hook's test must not runBefore the fix both tests fail (the fixture's 4 failures are reported as
4 pass, exit 0). After, both pass.