Add --resolve-extensions flag and bunfig.toml property for custom test file patterns

This change allows users to customize which file name patterns are recognized
as test files, providing flexibility for projects with different naming conventions.

Features:
- CLI flag: --resolve-extensions <STR>... (can specify multiple)
- bunfig.toml: [test] resolveExtensions = ".check" or [".check", ".verify"]
- CLI flag takes precedence over bunfig.toml configuration
- Dynamic error messages show configured extensions when no tests found
- Works with all JavaScript/TypeScript file extensions (.ts, .tsx, .js, .jsx, etc.)

Default behavior (unchanged):
- .test, _test, .spec, _spec suffixes continue to work when no custom config

Implementation:
- Modified Scanner.zig to accept custom test name suffixes
- Added resolve_extensions field to TestOptions in cli.zig
- Added CLI argument parsing in Arguments.zig
- Added bunfig.toml parsing in bunfig.zig with CLI override logic
- Updated error messages in test_command.zig to show custom extensions
- Added comprehensive test suite covering CLI, bunfig, and override scenarios

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-11-25 01:29:05 +00:00
parent ddcec61f59
commit 0dca0ee6a9
7 changed files with 558 additions and 7 deletions

View File

@@ -470,6 +470,37 @@ pub const Bunfig = struct {
},
}
}
if (test_.get("resolveExtensions")) |expr| brk: {
// Only apply bunfig value if not already set via CLI
if (this.ctx.test_options.resolve_extensions != null) break :brk;
switch (expr.data) {
.e_string => |str| {
const pattern = try str.string(allocator);
const patterns = try allocator.alloc(string, 1);
patterns[0] = pattern;
this.ctx.test_options.resolve_extensions = patterns;
},
.e_array => |arr| {
if (arr.items.len == 0) break :brk;
const patterns = try allocator.alloc(string, arr.items.len);
for (arr.items.slice(), 0..) |item, i| {
if (item.data != .e_string) {
try this.addError(item.loc, "resolveExtensions array must contain only strings");
return;
}
patterns[i] = try item.data.e_string.string(allocator);
}
this.ctx.test_options.resolve_extensions = patterns;
},
else => {
try this.addError(expr.loc, "resolveExtensions must be a string or array of strings");
return;
},
}
}
}
}

View File

@@ -353,6 +353,7 @@ pub const Command = struct {
test_filter_pattern: ?[]const u8 = null,
test_filter_regex: ?*RegularExpression = null,
max_concurrency: u32 = 20,
resolve_extensions: ?[]const []const u8 = null,
reporters: struct {
dots: bool = false,

View File

@@ -219,6 +219,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 <NUMBER> Maximum number of concurrent tests to execute at once. Default is 20.") catch unreachable,
clap.parseParam("--resolve-extensions <STR>... Test file name suffixes to match. Defaults to ['.test', '_test', '.spec', '_spec'].") catch unreachable,
};
pub const test_params = test_only_params ++ runtime_params_ ++ transpiler_params_ ++ base_params_;
@@ -566,6 +567,15 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
std.process.exit(1);
};
}
if (args.options("--resolve-extensions").len > 0) {
const extensions = args.options("--resolve-extensions");
const patterns = try allocator.alloc(string, extensions.len);
for (extensions, 0..) |ext, i| {
patterns[i] = ext;
}
ctx.test_options.resolve_extensions = patterns;
}
}
ctx.args.absolute_working_dir = cwd;

View File

@@ -14,6 +14,9 @@ scan_dir_buf: bun.PathBuffer = undefined,
options: *BundleOptions,
has_iterated: bool = false,
search_count: usize = 0,
/// Custom test name suffixes to use instead of the default ones.
/// If null, uses the default test_name_suffixes.
custom_test_name_suffixes: ?[]const []const u8 = null,
const log = bun.Output.scoped(.jest, .hidden);
const Fifo = bun.LinearFifo(ScanEntry, .Dynamic);
@@ -129,8 +132,16 @@ pub fn couldBeTestFile(this: *Scanner, name: []const u8, comptime needs_test_suf
if (extname.len == 0 or !this.options.loader(extname).isJavaScriptLike()) return false;
if (comptime !needs_test_suffix) return true;
const name_without_extension = name[0 .. name.len - extname.len];
inline for (test_name_suffixes) |suffix| {
if (strings.endsWithComptime(name_without_extension, suffix)) return true;
// Use custom suffixes if provided, otherwise use defaults
if (this.custom_test_name_suffixes) |custom_suffixes| {
for (custom_suffixes) |suffix| {
if (strings.endsWith(name_without_extension, suffix)) return true;
}
} else {
inline for (test_name_suffixes) |suffix| {
if (strings.endsWithComptime(name_without_extension, suffix)) return true;
}
}
return false;

View File

@@ -1447,6 +1447,7 @@ pub const TestCommand = struct {
var scanner = bun.handleOom(Scanner.init(ctx.allocator, &vm.transpiler, ctx.positionals.len));
defer scanner.deinit();
scanner.custom_test_name_suffixes = ctx.test_options.resolve_extensions;
const has_relative_path = for (ctx.positionals) |arg| {
if (std.fs.path.isAbsolute(arg) or
strings.startsWith(arg, "./") or
@@ -1590,15 +1591,39 @@ pub const TestCommand = struct {
if (ctx.positionals.len < 2) {
if (Output.isAIAgent()) {
// Be very clear to ai.
Output.errGeneric("0 test files matching **{{.test,.spec,_test_,_spec_}}.{{js,ts,jsx,tsx}} in --cwd={f}", .{bun.fmt.quote(bun.fs.FileSystem.instance.top_level_dir)});
const suffixes = ctx.test_options.resolve_extensions orelse &Scanner.test_name_suffixes;
var buf = bun.MutableString.initEmpty(bun.default_allocator);
defer buf.deinit();
var writer = buf.writer();
writer.writeAll("0 test files matching **{") catch {};
for (suffixes, 0..) |suffix, i| {
if (i > 0) writer.writeAll(",") catch {};
writer.writeAll(suffix) catch {};
}
writer.writeAll("}.{js,ts,jsx,tsx} in --cwd=") catch {};
Output.errGeneric("{s}{f}", .{ buf.list.items, bun.fmt.quote(bun.fs.FileSystem.instance.top_level_dir) });
} else {
// Be friendlier to humans.
const suffixes = ctx.test_options.resolve_extensions orelse &Scanner.test_name_suffixes;
var buf = bun.MutableString.initEmpty(bun.default_allocator);
defer buf.deinit();
var writer = buf.writer();
for (suffixes, 0..) |suffix, i| {
if (i > 0) {
if (i == suffixes.len - 1) {
writer.writeAll(" or ") catch {};
} else {
writer.writeAll(", ") catch {};
}
}
writer.print("\"{s}\"", .{suffix}) catch {};
}
Output.prettyErrorln(
\\<yellow>No tests found!<r>
\\
\\Tests need ".test", "_test_", ".spec" or "_spec_" in the filename <d>(ex: "MyApp.test.ts")<r>
\\Tests need {s} in the filename <d>(ex: "MyApp{s}.ts")<r>
\\
, .{});
, .{ buf.list.items, suffixes[0] });
}
} else {
if (Output.isAIAgent()) {
@@ -1624,11 +1649,26 @@ pub const TestCommand = struct {
Output.printStartEnd(ctx.start_time, std.time.nanoTimestamp());
}
const suffixes = ctx.test_options.resolve_extensions orelse &Scanner.test_name_suffixes;
var buf = bun.MutableString.initEmpty(bun.default_allocator);
defer buf.deinit();
var writer = buf.writer();
for (suffixes, 0..) |suffix, i| {
if (i > 0) {
if (i == suffixes.len - 1) {
writer.writeAll(" or ") catch {};
} else {
writer.writeAll(", ") catch {};
}
}
writer.print("\"{s}\"", .{suffix}) catch {};
}
Output.prettyErrorln(
\\
\\
\\<blue>note<r><d>:<r> Tests need ".test", "_test_", ".spec" or "_spec_" in the filename <d>(ex: "MyApp.test.ts")<r>
, .{});
\\<blue>note<r><d>:<r> Tests need {s} in the filename <d>(ex: "MyApp{s}.ts")<r>
, .{ buf.list.items, suffixes[0] });
// print a helpful note
if (has_file_like) |i| {

View File

@@ -0,0 +1,79 @@
# Test Resolve Extensions
The `--resolve-extensions` flag and `resolveExtensions` bunfig.toml property allow you to customize which file name patterns are recognized as test files.
## Default Behavior
By default, Bun recognizes test files with these suffixes:
- `.test` (e.g., `myfile.test.ts`)
- `_test` (e.g., `myfile_test.ts`)
- `.spec` (e.g., `myfile.spec.ts`)
- `_spec` (e.g., `myfile_spec.ts`)
## Usage
### CLI Flag
```bash
# Use a single custom extension
bun test --resolve-extensions .check
# Use multiple custom extensions
bun test --resolve-extensions .check --resolve-extensions .verify
```
### bunfig.toml
```toml
[test]
# Single extension
resolveExtensions = ".check"
# Multiple extensions
resolveExtensions = [".check", ".verify"]
```
## Examples
### Custom test suffix
If you prefer using `.check.ts` for your test files:
```toml
# bunfig.toml
[test]
resolveExtensions = ".check"
```
Now `myfile.check.ts` will be recognized as a test file, but `myfile.test.ts` will not.
### Multiple custom patterns
You can mix different patterns:
```toml
# bunfig.toml
[test]
resolveExtensions = [".check", ".verify", "_integration"]
```
This will recognize:
- `myfile.check.ts`
- `myfile.verify.ts`
- `myfile_integration.ts`
### Override via CLI
The CLI flag takes precedence over bunfig.toml:
```bash
# Even if bunfig.toml has resolveExtensions = ".check"
# This will only run .verify files
bun test --resolve-extensions .verify
```
## Notes
- The extensions are matched against the filename **before** the file extension (`.ts`, `.js`, etc.)
- All standard JavaScript/TypeScript file extensions (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`) are still supported
- When custom extensions are specified, the default patterns (`.test`, `_test`, `.spec`, `_spec`) are **replaced**, not supplemented

View File

@@ -0,0 +1,379 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
describe("bun test --resolve-extensions", () => {
test("CLI flag allows custom test file suffixes", async () => {
const dir = tempDirWithFiles("resolve-extensions-cli", {
"mytest.check.ts": `
import { test, expect } from "bun:test";
test("check test", () => {
console.log("RUNNING: check test");
expect(1).toBe(1);
});
`,
"regular.test.ts": `
import { test, expect } from "bun:test";
test("regular test", () => {
console.log("RUNNING: regular test");
expect(2).toBe(2);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--resolve-extensions", ".check"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(0);
const output = stdout + stderr;
expect(output).toContain("RUNNING: check test");
expect(output).not.toContain("RUNNING: regular test");
expect(output).toContain("1 pass");
});
test("CLI flag supports multiple extensions", async () => {
const dir = tempDirWithFiles("resolve-extensions-multiple", {
"alpha.check.ts": `
import { test, expect } from "bun:test";
test("check test", () => {
console.log("RUNNING: check");
expect(1).toBe(1);
});
`,
"beta.verify.ts": `
import { test, expect } from "bun:test";
test("verify test", () => {
console.log("RUNNING: verify");
expect(2).toBe(2);
});
`,
"gamma.test.ts": `
import { test, expect } from "bun:test";
test("regular test", () => {
console.log("RUNNING: regular");
expect(3).toBe(3);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--resolve-extensions", ".check", "--resolve-extensions", ".verify"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(0);
const output = stdout + stderr;
expect(output).toContain("RUNNING: check");
expect(output).toContain("RUNNING: verify");
expect(output).not.toContain("RUNNING: regular");
expect(output).toContain("2 pass");
});
test("bunfig.toml resolveExtensions as string", async () => {
const dir = tempDirWithFiles("resolve-extensions-bunfig-string", {
"mytest.check.ts": `
import { test, expect } from "bun:test";
test("check test", () => {
console.log("RUNNING: check test");
expect(1).toBe(1);
});
`,
"regular.test.ts": `
import { test, expect } from "bun:test";
test("regular test", () => {
console.log("RUNNING: regular test");
expect(2).toBe(2);
});
`,
"bunfig.toml": `[test]\nresolveExtensions = ".check"`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(0);
const output = stdout + stderr;
expect(output).toContain("RUNNING: check test");
expect(output).not.toContain("RUNNING: regular test");
expect(output).toContain("1 pass");
});
test("bunfig.toml resolveExtensions as array", async () => {
const dir = tempDirWithFiles("resolve-extensions-bunfig-array", {
"alpha.check.ts": `
import { test, expect } from "bun:test";
test("check test", () => {
console.log("RUNNING: check");
expect(1).toBe(1);
});
`,
"beta.verify.ts": `
import { test, expect } from "bun:test";
test("verify test", () => {
console.log("RUNNING: verify");
expect(2).toBe(2);
});
`,
"gamma.test.ts": `
import { test, expect } from "bun:test";
test("regular test", () => {
console.log("RUNNING: regular");
expect(3).toBe(3);
});
`,
"bunfig.toml": `[test]\nresolveExtensions = [".check", ".verify"]`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(0);
const output = stdout + stderr;
expect(output).toContain("RUNNING: check");
expect(output).toContain("RUNNING: verify");
expect(output).not.toContain("RUNNING: regular");
expect(output).toContain("2 pass");
});
test("CLI flag overrides bunfig.toml", async () => {
const dir = tempDirWithFiles("resolve-extensions-cli-override", {
"alpha.check.ts": `
import { test, expect } from "bun:test";
test("check test", () => {
console.log("RUNNING: check");
expect(1).toBe(1);
});
`,
"beta.verify.ts": `
import { test, expect } from "bun:test";
test("verify test", () => {
console.log("RUNNING: verify");
expect(2).toBe(2);
});
`,
"bunfig.toml": `[test]\nresolveExtensions = ".check"`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--resolve-extensions", ".verify"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(0);
const output = stdout + stderr;
expect(output).not.toContain("RUNNING: check");
expect(output).toContain("RUNNING: verify");
expect(output).toContain("1 pass");
});
test("custom extensions work with underscore prefix", async () => {
const dir = tempDirWithFiles("resolve-extensions-underscore", {
"mytest_check.ts": `
import { test, expect } from "bun:test";
test("check test", () => {
console.log("RUNNING: check");
expect(1).toBe(1);
});
`,
"regular.test.ts": `
import { test, expect } from "bun:test";
test("regular test", () => {
console.log("RUNNING: regular");
expect(2).toBe(2);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--resolve-extensions", "_check"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(0);
const output = stdout + stderr;
expect(output).toContain("RUNNING: check");
expect(output).not.toContain("RUNNING: regular");
expect(output).toContain("1 pass");
});
test("no tests found shows custom extensions in error message", async () => {
const dir = tempDirWithFiles("resolve-extensions-no-tests", {
"regular.test.ts": `
import { test, expect } from "bun:test";
test("regular test", () => expect(1).toBe(1));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--resolve-extensions", ".custom"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(1);
const output = stdout + stderr;
expect(output).toContain("No tests found");
expect(output).toContain(".custom");
});
test("works with nested directories", async () => {
const dir = tempDirWithFiles("resolve-extensions-nested", {
"src/feature/alpha.check.ts": `
import { test, expect } from "bun:test";
test("nested check", () => {
console.log("RUNNING: nested check");
expect(1).toBe(1);
});
`,
"src/feature/beta.test.ts": `
import { test, expect } from "bun:test";
test("nested test", () => {
console.log("RUNNING: nested test");
expect(2).toBe(2);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--resolve-extensions", ".check"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(0);
const output = stdout + stderr;
expect(output).toContain("RUNNING: nested check");
expect(output).not.toContain("RUNNING: nested test");
expect(output).toContain("1 pass");
});
test("works with default extensions when not specified", async () => {
const dir = tempDirWithFiles("resolve-extensions-default", {
"alpha.test.ts": `
import { test, expect } from "bun:test";
test("test suffix", () => {
console.log("RUNNING: test");
expect(1).toBe(1);
});
`,
"beta.spec.ts": `
import { test, expect } from "bun:test";
test("spec suffix", () => {
console.log("RUNNING: spec");
expect(2).toBe(2);
});
`,
"gamma_test.ts": `
import { test, expect } from "bun:test";
test("_test suffix", () => {
console.log("RUNNING: _test");
expect(3).toBe(3);
});
`,
"delta_spec.ts": `
import { test, expect } from "bun:test";
test("_spec suffix", () => {
console.log("RUNNING: _spec");
expect(4).toBe(4);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(0);
const output = stdout + stderr;
expect(output).toContain("RUNNING: test");
expect(output).toContain("RUNNING: spec");
expect(output).toContain("RUNNING: _test");
expect(output).toContain("RUNNING: _spec");
expect(output).toContain("4 pass");
});
});