Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/runtime/bunfig.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ Same as the top-level `preload` field, but only applies to `bun test`.
preload = ["./setup.ts"]
```

### `test.pathIgnorePatterns`

Exclude files and directories from test discovery using glob patterns. Matched directories are pruned during scanning, so their contents are never traversed. This is useful when your project contains submodules or vendored code with `*.test.ts` files that you don't want `bun test` to pick up.

```toml title="bunfig.toml" icon="settings"
[test]
pathIgnorePatterns = ["vendor/**", "submodules/**", "fixtures/**"]
```

Equivalent CLI flag: `--path-ignore-patterns`. CLI flags override the `bunfig.toml` value entirely.

### `test.smol`

Same as the top-level `smol` field, but only applies to `bun test`.
Expand Down
54 changes: 54 additions & 0 deletions docs/test/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,59 @@ mock.module("./external-api", () => ({
}));
```

### Path Ignore Patterns

Exclude files and directories from test discovery entirely using glob patterns. Unlike `coveragePathIgnorePatterns` which only affects coverage reports, `pathIgnorePatterns` prevents matching paths from being discovered and run as tests.

This is useful when your project contains submodules, vendored code, or other directories with `*.test.ts` files that you don't want `bun test` to pick up.

```toml title="bunfig.toml" icon="settings"
[test]
# Single pattern
pathIgnorePatterns = "vendor/**"

# Multiple patterns
pathIgnorePatterns = [
"vendor/**",
"submodules/**",
"fixtures/**"
]
```

This is equivalent to using `--path-ignore-patterns` on the command line:

```bash terminal icon="terminal"
bun test --path-ignore-patterns 'vendor/**' --path-ignore-patterns 'fixtures/**'
```

Directories matching a pattern are pruned during scanning, so their contents are never traversed. This means ignoring a large directory tree is efficient -- Bun won't spend time reading files inside it.

#### Common Use Cases

```toml title="bunfig.toml" icon="settings"
[test]
pathIgnorePatterns = [
# Git submodules with their own test suites
"submodules/**",

# Vendored dependencies
"vendor/**",
"third-party/**",

# Test fixtures that look like tests but aren't
"fixtures/**",
"**/test-data/**",

# Integration / E2E tests you want to run separately
"**/integration/**",
"e2e/**"
]
```

<Note>
Command-line `--path-ignore-patterns` flags override the `bunfig.toml` value entirely -- the two are not merged.
</Note>

## Timeouts

### Default Timeout
Expand Down Expand Up @@ -415,6 +468,7 @@ exact = true
# Test discovery
root = "src"
preload = ["./test-setup.ts", "./global-mocks.ts"]
pathIgnorePatterns = ["vendor/**", "submodules/**"]

# Execution settings
timeout = 10000
Expand Down
31 changes: 31 additions & 0 deletions src/bunfig.zig
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,37 @@ pub const Bunfig = struct {
},
}
}

if (test_.get("pathIgnorePatterns")) |expr| brk: {
// Only skip if --path-ignore-patterns was explicitly passed via CLI
if (this.ctx.test_options.path_ignore_patterns_from_cli) break :brk;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.path_ignore_patterns = 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, "pathIgnorePatterns array must contain only strings");
return;
}
patterns[i] = try item.data.e_string.string(allocator);
}
this.ctx.test_options.path_ignore_patterns = patterns;
},
else => {
try this.addError(expr.loc, "pathIgnorePatterns must be a string or array of strings");
return;
},
}
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,8 @@ pub const Command = struct {
concurrent_test_glob: ?[]const []const u8 = null,
bail: u32 = 0,
coverage: TestCommand.CodeCoverageOptions = .{},
path_ignore_patterns: []const []const u8 = &.{},
path_ignore_patterns_from_cli: bool = false,
test_filter_pattern: ?[]const u8 = null,
test_filter_regex: ?*RegularExpression = null,
max_concurrency: u32 = 20,
Expand Down
6 changes: 6 additions & 0 deletions src/cli/Arguments.zig
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,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("--path-ignore-patterns <STR>... Glob patterns for test file paths to ignore.") catch unreachable,
};
pub const test_params = test_only_params ++ runtime_params_ ++ transpiler_params_ ++ base_params_;

Expand Down Expand Up @@ -545,6 +546,11 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
ctx.test_options.coverage.reports_directory = dir;
}

if (args.options("--path-ignore-patterns").len > 0) {
ctx.test_options.path_ignore_patterns = args.options("--path-ignore-patterns");
ctx.test_options.path_ignore_patterns_from_cli = true;
}

if (args.option("--bail")) |bail| {
if (bail.len > 0) {
ctx.test_options.bail = std.fmt.parseInt(u32, bail, 10) catch |e| {
Expand Down
38 changes: 37 additions & 1 deletion src/cli/test/Scanner.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ exclusion_names: []const []const u8 = &.{},
/// When this list is empty, no filters are applied.
/// "test" suffixes (e.g. .spec.*) are always applied when traversing directories.
filter_names: []const []const u8 = &.{},
/// Glob patterns for paths to ignore. Matched against the path relative to the
/// project root (top_level_dir). When a file matches any pattern, it is excluded.
path_ignore_patterns: []const []const u8 = &.{},
dirs_to_scan: Fifo,
/// Paths to test files found while scanning.
test_files: std.ArrayListUnmanaged(bun.PathString),
Expand Down Expand Up @@ -156,8 +159,32 @@ pub fn doesPathMatchFilter(this: *Scanner, name: []const u8) bool {
return false;
}

/// Returns true if the given path matches any of the path ignore patterns.
/// The path is matched as a relative path from the project root.
pub fn matchesPathIgnorePattern(this: *Scanner, abs_path: []const u8) bool {
if (this.path_ignore_patterns.len == 0) return false;
const rel_path = bun.path.relative(this.fs.top_level_dir, abs_path);
for (this.path_ignore_patterns) |pattern| {
if (bun.glob.match(pattern, rel_path).matches()) return true;
// Only try trailing separator for ** patterns (e.g. "vendor/**").
// Single-star patterns like "vendor/*" must not prune entire
// directories because * doesn't cross directory boundaries.
if (strings.indexOf(pattern, "**") != null) {
if (rel_path.len > 0 and rel_path[rel_path.len - 1] != '/') {
var buf: [bun.MAX_PATH_BYTES]u8 = undefined;
if (rel_path.len < buf.len) {
@memcpy(buf[0..rel_path.len], rel_path);
buf[rel_path.len] = '/';
if (bun.glob.match(pattern, buf[0 .. rel_path.len + 1]).matches()) return true;
}
}
}
}
return false;
}

pub fn isTestFile(this: *Scanner, name: []const u8) bool {
return this.couldBeTestFile(name, false) and this.doesPathMatchFilter(name);
return this.couldBeTestFile(name, false) and this.doesPathMatchFilter(name) and !this.matchesPathIgnorePattern(name);
}

pub fn next(this: *Scanner, entry: *FileSystem.Entry, fd: bun.StoredFileDescriptorType) void {
Expand All @@ -176,6 +203,13 @@ pub fn next(this: *Scanner, entry: *FileSystem.Entry, fd: bun.StoredFileDescript
if (strings.eql(exclude_name, name)) return;
}

// Prune ignored directory trees early so we never traverse them.
if (this.path_ignore_patterns.len > 0) {
const parts = &[_][]const u8{ entry.dir, entry.base() };
const dir_path = this.fs.absBuf(parts, &this.open_dir_buf);
if (this.matchesPathIgnorePattern(dir_path)) return;
Comment thread
robobun marked this conversation as resolved.
}

this.search_count += 1;

this.dirs_to_scan.writeItem(.{
Expand All @@ -199,6 +233,8 @@ pub fn next(this: *Scanner, entry: *FileSystem.Entry, fd: bun.StoredFileDescript
if (!this.doesPathMatchFilter(rel_path)) return;
}

if (this.matchesPathIgnorePattern(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;
},
Expand Down
1 change: 1 addition & 0 deletions src/cli/test_command.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,7 @@ pub const TestCommand = struct {

var scanner = bun.handleOom(Scanner.init(ctx.allocator, &vm.transpiler, ctx.positionals.len));
defer scanner.deinit();
scanner.path_ignore_patterns = ctx.test_options.path_ignore_patterns;
const has_relative_path = for (ctx.positionals) |arg| {
if (std.fs.path.isAbsolute(arg) or
strings.startsWith(arg, "./") or
Expand Down
Loading
Loading