From 0dca0ee6a98375f7ec04fe60df74d7f9dfec32c3 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Tue, 25 Nov 2025 01:29:05 +0000 Subject: [PATCH] Add --resolve-extensions flag and bunfig.toml property for custom test file patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ... (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 --- src/bunfig.zig | 31 ++ src/cli.zig | 1 + src/cli/Arguments.zig | 10 + src/cli/test/Scanner.zig | 15 +- src/cli/test_command.zig | 50 ++- test/cli/test-resolve-extensions.md | 79 +++++ test/cli/test-resolve-extensions.test.ts | 379 +++++++++++++++++++++++ 7 files changed, 558 insertions(+), 7 deletions(-) create mode 100644 test/cli/test-resolve-extensions.md create mode 100644 test/cli/test-resolve-extensions.test.ts diff --git a/src/bunfig.zig b/src/bunfig.zig index 312debfa12..e3b6a26a70 100644 --- a/src/bunfig.zig +++ b/src/bunfig.zig @@ -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; + }, + } + } } } diff --git a/src/cli.zig b/src/cli.zig index b536d76556..87a0c9d1c8 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -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, diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index 9f163ad617..8bae551476 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -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 Maximum number of concurrent tests to execute at once. Default is 20.") catch unreachable, + clap.parseParam("--resolve-extensions ... 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; diff --git a/src/cli/test/Scanner.zig b/src/cli/test/Scanner.zig index 880572acb9..9514371df6 100644 --- a/src/cli/test/Scanner.zig +++ b/src/cli/test/Scanner.zig @@ -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; diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index ec1aa77aa2..76114c522c 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -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( \\No tests found! \\ - \\Tests need ".test", "_test_", ".spec" or "_spec_" in the filename (ex: "MyApp.test.ts") + \\Tests need {s} in the filename (ex: "MyApp{s}.ts") \\ - , .{}); + , .{ 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( \\ \\ - \\note: Tests need ".test", "_test_", ".spec" or "_spec_" in the filename (ex: "MyApp.test.ts") - , .{}); + \\note: Tests need {s} in the filename (ex: "MyApp{s}.ts") + , .{ buf.list.items, suffixes[0] }); // print a helpful note if (has_file_like) |i| { diff --git a/test/cli/test-resolve-extensions.md b/test/cli/test-resolve-extensions.md new file mode 100644 index 0000000000..9f34ed4c3d --- /dev/null +++ b/test/cli/test-resolve-extensions.md @@ -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 diff --git a/test/cli/test-resolve-extensions.test.ts b/test/cli/test-resolve-extensions.test.ts new file mode 100644 index 0000000000..eb457b24ad --- /dev/null +++ b/test/cli/test-resolve-extensions.test.ts @@ -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"); + }); +});