Skip to content

test: allow onTestFinished() and expect.assertions() in concurrent tests#29238

Open
robobun wants to merge 11 commits intomainfrom
farm/41a374fa/on-test-finished-concurrent
Open

test: allow onTestFinished() and expect.assertions() in concurrent tests#29238
robobun wants to merge 11 commits intomainfrom
farm/41a374fa/on-test-finished-concurrent

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 12, 2026

Fixes #29236.

Repro

// concurrent.test.ts
import { expect, onTestFinished, test } from "bun:test";

test.concurrent("a", async () => {
  onTestFinished(async () => { /* cleanup */ });
  await new Promise(r => setTimeout(r, 10));
  expect(1).toBe(1);
});

test.concurrent("b", async () => {
  onTestFinished(async () => { /* cleanup */ });
  await new Promise(r => setTimeout(r, 10));
  expect(1).toBe(1);
});
$ bun test concurrent.test.ts
error: Cannot call onTestFinished() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent.

A plain try/finally cleanup block in the same concurrent test body already worked, so the restriction was surprising — onTestFinished() is just a structured form of the same cleanup.

Cause

genericHook(), expect.assertions() and expect.hasAssertions() all look up "what sequence am I inside?" via BunTest.getCurrentStateData(). For concurrent groups with more than one sequence in flight that function returned entry_data = null, and every caller threw.

Fix

Added a push/pop stack (BunTest.current_callback_stack) of the innermost sequence whose JS callback is currently executing. Execution.stepSequenceOne pushes the (group, sequence, entry) tuple before handing control to runTestCallback and defers a pop. Any JS code that runs synchronously inside that call — including microtasks drained by runCallbackWithResultAndForcefullyDrainMicrotasks — now resolves via that stack top, so onTestFinished(), expect.assertions() and expect.hasAssertions() see the correct owning sequence.

Also updated packages/bun-types/test.d.ts and docs/test/lifecycle.mdx to note that onTestFinished() is callable from concurrent tests, as the reporter requested.

Verification

  • test/regression/issue/29236.test.ts — new test, reproduces the original failure (fails on main, passes with the fix).
  • test/js/bun/test/test-on-test-finished.test.ts — replaced the two cannot be called in concurrent test assertions with a positive test that each concurrent sequence runs its own onTestFinished hook.
  • test/js/bun/test/bun_test.fixture.ts — removed the two test.concurrent.failing("expect.assertions not yet supported in concurrent tests", ...) markers, which now pass.

Known limitation

If a user calls onTestFinished() after awaiting a timer-based promise (where control has yielded back to the event loop), the callback-execution stack is empty and the old error still fires. The reported repro and all idiomatic usages register the hook synchronously at the top of the test body, which this fix covers; the post-timer-await case is a larger refactor that would need AsyncContextFrame-based propagation to carry the sequence identity across promise resumptions.

genericHook() / expect.assertions() / expect.hasAssertions() used
getCurrentStateData() to look up which sequence they belonged to, but
that function bailed with entry_data=null whenever the active concurrent
group had more than one sequence in flight. The result was a runtime
throw even though a plain try/finally in the same concurrent test body
already worked fine.

Track the innermost JS callback frame with a push/pop stack on BunTest
— Execution.stepSequenceOne pushes the (group, sequence, entry) tuple
before handing control to runTestCallback and defers a pop. JS code
that runs synchronously inside that call, including microtasks drained
by runCallbackWithResultAndForcefullyDrainMicrotasks, sees the correct
owning sequence via getCurrentStateData.

Fixes #29236.
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 12, 2026

Updated 11:13 PM PT - Apr 12th, 2026

❌ Your commit 2d732d7a has 1 failures in Build #45423 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29238

That installs a local version of the PR into your bun-29238 executable, so you can run:

bun-29238 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR implements support for onTestFinished() and expect.assertions() in concurrent tests when registered synchronously within the test callback body. Documentation is updated to clarify this behavior, and tests are converted from expecting exceptions to asserting successful hook execution with correct ordering and lifecycle management.

Changes

Cohort / File(s) Summary
Documentation
docs/test/lifecycle.mdx
Updated onTestFinished() documentation to replace blanket "not supported" statement with qualified behavior: now works in concurrent tests only when registered synchronously within the test callback body, with clarifications on microtask handling and event-loop suspension points.
Test Fixtures & Snapshots
test/js/bun/test/bun_test.fixture.ts, test/js/bun/test/bun_test.test.ts
Converted two test.concurrent.failing() test cases that asserted expect.hasAssertions() and expect.assertions() were unsupported into passing test.concurrent() cases that assert both functions now work. Updated corresponding expected output snapshots.
Hook Lifecycle Tests
test/js/bun/test/test-on-test-finished.test.ts
Replaced tests asserting onTestFinished() throws exceptions within concurrent tests with behavioral tests that register hooks in separate concurrent sequences, verify per-sequence hook execution, and assert correct ordering of body and finished callback entries.
Regression Tests
test/regression/issue/29236.test.ts
Added three new regression tests validating: synchronous onTestFinished() registration succeeds in multiple concurrent tests, plain tests work with --concurrent flag, and appropriate error messaging when registration occurs after yielding control (e.g., after await).
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: enabling onTestFinished() and expect.assertions() to work in concurrent tests, which matches all file changes.
Description check ✅ Passed The PR description includes both required sections (What does this PR do, How did you verify), provides detailed context, repro code, root cause analysis, implementation details, and verification steps.
Linked Issues check ✅ Passed All coding requirements from issue #29236 are met: onTestFinished() now works in concurrent tests when registered synchronously, and bun-types/test.d.ts documentation has been updated to reflect this.
Out of Scope Changes check ✅ Passed All changes directly address issue #29236: documentation updates, test fixture modifications, regression tests, and implementation details align with the stated objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/test/lifecycle.mdx`:
- Line 109: Update the sentence "Works inside concurrent tests — each call is
attached to the specific concurrent test whose body is currently executing." to
include the synchronous-registration caveat: state that registration is only
guaranteed while the concurrent test callback is actively executing (including
any microtasks drained during that turn) and is not preserved after the callback
yields to a later event-loop turn; ensure the wording mentions "synchronous
registration" and clarifies it does not apply once the test callback has
returned or yielded to a subsequent tick.

In `@packages/bun-types/test.d.ts`:
- Around line 381-383: Update the onTestFinished() JSDoc to add a caveat:
although it can be called inside concurrent tests, registration must occur
synchronously in the test callback — registrations made after yielding to the
next event-loop turn (for example after an await, setTimeout, or other async
yield) are not guaranteed to attach to the intended concurrent test; reference
onTestFinished by name and state that it only reliably registers when called
synchronously within the currently executing test body.

In `@src/bun.js/test/bun_test.zig`:
- Line 281: Before calling this.current_callback_stack.deinit(), add a
stack-balance invariant to assert the callback stack is empty (e.g., check
length == 0 or use isEmpty()) so teardown fails fast on unpaired push/pop; place
the assertion immediately before the existing
this.current_callback_stack.deinit() call (in the same function where deinit is
called) and include a short message identifying current_callback_stack in the
assertion to aid debugging.

In `@test/regression/issue/29236.test.ts`:
- Around line 61-63: The current assertions use toContain which can match
substrings (e.g., "0 fail" matching "10 fail"); change the checks on the
variables output and exitCode to anchored regex line matches instead: replace
expect(output).toContain("4 pass") with expect(output).toMatch(/^\s*4 pass$/m)
and replace expect(output).toContain("0 fail") with
expect(output).toMatch(/^\s*0 fail$/m); do the same for the similar assertions
mentioned at lines 99-101 so they use /^\s*<n> pass$/m and /^\s*<m> fail$/m
respectively, keeping the existing exitCode assertion expect(exitCode).toBe(0)
intact.
- Around line 2-11: Replace the multi-line explanatory comment block that begins
with "// onTestFinished()" in this regression test with a single-line GitHub
issue URL comment; remove the extra prose and leave only the standard concise
one-line issue reference used across test/regression/issue/* tests (update the
comment in issue/29236.test.ts accordingly).
🪄 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: 1bf417db-3ac3-4649-92ca-1ecfb4e55fd1

📥 Commits

Reviewing files that changed from the base of the PR and between d7526e2 and dd5566a.

📒 Files selected for processing (8)
  • docs/test/lifecycle.mdx
  • packages/bun-types/test.d.ts
  • src/bun.js/test/Execution.zig
  • src/bun.js/test/bun_test.zig
  • test/js/bun/test/bun_test.fixture.ts
  • test/js/bun/test/bun_test.test.ts
  • test/js/bun/test/test-on-test-finished.test.ts
  • test/regression/issue/29236.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
test/regression/issue/29236.test.ts (2)

2-11: 🧹 Nitpick | 🔵 Trivial

Trim regression header to a single issue URL comment.

Please remove the multi-line bug-context prose and keep just the one-line issue URL comment used in test/regression/issue/*.

✂️ Suggested cleanup
 // https://github.com/oven-sh/bun/issues/29236
-//
-// onTestFinished() must be usable from inside a concurrent test, just like
-// a plain try/finally cleanup block is. Before the fix, any onTestFinished()
-// call from a concurrent test threw:
-//
-//   Cannot call onTestFinished() here. It cannot be called inside a
-//   concurrent test. Use test.serial or remove test.concurrent.
-//
-// because the hook lookup couldn't resolve which sequence owned the call
-// when more than one concurrent sequence was active.

Based on learnings: in test/regression/issue/*, keep the standard single-line GitHub issue URL comment and avoid additional inline bug-context prose.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/29236.test.ts` around lines 2 - 11, Replace the
multi-line regression header block (the prose describing onTestFinished()
behavior and error message) with the standard single-line GitHub issue URL
comment used by tests under test/regression/issue/*; specifically remove the
paragraph mentioning onTestFinished() and the quoted error and leave only the
one-line issue URL comment (referencing this test's issue number, e.g. `#29236`)
at the top of the file so the header matches other regression tests.

57-58: ⚠️ Potential issue | 🟡 Minor

Harden pass/fail count assertions to avoid substring false positives.

toContain("0 fail") can match unintended text like 10 fail.

🔍 Suggested assertion hardening
-  expect(output).toContain("4 pass");
-  expect(output).toContain("0 fail");
+  expect(output).toMatch(/\b4 pass\b/);
+  expect(output).toMatch(/\b0 fail\b/);
@@
-  expect(output).toContain("2 pass");
-  expect(output).toContain("0 fail");
+  expect(output).toMatch(/\b2 pass\b/);
+  expect(output).toMatch(/\b0 fail\b/);

Also applies to: 91-92

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/29236.test.ts` around lines 57 - 58, The assertions
using expect(output).toContain("4 pass") and expect(output).toContain("0 fail")
are vulnerable to substring matches (e.g. "10 fail"); replace these toContain
checks with stricter matches (e.g. use expect(output).toMatch with regex word
boundaries or line anchors such as /\b4 pass\b/ and /\b0 fail\b/ or match the
entire summary line) for both occurrences (the current
expect(output).toContain("4 pass") / expect(output).toContain("0 fail") pairs)
so they only succeed on exact pass/fail counts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29236.test.ts`:
- Line 24: Replace the time-based wait "await new Promise<void>(r =>
setTimeout(r, 10));" with an assertion that awaits the actual condition instead
of sleeping: locate each occurrence of that snippet and change it to wait for
the expected state (e.g., use a helper like waitFor(() => expect(...).toBe(...))
or poll in an async loop with short awaits or use
flush-promises/process.nextTick to yield) so the test verifies the condition
rather than elapsed time; update all occurrences noted (the snippet at the shown
line plus the other listed lines) to use the chosen await-for-condition helper.

---

Duplicate comments:
In `@test/regression/issue/29236.test.ts`:
- Around line 2-11: Replace the multi-line regression header block (the prose
describing onTestFinished() behavior and error message) with the standard
single-line GitHub issue URL comment used by tests under
test/regression/issue/*; specifically remove the paragraph mentioning
onTestFinished() and the quoted error and leave only the one-line issue URL
comment (referencing this test's issue number, e.g. `#29236`) at the top of the
file so the header matches other regression tests.
- Around line 57-58: The assertions using expect(output).toContain("4 pass") and
expect(output).toContain("0 fail") are vulnerable to substring matches (e.g. "10
fail"); replace these toContain checks with stricter matches (e.g. use
expect(output).toMatch with regex word boundaries or line anchors such as /\b4
pass\b/ and /\b0 fail\b/ or match the entire summary line) for both occurrences
(the current expect(output).toContain("4 pass") / expect(output).toContain("0
fail") pairs) so they only succeed on exact pass/fail counts.
🪄 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: 86a3f9ae-8a62-4811-b38c-9941cefed486

📥 Commits

Reviewing files that changed from the base of the PR and between dd5566a and 676ecb3.

📒 Files selected for processing (1)
  • test/regression/issue/29236.test.ts

- Update the onTestFinished()/expect.assertions() error message so it
  tells users to hoist the call above the first await, instead of the
  pre-fix wording that said to remove .concurrent / use describe().
- Drop the setTimeout-based waits from the child fixture; the bug
  reproduces synchronously because stepGroupOne() queues every
  concurrent sequence's callback in one pass.
- Add a regression test for the after-timer-await case that asserts
  the new wording is used (and the old 'Use test.serial' wording is
  not).
- Single-line regression header + regex pass/fail matchers.
- Stack-balance debugAssert in BunTest.deinit().
- Doc + JSDoc caveat that registration must be synchronous (microtasks
  drained before the first suspension point are covered).
autofix-ci bot and others added 2 commits April 12, 2026 22:48
…d tests

The describe block exercising onTestFinished() inside test.concurrent()
used setTimeout to keep both sequences in flight at the same time, but
stepGroupOne() already queues every concurrent sequence's callback in a
single synchronous pass, so a plain sync test body reproduces the bug.
The delay was flagged by CLAUDE.md's 'no setTimeout in tests' rule.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29236.test.ts`:
- Around line 116-122: The test gathers stdout/stderr from proc but never
asserts the child process actually failed; update
test/regression/issue/29236.test.ts to capture the subprocess exit status (use
proc.exitCode or proc.status on the proc object) after awaiting output and add
an assertion that the exit status is non-zero (e.g.
expect(proc.exitCode).not.toBe(0) or expect(proc.status).not.toBe(0)) so the
failure-path is explicitly verified alongside the existing output assertions on
stdout/stderr/output and the expect(...) checks.
🪄 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: 9bd5bfa3-e482-4b8c-adc9-2e9a112f7215

📥 Commits

Reviewing files that changed from the base of the PR and between 676ecb3 and cb7cab7.

📒 Files selected for processing (4)
  • docs/test/lifecycle.mdx
  • packages/bun-types/test.d.ts
  • src/bun.js/test/bun_test.zig
  • test/regression/issue/29236.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
test/regression/issue/29236.test.ts (1)

108-114: ⚠️ Potential issue | 🟠 Major

Assert failure-path subprocess exit status explicitly.

At Line 108, this test validates error text but never checks that the child process actually failed. That weakens the regression signal for the negative path.

✅ Suggested fix
-  const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
+  const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
   const output = stdout + stderr;
@@
   expect(output).not.toContain("Use test.serial");
   expect(output).toMatch(/before the first `?await`?/);
+  expect(exitCode).not.toBe(0);

As per coding guidelines: "Always check exit codes and test error scenarios when spawning processes in tests."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/29236.test.ts` around lines 108 - 114, The test
currently checks subprocess output but not whether the child process actually
failed; update the test around the proc/expect lines to assert a non-zero exit
status for the spawned process (e.g. assert proc.exitCode (or proc.status.code)
is not 0, awaiting proc.status() if the API requires) before or alongside the
existing stdout/stderr assertions so the negative-path regression is explicitly
verified; refer to the proc variable and existing stdout/stderr/output/expect
calls when adding the exit-code assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/regression/issue/29236.test.ts`:
- Around line 108-114: The test currently checks subprocess output but not
whether the child process actually failed; update the test around the
proc/expect lines to assert a non-zero exit status for the spawned process (e.g.
assert proc.exitCode (or proc.status.code) is not 0, awaiting proc.status() if
the API requires) before or alongside the existing stdout/stderr assertions so
the negative-path regression is explicitly verified; refer to the proc variable
and existing stdout/stderr/output/expect calls when adding the exit-code
assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 542d9c92-20d9-4efe-afbd-f9a189249d0e

📥 Commits

Reviewing files that changed from the base of the PR and between cb7cab7 and 91e2c5b.

📒 Files selected for processing (2)
  • test/js/bun/test/test-on-test-finished.test.ts
  • test/regression/issue/29236.test.ts

robobun added 2 commits April 12, 2026 22:54
The previous commit (776ec85) accidentally wiped the core Zig changes
(current_callback_stack push/pop), the updated error message, and the
doc caveats because the working tree was stale. This restores them and
also adds the exit-code assertion that was the original intent.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29236.test.ts`:
- Around line 112-113: The test rejects the runtime error text "Use test.serial"
while expecting a different pattern; update either the error text emitted by
onTestFinished (in the bun_test.zig onTestFinished error path) to include the
"before the first `?await`?" wording/regex, or adjust the test assertions in
issue/29236.test.ts by removing the negative check against "Use test.serial" and
instead assert the actual runtime message (e.g., expect output toContain "Use
test.serial or remove test.concurrent." or update expect(...).toMatch(...) to
match the real message); pick one fix and keep the message and test assertion
consistent.
🪄 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: 28ee795f-b708-4dd1-a921-fdab90406d6b

📥 Commits

Reviewing files that changed from the base of the PR and between 91e2c5b and 776ec85.

📒 Files selected for processing (1)
  • test/regression/issue/29236.test.ts

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 test/regression/issue/29236.test.ts:116-122 — The third regression test (lines 95-122) spawns a child process expected to exit non-zero but never awaits proc.exited or asserts the exit code, unlike the first two tests which both collect and assert exitCode === 0. If the fixture somehow exits with code 0, the test would fail with a confusing output-content error rather than a clear exit-code failure.

    Extended reasoning...

    What the bug is and how it manifests

    The third test in test/regression/issue/29236.test.ts (lines 95-122) verifies that calling onTestFinished() after an await in a concurrent test produces an error message telling users to hoist the call before the first await. It spawns a child process with two concurrent tests expected to fail. However, the Promise.all at line 116 only collects [stdout, stderr], not proc.exited. The exit code is never awaited and never asserted.

    The specific code path that triggers it

    Compare the three tests:

    • Test 1 (line 41): const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); then asserts expect(exitCode).toBe(0).
    • Test 2 (line 77): same pattern, asserts expect(exitCode).toBe(0).
    • Test 3 (line 116): const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]); — no proc.exited, no exit code assertion.

    Why existing code does not prevent it

    The two content checks (expect(output).not.toContain('Use test.serial') and expect(output).toMatch(/before the first await/)) provide a functional safety net: if the fixture passed unexpectedly, the error-message pattern match would fail because there would be no error output. So the test is not completely blind. However, the test is testing an explicit failure scenario — the fixture is designed to fail — and should assert exitCode !== 0 to make that intent clear, consistent with its siblings.

    Impact

    The practical risk is limited: awaiting proc.stdout.text() implicitly waits for the process to finish (pipes close on exit), so the process has completed before assertions run. But if the fixture were somehow modified to produce the matched error text while exiting 0, the test would silently pass a broken state. More immediately, the test is inconsistent with the pattern established by its two siblings in the same file, making the failure scenario's intent opaque to future readers.

    How to fix

    Add proc.exited to the Promise.all destructuring and assert expect(exitCode).not.toBe(0):
    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
    expect(exitCode).not.toBe(0);

    Step-by-step proof

    1. Test 1 (line 41) collects exitCode and asserts exitCode === 0 (success scenario).
    2. Test 2 (line 77) collects exitCode and asserts exitCode === 0 (success scenario).
    3. Test 3 (line 116): Promise.all only contains two elements — no proc.exited.
    4. No variable for exit code exists in test 3.
    5. The fixture runs two test.concurrent calls that each invoke onTestFinished() after Bun.sleep(5), which should throw the new error and cause the child process to exit non-zero.
    6. Asserting this exit code is non-zero would make the failure scenario explicit and consistent with sibling tests.

The push/pop callback stack introduced by this PR lets onTestFinished()
resolve the calling concurrent sequence, but expect.assertions(n) and
expect.hasAssertions() have a subtler problem: the call itself succeeds
synchronously, but the matching expect(v) matchers called after the
first `await` resume with an empty callback stack and can't find the
sequence, so they increment the global counter instead of the per-test
one. The sequence then ends with expect_call_count=0 vs
expect_assertions=exact:N and fails with a spurious 'expected N
assertions, but test ended with 0 assertions' error.

Rather than accepting silent miscounting, throw a clear actionable
error at call time so the user can hoist the test out of the concurrent
group. onTestFinished() is unaffected — it registers the cleanup hook
at call time, and any post-await call already hits the updated
'call it synchronously before the first `await`' error.
The push/pop callback stack made `expect(...).toMatchSnapshot()`
succeed inside a concurrent test body when called synchronously, but
a post-`await` call in the same body would silently resolve to the
wrong sequence or miss resolution entirely. Check for a multi-sequence
concurrent group directly and throw `SnapshotInConcurrentGroup` — the
same fix I applied to `expect.assertions`/`expect.hasAssertions` —
so the behaviour is consistent regardless of stack state.

Caught by claude[bot] review on #29238.
- Execution.zig + bun_test.zig: comment said expect.assertions used the
  push/pop stack; that's wrong after the revert — only onTestFinished
  does. expect.assertions / hasAssertions / snapshot matchers now
  explicitly reject concurrent-test calls in expect.zig before the
  stack is ever consulted.
- packages/bun-types/test.d.ts: hasAssertions/assertions JSDoc now
  tells TypeScript users the functions are not supported in concurrent
  tests, mirroring the restriction documented for onTestFinished.
- test/regression/issue/29236.test.ts: three top-level tests spawn
  isolated child processes, so make them test.concurrent per
  test/CLAUDE.md.
Comment on lines +422 to +425
/// Push an entry onto the callback-execution stack. Must be paired with
/// `popCurrentCallback`. Call this immediately before invoking user JS
/// from a concurrent-safe context so nested hooks (`onTestFinished`,
/// `expect.assertions`) can recover which sequence they belong to.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The pushCurrentCallback method docstring (lines 422-425 of bun_test.zig) still lists expect.assertions as a beneficiary of the callback stack, claiming it can "recover which sequence they belong to" — but the current_callback_stack field docstring in the same struct (lines 237-246) correctly says the opposite: these functions intentionally do not use this stack and throw unconditionally instead. Commit 64eb666 fixed the identical claim in Execution.zig but left the method docstring stale, creating an internal contradiction within the same struct.

Extended reasoning...

What the bug is:

The pushCurrentCallback method docstring in bun_test.zig lines 422-425 reads:

/// Push an entry onto the callback-execution stack. Must be paired with
/// popCurrentCallback. Call this immediately before invoking user JS
/// from a concurrent-safe context so nested hooks (onTestFinished,
/// expect.assertions) can recover which sequence they belong to.

This directly contradicts the current_callback_stack field docstring in the same struct (lines 237-246), which correctly states: 'expect.assertions() / expect.hasAssertions() / snapshot matchers intentionally do not use this stack — see expect.zig for why they reject concurrent-test calls outright.'

The specific code path that demonstrates the inaccuracy:

In expect.zig, both hasAssertions() and assertions() call isInMultiSequenceConcurrentGroup(buntest) before ever consulting getCurrentStateData() or the callback stack. The throw fires unconditionally before the stack is ever accessed. expect.assertions does not 'recover which sequence it belongs to' via the stack — it doesn't use the stack at all.

Why existing code does not prevent the mismatch:

Commit 64eb666 correctly updated the Execution.zig callsite comment to say these functions 'deliberately do NOT use this stack' and updated the current_callback_stack field docstring to match. However, the pushCurrentCallback method docstring was not updated, leaving an internal contradiction: the field doc and the caller's doc both say one thing, while the method doc says the opposite.

Impact:

This is documentation-only with no runtime impact. The only harm is that a future contributor reading the pushCurrentCallback method doc in isolation could conclude that expect.assertions stack-based recovery is already working, and attempt to build on a false premise when trying to relax the restriction.

How to fix:

Change the method docstring from listing 'onTestFinished, expect.assertions' to just 'onTestFinished', analogous to what 64eb666 already applied to Execution.zig and the field docstring.

Step-by-step proof:

  1. bun_test.zig lines 422-425: pushCurrentCallback docstring says expect.assertions can recover via the stack.
  2. bun_test.zig lines 241-246: current_callback_stack field docstring says expect.assertions intentionally does NOT use this stack.
  3. expect.zig: isInMultiSequenceConcurrentGroup throws before getCurrentStateData() is called — the stack is never consulted.
  4. Commit 64eb666 fixed the equivalent statement in Execution.zig but missed the method docstring in bun_test.zig.

WebKit's Options.cpp unconditionally prints
'WARNING: ASAN interferes with JSC signal handlers...' to stderr on
ASAN builds, which leaks into the describe/test inline snapshot on
the debian-13-x64-asan-test-bun CI lane. Strip the banner with the
same split/filter pattern already used in
test/js/node/string_decoder/string-decoder.test.js and
test/cli/install/bun-install-lifecycle-scripts.test.ts so the
snapshot stays portable across release and sanitizer lanes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

onTestFinished() should work in concurrent tests (and bun-types docs should mention the current restriction)

1 participant