diff --git a/docs/cli/test.md b/docs/cli/test.md index f266166a59..476b7c5a39 100644 --- a/docs/cli/test.md +++ b/docs/cli/test.md @@ -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. diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index ca5aa18aea..e37c1b0fc7 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -358,6 +358,28 @@ declare module "bun:test" { fn: (() => void | Promise) | ((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) | ((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). diff --git a/src/bun.js/test/bun_test.zig b/src/bun.js/test/bun_test.zig index 61bae4e157..34fd701ccb 100644 --- a/src/bun.js/test/bun_test.zig +++ b/src/bun.js/test/bun_test.zig @@ -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)}), } diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index 0f34d3daa5..6639cd5e5a 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -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)); diff --git a/test/js/bun/test/test-on-test-finished.test.ts b/test/js/bun/test/test-on-test-finished.test.ts new file mode 100644 index 0000000000..e97a24a2e4 --- /dev/null +++ b/test/js/bun/test/test-on-test-finished.test.ts @@ -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"]); + }); +});