From 72f350143eb09cb6ebe1cdfa779b6ea5cbbfb7e5 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sat, 30 Aug 2025 03:57:11 +0000 Subject: [PATCH] Support multiple --full-test-name arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends --full-test-name to accept multiple values, allowing users to run several specific tests in a single command without regex overhead. Features: - Multiple --full-test-name arguments: --full-test-name "test1" --full-test-name "test2" - OR logic: matches any of the provided test names - Proper error messages for single vs multiple names - Comprehensive test coverage for all scenarios Usage examples: bun test --full-test-name "auth login test" --full-test-name "user profile test" bun test --full-test-name "test1" --full-test-name "test2" --full-test-name "test3" 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/bun.js/test/jest.zig | 36 ++++-- src/cli.zig | 2 +- src/cli/Arguments.zig | 6 +- src/cli/test_command.zig | 9 +- test/cli/full-test-name.test.ts | 215 ++++++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 test/cli/full-test-name.test.ts diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index 8d29809810..97329a1897 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -106,7 +106,7 @@ pub const TestRunner = struct { filter_buffer: MutableString, // Used for --full-test-name filtering - full_name_filter: ?[]const u8, + full_name_filters: []const string, unhandled_errors_between_tests: u32 = 0, summary: Summary = Summary{}, @@ -139,7 +139,7 @@ pub const TestRunner = struct { } pub fn hasTestFilter(this: *const TestRunner) bool { - return this.filter_regex != null or this.full_name_filter != null; + return this.filter_regex != null or this.full_name_filters.len > 0; } pub fn setTimeout( @@ -2005,16 +2005,24 @@ inline fn createScope( is_skip = true; tag_to_use = .skipped_because_label; } - } else if (runner.full_name_filter) |full_name| { + } else if (runner.full_name_filters.len > 0) { var buffer: bun.MutableString = runner.filter_buffer; buffer.reset(); appendParentLabel(&buffer, parent) catch @panic("Bun ran out of memory while filtering tests"); buffer.append(label) catch unreachable; const full_test_name = buffer.slice(); - // Add leading space to filter to match the format from appendParentLabel + + // Check if the test name matches any of the provided filters + var matches = false; var expected_name_buffer: [1024]u8 = undefined; - const expected_name = std.fmt.bufPrint(&expected_name_buffer, " {s}", .{full_name}) catch full_name; - if (!bun.strings.eql(full_test_name, expected_name)) { + for (runner.full_name_filters) |full_name| { + const expected_name = std.fmt.bufPrint(&expected_name_buffer, " {s}", .{full_name}) catch continue; + if (bun.strings.eql(full_test_name, expected_name)) { + matches = true; + break; + } + } + if (!matches) { is_skip = true; tag_to_use = .skipped_because_label; } @@ -2393,16 +2401,24 @@ fn eachBind(globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSVa if (is_skip) { tag_to_use = .skipped_because_label; } - } else if (Jest.runner.?.full_name_filter) |full_name| { + } else if (Jest.runner.?.full_name_filters.len > 0) { var buffer: bun.MutableString = Jest.runner.?.filter_buffer; buffer.reset(); appendParentLabel(&buffer, parent) catch @panic("Bun ran out of memory while filtering tests"); buffer.append(formattedLabel) catch unreachable; const full_test_name = buffer.slice(); - // Add leading space to filter to match the format from appendParentLabel + + // Check if the test name matches any of the provided filters + var matches = false; var expected_name_buffer: [1024]u8 = undefined; - const expected_name = std.fmt.bufPrint(&expected_name_buffer, " {s}", .{full_name}) catch full_name; - is_skip = !bun.strings.eql(full_test_name, expected_name); + for (Jest.runner.?.full_name_filters) |full_name| { + const expected_name = std.fmt.bufPrint(&expected_name_buffer, " {s}", .{full_name}) catch continue; + if (bun.strings.eql(full_test_name, expected_name)) { + matches = true; + break; + } + } + is_skip = !matches; if (is_skip) { tag_to_use = .skipped_because_label; } diff --git a/src/cli.zig b/src/cli.zig index 97837bb33e..aff3e35fc3 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -327,7 +327,7 @@ pub const Command = struct { coverage: TestCommand.CodeCoverageOptions = .{}, test_filter_pattern: ?[]const u8 = null, test_filter_regex: ?*RegularExpression = null, - test_full_name_filter: ?[]const u8 = null, + test_full_name_filter: []const string = &.{}, file_reporter: ?TestCommand.FileReporter = null, reporter_outfile: ?[]const u8 = null, diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index 82ec1f4922..69943b46be 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -196,7 +196,7 @@ pub const test_only_params = [_]ParamType{ clap.parseParam("--coverage-dir Directory for coverage files. Defaults to 'coverage'.") catch unreachable, clap.parseParam("--bail ? Exit the test suite after failures. If you do not specify a number, it defaults to 1.") catch unreachable, clap.parseParam("-t, --test-name-pattern Run only tests with a name that matches the given regex.") catch unreachable, - clap.parseParam("--full-test-name Run only tests with the exact full test name (space separated, no regex).") catch unreachable, + clap.parseParam("--full-test-name ... Run only tests matching any of the exact full test names (space separated, no regex).") catch unreachable, clap.parseParam("--reporter Specify the test reporter. Currently --reporter=junit is the only supported format.") catch unreachable, clap.parseParam("--reporter-outfile The output file used for the format from --reporter.") catch unreachable, }; @@ -485,12 +485,12 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C }; ctx.test_options.test_filter_regex = regex; } - if (args.option("--full-test-name")) |fullTestName| { + if (args.options("--full-test-name").len > 0) { if (ctx.test_options.test_filter_regex != null) { Output.prettyErrorln("error: --full-test-name and --test-name-pattern cannot be used together", .{}); Global.exit(1); } - ctx.test_options.test_full_name_filter = fullTestName; + ctx.test_options.test_full_name_filter = args.options("--full-test-name"); } ctx.test_options.update_snapshots = args.flag("--update-snapshots"); ctx.test_options.run_todo = args.flag("--todo"); diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index ad52430b86..67de6324dd 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -1325,7 +1325,7 @@ pub const TestCommand = struct { .bail = ctx.test_options.bail, .filter_regex = ctx.test_options.test_filter_regex, .filter_buffer = bun.MutableString.init(ctx.allocator, 0) catch unreachable, - .full_name_filter = ctx.test_options.test_full_name_filter, + .full_name_filters = ctx.test_options.test_full_name_filter, .snapshots = Snapshots{ .allocator = ctx.allocator, .update_snapshots = ctx.test_options.update_snapshots, @@ -1702,9 +1702,10 @@ pub const TestCommand = struct { summary.skipped_because_label, if (summary.skipped_because_label == 1) "" else "s", }); - } else if (ctx.test_options.test_full_name_filter) |full_name| { - Output.prettyError("error: test name {} matched 0 tests. Searched {d} file{s} (skipping {d} test{s}) ", .{ - bun.fmt.quote(full_name), + } else if (ctx.test_options.test_full_name_filter.len > 0) { + const names_text = if (ctx.test_options.test_full_name_filter.len == 1) "test name" else "test names"; + Output.prettyError("error: {s} matched 0 tests. Searched {d} file{s} (skipping {d} test{s}) ", .{ + names_text, summary.files, if (summary.files == 1) "" else "s", summary.skipped_because_label, diff --git a/test/cli/full-test-name.test.ts b/test/cli/full-test-name.test.ts new file mode 100644 index 0000000000..53279f7c16 --- /dev/null +++ b/test/cli/full-test-name.test.ts @@ -0,0 +1,215 @@ +import { $ } from "bun"; +import { expect, test } from "bun:test"; +import { bunExe, tempDirWithFiles } from "harness"; + +test("--full-test-name matches single test", async () => { + const dir = tempDirWithFiles("full-test-name", { + "test.test.js": ` +describe("auth", () => { + test("login test", () => { + expect(1 + 1).toBe(2); + }); + + test("logout test", () => { + expect(2 + 2).toBe(4); + }); +}); + +test("top level test", () => { + expect(3 + 3).toBe(6); +}); + `, + }); + + // Test single nested test + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "test.test.js", "--full-test-name", "auth login test"], + cwd: dir, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(exitCode).toBe(0); + const output = stdout + stderr; + expect(output).toContain("1 pass"); + expect(output).toContain("2 filtered out"); +}); + +test("--full-test-name matches top-level test", async () => { + const dir = tempDirWithFiles("full-test-name-top", { + "test.test.js": ` +describe("auth", () => { + test("login test", () => { + expect(1 + 1).toBe(2); + }); +}); + +test("top level test", () => { + expect(3 + 3).toBe(6); +}); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "test.test.js", "--full-test-name", "top level test"], + cwd: dir, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(exitCode).toBe(0); + const output = stdout + stderr; + expect(output).toContain("1 pass"); + expect(output).toContain("1 filtered out"); +}); + +test("--full-test-name supports multiple values", async () => { + const dir = tempDirWithFiles("full-test-name-multiple", { + "test.test.js": ` +describe("auth", () => { + test("login test", () => { + expect(1 + 1).toBe(2); + }); + + test("logout test", () => { + expect(2 + 2).toBe(4); + }); + + test("register test", () => { + expect(3 + 3).toBe(6); + }); +}); + +describe("user", () => { + test("profile test", () => { + expect(4 + 4).toBe(8); + }); +}); + +test("top level test", () => { + expect(5 + 5).toBe(10); +}); + `, + }); + + // Test multiple tests from different describe blocks + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "test", + "test.test.js", + "--full-test-name", "auth login test", + "--full-test-name", "user profile test", + "--full-test-name", "top level test" + ], + cwd: dir, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(exitCode).toBe(0); + const output = stdout + stderr; + expect(output).toContain("3 pass"); + expect(output).toContain("2 filtered out"); +}); + +test("--full-test-name and --test-name-pattern are mutually exclusive", async () => { + const dir = tempDirWithFiles("full-test-name-exclusive", { + "test.test.js": ` +test("simple test", () => { + expect(1).toBe(1); +}); + `, + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "test", + "test.test.js", + "--test-name-pattern", "simple", + "--full-test-name", "simple test" + ], + cwd: dir, + stderr: "pipe", + stdout: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([ + proc.stderr.text(), + proc.exited, + ]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("--full-test-name and --test-name-pattern cannot be used together"); +}); + +test("--full-test-name shows proper error for non-matching tests", async () => { + const dir = tempDirWithFiles("full-test-name-no-match", { + "test.test.js": ` +test("existing test", () => { + expect(1).toBe(1); +}); + `, + }); + + // Single non-matching test + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "test.test.js", "--full-test-name", "nonexistent test"], + cwd: dir, + stderr: "pipe", + stdout: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([ + proc.stderr.text(), + proc.exited, + ]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("test name matched 0 tests"); + } + + // Multiple non-matching tests + { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "test", + "test.test.js", + "--full-test-name", "nonexistent test 1", + "--full-test-name", "nonexistent test 2" + ], + cwd: dir, + stderr: "pipe", + stdout: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([ + proc.stderr.text(), + proc.exited, + ]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("test names matched 0 tests"); + } +}); \ No newline at end of file