bun test: write coverage reports when --bail stops the run
Problem
bun test --bail --coverageemits no coverage at all once bail trips: no text table, nolcov.info, the coverage directory is not even created. The JUnit report and--update-timingsfile for the same run are written.- Coverage is only generated on the normal end-of-run path (
TestCommand::exec,src/runtime/cli/test_command.rs, thegenerate_code_coveragecall before the pass/fail counts). The two serial bail exits skip that path entirely:CommandLineReporter::handle_test_completed(a test failure reaches--bail):print_summary, "Bailed out", JUnit, timings,Global::exit(1).TestCommand::run_one_file(a test file fails to load, e.g. a top-level throw or a syntax error): same sequence, thenglobal_exit().
--parallelis not affected:Coordinator::bail_outonly stops dispatching files, and the coordinator still merges the workers' coverage chunks after the run loop ends.- Long-standing (same on 1.3.x);
jest --bailwrites coverage for what ran.
Fix
- Adds
CommandLineReporter::bail_out(vm), which is what both serial bail sites now call: flush, coverage reporters, summary line, "Bailed out" message, JUnit, timings. Each caller still exits the way it did before (Global::exit(1)mid-file, VM teardown plusglobal_exit()on load failure), so the only behavioral change is the coverage output. bail_outcalls the existinggenerate_code_coverage(vm, opts)on a clone of the configuredCodeCoverageOptionswhen coverage is enabled. The normal end-of-run path inexecstill calls it the same way, so itsfractions.failingexit-code check is unchanged.generate_code_coveragenow returns thelcov.infowrite error instead of exiting inside:execkeeps the old print-and-exit-1 behavior, whilebail_outprints the error and still writes the summary, the "Bailed out" message, the JUnit report and timings (the process exits 1 right after either way).generate_code_coverage/for_each_coverage_reportnow take&VirtualMachine: they only readvm.global(), and the mid-file bail site has no&mut VirtualMachinein scope (it usesVirtualMachine::get()).- The report is correct at bail time because coverage data is read from JSC's control-flow profiler and the thread-local
ByteRangeMappingtable for whatever files were loaded so far; nothing about it depends on the run having finished. The table/lcov produced on bail are byte-identical to what a non-bail run of the same executed code produces (checked on both this build and the release binary). - Verified:
test/cli/test/coverage.test.ts, new--bailblock: one test per bail site, plus one where--coverage-dirpoints at a plain file so the lcov write fails and the JUnit report and bail message must still appear. The first two fail on the current release (table missing from stderr,cov/lcov.infoabsent) and pass with this change. The inline lcov snapshot matches what the release binary produces for the same executed code without--bail.- The spawned child runs with
detect_leaks=0(same pattern as other tests that spawn into a known leaky exit): the mid-file bail exits throughGlobal::exitwithout VM teardown, so on the ASAN lane LSan appends a report to stderr and aborts. That is #32183 and happens without--coveragetoo (checked locally:bun test --bailalone reports the same leak set underdetect_leaks=1); a passing--coveragerun underBUN_DESTRUCT_VM_ON_EXIT=1is LSan-clean, so the coverage code itself is not leaking. bun bd test test/cli/test/coverage.test.ts test/regression/issue/26851.test.ts test/regression/issue/12250.test.ts(26851 covers JUnit-on-bail through the refactored sites).bun bd test test/cli/test/bun-test.test.ts -t bail,test/js/bun/test/test-test.test.ts -t bail(the latter exercises the load-failure bail site),test/cli/test/parallel.test.ts -t "coverage|bail".- Serial
coverageThresholdexit codes unchanged (text reporter exits 1, lcov-only still exits 0 as before; that gap is #32118).
Background
--bail[=N]makesbun teststop after N test failures. A test file that throws while being loaded counts as one failure.--coveragemakes the transpiler register every loaded (non-test, by default) file in aByteRangeMappingtable and turns on JSC's control-flow profiler. A coverage report for a file is produced on demand by asking the profiler which basic blocks and functions of that file executed;--coverage-reporter=textprints the table to stderr,--coverage-reporter=lcovwrites<coverage-dir>/lcov.info(via a temp file and rename).CommandLineReporteris the single object that owns run-wide state forbun test(counts, JUnit buffer, timings);write_junit_report_if_neededandwrite_timings_if_neededwere already the "must also run on bail" hooks, this adds coverage to that set.- The text reporter's "Uncovered Line #s" column drops a lone single-line range (pre-existing, unrelated, reported separately); the test fixture uses a two-line uncovered branch so its snapshot does not depend on that.
Notes
Rebased onto main after #39770, which turned the generate_code_coverage / print_code_coverage const generics into plain bool parameters. The conflict was in the helper and at the exec call site: write_code_coverage_if_needed now forwards opts.reporters.text, opts.reporters.lcov and Output::enable_ansi_colors_stderr() to the new signature instead of holding the old 8-way dispatch, and the exec block that did the same inline is replaced by the helper call as before. The &mut VirtualMachine to &VirtualMachine change and both bail sites applied unchanged. Re-ran the suites listed under Verified on the rebased build.
Rebased again after #39934, which appended a --parallel merges line coverage across workers test to the end of test/cli/test/coverage.test.ts, the same spot where the --bail block is added here. Kept both, main's test first. No source conflict. Re-ran the same suites.
Rebased a third time after #40678, which replaced the old generate_code_coverage / print_code_coverage / render_lcov trio with for_each_coverage_report plus a generate_code_coverage(vm, opts) that reads the reporter flags from opts and reports lcov write errors itself. That made the write_code_coverage_if_needed helper from earlier revisions redundant, so it is gone: bail_out now calls generate_code_coverage directly behind an enabled check, and the exec call site is left exactly as main has it. The &mut VirtualMachine to &VirtualMachine relaxation moved to the two new functions. The test file conflict was again a test appended at the end by main (#40586's --parallel merges function coverage across workers); kept both, main's first. Re-ran the same suites.
[review] gate passed · iteration 2 · 2 files touched
fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/test/coverage.test.ts
bun test v1.4.1 (65362b53b)
test/cli/test/coverage.test.ts:
bun test v1.4.1 (65362b53b)
demo.test.ts:
--------------|---------|---------|-------------------
File | % Funcs | % Lines | Uncovered Line #s
--------------|---------|---------|-------------------
All files | 0.00 | 66.67 |
demo.test.ts | 0.00 | 66.67 | 1
--------------|---------|---------|-------------------
0 pass
0 fail
Ran 0 tests across 1 file. [212.00ms]
(pass) coverage crash [305.06ms]
bun test v1.4.1 (65362b53b)
demo2.ts:
0 pass
0 fail
Ran 0 tests across 1 file. [223.00ms]
(pass) lcov coverage reporter [291.87ms]
(pass) coverage excludes node_modules directory [268.41ms]
(pass) coveragePathIgnorePatterns - single pattern string [299.60ms]
(pass) coveragePathIgnorePatterns - partial coverage without nan [374.29ms]
(pass) coveragePathIgnorePatterns - array of patterns [312.20ms]
(pass) coveragePathIgnorePatterns - glob patterns [384.68ms]
(pass) coveragePathIgnorePatterns - lcov reporter [300.18ms]
(pass) co
... (truncated)
release without fix: 5 FAILED
bun test v1.4.1-canary.1 (65362b53b)
test/cli/test/coverage.test.ts:
bun test v1.4.1-canary.1 (65362b53b)
demo.test.ts:
0 pass
0 fail
Ran 0 tests across 1 file. [4.00ms]
(pass) coverage crash [7.80ms]
bun test v1.4.1-canary.1 (65362b53b)
demo2.ts:
0 pass
0 fail
Ran 0 tests across 1 file. [4.00ms]
(pass) lcov coverage reporter [8.04ms]
(pass) coverage excludes node_modules directory [6.81ms]
(pass) coveragePathIgnorePatterns - single pattern string [6.72ms]
184 |
185 | let stderr = result.stderr.toString("utf-8");
186 | // Normalize output for cross-platform consistency
187 | stderr = normalizeBunSnapshot(stderr, dir);
188 |
189 | expect(stderr).toMatchInlineSnapshot(`
^
error: expect(received).toMatchInlineSnapshot(expected)
"test.test.ts:
(pass) should call only some functions
---------------|---------|---------|-------------------
File | % Funcs | % Lines | Uncovered Line #s
---------------|---------|---------|-------------------
All files | 75.00 | 83.33 |
- include-me.ts | 50.00 | 66.67 | 6
+ include-me.ts | 50.00 | 66.67 |
test.test.ts | 100.00 | 100.00 |
----------
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/test/coverage.test.ts
bun test v1.4.1 (65362b53b)
test/cli/test/coverage.test.ts:
bun test v1.4.1 (65362b53b)
demo.test.ts:
--------------|---------|---------|-------------------
File | % Funcs | % Lines | Uncovered Line #s
--------------|---------|---------|-------------------
All files | 0.00 | 66.67 |
demo.test.ts | 0.00 | 66.67 | 1
--------------|---------|---------|-------------------
0 pass
0 fail
Ran 0 tests across 1 file. [208.00ms]
(pass) coverage crash [299.56ms]
bun test v1.4.1 (65362b53b)
demo2.ts:
0 pass
0 fail
Ran 0 tests across 1 file. [296.00ms]
(pass) lcov coverage reporter [365.33ms]
(pass) coverage excludes node_modules directory [327.84ms]
(pass) coveragePathIgnorePatterns - single pattern string [295.97ms]
(pass) coveragePathIgnorePatterns - partial coverage without nan [289.95ms]
(pass) coveragePathIgnorePatterns - array of patterns [298.49ms]
(pass) coveragePathIgnorePatterns - glob patterns [455.04ms]
(pass) coveragePathIgnorePatterns - lcov reporter [366.27ms]
(pass) co
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 680ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/14] cxx obj/unified/UnifiedSource-src_jsc_bindings_node-0.cpp.o
[2/14] cxx obj/unified/UnifiedSource-src_uws_sys-0.cpp.o
[3/14] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[4/14] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[5/14] cxx obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o
[6/14] cxx obj/src/jsc/bindings/bindings.cpp.o
[7/14] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[8/14] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[9/14] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[10/14] gen cpp.rs (cppbind)
[10/14] cargo bun_runtime → libbun_runtime.a
[1m[92m Compiling[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
[1m[92m Finished[0m `release` profile [optimized + debuginfo] target(s) in 4m 14s
[11/14] link bun-profile
[13/14] strip bun
[13/14] bun-profile --revision
1.4.1-canary.1+a08285685
[build] done
bun test v1.4.1-canary.1 (a08285685)
test/cli
... (truncated)
diff hotspot
src/runtime/cli/test_command.rs | 59 ++++++++------
test/cli/test/coverage.test.ts | 176 +++++++++++++++++++++++++++++++++++++++-
2 files changed, 208 insertions(+), 27 deletions(-)
gate history · 5 passed · 0 rejected · iteration 2
evidence per changed file
file reads edits tests
src/runtime/cli/test_command.rs 22 17 0
test/cli/test/coverage.test.ts 10 10 0