Compare commits

...

5 Commits

Author SHA1 Message Date
Dylan Conway
10bc4e2ceb fixup 2025-12-18 14:09:36 -08:00
Dylan Conway
1aae46fb9a Merge branch 'main' into claude/test-path-ignore-patterns 2025-12-18 14:07:54 -08:00
Dylan Conway
8d57d24055 fix: add error handling for invalid testPathIgnorePatterns types
Report clear errors when testPathIgnorePatterns is not a string or
array of strings, matching the behavior of coveragePathIgnorePatterns.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 09:42:07 -08:00
Dylan Conway
32cc3c6c24 feat(test): add --test-path-ignore-patterns CLI option
Adds CLI support for excluding test files matching glob patterns.

Usage:
```bash
bun test --test-path-ignore-patterns "**/fixtures/**"
bun test --test-path-ignore-patterns "skip1*" --test-path-ignore-patterns "skip2*"
```

The option can be specified multiple times to add multiple patterns.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 00:28:20 -08:00
Dylan Conway
3e6cc6b6d1 feat(test): add testPathIgnorePatterns support to bunfig
Adds support for `testPathIgnorePatterns` in bunfig.toml to exclude
test files matching glob patterns from test discovery.

Example usage:
```toml
[test]
testPathIgnorePatterns = ["__fixtures__/**", "**/helpers/*"]
```

- Parse testPathIgnorePatterns from bunfig (string or array of strings)
- Add ignore_patterns field to Scanner
- Check patterns against both files and directories during scan
- Add tests for file exclusion, glob patterns, and directory exclusion

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 00:22:53 -08:00
6 changed files with 303 additions and 1 deletions

View File

@@ -470,6 +470,25 @@ pub const Bunfig = struct {
},
}
}
if (test_.get("testPathIgnorePatterns")) |expr| {
if (try expr.asStringCloned(bun.default_allocator)) |pattern| {
try this.ctx.test_options.ignore_patterns.append(pattern);
} else if (expr.asArray()) |arr| {
try this.ctx.test_options.ignore_patterns.ensureUnusedCapacity(arr.array.items.len);
for (arr.array.items.slice()) |item| {
if (try item.asStringCloned(bun.default_allocator)) |pattern| {
this.ctx.test_options.ignore_patterns.appendAssumeCapacity(pattern);
} else {
try this.addError(item.loc, "testPathIgnorePatterns array must contain only strings");
return;
}
}
} else {
try this.addError(expr.loc, "testPathIgnorePatterns 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,
ignore_patterns: bun.collections.ArrayListDefault([]const u8) = .init(),
reporters: struct {
dots: bool = false,

View File

@@ -224,6 +224,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("--test-path-ignore-patterns <STR>... Glob patterns to exclude from test discovery.") catch unreachable,
};
pub const test_params = test_only_params ++ runtime_params_ ++ transpiler_params_ ++ base_params_;
@@ -571,6 +572,10 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
std.process.exit(1);
};
}
for (args.options("--test-path-ignore-patterns")) |pattern| {
try ctx.test_options.ignore_patterns.append(pattern);
}
}
ctx.args.absolute_working_dir = cwd;

View File

@@ -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 to ignore when scanning for test files.
/// Memory is borrowed.
ignore_patterns: []const []const u8 = &.{},
dirs_to_scan: Fifo,
/// Paths to test files found while scanning.
test_files: std.ArrayListUnmanaged(bun.PathString),
@@ -156,6 +159,13 @@ pub fn doesPathMatchFilter(this: *Scanner, name: []const u8) bool {
return false;
}
pub fn matchesIgnorePattern(this: *Scanner, rel_path: []const u8) bool {
for (this.ignore_patterns) |pattern| {
if (bun.glob.match(pattern, rel_path).matches()) return true;
}
return false;
}
pub fn isTestFile(this: *Scanner, name: []const u8) bool {
return this.couldBeTestFile(name, false) and this.doesPathMatchFilter(name);
}
@@ -176,6 +186,14 @@ pub fn next(this: *Scanner, entry: *FileSystem.Entry, fd: bun.StoredFileDescript
if (strings.eql(exclude_name, name)) return;
}
// Check if this directory matches any ignore pattern
if (this.ignore_patterns.len > 0) {
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.matchesIgnorePattern(rel_path)) return;
}
this.search_count += 1;
this.dirs_to_scan.writeItem(.{
@@ -193,9 +211,12 @@ pub fn next(this: *Scanner, entry: *FileSystem.Entry, fd: bun.StoredFileDescript
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);
// Check if this file matches any ignore pattern
if (this.matchesIgnorePattern(rel_path)) return;
if (!this.doesAbsolutePathMatchFilter(path)) {
const rel_path = bun.path.relative(this.fs.top_level_dir, path);
if (!this.doesPathMatchFilter(rel_path)) return;
}

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.ignore_patterns = ctx.test_options.ignore_patterns.items();
const has_relative_path = for (ctx.positionals) |arg| {
if (std.fs.path.isAbsolute(arg) or
strings.startsWith(arg, "./") or

View File

@@ -196,4 +196,259 @@ describe("bunfig.toml test options", () => {
// 2 tests * 2 reruns = 4 total test runs
expect(output).toContain("4 pass");
});
test("testPathIgnorePatterns excludes matching files", async () => {
const dir = tempDirWithFiles("bunfig-test-ignore-patterns", {
"included.test.ts": `
import { test, expect } from "bun:test";
test("included", () => {
console.log("RUNNING: included");
expect(1).toBe(1);
});
`,
"ignored.test.ts": `
import { test, expect } from "bun:test";
test("ignored", () => {
console.log("RUNNING: ignored");
expect(1).toBe(1);
});
`,
"bunfig.toml": `[test]\ntestPathIgnorePatterns = ["ignored.test.ts"]`,
});
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;
// Only the included test should run
expect(output).toContain("RUNNING: included");
expect(output).not.toContain("RUNNING: ignored");
expect(output).toContain("1 pass");
});
test("testPathIgnorePatterns works with glob patterns", async () => {
const dir = tempDirWithFiles("bunfig-test-ignore-glob", {
"a.test.ts": `
import { test, expect } from "bun:test";
test("test a", () => {
console.log("RUNNING: a");
expect(1).toBe(1);
});
`,
"b.test.ts": `
import { test, expect } from "bun:test";
test("test b", () => {
console.log("RUNNING: b");
expect(1).toBe(1);
});
`,
"ignored/c.test.ts": `
import { test, expect } from "bun:test";
test("test c", () => {
console.log("RUNNING: c");
expect(1).toBe(1);
});
`,
"bunfig.toml": `[test]\ntestPathIgnorePatterns = ["ignored/**"]`,
});
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: a");
expect(output).toContain("RUNNING: b");
expect(output).not.toContain("RUNNING: c");
expect(output).toContain("2 pass");
});
test("testPathIgnorePatterns works with single string", async () => {
const dir = tempDirWithFiles("bunfig-test-ignore-string", {
"included.test.ts": `
import { test, expect } from "bun:test";
test("included", () => {
console.log("RUNNING: included");
expect(1).toBe(1);
});
`,
"skipped.test.ts": `
import { test, expect } from "bun:test";
test("skipped", () => {
console.log("RUNNING: skipped");
expect(1).toBe(1);
});
`,
"bunfig.toml": `[test]\ntestPathIgnorePatterns = "**/skipped*"`,
});
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: included");
expect(output).not.toContain("RUNNING: skipped");
expect(output).toContain("1 pass");
});
test("testPathIgnorePatterns excludes matching directories", async () => {
const dir = tempDirWithFiles("bunfig-test-ignore-dir", {
"a.test.ts": `
import { test, expect } from "bun:test";
test("test a", () => {
console.log("RUNNING: a");
expect(1).toBe(1);
});
`,
"__fixtures__/fixture.test.ts": `
import { test, expect } from "bun:test";
test("fixture test", () => {
console.log("RUNNING: fixture");
expect(1).toBe(1);
});
`,
"bunfig.toml": `[test]\ntestPathIgnorePatterns = ["__fixtures__"]`,
});
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: a");
expect(output).not.toContain("RUNNING: fixture");
expect(output).toContain("1 pass");
});
test("--test-path-ignore-patterns CLI option works", async () => {
const dir = tempDirWithFiles("bunfig-test-ignore-cli", {
"included.test.ts": `
import { test, expect } from "bun:test";
test("included", () => {
console.log("RUNNING: included");
expect(1).toBe(1);
});
`,
"excluded.test.ts": `
import { test, expect } from "bun:test";
test("excluded", () => {
console.log("RUNNING: excluded");
expect(1).toBe(1);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--test-path-ignore-patterns", "**/excluded*"],
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: included");
expect(output).not.toContain("RUNNING: excluded");
expect(output).toContain("1 pass");
});
test("--test-path-ignore-patterns CLI option works with multiple patterns", async () => {
const dir = tempDirWithFiles("bunfig-test-ignore-cli-multi", {
"a.test.ts": `
import { test, expect } from "bun:test";
test("test a", () => {
console.log("RUNNING: a");
expect(1).toBe(1);
});
`,
"skip1.test.ts": `
import { test, expect } from "bun:test";
test("skip1", () => {
console.log("RUNNING: skip1");
expect(1).toBe(1);
});
`,
"skip2.test.ts": `
import { test, expect } from "bun:test";
test("skip2", () => {
console.log("RUNNING: skip2");
expect(1).toBe(1);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--test-path-ignore-patterns", "**/skip1*", "--test-path-ignore-patterns", "**/skip2*"],
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: a");
expect(output).not.toContain("RUNNING: skip1");
expect(output).not.toContain("RUNNING: skip2");
expect(output).toContain("1 pass");
});
});