Files
bun.sh/test/cli/test/concurrent-test-glob.test.ts
robobun e58a4a7282 feat: add concurrent-test-glob option to bunfig.toml for selective concurrent test execution (#22898)
## 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>
2025-09-23 23:01:15 -07:00

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");
});
});