Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
8588698812 chore(test): remove unused stdout variables
Address CodeRabbit review feedback - use destructuring placeholder for
unused stdout variable in test assertions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:22:42 +00:00
Claude Bot
d5ef8a2d94 fix(test): support section names in --config flag
Fixes #25647

When using `bun test --config=ci`, the config value was being treated as
a file path, causing ENOENT errors. This change makes the test runner
recognize section names (like "ci", "staging") and look for the
corresponding `[test.<name>]` section in bunfig.toml.

Changes:
- Add `isConfigSectionName()` to detect section names vs file paths
- Store the section name in `TestOptions.config_section_name`
- Refactor bunfig test config parsing into `parseTestOptions()` helper
- Parse base `[test]` section first, then override with conditional section
- Add `timeout` field parsing to test config (was missing)

Example usage:
```toml
[test]
timeout = 5000

[test.ci]
timeout = 30000
```

```bash
bun test --config=ci  # Uses [test.ci] section
```

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:13:54 +00:00
4 changed files with 421 additions and 205 deletions

View File

@@ -185,6 +185,220 @@ pub const Bunfig = struct {
}
}
/// Parse test configuration options from a test section object.
/// This is called once for the base [test] section and optionally
/// again for a conditional section like [test.ci].
fn parseTestOptions(this: *Parser, test_: js_ast.Expr, allocator: std.mem.Allocator) !void {
if (test_.get("root")) |root| {
this.ctx.debug.test_directory = root.asString(this.allocator) orelse "";
}
if (test_.get("preload")) |expr| {
try this.loadPreload(allocator, expr);
}
if (test_.get("smol")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.runtime_options.smol = expr.data.e_boolean.value;
}
if (test_.get("coverage")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.test_options.coverage.enabled = expr.data.e_boolean.value;
}
if (test_.get("onlyFailures")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.test_options.reporters.only_failures = expr.data.e_boolean.value;
}
if (test_.get("reporter")) |expr| {
try this.expect(expr, .e_object);
if (expr.get("junit")) |junit_expr| {
try this.expectString(junit_expr);
if (junit_expr.data.e_string.len() > 0) {
this.ctx.test_options.reporters.junit = true;
this.ctx.test_options.reporter_outfile = try junit_expr.data.e_string.string(allocator);
}
}
if (expr.get("dots") orelse expr.get("dot")) |dots_expr| {
try this.expect(dots_expr, .e_boolean);
this.ctx.test_options.reporters.dots = dots_expr.data.e_boolean.value;
}
}
if (test_.get("coverageReporter")) |expr| brk: {
this.ctx.test_options.coverage.reporters = .{ .text = false, .lcov = false };
if (expr.data == .e_string) {
const item_str = expr.asString(bun.default_allocator) orelse "";
if (bun.strings.eqlComptime(item_str, "text")) {
this.ctx.test_options.coverage.reporters.text = true;
} else if (bun.strings.eqlComptime(item_str, "lcov")) {
this.ctx.test_options.coverage.reporters.lcov = true;
} else {
try this.addErrorFormat(expr.loc, allocator, "Invalid coverage reporter \"{s}\"", .{item_str});
}
break :brk;
}
try this.expect(expr, .e_array);
const items = expr.data.e_array.items.slice();
for (items) |item| {
try this.expectString(item);
const item_str = item.asString(bun.default_allocator) orelse "";
if (bun.strings.eqlComptime(item_str, "text")) {
this.ctx.test_options.coverage.reporters.text = true;
} else if (bun.strings.eqlComptime(item_str, "lcov")) {
this.ctx.test_options.coverage.reporters.lcov = true;
} else {
try this.addErrorFormat(item.loc, allocator, "Invalid coverage reporter \"{s}\"", .{item_str});
}
}
}
if (test_.get("coverageDir")) |expr| {
try this.expectString(expr);
this.ctx.test_options.coverage.reports_directory = try expr.data.e_string.string(allocator);
}
if (test_.get("coverageThreshold")) |expr| outer: {
if (expr.data == .e_number) {
this.ctx.test_options.coverage.fractions.functions = expr.data.e_number.value;
this.ctx.test_options.coverage.fractions.lines = expr.data.e_number.value;
this.ctx.test_options.coverage.fractions.stmts = expr.data.e_number.value;
this.ctx.test_options.coverage.fail_on_low_coverage = true;
break :outer;
}
try this.expect(expr, .e_object);
if (expr.get("functions")) |functions| {
try this.expect(functions, .e_number);
this.ctx.test_options.coverage.fractions.functions = functions.data.e_number.value;
this.ctx.test_options.coverage.fail_on_low_coverage = true;
}
if (expr.get("lines")) |lines| {
try this.expect(lines, .e_number);
this.ctx.test_options.coverage.fractions.lines = lines.data.e_number.value;
this.ctx.test_options.coverage.fail_on_low_coverage = true;
}
if (expr.get("statements")) |stmts| {
try this.expect(stmts, .e_number);
this.ctx.test_options.coverage.fractions.stmts = stmts.data.e_number.value;
this.ctx.test_options.coverage.fail_on_low_coverage = true;
}
}
// This mostly exists for debugging.
if (test_.get("coverageIgnoreSourcemaps")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.test_options.coverage.ignore_sourcemap = expr.data.e_boolean.value;
}
if (test_.get("coverageSkipTestFiles")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.test_options.coverage.skip_test_files = expr.data.e_boolean.value;
}
if (test_.get("randomize")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.test_options.randomize = expr.data.e_boolean.value;
}
if (test_.get("seed")) |expr| {
try this.expect(expr, .e_number);
const seed_value = expr.data.e_number.toU32();
// Validate that randomize is true when seed is specified
if (!this.ctx.test_options.randomize) {
try this.addError(expr.loc, "\"seed\" can only be used when \"randomize\" is true");
}
this.ctx.test_options.seed = seed_value;
}
if (test_.get("rerunEach")) |expr| {
try this.expect(expr, .e_number);
this.ctx.test_options.repeat_count = expr.data.e_number.toU32();
}
if (test_.get("timeout")) |expr| {
try this.expect(expr, .e_number);
this.ctx.test_options.default_timeout_ms = expr.data.e_number.toU32();
}
if (test_.get("concurrentTestGlob")) |expr| {
switch (expr.data) {
.e_string => |str| {
// Reject empty strings
if (str.len() == 0) {
try this.addError(expr.loc, "concurrentTestGlob cannot be an empty string");
return;
}
const pattern = try str.string(allocator);
const patterns = try allocator.alloc(string, 1);
patterns[0] = pattern;
this.ctx.test_options.concurrent_test_glob = patterns;
},
.e_array => |arr| {
if (arr.items.len == 0) {
try this.addError(expr.loc, "concurrentTestGlob array cannot be empty");
return;
}
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, "concurrentTestGlob array must contain only strings");
return;
}
// Reject empty strings in array
if (item.data.e_string.len() == 0) {
try this.addError(item.loc, "concurrentTestGlob patterns cannot be empty strings");
return;
}
patterns[i] = try item.data.e_string.string(allocator);
}
this.ctx.test_options.concurrent_test_glob = patterns;
},
else => {
try this.addError(expr.loc, "concurrentTestGlob must be a string or array of strings");
return;
},
}
}
if (test_.get("coveragePathIgnorePatterns")) |expr| 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.coverage.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, "coveragePathIgnorePatterns array must contain only strings");
return;
}
patterns[i] = try item.data.e_string.string(allocator);
}
this.ctx.test_options.coverage.ignore_patterns = patterns;
},
else => {
try this.addError(expr.loc, "coveragePathIgnorePatterns must be a string or array of strings");
return;
},
}
}
}
pub fn parse(this: *Parser, comptime cmd: Command.Tag) !void {
bun.analytics.Features.bunfig += 1;
@@ -261,213 +475,15 @@ pub const Bunfig = struct {
}
if (comptime cmd == .TestCommand) {
// First, parse the base [test] section if present
if (json.get("test")) |test_| {
if (test_.get("root")) |root| {
this.ctx.debug.test_directory = root.asString(this.allocator) orelse "";
}
try this.parseTestOptions(test_, allocator);
if (test_.get("preload")) |expr| {
try this.loadPreload(allocator, expr);
}
if (test_.get("smol")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.runtime_options.smol = expr.data.e_boolean.value;
}
if (test_.get("coverage")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.test_options.coverage.enabled = expr.data.e_boolean.value;
}
if (test_.get("onlyFailures")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.test_options.reporters.only_failures = expr.data.e_boolean.value;
}
if (test_.get("reporter")) |expr| {
try this.expect(expr, .e_object);
if (expr.get("junit")) |junit_expr| {
try this.expectString(junit_expr);
if (junit_expr.data.e_string.len() > 0) {
this.ctx.test_options.reporters.junit = true;
this.ctx.test_options.reporter_outfile = try junit_expr.data.e_string.string(allocator);
}
}
if (expr.get("dots") orelse expr.get("dot")) |dots_expr| {
try this.expect(dots_expr, .e_boolean);
this.ctx.test_options.reporters.dots = dots_expr.data.e_boolean.value;
}
}
if (test_.get("coverageReporter")) |expr| brk: {
this.ctx.test_options.coverage.reporters = .{ .text = false, .lcov = false };
if (expr.data == .e_string) {
const item_str = expr.asString(bun.default_allocator) orelse "";
if (bun.strings.eqlComptime(item_str, "text")) {
this.ctx.test_options.coverage.reporters.text = true;
} else if (bun.strings.eqlComptime(item_str, "lcov")) {
this.ctx.test_options.coverage.reporters.lcov = true;
} else {
try this.addErrorFormat(expr.loc, allocator, "Invalid coverage reporter \"{s}\"", .{item_str});
}
break :brk;
}
try this.expect(expr, .e_array);
const items = expr.data.e_array.items.slice();
for (items) |item| {
try this.expectString(item);
const item_str = item.asString(bun.default_allocator) orelse "";
if (bun.strings.eqlComptime(item_str, "text")) {
this.ctx.test_options.coverage.reporters.text = true;
} else if (bun.strings.eqlComptime(item_str, "lcov")) {
this.ctx.test_options.coverage.reporters.lcov = true;
} else {
try this.addErrorFormat(item.loc, allocator, "Invalid coverage reporter \"{s}\"", .{item_str});
}
}
}
if (test_.get("coverageDir")) |expr| {
try this.expectString(expr);
this.ctx.test_options.coverage.reports_directory = try expr.data.e_string.string(allocator);
}
if (test_.get("coverageThreshold")) |expr| outer: {
if (expr.data == .e_number) {
this.ctx.test_options.coverage.fractions.functions = expr.data.e_number.value;
this.ctx.test_options.coverage.fractions.lines = expr.data.e_number.value;
this.ctx.test_options.coverage.fractions.stmts = expr.data.e_number.value;
this.ctx.test_options.coverage.fail_on_low_coverage = true;
break :outer;
}
try this.expect(expr, .e_object);
if (expr.get("functions")) |functions| {
try this.expect(functions, .e_number);
this.ctx.test_options.coverage.fractions.functions = functions.data.e_number.value;
this.ctx.test_options.coverage.fail_on_low_coverage = true;
}
if (expr.get("lines")) |lines| {
try this.expect(lines, .e_number);
this.ctx.test_options.coverage.fractions.lines = lines.data.e_number.value;
this.ctx.test_options.coverage.fail_on_low_coverage = true;
}
if (expr.get("statements")) |stmts| {
try this.expect(stmts, .e_number);
this.ctx.test_options.coverage.fractions.stmts = stmts.data.e_number.value;
this.ctx.test_options.coverage.fail_on_low_coverage = true;
}
}
// This mostly exists for debugging.
if (test_.get("coverageIgnoreSourcemaps")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.test_options.coverage.ignore_sourcemap = expr.data.e_boolean.value;
}
if (test_.get("coverageSkipTestFiles")) |expr| {
try this.expect(expr, .e_boolean);
this.ctx.test_options.coverage.skip_test_files = expr.data.e_boolean.value;
}
var randomize_from_config: ?bool = null;
if (test_.get("randomize")) |expr| {
try this.expect(expr, .e_boolean);
randomize_from_config = expr.data.e_boolean.value;
this.ctx.test_options.randomize = expr.data.e_boolean.value;
}
if (test_.get("seed")) |expr| {
try this.expect(expr, .e_number);
const seed_value = expr.data.e_number.toU32();
// Validate that randomize is true when seed is specified
// Either randomize must be set to true in this config, or already enabled
const has_randomize_true = (randomize_from_config orelse this.ctx.test_options.randomize);
if (!has_randomize_true) {
try this.addError(expr.loc, "\"seed\" can only be used when \"randomize\" is true");
}
this.ctx.test_options.seed = seed_value;
}
if (test_.get("rerunEach")) |expr| {
try this.expect(expr, .e_number);
this.ctx.test_options.repeat_count = expr.data.e_number.toU32();
}
if (test_.get("concurrentTestGlob")) |expr| {
switch (expr.data) {
.e_string => |str| {
// Reject empty strings
if (str.len() == 0) {
try this.addError(expr.loc, "concurrentTestGlob cannot be an empty string");
return;
}
const pattern = try str.string(allocator);
const patterns = try allocator.alloc(string, 1);
patterns[0] = pattern;
this.ctx.test_options.concurrent_test_glob = patterns;
},
.e_array => |arr| {
if (arr.items.len == 0) {
try this.addError(expr.loc, "concurrentTestGlob array cannot be empty");
return;
}
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, "concurrentTestGlob array must contain only strings");
return;
}
// Reject empty strings in array
if (item.data.e_string.len() == 0) {
try this.addError(item.loc, "concurrentTestGlob patterns cannot be empty strings");
return;
}
patterns[i] = try item.data.e_string.string(allocator);
}
this.ctx.test_options.concurrent_test_glob = patterns;
},
else => {
try this.addError(expr.loc, "concurrentTestGlob must be a string or array of strings");
return;
},
}
}
if (test_.get("coveragePathIgnorePatterns")) |expr| 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.coverage.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, "coveragePathIgnorePatterns array must contain only strings");
return;
}
patterns[i] = try item.data.e_string.string(allocator);
}
this.ctx.test_options.coverage.ignore_patterns = patterns;
},
else => {
try this.addError(expr.loc, "coveragePathIgnorePatterns must be a string or array of strings");
return;
},
// If a conditional config section was specified (e.g., --config=ci),
// look for and apply [test.<section_name>] to override settings
if (this.ctx.test_options.config_section_name) |section_name| {
if (test_.get(section_name)) |conditional_section| {
try this.parseTestOptions(conditional_section, allocator);
}
}
}

View File

@@ -360,6 +360,9 @@ pub const Command = struct {
junit: bool = false,
} = .{},
reporter_outfile: ?[]const u8 = null,
/// The name of a conditional config section from bunfig.toml (e.g., "ci" for [test.ci])
config_section_name: ?[]const u8 = null,
};
pub const Debugger = union(enum) {

View File

@@ -296,6 +296,23 @@ fn getHomeConfigPath(buf: *bun.PathBuffer) ?[:0]const u8 {
return null;
}
/// Check if the config value looks like a section name rather than a file path.
/// A section name does not contain path separators and doesn't end with .toml
fn isConfigSectionName(value: []const u8) bool {
if (value.len == 0) return false;
// If it contains path separators, it's a path
if (std.mem.indexOfAny(u8, value, "/\\")) |_| return false;
// If it ends with .toml, it's a file
if (bun.strings.endsWithComptime(value, ".toml")) return false;
// If it starts with a dot, it's likely a hidden file
if (value[0] == '.') return false;
return true;
}
pub fn loadConfig(allocator: std.mem.Allocator, user_config_path_: ?string, ctx: Command.Context, comptime cmd: Command.Tag) OOM!void {
var config_buf: bun.PathBuffer = undefined;
if (comptime cmd.readGlobalConfig()) {
@@ -317,6 +334,19 @@ pub fn loadConfig(allocator: std.mem.Allocator, user_config_path_: ?string, ctx:
var config_path_: []const u8 = user_config_path_ orelse "";
// For the test command, check if the --config value is a section name rather than a file path.
// Section names are used for conditional configuration like [test.ci] in bunfig.toml.
if (comptime cmd == .TestCommand) {
if (user_config_path_) |user_config| {
if (isConfigSectionName(user_config)) {
// Store the section name for the bunfig parser to use
ctx.test_options.config_section_name = user_config;
// Load the default bunfig.toml instead
config_path_ = "bunfig.toml";
}
}
}
var auto_loaded: bool = false;
if (config_path_.len == 0 and (user_config_path_ != null or
Command.Tag.always_loads_config.get(cmd) or

View File

@@ -0,0 +1,167 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/25647
describe("issue #25647: bun test --config= should support section names", () => {
test("--config=ci should load [test.ci] section from bunfig.toml", async () => {
using dir = tempDir("test-25647", {
"bunfig.toml": `
[test]
timeout = 5000
[test.ci]
timeout = 30000
`,
"example.test.ts": `
import { test, expect } from "bun:test";
test("check timeout from conditional config", () => {
// The test runner reads the timeout from config
// We can't directly check the timeout value, but we can verify
// the config was loaded successfully by running the test
expect(1).toBe(1);
});
`,
});
// Run with --config=ci (section name, not file path)
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--config=ci", "example.test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should NOT get ENOENT error trying to open "ci" as a file
expect(stderr).not.toContain("ENOENT");
expect(stderr).not.toContain("No such file or directory");
// Test should pass (1 pass, 0 fail). Results are printed to stderr.
expect(stderr).toContain("1 pass");
expect(exitCode).toBe(0);
});
test("--config=staging should load [test.staging] section", async () => {
using dir = tempDir("test-25647-staging", {
"bunfig.toml": `
[test]
timeout = 1000
[test.staging]
timeout = 60000
`,
"example.test.ts": `
import { test, expect } from "bun:test";
test("staging config test", () => {
expect(true).toBe(true);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--config=staging", "example.test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).not.toContain("ENOENT");
expect(exitCode).toBe(0);
});
test("--config=./bunfig.toml should still work as a file path", async () => {
using dir = tempDir("test-25647-filepath", {
"bunfig.toml": `
[test]
timeout = 5000
`,
"example.test.ts": `
import { test, expect } from "bun:test";
test("file path config test", () => {
expect(true).toBe(true);
});
`,
});
// Pass an explicit file path (with ./)
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--config=./bunfig.toml", "example.test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
});
test("--config=custom.toml should work as a file path", async () => {
using dir = tempDir("test-25647-custom", {
"custom.toml": `
[test]
timeout = 5000
`,
"example.test.ts": `
import { test, expect } from "bun:test";
test("custom file config test", () => {
expect(true).toBe(true);
});
`,
});
// Pass a .toml file (should be treated as file path)
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--config=custom.toml", "example.test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
});
test("--config=nonexistent (section that doesn't exist) should still work", async () => {
using dir = tempDir("test-25647-nonexistent", {
"bunfig.toml": `
[test]
timeout = 5000
`,
"example.test.ts": `
import { test, expect } from "bun:test";
test("test with missing conditional section", () => {
expect(true).toBe(true);
});
`,
});
// Pass a section name that doesn't exist - should still load base [test] and not error
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--config=nonexistent", "example.test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should NOT get ENOENT error
expect(stderr).not.toContain("ENOENT");
expect(exitCode).toBe(0);
});
});