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