Compare commits

...

4 Commits

Author SHA1 Message Date
autofix-ci[bot]
1f8a7fbd29 [autofix.ci] apply automated fixes 2025-08-20 04:07:12 +00:00
RiskyMH
fba0dd7e03 rename to test.filePatterns 2025-08-20 14:05:06 +10:00
autofix-ci[bot]
63157f00d1 [autofix.ci] apply automated fixes 2025-08-16 00:08:24 +00:00
Claude Bot
1e07dac070 Add bunfig test.glob option to customize test file patterns
Allow users to specify custom glob patterns for test file discovery via bunfig.toml:

[test]
glob = "*.mytest.js"
# or
glob = ["*.mytest.js", "*.spec.ts"]

- Add glob_patterns field to TestOptions structure
- Update bunfig parser to handle test.glob string/array configuration
- Modify test Scanner to use custom patterns when available
- Resolve glob patterns relative to bunfig.toml directory
- Add comprehensive tests covering various use cases
- Update documentation for bunfig.toml and test configuration

When custom patterns are specified, they completely replace the default
test discovery patterns (*.test.*, *_test.*, *.spec.*, *_spec.*).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-16 00:06:36 +00:00
7 changed files with 629 additions and 9 deletions

View File

@@ -141,6 +141,34 @@ The root directory to run tests from. Default `.`.
root = "./__tests__"
```
### `test.filePatterns`
Customize the file patterns used to identify test files. Can be a single string or an array of strings.
```toml
[test]
# Single pattern
filePatterns = "*.mytest.js"
# Multiple patterns
filePatterns = ["*.mytest.js", "*.spec.ts", "**/*.unit.js"]
```
By default, Bun uses these patterns:
- `*.test.{js|jsx|ts|tsx}`
- `*_test.{js|jsx|ts|tsx}`
- `*.spec.{js|jsx|ts|tsx}`
- `*_spec.{js|jsx|ts|tsx}`
When custom patterns are specified, they completely replace the default patterns.
**Path resolution:**
- Patterns starting with `./` or `../` are resolved relative to the directory containing your `bunfig.toml` file
- Other glob patterns like `**/*.test.ts` are matched against relative paths from the current working directory
- Absolute paths are used as-is
### `test.preload`
Same as the top-level `preload` field, but only applies to `bun test`.

View File

@@ -20,6 +20,35 @@ The `root` option specifies a root directory for test discovery, overriding the
root = "src" # Only scan for tests in the src directory
```
#### filePatterns
The `filePatterns` option allows you to customize the patterns used to identify test files, overriding the default patterns. You can specify a single string or an array of strings.
```toml
[test]
filePatterns = "*.mytest.js" # Single pattern
```
```toml
[test]
filePatterns = ["*.mytest.js", "*.spec.ts", "**/*.unit.js"] # Multiple patterns
```
By default, `bun test` searches for files matching these patterns:
- `*.test.{js|jsx|ts|tsx}`
- `*_test.{js|jsx|ts|tsx}`
- `*.spec.{js|jsx|ts|tsx}`
- `*_spec.{js|jsx|ts|tsx}`
When you specify custom patterns, these default patterns are completely replaced with your custom ones.
**Path resolution:**
- Patterns starting with `./` or `../` are resolved relative to the directory containing your `bunfig.toml` file
- Other glob patterns like `**/*.test.ts` are matched against relative paths from the current working directory
- Absolute paths are used as-is
### Reporters
#### reporter.junit

View File

@@ -352,6 +352,65 @@ pub const Bunfig = struct {
},
}
}
if (test_.get("filePatterns")) |expr| brk: {
// Get the directory containing the bunfig file for relative path resolution
const bunfig_dir = std.fs.path.dirname(this.source.path.text) orelse "./";
switch (expr.data) {
.e_string => |str| {
const pattern = try str.string(allocator);
const patterns = try allocator.alloc(string, 1);
// Only resolve relative to bunfig.toml if it starts with ./ or ../
if (std.fs.path.isAbsolute(pattern)) {
patterns[0] = pattern;
} else if (strings.startsWith(pattern, "./") or strings.startsWith(pattern, "../")) {
// Resolve any ../ in the path
var buffer: bun.PathBuffer = undefined;
const resolved = bun.path.joinAbsStringBuf(bunfig_dir, &buffer, &[_][]const u8{pattern}, .auto);
patterns[0] = try allocator.dupe(u8, resolved);
} else {
// Use pattern as-is for glob patterns like **/*.test.ts
patterns[0] = pattern;
}
this.ctx.test_options.file_patterns = patterns;
},
.e_array => |arr| {
// Set empty array to explicitly disable test discovery
if (arr.items.len == 0) {
const patterns = try allocator.alloc(string, 0);
this.ctx.test_options.file_patterns = patterns;
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, "test.filePatterns array must contain only strings");
return;
}
const pattern = try item.data.e_string.string(allocator);
// Only resolve relative to bunfig.toml if it starts with ./ or ../
if (std.fs.path.isAbsolute(pattern)) {
patterns[i] = pattern;
} else if (strings.startsWith(pattern, "./") or strings.startsWith(pattern, "../")) {
// Resolve any ../ in the path
var buffer: bun.PathBuffer = undefined;
const resolved = bun.path.joinAbsStringBuf(bunfig_dir, &buffer, &[_][]const u8{pattern}, .auto);
patterns[i] = try allocator.dupe(u8, resolved);
} else {
// Use pattern as-is for glob patterns like **/*.test.ts
patterns[i] = pattern;
}
}
this.ctx.test_options.file_patterns = patterns;
},
else => {
try this.addError(expr.loc, "test.filePatterns must be a string or array of strings");
return;
},
}
}
}
}

View File

@@ -328,6 +328,9 @@ pub const Command = struct {
test_filter_pattern: ?[]const u8 = null,
test_filter_regex: ?*RegularExpression = null,
/// Test file patterns. If specified, these override the default test discovery patterns.
file_patterns: ?[]const string = null,
file_reporter: ?TestCommand.FileReporter = null,
reporter_outfile: ?[]const u8 = null,
};

View File

@@ -14,6 +14,8 @@ scan_dir_buf: bun.PathBuffer = undefined,
options: *BundleOptions,
has_iterated: bool = false,
search_count: usize = 0,
/// Custom glob patterns for test files. If set, these override the default patterns.
custom_file_patterns: ?[]const string = null,
const log = bun.Output.scoped(.jest, .hidden);
const Fifo = std.fifo.LinearFifo(ScanEntry, .Dynamic);
@@ -32,6 +34,7 @@ pub fn init(
alloc: Allocator,
transpiler: *Transpiler,
initial_results_capacity: usize,
custom_file_patterns: ?[]const string,
) Allocator.Error!Scanner {
const results = try std.ArrayListUnmanaged(bun.PathString).initCapacity(
alloc,
@@ -42,6 +45,7 @@ pub fn init(
.options = &transpiler.options,
.fs = transpiler.fs,
.test_files = results,
.custom_file_patterns = custom_file_patterns,
};
}
@@ -129,6 +133,12 @@ pub fn couldBeTestFile(this: *Scanner, name: []const u8, comptime needs_test_suf
const extname = std.fs.path.extension(name);
if (extname.len == 0 or !this.options.loader(extname).isJavaScriptLike()) return false;
if (comptime !needs_test_suffix) return true;
if (this.custom_file_patterns != null) {
return true;
}
// Fall back to default test name suffixes
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;
@@ -190,18 +200,41 @@ pub fn next(this: *Scanner, entry: *FileSystem.Entry, fd: bun.StoredFileDescript
if (!entry.abs_path.isEmpty()) return;
this.search_count += 1;
if (!this.couldBeTestFile(name, true)) return;
const parts = &[_][]const u8{ entry.dir, entry.base() };
const path = this.fs.absBuf(parts, &this.open_dir_buf);
if (this.custom_file_patterns) |patterns| {
if (patterns.len == 0) return;
if (!this.doesAbsolutePathMatchFilter(path)) {
const parts = &[_][]const u8{ entry.dir, entry.base() };
const path = this.fs.absBuf(parts, &this.open_dir_buf);
const rel_path = bun.path.relative(this.fs.top_level_dir, path);
if (!this.doesPathMatchFilter(rel_path)) return;
}
entry.abs_path = bun.PathString.init(this.fs.filename_store.append(@TypeOf(path), path) catch unreachable);
this.test_files.append(this.allocator(), entry.abs_path) catch unreachable;
var matches = false;
for (patterns) |pattern| {
const path_to_match = if (std.fs.path.isAbsolute(pattern)) path else rel_path;
if (bun.glob.match(bun.default_allocator, pattern, path_to_match).matches()) {
matches = true;
break;
}
}
if (!matches) return;
entry.abs_path = bun.PathString.init(this.fs.filename_store.append(@TypeOf(path), path) catch unreachable);
this.test_files.append(this.allocator(), entry.abs_path) catch unreachable;
} else {
if (!this.couldBeTestFile(name, true)) return;
const parts = &[_][]const u8{ entry.dir, entry.base() };
const path = this.fs.absBuf(parts, &this.open_dir_buf);
if (!this.doesAbsolutePathMatchFilter(path)) {
const rel_path = bun.path.relative(this.fs.top_level_dir, path);
if (!this.doesPathMatchFilter(rel_path)) return;
}
entry.abs_path = bun.PathString.init(this.fs.filename_store.append(@TypeOf(path), path) catch unreachable);
this.test_files.append(this.allocator(), entry.abs_path) catch unreachable;
}
},
}
}
@@ -210,6 +243,8 @@ inline fn allocator(self: *const Scanner) Allocator {
return self.dirs_to_scan.allocator;
}
const string = []const u8;
const std = @import("std");
const BundleOptions = @import("../../options.zig").BundleOptions;
const Allocator = std.mem.Allocator;

View File

@@ -1424,7 +1424,7 @@ pub const TestCommand = struct {
//
try vm.ensureDebugger(false);
var scanner = Scanner.init(ctx.allocator, &vm.transpiler, ctx.positionals.len) catch bun.outOfMemory();
var scanner = Scanner.init(ctx.allocator, &vm.transpiler, ctx.positionals.len, ctx.test_options.file_patterns) catch bun.outOfMemory();
defer scanner.deinit();
const has_relative_path = for (ctx.positionals) |arg| {
if (std.fs.path.isAbsolute(arg) or

View File

@@ -0,0 +1,466 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
test("bunfig test.filePatterns with single string pattern", async () => {
const dir = tempDirWithFiles("test-filepatterns-single", {
"bunfig.toml": `
[test]
filePatterns = "*.mytest.js"
`,
"example.mytest.js": `
import { test, expect } from "bun:test";
test("custom glob test", () => {
expect(1).toBe(1);
});
`,
"example.test.js": `
import { test, expect } from "bun:test";
test("default pattern test", () => {
expect(1).toBe(1);
});
`,
"example.spec.js": `
import { test, expect } from "bun:test";
test("spec test", () => {
expect(1).toBe(1);
});
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Test output goes to stderr by default
expect(stderr).toContain("1 pass");
// Verify only the mytest.js file was run (1 test)
expect(stderr).toContain("Ran 1 test");
});
test("bunfig test.filePatterns with array of patterns", async () => {
const dir = tempDirWithFiles("test-filepatterns-array", {
"bunfig.toml": `
[test]
filePatterns = ["*.custom.js", "*.mytest.ts"]
`,
"example.custom.js": `
import { test, expect } from "bun:test";
test("custom js test", () => {
expect(1).toBe(1);
});
`,
"example.mytest.ts": `
import { test, expect } from "bun:test";
test("custom ts test", () => {
expect(1).toBe(1);
});
`,
"example.test.js": `
import { test, expect } from "bun:test";
test("default pattern test", () => {
expect(1).toBe(1);
});
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "test", "--reporter", "junit", "--reporter-outfile", "results.xml"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// 2 tests should run (custom.js and mytest.ts)
expect(stderr).toContain("2 pass");
expect(stderr).toContain("Ran 2 tests");
});
test("bunfig test.filePatterns with nested directories", async () => {
const dir = tempDirWithFiles("test-filepatterns-nested", {
"bunfig.toml": `
[test]
filePatterns = "**/*.custom.js"
`,
"src/example.custom.js": `
import { test, expect } from "bun:test";
test("nested custom test", () => {
expect(1).toBe(1);
});
`,
"lib/utils/helper.custom.js": `
import { test, expect } from "bun:test";
test("deeply nested custom test", () => {
expect(1).toBe(1);
});
`,
"example.test.js": `
import { test, expect } from "bun:test";
test("root test", () => {
expect(1).toBe(1);
});
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "test", "--reporter", "junit", "--reporter-outfile", "results.xml"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// 2 custom tests in nested directories
expect(stderr).toContain("2 pass");
expect(stderr).toContain("Ran 2 tests");
});
test("bunfig test.filePatterns with relative paths", async () => {
const dir = tempDirWithFiles("test-filepatterns-relative", {
"bunfig.toml": `
[test]
filePatterns = "tests/*.unit.js"
`,
"tests/example.unit.js": `
import { test, expect } from "bun:test";
test("unit test", () => {
expect(1).toBe(1);
});
`,
"tests/example.integration.js": `
import { test, expect } from "bun:test";
test("integration test", () => {
expect(1).toBe(1);
});
`,
"example.test.js": `
import { test, expect } from "bun:test";
test("default test", () => {
expect(1).toBe(1);
});
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "test", "--reporter", "junit", "--reporter-outfile", "results.xml"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Only 1 test from tests/*.unit.js
expect(stderr).toContain("1 pass");
expect(stderr).toContain("Ran 1 test");
});
test("bunfig test.filePatterns error handling for invalid type", async () => {
const dir = tempDirWithFiles("test-filepatterns-invalid", {
"bunfig.toml": `
[test]
filePatterns = 123
`,
"example.test.js": `
import { test, expect } from "bun:test";
test("test", () => {
expect(1).toBe(1);
});
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(1);
expect(stderr).toContain("test.filePatterns must be a string or array of strings");
});
test("bunfig test.filePatterns error handling for invalid array element", async () => {
const dir = tempDirWithFiles("test-filepatterns-invalid-array", {
"bunfig.toml": `
[test]
filePatterns = ["*.test.js", 123]
`,
"example.test.js": `
import { test, expect } from "bun:test";
test("test", () => {
expect(1).toBe(1);
});
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(1);
expect(stderr).toContain("test.filePatterns array must contain only strings");
});
test("bunfig test.filePatterns fallback to default patterns when not specified", async () => {
const dir = tempDirWithFiles("test-filepatterns-fallback", {
"bunfig.toml": `
[test]
# No glob specified
`,
"example.test.js": `
import { test, expect } from "bun:test";
test("default test pattern", () => {
expect(1).toBe(1);
});
`,
"example.spec.ts": `
import { test, expect } from "bun:test";
test("default spec pattern", () => {
expect(1).toBe(1);
});
`,
"example.custom.js": `
import { test, expect } from "bun:test";
test("non-matching pattern", () => {
expect(1).toBe(1);
});
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "test", "--reporter", "junit", "--reporter-outfile", "results.xml"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Should run default test patterns (test and spec files)
expect(stderr).toContain("2 pass");
expect(stderr).toContain("Ran 2 tests");
});
test("bunfig test.filePatterns resolves paths relative to bunfig.toml location", async () => {
const dir = tempDirWithFiles("test-filepatterns-cwd", {
"bunfig.toml": `
[test]
filePatterns = "mydir/*.mytest.js"
`,
"mydir/example.mytest.js": `
import { test, expect } from "bun:test";
test("relative path test", () => {
expect(1).toBe(1);
});
`,
"example.test.js": `
import { test, expect } from "bun:test";
test("root test", () => {
expect(1).toBe(1);
});
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "test", "--reporter", "junit", "--reporter-outfile", "results.xml"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Only the nested test file should run
expect(stderr).toContain("1 pass");
expect(stderr).toContain("Ran 1 test");
});
test("bunfig test.filePatterns with ./ relative path resolution", async () => {
// Test that ./ patterns are resolved relative to bunfig.toml location
const dir = tempDirWithFiles("test-filepatterns-relative-dot", {
"project/bunfig.toml": `
[test]
filePatterns = "./tests/*.mytest.js"
`,
"project/tests/example.mytest.js": `
import { test, expect } from "bun:test";
test("relative dot test", () => {
expect(1).toBe(1);
});
`,
"tests/example.mytest.js": `
import { test, expect } from "bun:test";
test("should not run", () => {
expect(1).toBe(1);
});
`,
"project/example.test.js": `
import { test, expect } from "bun:test";
test("default test", () => {
expect(1).toBe(1);
});
`,
});
// Run from the project directory where bunfig.toml is located
const proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: `${dir}/project`,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Should only run the test in ./tests relative to bunfig.toml location
expect(stderr).toContain("1 pass");
expect(stderr).toContain("Ran 1 test");
});
test.skip("bunfig test.filePatterns with ../ relative path resolution", async () => {
// Note: This test is skipped because bun test doesn't scan parent directories
// when running from a subdirectory, which is expected behavior
const dir = tempDirWithFiles("test-filepatterns-relative-dotdot", {
"project/config/bunfig.toml": `
[test]
filePatterns = "../tests/*.mytest.js"
`,
"project/tests/example.mytest.js": `
import { test, expect } from "bun:test";
test("relative parent test", () => {
expect(1).toBe(1);
});
`,
"project/config/tests/example.mytest.js": `
import { test, expect } from "bun:test";
test("should not run", () => {
expect(1).toBe(1);
});
`,
"project/config/example.test.js": `
import { test, expect } from "bun:test";
test("default test", () => {
expect(1).toBe(1);
});
`,
});
// Run from the config directory where bunfig.toml is located
const proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: `${dir}/project/config`,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Should only run the test in ../tests relative to bunfig.toml
expect(stderr).toContain("1 pass");
expect(stderr).toContain("Ran 1 test");
});
test("bunfig test.filePatterns with absolute paths", async () => {
const dir = tempDirWithFiles("test-filepatterns-absolute", {
"bunfig.toml": "", // Will be written after we know the absolute path
"tests/example.mytest.js": `
import { test, expect } from "bun:test";
test("absolute path test", () => {
expect(1).toBe(1);
});
`,
"example.test.js": `
import { test, expect } from "bun:test";
test("default test", () => {
expect(1).toBe(1);
});
`,
});
// Write bunfig.toml with absolute path
const absolutePath = `${dir}/tests/*.mytest.js`;
await Bun.write(
`${dir}/bunfig.toml`,
`
[test]
filePatterns = "${absolutePath}"
`,
);
const proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Should only run the test specified by absolute path
expect(stderr).toContain("1 pass");
expect(stderr).toContain("Ran 1 test");
});
test("bunfig test.filePatterns with empty array should not match any files", async () => {
const dir = tempDirWithFiles("test-filepatterns-empty", {
"bunfig.toml": `
[test]
filePatterns = []
`,
"example.test.js": `
import { test, expect } from "bun:test";
test("test", () => {
expect(1).toBe(1);
});
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// When no test files are found, bun test exits with code 1
expect(exitCode).toBe(1);
expect(stderr).toContain("0 test files matching");
});