mirror of
https://github.com/oven-sh/bun
synced 2026-02-13 20:39:05 +00:00
## Summary Adds a new `concurrentTestGlob` configuration option to bunfig.toml that allows test files matching a glob pattern to automatically run with concurrent test execution enabled. This provides granular control over which tests run concurrently without modifying test files or using the global `--concurrent` flag. ## Problem Currently, enabling concurrent test execution in Bun requires either: 1. Using the `--concurrent` flag (affects ALL tests) 2. Manually adding `test.concurrent()` to individual test functions (requires modifying test files) This creates challenges for: - Large codebases wanting to gradually migrate to concurrent testing - Projects with mixed test types (unit tests that need isolation vs integration tests that can run in parallel) - CI/CD pipelines that want to optimize test execution without code changes ## Solution This PR introduces a `concurrentTestGlob` option in bunfig.toml that automatically enables concurrent execution for test files matching a specified glob pattern: ```toml [test] concurrentTestGlob = "**/concurrent-*.test.ts" ``` ### Key Features - ✅ Non-breaking: Completely opt-in via configuration - ✅ Flexible: Use glob patterns to target specific test files or directories - ✅ Override-friendly: `--concurrent` flag still forces all tests to run concurrently - ✅ Zero code changes: No need to modify existing test files ## Implementation Details ### Code Changes 1. Added `concurrent_test_glob` field to `TestOptions` struct (`src/cli.zig`) 2. Added parsing for `concurrentTestGlob` from bunfig.toml (`src/bunfig.zig`) 3. Added `concurrent_test_glob` field to `TestRunner` (`src/bun.js/test/jest.zig`) 4. Implemented `shouldFileRunConcurrently()` method that checks file paths against the glob pattern 5. Updated test execution logic to apply concurrent mode based on glob matching (`src/bun.js/test/ScopeFunctions.zig`) ### How It Works - When a test file is loaded, its path is checked against the configured glob pattern - If it matches, all tests in that file run concurrently (as if `--concurrent` was passed) - Files not matching the pattern run sequentially as normal - The `--concurrent` CLI flag overrides this behavior when specified ## Usage Examples ### Basic Usage ```toml # bunfig.toml [test] concurrentTestGlob = "**/integration/*.test.ts" ``` ### Multiple Patterns ```toml [test] concurrentTestGlob = [ "**/integration/*.test.ts", "**/e2e/*.test.ts", "**/concurrent-*.test.ts" ] ``` ### Migration Strategy Teams can gradually migrate to concurrent testing: 1. Start with integration tests: `"**/integration/*.test.ts"` 2. Add stable unit tests: `"**/fast-*.test.ts"` 3. Eventually migrate most tests except those requiring isolation ## Testing Added comprehensive test coverage in `test/cli/test/concurrent-test-glob.test.ts`: - ✅ Tests matching glob patterns run concurrently (verified via execution order logging) - ✅ Tests not matching patterns run sequentially (verified via shared state and execution order) - ✅ `--concurrent` flag properly overrides the glob setting - Tests use file system logging to deterministically verify concurrent vs sequential execution ## Documentation Complete documentation added: - `docs/runtime/bunfig.md` - Configuration reference - `docs/test/configuration.md` - Test configuration details - `docs/test/examples/concurrent-test-glob.md` - Comprehensive example with migration guide ## Performance Considerations - Glob matching happens once per test file during loading - Uses Bun's existing `glob.match()` implementation - Minimal overhead: simple string pattern matching - Future optimization: Could cache match results per file path ## Breaking Changes None. This is a fully backward-compatible, opt-in feature. ## Checklist - [x] Implementation complete and building - [x] Tests passing - [x] Documentation updated - [x] No breaking changes - [x] Follows existing code patterns ## Related Issues This addresses common requests for more granular control over concurrent test execution, particularly for large codebases migrating from other test runners. 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
251 lines
7.6 KiB
TypeScript
251 lines
7.6 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { bunEnv, bunExe, tempDir } from "harness";
|
|
import { join } from "path";
|
|
|
|
describe("concurrent-test-glob", () => {
|
|
test("tests matching glob pattern run concurrently", async () => {
|
|
// Create test files that log their execution
|
|
const testFile1 = `
|
|
import { test, expect } from "bun:test";
|
|
import { appendFileSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
const logFile = join(import.meta.dir, "execution.log");
|
|
|
|
test("test 1", async () => {
|
|
appendFileSync(logFile, "test1-start\\n");
|
|
await Bun.sleep(50);
|
|
appendFileSync(logFile, "test1-end\\n");
|
|
expect(1).toBe(1);
|
|
});
|
|
|
|
test("test 2", async () => {
|
|
appendFileSync(logFile, "test2-start\\n");
|
|
await Bun.sleep(50);
|
|
appendFileSync(logFile, "test2-end\\n");
|
|
expect(2).toBe(2);
|
|
});
|
|
`;
|
|
|
|
const testFile2 = `
|
|
import { test, expect } from "bun:test";
|
|
import { appendFileSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
const logFile = join(import.meta.dir, "execution.log");
|
|
|
|
test("test 3", async () => {
|
|
appendFileSync(logFile, "test3-start\\n");
|
|
await Bun.sleep(50);
|
|
appendFileSync(logFile, "test3-end\\n");
|
|
expect(3).toBe(3);
|
|
});
|
|
|
|
test("test 4", async () => {
|
|
appendFileSync(logFile, "test4-start\\n");
|
|
await Bun.sleep(50);
|
|
appendFileSync(logFile, "test4-end\\n");
|
|
expect(4).toBe(4);
|
|
});
|
|
`;
|
|
|
|
using dir = tempDir("concurrent-glob", {
|
|
"bunfig.toml": `[test]\nconcurrentTestGlob = "**/concurrent-*.test.ts"`,
|
|
"concurrent-1.test.ts": testFile1,
|
|
"concurrent-2.test.ts": testFile2,
|
|
"execution.log": "",
|
|
});
|
|
await using proc = Bun.spawn({
|
|
cmd: [bunExe(), "test"],
|
|
env: bunEnv,
|
|
cwd: String(dir),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout + stderr).toContain("4 pass");
|
|
|
|
// Read the execution log to verify concurrent execution
|
|
const logPath = join(String(dir), "execution.log");
|
|
const log = await Bun.file(logPath).text();
|
|
const lines = log.trim().split("\n").filter(Boolean);
|
|
|
|
// If tests ran concurrently, we should see interleaved starts
|
|
// Count how many "start" events occur before the first "end" event
|
|
const firstEndIndex = lines.findIndex(line => line.includes("-end"));
|
|
const startsBeforeFirstEnd = lines.slice(0, firstEndIndex).filter(line => line.includes("-start")).length;
|
|
|
|
// With concurrent execution, we expect multiple starts before the first end
|
|
// With sequential execution, we'd see start-end-start-end pattern
|
|
expect(startsBeforeFirstEnd).toBeGreaterThan(1);
|
|
});
|
|
|
|
test("tests not matching glob pattern run sequentially", async () => {
|
|
const testFile = `
|
|
import { test, expect } from "bun:test";
|
|
import { appendFileSync, existsSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
const logFile = join(import.meta.dir, "sequential.log");
|
|
|
|
// Initialize the log file
|
|
if (!existsSync(logFile)) {
|
|
appendFileSync(logFile, "");
|
|
}
|
|
|
|
// These tests share state and would fail if run concurrently
|
|
let sharedCounter = 0;
|
|
|
|
test("sequential test 1", async () => {
|
|
appendFileSync(logFile, "seq1-start\\n");
|
|
sharedCounter = 1;
|
|
await Bun.sleep(50); // Give time for race condition if concurrent
|
|
expect(sharedCounter).toBe(1); // Would fail if test 2 ran concurrently
|
|
appendFileSync(logFile, "seq1-end\\n");
|
|
});
|
|
|
|
test("sequential test 2", async () => {
|
|
appendFileSync(logFile, "seq2-start\\n");
|
|
expect(sharedCounter).toBe(1); // Should be 1 from test 1
|
|
sharedCounter = 2;
|
|
await Bun.sleep(50);
|
|
expect(sharedCounter).toBe(2);
|
|
appendFileSync(logFile, "seq2-end\\n");
|
|
});
|
|
`;
|
|
|
|
using dir = tempDir("sequential-glob", {
|
|
"bunfig.toml": `[test]\nconcurrentTestGlob = "**/concurrent-*.test.ts"`,
|
|
"sequential.test.ts": testFile,
|
|
"sequential.log": "",
|
|
});
|
|
|
|
await using proc = Bun.spawn({
|
|
cmd: [bunExe(), "test"],
|
|
env: bunEnv,
|
|
cwd: String(dir),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout + stderr).toContain("2 pass");
|
|
|
|
// Verify sequential execution pattern
|
|
const logPath = join(String(dir), "sequential.log");
|
|
const log = await Bun.file(logPath).text();
|
|
const lines = log.trim().split("\n").filter(Boolean);
|
|
|
|
// Sequential execution should show: seq1-start, seq1-end, seq2-start, seq2-end
|
|
expect(lines).toEqual(["seq1-start", "seq1-end", "seq2-start", "seq2-end"]);
|
|
});
|
|
|
|
test("multiple glob patterns work correctly", async () => {
|
|
const testFile1 = `
|
|
import { test, expect } from "bun:test";
|
|
import { appendFileSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
const logFile = join(import.meta.dir, "execution.log");
|
|
|
|
test("test 1", async () => {
|
|
appendFileSync(logFile, "test1-start\\n");
|
|
await Bun.sleep(50);
|
|
appendFileSync(logFile, "test1-end\\n");
|
|
expect(1).toBe(1);
|
|
});
|
|
|
|
test("test 2", async () => {
|
|
appendFileSync(logFile, "test2-start\\n");
|
|
await Bun.sleep(50);
|
|
appendFileSync(logFile, "test2-end\\n");
|
|
expect(2).toBe(2);
|
|
});
|
|
`;
|
|
|
|
const testFile2 = `
|
|
import { test, expect } from "bun:test";
|
|
import { appendFileSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
const logFile = join(import.meta.dir, "execution.log");
|
|
|
|
test("test 3", async () => {
|
|
appendFileSync(logFile, "test3-start\\n");
|
|
await Bun.sleep(50);
|
|
appendFileSync(logFile, "test3-end\\n");
|
|
expect(3).toBe(3);
|
|
});
|
|
|
|
test("test 4", async () => {
|
|
appendFileSync(logFile, "test4-start\\n");
|
|
await Bun.sleep(50);
|
|
appendFileSync(logFile, "test4-end\\n");
|
|
expect(4).toBe(4);
|
|
});
|
|
`;
|
|
|
|
using dir = tempDir("multiple-patterns", {
|
|
"bunfig.toml": `[test]\nconcurrentTestGlob = ["**/async-*.test.ts", "**/parallel-*.test.ts"]`,
|
|
"async-one.test.ts": testFile1,
|
|
"parallel-two.test.ts": testFile2,
|
|
"execution.log": "",
|
|
});
|
|
|
|
await using proc = Bun.spawn({
|
|
cmd: [bunExe(), "test"],
|
|
env: bunEnv,
|
|
cwd: String(dir),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout + stderr).toContain("4 pass");
|
|
|
|
// Read the execution log to verify concurrent execution
|
|
const logPath = join(String(dir), "execution.log");
|
|
const log = await Bun.file(logPath).text();
|
|
const lines = log.trim().split("\n").filter(Boolean);
|
|
|
|
// If tests ran concurrently, we should see interleaved starts
|
|
const firstEndIndex = lines.findIndex(line => line.includes("-end"));
|
|
const startsBeforeFirstEnd = lines.slice(0, firstEndIndex).filter(line => line.includes("-start")).length;
|
|
|
|
// With concurrent execution, we expect multiple starts before the first end
|
|
expect(startsBeforeFirstEnd).toBeGreaterThan(1);
|
|
});
|
|
|
|
test("concurrent flag overrides concurrent-test-glob", async () => {
|
|
using dir = tempDir("concurrent-override", {
|
|
"bunfig.toml": `[test]\nconcurrentTestGlob = "**/concurrent-*.test.ts"`,
|
|
"sequential.test.ts": `import { test, expect } from "bun:test";
|
|
|
|
test("test 1", () => {
|
|
expect(1).toBe(1);
|
|
});`,
|
|
});
|
|
|
|
// Run with --concurrent flag
|
|
await using proc = Bun.spawn({
|
|
cmd: [bunExe(), "test", "--concurrent"],
|
|
env: bunEnv,
|
|
cwd: String(dir),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout + stderr).toContain("1 pass");
|
|
});
|
|
});
|