diff --git a/src/bun.js/ConsoleObject.zig b/src/bun.js/ConsoleObject.zig index 7ad178704fe..2855787868c 100644 --- a/src/bun.js/ConsoleObject.zig +++ b/src/bun.js/ConsoleObject.zig @@ -168,7 +168,7 @@ fn messageWithTypeAndLevel_( const Writer = @TypeOf(writer); if (bun.jsc.Jest.Jest.runner) |runner| { - runner.bun_test_root.onBeforePrint(); + runner.bun_test_root.onBeforePrint(null); } var print_length = len; diff --git a/src/bun.js/test/bun_test.zig b/src/bun.js/test/bun_test.zig index c8879d623e0..b44ec3aeed1 100644 --- a/src/bun.js/test/bun_test.zig +++ b/src/bun.js/test/bun_test.zig @@ -125,6 +125,10 @@ pub const BunTestPtr = bun.ptr.shared.WithOptions(*BunTest, .{ pub const BunTestRoot = struct { gpa: std.mem.Allocator, active_file: BunTestPtr.Optional, + /// Set during BunTest.run() so that describe callbacks in parallel mode + /// can find their BunTest via cloneActiveFile() even after active_file + /// has been detached. + running_file: BunTestPtr.Optional = .initNull(), hook_scope: *DescribeScope, @@ -170,15 +174,30 @@ pub const BunTestRoot = struct { this.active_file.deinit(); this.active_file = .initNull(); } + /// Detach the active file without nullifying its reporter. + /// Used for file parallelism: the file is removed from the active slot + /// (allowing another file to be loaded) but keeps its reporter alive + /// so that test results can still be reported while execution continues. + pub fn detachFile(this: *BunTestRoot) void { + group.begin(@src()); + defer group.end(); + + bun.assert(this.active_file.get() != null); + this.active_file.deinit(); + this.active_file = .initNull(); + } pub fn getActiveFileUnlessInPreload(this: *BunTestRoot, vm: *jsc.VirtualMachine) ?*BunTest { if (vm.is_in_preload) { return null; } - return this.active_file.get(); + return this.active_file.get() orelse this.running_file.get(); } pub fn cloneActiveFile(this: *BunTestRoot) ?BunTestPtr { var clone = this.active_file.clone(); - return clone.take(); + if (clone.take()) |ptr| return ptr; + // Fallback: check running_file (set during BunTest.run() for parallel mode) + var running_clone = this.running_file.clone(); + return running_clone.take(); } pub const FirstLast = struct { @@ -186,17 +205,27 @@ pub const BunTestRoot = struct { last: bool, }; - pub fn onBeforePrint(this: *BunTestRoot) void { - if (this.active_file.get()) |active_file| { - if (active_file.reporter) |reporter| { - if (reporter.reporters.dots and reporter.last_printed_dot) { - bun.Output.prettyError("\n", .{}); - bun.Output.flush(); - reporter.last_printed_dot = false; - } - if (bun.jsc.Jest.Jest.runner) |runner| { - runner.current_file.printIfNeeded(); + pub fn onBeforePrint(this: *BunTestRoot, buntest: ?*BunTest) void { + const file = buntest orelse if (this.active_file.get()) |af| af else return; + if (file.reporter) |reporter| { + if (reporter.reporters.dots and reporter.last_printed_dot) { + bun.Output.prettyError("\n", .{}); + bun.Output.flush(); + reporter.last_printed_dot = false; + } + if (bun.jsc.Jest.Jest.runner) |runner| { + // If the file has changed (or this is the first print), update the current_file header + if (runner.current_file_id == null or runner.current_file_id.? != file.file_id) { + const file_path = runner.files.items(.source)[file.file_id].path.text; + const file_title = bun.path.relative(bun.fs.FileSystem.instance.top_level_dir, file_path); + const file_prefix: []const u8 = if (bun.Output.is_github_action) "::group::" else ""; + runner.current_file.has_printed_filename = false; + runner.current_file.freeAndClear(); + runner.current_file.title = bun.handleOom(bun.default_allocator.dupe(u8, file_title)); + runner.current_file.prefix = bun.handleOom(bun.default_allocator.dupe(u8, file_prefix)); + runner.current_file_id = file.file_id; } + runner.current_file.printIfNeeded(); } } } @@ -531,6 +560,16 @@ pub const BunTest = struct { this.in_run_loop = true; defer this.in_run_loop = false; + // Make this BunTest findable via cloneActiveFile() during collection, + // even if active_file has been detached (parallel mode). + const prev_running = this.bun_test_root.running_file; + var cloned = this_strong.clone(); + this.bun_test_root.running_file = cloned.toOptional(); + defer { + this.bun_test_root.running_file.deinit(); + this.bun_test_root.running_file = prev_running; + } + var min_timeout: bun.timespec = .epoch; while (this.result_queue.readItem()) |result| { @@ -732,7 +771,7 @@ pub const BunTest = struct { if (handle_status == .hide_error) return; // do not print error, it was already consumed if (exception == null) return; // the exception should not be visible (eg m_terminationException) - this.bun_test_root.onBeforePrint(); + this.bun_test_root.onBeforePrint(this); if (handle_status == .show_unhandled_error_between_tests or handle_status == .show_unhandled_error_in_describe) { this.reporter.?.jest.unhandled_errors_between_tests += 1; bun.Output.prettyErrorln( diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index 59765900b14..e2503e02bee 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -29,7 +29,7 @@ const CurrentFile = struct { print(title, prefix, repeat_count, repeat_index); } - fn freeAndClear(this: *CurrentFile) void { + pub fn freeAndClear(this: *CurrentFile) void { bun.default_allocator.free(this.title); bun.default_allocator.free(this.prefix); } @@ -63,6 +63,7 @@ const CurrentFile = struct { pub const TestRunner = struct { current_file: CurrentFile = CurrentFile{}, + current_file_id: ?File.ID = null, files: File.List = .{}, index: File.Map = File.Map{}, only: bool = false, diff --git a/src/cli.zig b/src/cli.zig index e4b255587f8..335344a46db 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -357,6 +357,7 @@ pub const Command = struct { test_filter_pattern: ?[]const u8 = null, test_filter_regex: ?*RegularExpression = null, max_concurrency: u32 = 20, + file_parallelism: u32 = 1, reporters: struct { dots: bool = false, diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index a9953bbd323..dd88a86c77a 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -241,6 +241,7 @@ pub const test_only_params = [_]ParamType{ clap.parseParam("--dots Enable dots reporter. Shorthand for --reporter=dots.") catch unreachable, clap.parseParam("--only-failures Only display test failures, hiding passing tests.") catch unreachable, clap.parseParam("--max-concurrency Maximum number of concurrent tests to execute at once. Default is 20.") catch unreachable, + clap.parseParam("--file-parallelism Number of test files to run in parallel. Default is 1 (sequential).") catch unreachable, clap.parseParam("--path-ignore-patterns ... Glob patterns for test file paths to ignore.") catch unreachable, }; pub const test_params = test_only_params ++ runtime_params_ ++ transpiler_params_ ++ base_params_; @@ -497,6 +498,19 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C } } + if (args.option("--file-parallelism")) |file_parallelism| { + if (file_parallelism.len > 0) { + ctx.test_options.file_parallelism = std.fmt.parseInt(u32, file_parallelism, 10) catch { + Output.prettyErrorln("error: Invalid file-parallelism: \"{s}\"", .{file_parallelism}); + Global.exit(1); + }; + if (ctx.test_options.file_parallelism == 0) { + Output.prettyErrorln("error: --file-parallelism must be greater than 0", .{}); + Global.exit(1); + } + } + } + if (!ctx.test_options.coverage.enabled) { ctx.test_options.coverage.enabled = args.flag("--coverage"); } diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 5e81bf0a542..24f8cc2ea8a 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -895,7 +895,7 @@ pub const CommandLineReporter = struct { } else if (((comptime result.basicResult()) != .fail) and (buntest.reporter != null and buntest.reporter.?.reporters.only_failures)) { // when using --only-failures, only print failures } else { - buntest.bun_test_root.onBeforePrint(); + buntest.bun_test_root.onBeforePrint(buntest); writeTestStatusLine(result, &writer); const dim = switch (comptime result.basicResult()) { @@ -1824,35 +1824,248 @@ pub const TestCommand = struct { files_: []const PathString, allocator_: std.mem.Allocator, ) void { - const Context = struct { - reporter: *CommandLineReporter, - vm: *jsc.VirtualMachine, - files: []const PathString, - allocator: std.mem.Allocator, - pub fn begin(this: *@This()) void { - const reporter = this.reporter; - const vm = this.vm; - var files = this.files; - bun.assert(files.len > 0); - - if (files.len > 1) { - for (files[0 .. files.len - 1], 0..) |file_name, i| { - TestCommand.run(reporter, vm, file_name.slice(), .{ .first = i == 0, .last = false }) catch |err| handleTopLevelTestErrorBeforeJavaScriptStart(err); - reporter.jest.default_timeout_override = std.math.maxInt(u32); - Global.mimalloc_cleanup(false); - } - } - - TestCommand.run(reporter, vm, files[files.len - 1].slice(), .{ .first = files.len == 1, .last = true }) catch |err| handleTopLevelTestErrorBeforeJavaScriptStart(err); - } - }; + const file_parallelism = reporter_.jest.test_options.file_parallelism; var arena = bun.MimallocArena.init(); vm_.eventLoop().ensureWaker(); vm_.arena = &arena; vm_.allocator = arena.allocator(); - var ctx = Context{ .reporter = reporter_, .vm = vm_, .files = files_, .allocator = allocator_ }; - vm_.runWithAPILock(Context, &ctx, Context.begin); + + if (file_parallelism <= 1 or reporter_.repeat_count > 1) { + // Sequential mode (default): run files one at a time + const SeqContext = struct { + reporter: *CommandLineReporter, + vm: *jsc.VirtualMachine, + files: []const PathString, + allocator: std.mem.Allocator, + pub fn begin(this: *@This()) void { + const reporter = this.reporter; + const vm = this.vm; + var files = this.files; + bun.assert(files.len > 0); + + if (files.len > 1) { + for (files[0 .. files.len - 1], 0..) |file_name, i| { + TestCommand.run(reporter, vm, file_name.slice(), .{ .first = i == 0, .last = false }) catch |err| handleTopLevelTestErrorBeforeJavaScriptStart(err); + reporter.jest.default_timeout_override = std.math.maxInt(u32); + Global.mimalloc_cleanup(false); + } + } + + TestCommand.run(reporter, vm, files[files.len - 1].slice(), .{ .first = files.len == 1, .last = true }) catch |err| handleTopLevelTestErrorBeforeJavaScriptStart(err); + } + }; + + var ctx = SeqContext{ .reporter = reporter_, .vm = vm_, .files = files_, .allocator = allocator_ }; + vm_.runWithAPILock(SeqContext, &ctx, SeqContext.begin); + } else { + // Parallel mode: run multiple files concurrently via the event loop + const ParContext = struct { + reporter: *CommandLineReporter, + vm: *jsc.VirtualMachine, + files: []const PathString, + allocator: std.mem.Allocator, + parallelism: u32, + + pub fn begin(this: *@This()) void { + this.runParallel() catch |err| handleTopLevelTestErrorBeforeJavaScriptStart(err); + } + + fn runParallel(this: *@This()) !void { + const reporter = this.reporter; + const vm = this.vm; + const files = this.files; + bun.assert(files.len > 0); + + const parallelism = @min(this.parallelism, @as(u32, @truncate(files.len))); + + // Track running files with strong references + var running_files: std.ArrayListUnmanaged(bun_test.BunTestPtr) = .{}; + defer { + for (running_files.items) |*strong| { + strong.get().reporter = null; + strong.deinit(); + } + running_files.deinit(this.allocator); + } + + var file_index: usize = 0; + const bun_test_root = &jest.Jest.runner.?.bun_test_root; + + // Save the initial timeout so we can restore it between files, + // preventing one file's jest.setTimeout() from leaking to others. + const initial_timeout_override = reporter.jest.default_timeout_override; + + while (file_index < files.len or running_files.items.len > 0) { + // Load files up to parallelism limit + while (running_files.items.len < parallelism and file_index < files.len) { + const is_first = file_index == 0; + const is_last = file_index == files.len - 1; + const file_name = files[file_index].slice(); + file_index += 1; + + // Restore timeout before loading each file so a previous + // file's jest.setTimeout() doesn't affect new files. + reporter.jest.default_timeout_override = initial_timeout_override; + + const buntest_strong = loadAndStartFile( + reporter, + vm, + bun_test_root, + file_name, + .{ .first = is_first, .last = is_last }, + ) catch |err| { + if (err == error.ModuleNotFound) { + // Already reported via unhandledRejection; skip this file. + continue; + } + return err; + }; + + bun.handleOom(running_files.append(this.allocator, buntest_strong)); + } + + if (running_files.items.len == 0) break; + + // Process event loop + vm.eventLoop().tick(); + + // Check for wakeup requests from any running file + var any_wants_wakeup = false; + for (running_files.items) |strong| { + const buntest = strong.get(); + if (buntest.wants_wakeup) { + buntest.wants_wakeup = false; + any_wants_wakeup = true; + } + } + if (any_wants_wakeup) { + vm.wakeup(); + } + + vm.eventLoop().autoTick(); + vm.eventLoop().tick(); + + // Handle unhandled rejections + vm.global.handleRejectedPromises(); + + // Remove completed files + var i: usize = 0; + while (i < running_files.items.len) { + const buntest = running_files.items[i].get(); + if (buntest.phase == .done) { + vm.eventLoop().tickImmediateTasks(vm); + + if (Output.is_github_action) { + Output.prettyErrorln("\n::endgroup::\n", .{}); + Output.flush(); + } + + vm.auto_killer.clear(); + vm.auto_killer.disable(); + + reporter.jest.default_timeout_override = initial_timeout_override; + Global.mimalloc_cleanup(false); + + var strong = running_files.orderedRemove(i); + strong.get().reporter = null; + strong.deinit(); + } else { + i += 1; + } + } + } + } + + /// Load a single test file, start its test execution, and return a strong reference. + /// The file is detached from active_file after loading, allowing another file to be loaded next. + fn loadAndStartFile( + reporter: *CommandLineReporter, + vm: *jsc.VirtualMachine, + bun_test_root: *bun_test.BunTestRoot, + file_name: string, + first_last: bun_test.BunTestRoot.FirstLast, + ) !bun_test.BunTestPtr { + js_ast.Expr.Data.Store.reset(); + js_ast.Stmt.Data.Store.reset(); + + const resolution = try vm.transpiler.resolveEntryPoint(file_name); + try vm.clearEntryPoint(); + + const file_path = bun.handleOom(bun.fs.FileSystem.instance.filename_store.append([]const u8, resolution.path_pair.primary.text)); + const file_id = jest.Jest.runner.?.getOrPutFile(file_path).file_id; + + vm.onUnhandledRejectionCtx = null; + vm.onUnhandledRejection = jest.on_unhandled_rejection.onUnhandledRejection; + + const should_run_concurrent = reporter.jest.shouldFileRunConcurrently(file_id); + bun_test_root.enterFile(file_id, reporter, should_run_concurrent, first_last); + + // Don't call current_file.set() here; onBeforePrint will handle + // printing file headers when test results arrive, which correctly + // interleaves headers with results when files run in parallel. + + vm.wakeup(); + var promise = try vm.loadEntryPointForTestRunner(file_path); + reporter.summary().files += 1; + + switch (promise.status()) { + .rejected => { + vm.unhandledRejection(vm.global, promise.result(), promise.asValue()); + reporter.summary().fail += 1; + + if (reporter.jest.bail == reporter.summary().fail) { + reporter.printSummary(); + Output.prettyError("\nBailed out after {d} failure{s}\n", .{ reporter.jest.bail, if (reporter.jest.bail == 1) "" else "s" }); + reporter.writeJUnitReportIfNeeded(); + + vm.exit_handler.exit_code = 1; + vm.is_shutting_down = true; + vm.runWithAPILock(jsc.VirtualMachine, vm, jsc.VirtualMachine.globalExit); + } + + bun_test_root.exitFile(); + return error.ModuleNotFound; + }, + else => {}, + } + + vm.eventLoop().tick(); + + var buntest_strong = bun_test_root.cloneActiveFile() orelse { + bun_test_root.exitFile(); + return error.ModuleNotFound; + }; + + // Detach from active_file without nullifying reporter; + // the file keeps its reporter alive so test results can still be reported. + bun_test_root.detachFile(); + + const buntest = buntest_strong.get(); + + // Start test execution + if (buntest.result_queue.readableLength() == 0) { + buntest.addResult(.start); + } + bun_test.BunTest.run(buntest_strong, vm.global) catch |e| { + buntest.onUncaughtException(vm.global, vm.global.takeException(e), false, .start); + }; + + vm.eventLoop().tick(); + + return buntest_strong; + } + }; + + var ctx = ParContext{ + .reporter = reporter_, + .vm = vm_, + .files = files_, + .allocator = allocator_, + .parallelism = file_parallelism, + }; + vm_.runWithAPILock(ParContext, &ctx, ParContext.begin); + } } fn timerNoop(_: *uws.Timer) callconv(.c) void {} @@ -1912,6 +2125,7 @@ pub const TestCommand = struct { defer bun_test_root.exitFile(); reporter.jest.current_file.set(file_title, file_prefix, repeat_count, repeat_index, reporter); + reporter.jest.current_file_id = file_id; bun.jsc.Jest.bun_test.debug.group.log("loadEntryPointForTestRunner(\"{f}\")", .{std.zig.fmtString(file_path)}); diff --git a/test/cli/test/file-parallelism.test.ts b/test/cli/test/file-parallelism.test.ts new file mode 100644 index 00000000000..d3acfad8dc3 --- /dev/null +++ b/test/cli/test/file-parallelism.test.ts @@ -0,0 +1,173 @@ +import { spawnSync } from "bun"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +function runBunTest(cwd: string, args: string[] = []): { stderr: string; exitCode: number } { + const result = spawnSync({ + cwd, + cmd: [bunExe(), "test", ...args], + env: { ...bunEnv, AGENT: "0" }, + stderr: "pipe", + stdout: "ignore", + }); + return { + stderr: result.stderr.toString(), + exitCode: result.exitCode, + }; +} + +describe("--file-parallelism", () => { + test("runs test files in parallel with --file-parallelism 2", () => { + using cwd = tempDir("file-par", { + "a.test.ts": ` + import { test, expect } from "bun:test"; + test("test A1", () => { expect(1 + 1).toBe(2); }); + test("test A2", () => { expect(2 + 2).toBe(4); }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + test("test B1", () => { expect(3 + 3).toBe(6); }); + test("test B2", () => { expect(4 + 4).toBe(8); }); + `, + }); + + const { stderr, exitCode } = runBunTest(String(cwd), ["--file-parallelism", "2"]); + expect(stderr).toContain("test A1"); + expect(stderr).toContain("test A2"); + expect(stderr).toContain("test B1"); + expect(stderr).toContain("test B2"); + expect(stderr).toContain("4 pass"); + expect(stderr).toContain("2 files"); + expect(exitCode).toBe(0); + }); + + test("handles test failures correctly in parallel mode", () => { + using cwd = tempDir("file-par-fail", { + "pass.test.ts": ` + import { test, expect } from "bun:test"; + test("passing test", () => { expect(true).toBe(true); }); + `, + "fail.test.ts": ` + import { test, expect } from "bun:test"; + test("failing test", () => { expect(true).toBe(false); }); + `, + }); + + const { stderr, exitCode } = runBunTest(String(cwd), ["--file-parallelism", "2"]); + expect(stderr).toContain("passing test"); + expect(stderr).toContain("failing test"); + expect(stderr).toContain("1 pass"); + expect(stderr).toContain("1 fail"); + expect(exitCode).toBe(1); + }); + + test("sequential mode (default) still works", () => { + using cwd = tempDir("file-par-seq", { + "a.test.ts": ` + import { test, expect } from "bun:test"; + test("test A", () => { expect(1).toBe(1); }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + test("test B", () => { expect(2).toBe(2); }); + `, + }); + + const { stderr, exitCode } = runBunTest(String(cwd)); + expect(stderr).toContain("test A"); + expect(stderr).toContain("test B"); + expect(stderr).toContain("2 pass"); + expect(exitCode).toBe(0); + }); + + test("--file-parallelism with many files", () => { + const files: Record = {}; + for (let i = 0; i < 6; i++) { + files[`test_${i}.test.ts`] = ` + import { test, expect } from "bun:test"; + test("test from file ${i}", () => { expect(${i}).toBe(${i}); }); + `; + } + using cwd = tempDir("file-par-many", files); + + const { stderr, exitCode } = runBunTest(String(cwd), ["--file-parallelism", "3"]); + for (let i = 0; i < 6; i++) { + expect(stderr).toContain(`test from file ${i}`); + } + expect(stderr).toContain("6 pass"); + expect(stderr).toContain("6 files"); + expect(exitCode).toBe(0); + }); + + test("rejects --file-parallelism 0", () => { + using cwd = tempDir("file-par-zero", { + "a.test.ts": ` + import { test, expect } from "bun:test"; + test("test", () => {}); + `, + }); + + const { stderr, exitCode } = runBunTest(String(cwd), ["--file-parallelism", "0"]); + expect(stderr).toContain("--file-parallelism must be greater than 0"); + expect(exitCode).toBe(1); + }); + + test("proves files actually overlap in parallel mode", () => { + // Each file writes its own marker, then polls for the other file's marker. + // If execution is sequential, the second file's marker never appears while + // the first file is running, so the first file times out / fails. + using cwd = tempDir("file-par-overlap", { + "a.test.ts": ` + import { test, expect } from "bun:test"; + import { writeFileSync, existsSync } from "fs"; + import { join } from "path"; + + test("a waits for b", async () => { + const dir = process.cwd(); + writeFileSync(join(dir, "a.marker"), "a"); + // Poll for b's marker (up to 5s) + const start = Date.now(); + while (!existsSync(join(dir, "b.marker"))) { + if (Date.now() - start > 5000) throw new Error("timed out waiting for b.marker"); + await Bun.sleep(10); + } + expect(true).toBe(true); + }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + import { writeFileSync, existsSync } from "fs"; + import { join } from "path"; + + test("b waits for a", async () => { + const dir = process.cwd(); + writeFileSync(join(dir, "b.marker"), "b"); + // Poll for a's marker (up to 5s) + const start = Date.now(); + while (!existsSync(join(dir, "a.marker"))) { + if (Date.now() - start > 5000) throw new Error("timed out waiting for a.marker"); + await Bun.sleep(10); + } + expect(true).toBe(true); + }); + `, + }); + + const { stderr, exitCode } = runBunTest(String(cwd), ["--file-parallelism", "2"]); + expect(stderr).toContain("2 pass"); + expect(exitCode).toBe(0); + }); + + test("rejects invalid --file-parallelism value", () => { + using cwd = tempDir("file-par-invalid", { + "a.test.ts": ` + import { test, expect } from "bun:test"; + test("test", () => {}); + `, + }); + + const { stderr, exitCode } = runBunTest(String(cwd), ["--file-parallelism", "abc"]); + expect(stderr).toContain("Invalid file-parallelism"); + expect(exitCode).toBe(1); + }); +});