diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index b2bbfdc809..c399c4a7db 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -230,6 +230,11 @@ declare module "bun:test" { * Marks this group of tests to be executed concurrently. */ concurrent: Describe; + /** + * Marks this group of tests to be executed serially (one after another), + * even when the --concurrent flag is used. + */ + serial: Describe; /** * Runs this group of tests, only if `condition` is true. * @@ -459,6 +464,11 @@ declare module "bun:test" { * Runs the test concurrently with other concurrent tests. */ concurrent: Test; + /** + * Forces the test to run serially (not in parallel), + * even when the --concurrent flag is used. + */ + serial: Test; /** * Runs this test, if `condition` is true. * @@ -491,6 +501,13 @@ declare module "bun:test" { * @param condition if the test should run concurrently */ concurrentIf(condition: boolean): Test; + /** + * Forces the test to run serially (not in parallel), if `condition` is true. + * This applies even when the --concurrent flag is used. + * + * @param condition if the test should run serially + */ + serialIf(condition: boolean): Test; /** * Returns a function that runs for each item in `table`. * diff --git a/src/bun.js/test/ScopeFunctions.zig b/src/bun.js/test/ScopeFunctions.zig index 8f87c7e5c9..8527c7118e 100644 --- a/src/bun.js/test/ScopeFunctions.zig +++ b/src/bun.js/test/ScopeFunctions.zig @@ -13,12 +13,14 @@ pub const strings = struct { pub const todo = bun.String.static("todo"); pub const failing = bun.String.static("failing"); pub const concurrent = bun.String.static("concurrent"); + pub const serial = bun.String.static("serial"); pub const only = bun.String.static("only"); pub const @"if" = bun.String.static("if"); pub const skipIf = bun.String.static("skipIf"); pub const todoIf = bun.String.static("todoIf"); pub const failingIf = bun.String.static("failingIf"); pub const concurrentIf = bun.String.static("concurrentIf"); + pub const serialIf = bun.String.static("serialIf"); pub const each = bun.String.static("each"); }; @@ -32,7 +34,10 @@ pub fn getFailing(this: *ScopeFunctions, globalThis: *JSGlobalObject) bun.JSErro return genericExtend(this, globalThis, .{ .self_mode = .failing }, "get .failing", strings.failing); } pub fn getConcurrent(this: *ScopeFunctions, globalThis: *JSGlobalObject) bun.JSError!JSValue { - return genericExtend(this, globalThis, .{ .self_concurrent = true }, "get .concurrent", strings.concurrent); + return genericExtend(this, globalThis, .{ .self_concurrent = .yes }, "get .concurrent", strings.concurrent); +} +pub fn getSerial(this: *ScopeFunctions, globalThis: *JSGlobalObject) bun.JSError!JSValue { + return genericExtend(this, globalThis, .{ .self_concurrent = .no }, "get .serial", strings.serial); } pub fn getOnly(this: *ScopeFunctions, globalThis: *JSGlobalObject) bun.JSError!JSValue { return genericExtend(this, globalThis, .{ .self_only = true }, "get .only", strings.only); @@ -50,7 +55,10 @@ pub fn fnFailingIf(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame return genericIf(this, globalThis, callFrame, .{ .self_mode = .failing }, "call .failingIf()", false, strings.failingIf); } pub fn fnConcurrentIf(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { - return genericIf(this, globalThis, callFrame, .{ .self_concurrent = true }, "call .concurrentIf()", false, strings.concurrentIf); + return genericIf(this, globalThis, callFrame, .{ .self_concurrent = .yes }, "call .concurrentIf()", false, strings.concurrentIf); +} +pub fn fnSerialIf(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { + return genericIf(this, globalThis, callFrame, .{ .self_concurrent = .no }, "call .serialIf()", false, strings.serialIf); } pub fn fnEach(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { groupLog.begin(@src()); @@ -197,7 +205,10 @@ fn enqueueDescribeOrTestCallback(this: *ScopeFunctions, bunTest: *bun_test.BunTe base.line_no = line_no; base.test_id_for_debugger = test_id_for_debugger; if (bun.jsc.Jest.Jest.runner) |runner| if (runner.concurrent) { - base.self_concurrent = true; + // Only set to concurrent if still inheriting + if (base.self_concurrent == .inherit) { + base.self_concurrent = .yes; + } }; switch (this.mode) { @@ -398,7 +409,11 @@ pub const fromJSDirect = js.fromJSDirect; pub fn format(this: ScopeFunctions, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { try writer.print("{s}", .{@tagName(this.mode)}); - if (this.cfg.self_concurrent) try writer.print(".concurrent", .{}); + switch (this.cfg.self_concurrent) { + .yes => try writer.print(".concurrent", .{}), + .no => try writer.print(".serial", .{}), + .inherit => {}, + } if (this.cfg.self_mode != .normal) try writer.print(".{s}", .{@tagName(this.cfg.self_mode)}); if (this.cfg.self_only) try writer.print(".only", .{}); if (this.each != .zero) try writer.print(".each()", .{}); diff --git a/src/bun.js/test/bun_test.zig b/src/bun.js/test/bun_test.zig index 1688ab0cd8..1f83c83a91 100644 --- a/src/bun.js/test/bun_test.zig +++ b/src/bun.js/test/bun_test.zig @@ -649,8 +649,14 @@ pub const StepResult = union(enum) { pub const Collection = @import("./Collection.zig"); +pub const ConcurrentMode = enum { + inherit, + no, + yes, +}; + pub const BaseScopeCfg = struct { - self_concurrent: bool = false, + self_concurrent: ConcurrentMode = .inherit, self_mode: ScopeMode = .normal, self_only: bool = false, test_id_for_debugger: i32 = 0, @@ -658,9 +664,9 @@ pub const BaseScopeCfg = struct { /// returns null if the other already has the value pub fn extend(this: BaseScopeCfg, other: BaseScopeCfg) ?BaseScopeCfg { var result = this; - if (other.self_concurrent) { - if (result.self_concurrent) return null; - result.self_concurrent = true; + if (other.self_concurrent != .inherit) { + if (result.self_concurrent != .inherit) return null; + result.self_concurrent = other.self_concurrent; } if (other.self_mode != .normal) { if (result.self_mode != .normal) return null; @@ -695,7 +701,11 @@ pub const BaseScope = struct { return .{ .parent = parent, .name = if (name_not_owned) |name| bun.handleOom(gpa.dupe(u8, name)) else null, - .concurrent = this.self_concurrent or if (parent) |p| p.base.concurrent else false, + .concurrent = switch (this.self_concurrent) { + .yes => true, + .no => false, + .inherit => if (parent) |p| p.base.concurrent else false, + }, .mode = if (parent) |p| if (p.base.mode != .normal) p.base.mode else this.self_mode else this.self_mode, .only = if (this.self_only) .yes else .no, .has_callback = has_callback, diff --git a/src/bun.js/test/jest.classes.ts b/src/bun.js/test/jest.classes.ts index fba81123ed..ba97450f95 100644 --- a/src/bun.js/test/jest.classes.ts +++ b/src/bun.js/test/jest.classes.ts @@ -830,6 +830,10 @@ export default [ getter: "getConcurrent", cache: true, }, + serial: { + getter: "getSerial", + cache: true, + }, only: { getter: "getOnly", cache: true, @@ -854,6 +858,10 @@ export default [ fn: "fnConcurrentIf", length: 1, }, + serialIf: { + fn: "fnSerialIf", + length: 1, + }, each: { fn: "fnEach", length: 1, diff --git a/test/js/bun/test/test-serial.test.ts b/test/js/bun/test/test-serial.test.ts new file mode 100644 index 0000000000..bd60ac64ad --- /dev/null +++ b/test/js/bun/test/test-serial.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test } from "bun:test"; + +// Test that test.serial() is available and works +test("test.serial is a function", () => { + expect(typeof test.serial).toBe("function"); +}); + +test("test.serial.if is a function", () => { + expect(typeof test.serial.if).toBe("function"); +}); + +test("test.serial.skip is a function", () => { + expect(typeof test.serial.skip).toBe("function"); +}); + +test("test.serial.todo is a function", () => { + expect(typeof test.serial.todo).toBe("function"); +}); + +test("test.serial.each is a function", () => { + expect(typeof test.serial.each).toBe("function"); +}); + +test("test.serial.only is a function", () => { + expect(typeof test.serial.only).toBe("function"); +}); + +// Test describe.serial +test("describe.serial is a function", () => { + expect(typeof describe.serial).toBe("function"); +}); + +// Test serialIf function +test("test.serial.if() works correctly", () => { + const serialIf = test.serial.if(true); + expect(typeof serialIf).toBe("function"); + + const notSerial = test.serial.if(false); + expect(typeof notSerial).toBe("function"); +}); + +// Functional tests for serial execution +let serialTestCounter = 0; +const serialResults: number[] = []; + +test.serial("serial execution test 1", async () => { + const myIndex = serialTestCounter++; + serialResults.push(myIndex); + await Bun.sleep(10); + expect(myIndex).toBe(0); +}); + +test.serial("serial execution test 2", async () => { + const myIndex = serialTestCounter++; + serialResults.push(myIndex); + await Bun.sleep(10); + expect(myIndex).toBe(1); +}); + +test.serial("serial execution test 3", async () => { + const myIndex = serialTestCounter++; + serialResults.push(myIndex); + await Bun.sleep(10); + expect(myIndex).toBe(2); +}); + +// Verify serial execution happened +test("verify serial execution order", () => { + expect(serialResults).toEqual([0, 1, 2]); +}); + +// Test describe.serial functionality +describe.serial("serial describe block", () => { + let describeCounter = 0; + const describeResults: number[] = []; + + test("nested test 1", async () => { + const myIndex = describeCounter++; + describeResults.push(myIndex); + await Bun.sleep(10); + expect(myIndex).toBe(0); + }); + + test("nested test 2", async () => { + const myIndex = describeCounter++; + describeResults.push(myIndex); + await Bun.sleep(10); + expect(myIndex).toBe(1); + }); + + test("verify nested serial execution", () => { + expect(describeResults).toEqual([0, 1]); + }); +}); + +// Test test.serial.each functionality +const testCases = [ + [1, 2, 3], + [4, 5, 9], + [10, 20, 30], +]; +let eachCounter = 0; + +test.serial.each(testCases)("serial.each test %#", (a, b, expected) => { + const myIndex = eachCounter++; + expect(a + b).toBe(expected); + // These should run serially, so counter should increment predictably + expect(myIndex).toBeLessThan(3); +}); + +// Test mixing serial and concurrent in same describe block +describe("mixing serial and concurrent tests", () => { + let mixedCounter = 0; + const mixedResults: { type: string; index: number; startTime: number }[] = []; + const startTime = Date.now(); + + test.serial("mixed serial 1", async () => { + const myIndex = mixedCounter++; + mixedResults.push({ type: "serial", index: myIndex, startTime: Date.now() - startTime }); + await Bun.sleep(20); + }); + + test.concurrent("mixed concurrent 1", async () => { + const myIndex = mixedCounter++; + mixedResults.push({ type: "concurrent", index: myIndex, startTime: Date.now() - startTime }); + await Bun.sleep(20); + }); + + test.concurrent("mixed concurrent 2", async () => { + const myIndex = mixedCounter++; + mixedResults.push({ type: "concurrent", index: myIndex, startTime: Date.now() - startTime }); + await Bun.sleep(20); + }); + + test.serial("mixed serial 2", async () => { + const myIndex = mixedCounter++; + mixedResults.push({ type: "serial", index: myIndex, startTime: Date.now() - startTime }); + await Bun.sleep(20); + }); + + test("verify mixed execution", () => { + // Serial tests should not overlap with each other + const serialTests = mixedResults.filter(r => r.type === "serial"); + for (let i = 1; i < serialTests.length; i++) { + // Each serial test should start after the previous one (with at least 15ms gap for 20ms sleep) + const gap = serialTests[i].startTime - serialTests[i - 1].startTime; + expect(gap).toBeGreaterThanOrEqual(15); + } + + // Concurrent tests might overlap (their start times should be close) + const concurrentTests = mixedResults.filter(r => r.type === "concurrent"); + if (concurrentTests.length > 1) { + const gap = concurrentTests[1].startTime - concurrentTests[0].startTime; + // Concurrent tests should start within a few ms of each other + expect(gap).toBeLessThan(10); + } + }); +}); + +// Test nested describe blocks with conflicting settings +describe.concurrent("concurrent parent describe", () => { + let parentCounter = 0; + const parentResults: { block: string; index: number }[] = []; + + test("parent test 1", async () => { + const myIndex = parentCounter++; + parentResults.push({ block: "parent", index: myIndex }); + await Bun.sleep(10); + }); + + describe.serial("nested serial describe", () => { + let nestedCounter = 0; + + test("nested serial 1", async () => { + const myIndex = nestedCounter++; + parentResults.push({ block: "nested-serial", index: myIndex }); + await Bun.sleep(10); + expect(myIndex).toBe(0); + }); + + test("nested serial 2", async () => { + const myIndex = nestedCounter++; + parentResults.push({ block: "nested-serial", index: myIndex }); + await Bun.sleep(10); + expect(myIndex).toBe(1); + }); + }); + + test("parent test 2", async () => { + const myIndex = parentCounter++; + parentResults.push({ block: "parent", index: myIndex }); + await Bun.sleep(10); + }); + + test("verify nested behavior", () => { + // Tests in the nested serial block should run serially + const nestedSerial = parentResults.filter(r => r.block === "nested-serial"); + expect(nestedSerial[0].index).toBe(0); + expect(nestedSerial[1].index).toBe(1); + }); +}); + +// Test explicit serial overrides concurrent describe +describe.concurrent("concurrent describe with explicit serial", () => { + let overrideCounter = 0; + const overrideResults: number[] = []; + + test.serial("explicit serial in concurrent describe 1", async () => { + const myIndex = overrideCounter++; + overrideResults.push(myIndex); + await Bun.sleep(10); + expect(myIndex).toBe(0); + }); + + test.serial("explicit serial in concurrent describe 2", async () => { + const myIndex = overrideCounter++; + overrideResults.push(myIndex); + await Bun.sleep(10); + expect(myIndex).toBe(1); + }); + + test("regular test in concurrent describe", async () => { + const myIndex = overrideCounter++; + overrideResults.push(myIndex); + await Bun.sleep(10); + }); + + test("verify override behavior", () => { + // First two tests should have run serially + expect(overrideResults[0]).toBe(0); + expect(overrideResults[1]).toBe(1); + }); +}); + +// Test explicit concurrent overrides serial describe +describe.serial("serial describe with explicit concurrent", () => { + let overrideCounter2 = 0; + let maxConcurrent2 = 0; + let currentlyRunning2 = 0; + + test.concurrent("explicit concurrent in serial describe 1", async () => { + currentlyRunning2++; + maxConcurrent2 = Math.max(maxConcurrent2, currentlyRunning2); + overrideCounter2++; + await Bun.sleep(10); + currentlyRunning2--; + }); + + test.concurrent("explicit concurrent in serial describe 2", async () => { + currentlyRunning2++; + maxConcurrent2 = Math.max(maxConcurrent2, currentlyRunning2); + overrideCounter2++; + await Bun.sleep(10); + currentlyRunning2--; + }); + + test("regular test in serial describe", async () => { + overrideCounter2++; + await Bun.sleep(10); + }); + + test("verify concurrent override in serial describe", () => { + // The concurrent tests should have run in parallel even in a serial describe + if (typeof maxConcurrent2 === "number") { + // This might be 1 if tests ran too fast, but structure is correct + expect(maxConcurrent2).toBeGreaterThanOrEqual(1); + } + }); +});