Support multiple --full-test-name arguments

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 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-08-30 03:57:11 +00:00
parent 20e17a09c3
commit 72f350143e
5 changed files with 250 additions and 18 deletions

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -196,7 +196,7 @@ pub const test_only_params = [_]ParamType{
clap.parseParam("--coverage-dir <STR> Directory for coverage files. Defaults to 'coverage'.") catch unreachable,
clap.parseParam("--bail <NUMBER>? Exit the test suite after <NUMBER> failures. If you do not specify a number, it defaults to 1.") catch unreachable,
clap.parseParam("-t, --test-name-pattern <STR> Run only tests with a name that matches the given regex.") catch unreachable,
clap.parseParam("--full-test-name <STR> Run only tests with the exact full test name (space separated, no regex).") catch unreachable,
clap.parseParam("--full-test-name <STR>... Run only tests matching any of the exact full test names (space separated, no regex).") catch unreachable,
clap.parseParam("--reporter <STR> Specify the test reporter. Currently --reporter=junit is the only supported format.") catch unreachable,
clap.parseParam("--reporter-outfile <STR> 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("<r><red>error<r>: --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");

View File

@@ -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("<red>error<r><d>:<r> test name <b>{}<r> 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("<red>error<r><d>:<r> {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,

View File

@@ -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");
}
});