From 642d04b9f2296ae41d842acdf120382c765e632e Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 15 Oct 2025 17:38:02 -0700 Subject: [PATCH] Add --pass-with-no-tests flag to test runner (#23424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds support for the `--pass-with-no-tests` CLI flag to the test runner, addressing issue #20814. With the latest v1.2.8 release, the test runner now fails when no tests match a filter. While this is useful for agentic coding workflows, there are legitimate cases where the previous behavior is preferred, such as in monorepos where a standard test file pattern is used as a filter but not all packages contain tests. This flag makes the test runner behave like Jest and Vitest, exiting with code 0 when no tests are found. ## Changes - Added `--pass-with-no-tests` flag to CLI arguments in `src/cli/Arguments.zig` - Added `pass_with_no_tests` field to `TestOptions` struct in `src/cli.zig` - Updated test runner logic in `src/cli/test_command.zig` to respect the flag - Added comprehensive tests in `test/cli/test/pass-with-no-tests.test.ts` ## Test Plan All new tests pass: - ✅ `--pass-with-no-tests` exits with 0 when no test files found - ✅ `--pass-with-no-tests` exits with 0 when filters match no tests - ✅ Without flag, still exits with 1 when no tests found (preserves existing behavior) - ✅ `--pass-with-no-tests` still fails when actual tests fail Closes #20814 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Bot Co-authored-by: Claude Co-authored-by: pfg --- src/cli.zig | 1 + src/cli/Arguments.zig | 2 + src/cli/test_command.zig | 3 +- test/cli/test/pass-with-no-tests.test.ts | 99 ++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 test/cli/test/pass-with-no-tests.test.ts diff --git a/src/cli.zig b/src/cli.zig index bccc5c29f1..242c0308cc 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -342,6 +342,7 @@ pub const Command = struct { repeat_count: u32 = 0, run_todo: bool = false, only: bool = false, + pass_with_no_tests: bool = false, concurrent: bool = false, randomize: bool = false, seed: ?u32 = null, diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index 499f2df7e5..5e04ddd6a9 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -197,6 +197,7 @@ pub const test_only_params = [_]ParamType{ clap.parseParam("--rerun-each Re-run each test file times, helps catch certain bugs") catch unreachable, clap.parseParam("--todo Include tests that are marked with \"test.todo()\"") catch unreachable, clap.parseParam("--only Run only tests that are marked with \"test.only()\" or \"describe.only()\"") catch unreachable, + clap.parseParam("--pass-with-no-tests Exit with code 0 when no tests are found") catch unreachable, clap.parseParam("--concurrent Treat all tests as `test.concurrent()` tests") catch unreachable, clap.parseParam("--randomize Run tests in random order") catch unreachable, clap.parseParam("--seed Set the random seed for test randomization") catch unreachable, @@ -509,6 +510,7 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C ctx.test_options.update_snapshots = args.flag("--update-snapshots"); ctx.test_options.run_todo = args.flag("--todo"); ctx.test_options.only = args.flag("--only"); + ctx.test_options.pass_with_no_tests = args.flag("--pass-with-no-tests"); ctx.test_options.concurrent = args.flag("--concurrent"); ctx.test_options.randomize = args.flag("--randomize"); diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 23b70343f8..67a0ea8199 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -1766,7 +1766,8 @@ pub const TestCommand = struct { } const summary = reporter.summary(); - if (failed_to_find_any_tests or summary.didLabelFilterOutAllTests() or summary.fail > 0 or (coverage_options.enabled and coverage_options.fractions.failing and coverage_options.fail_on_low_coverage) or !write_snapshots_success) { + const should_fail_on_no_tests = !ctx.test_options.pass_with_no_tests and (failed_to_find_any_tests or summary.didLabelFilterOutAllTests()); + if (should_fail_on_no_tests or summary.fail > 0 or (coverage_options.enabled and coverage_options.fractions.failing and coverage_options.fail_on_low_coverage) or !write_snapshots_success) { vm.exit_handler.exit_code = 1; } else if (reporter.jest.unhandled_errors_between_tests > 0) { vm.exit_handler.exit_code = 1; diff --git a/test/cli/test/pass-with-no-tests.test.ts b/test/cli/test/pass-with-no-tests.test.ts new file mode 100644 index 0000000000..5b39edc57c --- /dev/null +++ b/test/cli/test/pass-with-no-tests.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +test("--pass-with-no-tests exits with 0 when no test files found", async () => { + using dir = tempDir("pass-with-no-tests", { + "not-a-test.ts": `console.log("hello");`, + }); + + const { exited, stderr } = Bun.spawn({ + cmd: [bunExe(), "test", "--pass-with-no-tests"], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + env: bunEnv, + }); + + const [err, exitCode] = await Promise.all([stderr.text(), exited]); + + expect(exitCode).toBe(0); + expect(err).toContain("No tests found!"); +}); + +test("--pass-with-no-tests exits with 0 when filters match no tests", async () => { + using dir = tempDir("pass-with-no-tests-filter", { + "some.test.ts": `import { test } from "bun:test"; test("example", () => {});`, + }); + + const { exited, stderr } = Bun.spawn({ + cmd: [bunExe(), "test", "--pass-with-no-tests", "-t", "nonexistent"], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + env: bunEnv, + }); + + const [err, exitCode] = await Promise.all([stderr.text(), exited]); + + expect(exitCode).toBe(0); +}); + +test("without --pass-with-no-tests, exits with 1 when no test files found", async () => { + using dir = tempDir("fail-with-no-tests", { + "not-a-test.ts": `console.log("hello");`, + }); + + const { exited, stderr } = Bun.spawn({ + cmd: [bunExe(), "test"], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + env: bunEnv, + }); + + const [err, exitCode] = await Promise.all([stderr.text(), exited]); + + expect(exitCode).toBe(1); + expect(err).toContain("No tests found!"); +}); + +test("without --pass-with-no-tests, exits with 1 when filters match no tests", async () => { + using dir = tempDir("fail-with-no-tests-filter", { + "some.test.ts": `import { test } from "bun:test"; test("example", () => {});`, + }); + + const { exited } = Bun.spawn({ + cmd: [bunExe(), "test", "-t", "nonexistent"], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + env: bunEnv, + }); + + const exitCode = await exited; + + expect(exitCode).toBe(1); +}); + +test("--pass-with-no-tests still fails when tests fail", async () => { + using dir = tempDir("pass-with-no-tests-but-fail", { + "test.test.ts": `import { test, expect } from "bun:test"; test("failing", () => { expect(1).toBe(2); });`, + }); + + const { exited } = Bun.spawn({ + cmd: [bunExe(), "test", "--pass-with-no-tests"], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + env: bunEnv, + }); + + const exitCode = await exited; + + expect(exitCode).toBe(1); +});