feat(test): implement onTestFinished hook for bun:test (#24038)

## Summary

Implements `onTestFinished()` for `bun:test`, which runs after all
`afterEach` hooks have completed.

## Implementation

- Added `onTestFinished` export to the test module in `jest.zig`
- Modified `genericHook` in `bun_test.zig` to handle `onTestFinished` as
a special case that:
  - Can only be called inside a test (not in describe blocks or preload)
  - Appends hooks at the very end of the execution sequence
- Added comprehensive tests covering basic ordering, multiple callbacks,
async callbacks, and interaction with other hooks

## Execution Order

When called inside a test:
1. Test body executes
2. `afterAll` hooks (if added inside the test)
3. `afterEach` hooks
4. `onTestFinished` hooks 

## Test Plan

-  All new tests pass with `bun bd test`
-  Tests correctly fail with `USE_SYSTEM_BUN=1` (feature not in
released version)
-  Verifies correct ordering with `afterEach`, `afterAll`, and multiple
`onTestFinished` calls
-  Tests async `onTestFinished` callbacks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: pfg <pfg@pfg.pw>
This commit is contained in:
robobun
2025-10-24 19:07:40 -07:00
committed by GitHub
parent afd125fc12
commit a3f18b9e0e
5 changed files with 184 additions and 28 deletions

View File

@@ -257,12 +257,13 @@ $ bun test --watch
Bun supports the following lifecycle hooks:
| Hook | Description |
| ------------ | --------------------------- |
| `beforeAll` | Runs once before all tests. |
| `beforeEach` | Runs before each test. |
| `afterEach` | Runs after each test. |
| `afterAll` | Runs once after all tests. |
| Hook | Description |
| ---------------- | -------------------------------------------------------- |
| `beforeAll` | Runs once before all tests. |
| `beforeEach` | Runs before each test. |
| `afterEach` | Runs after each test. |
| `afterAll` | Runs once after all tests. |
| `onTestFinished` | Runs after a test finishes, including after `afterEach`. |
These hooks can be defined inside test files, or in a separate file that is preloaded with the `--preload` flag.

View File

@@ -358,6 +358,28 @@ declare module "bun:test" {
fn: (() => void | Promise<unknown>) | ((done: (err?: unknown) => void) => void),
options?: HookOptions,
): void;
/**
* Runs a function after a test finishes, including after all afterEach hooks.
*
* This is useful for cleanup tasks that need to run at the very end of a test,
* after all other hooks have completed.
*
* Can only be called inside a test, not in describe blocks.
*
* @example
* test("my test", () => {
* onTestFinished(() => {
* // This runs after all afterEach hooks
* console.log("Test finished!");
* });
* });
*
* @param fn the function to run
*/
export function onTestFinished(
fn: (() => void | Promise<unknown>) | ((done: (err?: unknown) => void) => void),
options?: HookOptions,
): void;
/**
* Sets the default timeout for all tests in the current file. If a test specifies a timeout, it will
* override this value. The default timeout is 5000ms (5 seconds).

View File

@@ -56,6 +56,9 @@ pub const js_fns = struct {
.timeout = args.options.timeout,
};
const bunTest = bunTestRoot.getActiveFileUnlessInPreload(globalThis.bunVM()) orelse {
if (tag == .onTestFinished) {
return globalThis.throw("Cannot call {s}() in preload. It can only be called inside a test.", .{@tagName(tag)});
}
group.log("genericHook in preload", .{});
_ = try bunTestRoot.hook_scope.appendHook(bunTestRoot.gpa, tag, args.callback, cfg, .{}, .preload);
@@ -64,36 +67,49 @@ pub const js_fns = struct {
switch (bunTest.phase) {
.collection => {
if (tag == .onTestFinished) {
return globalThis.throw("Cannot call {s}() outside of a test. It can only be called inside a test.", .{@tagName(tag)});
}
_ = try bunTest.collection.active_scope.appendHook(bunTest.gpa, tag, args.callback, cfg, .{}, .collection);
return .js_undefined;
},
.execution => {
if (tag == .afterAll or tag == .afterEach) {
// allowed
const active = bunTest.getCurrentStateData();
const sequence, _ = bunTest.execution.getCurrentAndValidExecutionSequence(active) orelse {
return globalThis.throw("Cannot call {s}() here. It cannot be called inside a concurrent test. Call it inside describe() instead.", .{@tagName(tag)});
};
var append_point = sequence.active_entry;
const active = bunTest.getCurrentStateData();
const sequence, _ = bunTest.execution.getCurrentAndValidExecutionSequence(active) orelse {
const message = if (tag == .onTestFinished)
"Cannot call {s}() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent."
else
"Cannot call {s}() here. It cannot be called inside a concurrent test. Call it inside describe() instead.";
return globalThis.throw(message, .{@tagName(tag)});
};
var iter = append_point;
const before_test_entry = while (iter) |entry| : (iter = entry.next) {
if (entry == sequence.test_entry) break true;
} else false;
const append_point = switch (tag) {
.afterAll, .afterEach => blk: {
var iter = sequence.active_entry;
while (iter) |entry| : (iter = entry.next) {
if (entry == sequence.test_entry) break :blk sequence.test_entry.?;
}
if (before_test_entry) append_point = sequence.test_entry;
break :blk sequence.active_entry orelse return globalThis.throw("Cannot call {s}() here. Call it inside describe() instead.", .{@tagName(tag)});
},
.onTestFinished => blk: {
// Find the last entry in the sequence
var last_entry = sequence.active_entry orelse return globalThis.throw("Cannot call {s}() here. Call it inside a test instead.", .{@tagName(tag)});
while (last_entry.next) |next_entry| {
last_entry = next_entry;
}
break :blk last_entry;
},
else => return globalThis.throw("Cannot call {s}() inside a test. Call it inside describe() instead.", .{@tagName(tag)}),
};
const append_point_value = append_point orelse return globalThis.throw("Cannot call {s}() here. Call it inside describe() instead.", .{@tagName(tag)});
const new_item = ExecutionEntry.create(bunTest.gpa, null, args.callback, cfg, null, .{}, .execution);
new_item.next = append_point.next;
append_point.next = new_item;
bun.handleOom(bunTest.extra_execution_entries.append(new_item));
const new_item = ExecutionEntry.create(bunTest.gpa, null, args.callback, cfg, null, .{}, .execution);
new_item.next = append_point_value.next;
append_point_value.next = new_item;
bun.handleOom(bunTest.extra_execution_entries.append(new_item));
return .js_undefined;
}
return globalThis.throw("Cannot call {s}() inside a test. Call it inside describe() instead.", .{@tagName(tag)});
return .js_undefined;
},
.done => return globalThis.throw("Cannot call {s}() after the test run has completed", .{@tagName(tag)}),
}

View File

@@ -163,7 +163,7 @@ pub const Jest = struct {
}
pub fn createTestModule(globalObject: *JSGlobalObject) bun.JSError!JSValue {
const module = JSValue.createEmptyObject(globalObject, 19);
const module = JSValue.createEmptyObject(globalObject, 20);
const test_scope_functions = try bun_test.ScopeFunctions.createBound(globalObject, .@"test", .zero, .{}, bun_test.ScopeFunctions.strings.@"test");
module.put(globalObject, ZigString.static("test"), test_scope_functions);
@@ -183,6 +183,7 @@ pub const Jest = struct {
module.put(globalObject, ZigString.static("beforeAll"), jsc.host_fn.NewFunction(globalObject, ZigString.static("beforeAll"), 1, bun_test.js_fns.genericHook(.beforeAll).hookFn, false));
module.put(globalObject, ZigString.static("afterAll"), jsc.host_fn.NewFunction(globalObject, ZigString.static("afterAll"), 1, bun_test.js_fns.genericHook(.afterAll).hookFn, false));
module.put(globalObject, ZigString.static("afterEach"), jsc.host_fn.NewFunction(globalObject, ZigString.static("afterEach"), 1, bun_test.js_fns.genericHook(.afterEach).hookFn, false));
module.put(globalObject, ZigString.static("onTestFinished"), jsc.host_fn.NewFunction(globalObject, ZigString.static("onTestFinished"), 1, bun_test.js_fns.genericHook(.onTestFinished).hookFn, false));
module.put(globalObject, ZigString.static("setDefaultTimeout"), jsc.host_fn.NewFunction(globalObject, ZigString.static("setDefaultTimeout"), 1, jsSetDefaultTimeout, false));
module.put(globalObject, ZigString.static("expect"), Expect.js.getConstructor(globalObject));
module.put(globalObject, ZigString.static("expectTypeOf"), ExpectTypeOf.js.getConstructor(globalObject));

View File

@@ -0,0 +1,116 @@
import { afterAll, afterEach, describe, expect, onTestFinished, test } from "bun:test";
// Test the basic ordering of onTestFinished
describe("onTestFinished ordering", () => {
const output: string[] = [];
afterEach(() => {
output.push("afterEach");
});
test("test 1", () => {
afterAll(() => {
output.push("inner afterAll");
});
onTestFinished(() => {
output.push("onTestFinished");
});
output.push("test 1");
});
test("test 2", () => {
// After test 2 starts, verify the order from test 1
expect(output).toEqual(["test 1", "inner afterAll", "afterEach", "onTestFinished"]);
});
});
// Test multiple onTestFinished calls
describe("multiple onTestFinished", () => {
const output: string[] = [];
afterEach(() => {
output.push("afterEach");
});
test("test with multiple onTestFinished", () => {
onTestFinished(() => {
output.push("onTestFinished 1");
});
onTestFinished(() => {
output.push("onTestFinished 2");
});
output.push("test");
});
test("verify order", () => {
expect(output).toEqual(["test", "afterEach", "onTestFinished 1", "onTestFinished 2"]);
});
});
// Test onTestFinished with async callbacks
describe("async onTestFinished", () => {
const output: string[] = [];
afterEach(() => {
output.push("afterEach");
});
test("async onTestFinished", async () => {
onTestFinished(async () => {
await new Promise(resolve => setTimeout(resolve, 1));
output.push("onTestFinished async");
});
output.push("test");
});
test("verify async order", () => {
expect(output).toEqual(["test", "afterEach", "onTestFinished async"]);
});
});
// Test that onTestFinished throws proper error in concurrent tests
describe("onTestFinished errors", () => {
test.concurrent("cannot be called in concurrent test 1", () => {
expect(() => {
onTestFinished(() => {
console.log("should not run");
});
}).toThrow(
"Cannot call onTestFinished() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent.",
);
});
test.concurrent("cannot be called in concurrent test 2", () => {
expect(() => {
onTestFinished(() => {
console.log("should not run");
});
}).toThrow(
"Cannot call onTestFinished() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent.",
);
});
});
// Test onTestFinished with afterEach and afterAll together
describe("onTestFinished with all hooks", () => {
const output: string[] = [];
afterEach(() => {
output.push("afterEach");
});
test("test with all hooks", () => {
afterAll(() => {
output.push("inner afterAll");
});
onTestFinished(() => {
output.push("onTestFinished");
});
output.push("test");
});
test("verify complete order", () => {
// Expected order: test body, inner afterAll, afterEach, onTestFinished
expect(output).toEqual(["test", "inner afterAll", "afterEach", "onTestFinished"]);
});
});