From 79de51894974ee5178cc9180dddd9d9651514804 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:23:48 +0000 Subject: [PATCH 1/2] test: pin the failure counts of non-test failures in bun test No test asserted these counts: - --rerun-each: an error outside a test counts on every run, a file that fails to load counts once. - exit code 1 when the only failure is an error outside a test. - --parallel: files left when no worker starts count as failed. --- .../cli/test/parallel-startup-failure.test.ts | 7 +++++ test/js/bun/test/test-test.test.ts | 29 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/test/cli/test/parallel-startup-failure.test.ts b/test/cli/test/parallel-startup-failure.test.ts index 8d3ae7d7ba17..943cf932826a 100644 --- a/test/cli/test/parallel-startup-failure.test.ts +++ b/test/cli/test/parallel-startup-failure.test.ts @@ -67,6 +67,11 @@ test.skipIf(!hasHook)( const spawns = (stderr.match(/exited during startup/g) ?? []).length; expect(spawns).toBeGreaterThanOrEqual(2); expect(spawns).toBeLessThanOrEqual(4); + // Each file that no worker could take counts as one failure. + expect(stderr).toContain("✗ a.test.js (no live workers)\n"); + expect(stderr).toContain("✗ b.test.js (no live workers)\n"); + expect(stderr).toContain(" 0 pass\n 2 fail\n"); + expect(stderr).toContain("Ran 2 tests across 2 files."); expect(exitCode).not.toBe(0); }, 60_000, @@ -87,6 +92,8 @@ test.skipIf(!hasHook)( expect(stderr).toContain("a.test.js"); expect(stderr).toContain("b.test.js"); expect(stderr).not.toContain("retrying"); + expect(stderr).toContain(" 0 pass\n 2 fail\n"); + expect(stderr).toContain("Ran 2 tests across 2 files."); expect(exitCode).not.toBe(0); }, 60_000, diff --git a/test/js/bun/test/test-test.test.ts b/test/js/bun/test/test-test.test.ts index 1fad55319b9b..4aaa33483fb4 100644 --- a/test/js/bun/test/test-test.test.ts +++ b/test/js/bun/test/test-test.test.ts @@ -356,6 +356,32 @@ it("invalid syntax counts towards bail", async () => { } }); +it("--rerun-each counts an error outside a test on every run and a file that fails to load once", async () => { + using test_dir = tempDir("rerun-each-errors", { + "a-describe.test.js": ` + import { describe, expect, test } from "bun:test"; + describe("boom", () => { + throw new Error("describe-body-throw"); + }); + test("ok", () => { + expect(1).toBe(1); + }); + `, + "b-load.test.js": `throw new Error("top-level-throw");`, + }); + await using proc = spawn({ + cmd: [bunExe(), "test", "--rerun-each=2"], + cwd: String(test_dir), + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 2 pass\n 1 fail\n 3 errors\n"); + expect(stderr).toContain("Ran 3 tests across 2 files."); + expect(exitCode).toBe(1); +}); + describe("skip test inner", () => { it("should pass", () => { expect(2 + 2).toBe(4); @@ -719,7 +745,7 @@ test("my-test", () => { "package.json": "{}", }); - const { stderr, exited } = spawnSync({ + const { stderr, exitCode } = spawnSync({ cmd: [bunExe(), "test", "my-test.test.js"], cwd: test_dir, stdout: "inherit", @@ -746,6 +772,7 @@ test("my-test", () => { expect(output).toContain("1 error"); expect(output).toContain("Ran 1 test across 1 file"); + expect(exitCode).toBe(1); }); } }); From 251204bb3556ee30ae419ed067a172dbddff1bfe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:12:06 +0000 Subject: [PATCH 2/2] bun test: count a failure that is not a finished test only through fail_file The two counters that fail a run, Summary.fail and the count of errors outside a test, become private fields of Summary inside a small module in jest.rs. The count of errors moves from TestRunner into Summary. Three writers remain: - count_failed_test, for a finished test. - merge_worker_counts, for the totals a --parallel worker sends for a file. The records of those failures arrive as TestDone frames. - fail_file(kind, site, detail), for a load failure, an error outside a test, a crashed worker, a file aborted by a worker panic and a file no worker took. It counts what the kind counts and then hands the record to the reporter. The six sites that counted such a failure and recorded it themselves now call fail_file. A file no worker took is the one kind with no record. Console output and the JUnit document do not change. --- src/runtime/cli/test/parallel/Coordinator.rs | 78 +++++++------- src/runtime/cli/test/parallel/runner.rs | 5 +- src/runtime/cli/test_command.rs | 84 ++++++++++----- src/runtime/test_runner/bun_test.rs | 27 +++-- src/runtime/test_runner/jest.rs | 104 ++++++++++++++++--- 5 files changed, 196 insertions(+), 102 deletions(-) diff --git a/src/runtime/cli/test/parallel/Coordinator.rs b/src/runtime/cli/test/parallel/Coordinator.rs index ccc69934b1fe..5df4b7759ce1 100644 --- a/src/runtime/cli/test/parallel/Coordinator.rs +++ b/src/runtime/cli/test/parallel/Coordinator.rs @@ -17,7 +17,8 @@ use bun_ptr::Interned; use super::frame::{self, Frame}; use super::worker::{Worker, WorkerPipe}; -use crate::test_command::{CommandLineReporter, FileFailureRecord, TestFailure}; +use crate::test_command::{CommandLineReporter, FailureSite, FileFailureRecord, TestFailure}; +use crate::test_runner::jest::FileFailure; // `Status` lives in `crate::api::bun::process` // (not the lower-tier `bun_spawn` crate). Worker.exit_status is this type. @@ -192,10 +193,7 @@ impl<'a> Coordinator<'a> { .filter_map(|w| w.inflight.map(|idx| (idx, now - w.dispatched_at))) .collect(); for (idx, ms) in &running { - self.reporter.summary().fail += 1; - self.reporter.summary().files += 1; - self.mark_crashed(*idx, *ms); - self.files_done += 1; + self.fail_crashed_file(*idx, *ms); } if !running.is_empty() { bun_core::pretty_errorln!("\nInterrupted while still running:"); @@ -487,26 +485,19 @@ impl<'a> Coordinator<'a> { return; } - // Reshaped for borrowck — `summary()` mutably borrows - // `self.reporter`, so the unhandled-errors counter (also on - // `self.reporter.jest`) and `bail_out()` must run after the - // summary borrow is released. - { - let summary = self.reporter.summary(); - summary.pass += pass; - summary.fail += fail; - summary.skip += skip; - summary.todo += todo; - summary.expectations += expectations; - summary.skipped_because_label += skipped_label; - summary.files += files; - } - self.reporter.jest.unhandled_errors_between_tests += unhandled; + let summary = self.reporter.summary(); + summary.pass += pass; + summary.merge_worker_counts(fail, unhandled); + summary.skip += skip; + summary.todo += todo; + summary.expectations += expectations; + summary.skipped_because_label += skipped_label; + summary.files += files; self.record_timing(idx, w.dispatched_at); w.inflight = None; self.files_done += 1; - let fail_now = self.reporter.summary().fail; + let fail_now = self.reporter.summary().fail(); if self.bail > 0 && fail_now >= self.bail { self.bail_out(); } @@ -735,34 +726,34 @@ impl<'a> Coordinator<'a> { bstr::BStr::new(self.rel_path(file_idx)), bstr::BStr::new(reason), ); - self.reporter.summary().fail += 1; - self.reporter.summary().files += 1; - self.files_done += 1; - self.record_file_failure(file_idx, b"(aborted)", reason); + self.fail_file(file_idx, FileFailure::Aborted, reason); } - fn mark_crashed(&mut self, file_idx: u32, elapsed_ms: i64) { + fn fail_crashed_file(&mut self, file_idx: u32, elapsed_ms: i64) { if let Some(file) = self.test_records.get_mut(file_idx as usize) { file.elapsed_ns = u64::try_from(elapsed_ms).unwrap_or(0) * bun_core::time::NS_PER_MS; } - self.record_file_failure( + self.fail_file( file_idx, - b"(worker crashed)", + FileFailure::WorkerCrashed, b"worker process crashed before reporting results", ); } - /// The failed testcase a structured reporter gets for a file that the coordinator fails itself. - fn record_file_failure(&mut self, file_idx: u32, name: &'static [u8], message: &[u8]) { - if let Some(file) = self.test_records.get_mut(file_idx as usize) { - file.failure = Some(Box::new(FileFailureRecord { - name, - failure: Some(TestFailure { + /// Counts a file that the coordinator fails itself. `message` goes into its record. + fn fail_file(&mut self, file_idx: u32, kind: FileFailure, message: &[u8]) { + self.reporter.fail_file( + kind, + FailureSite::Coordinator(self.test_records.get_mut(file_idx as usize)), + || { + Some(TestFailure { message: message.to_vec(), ..Default::default() - }), - })); - } + }) + }, + ); + self.reporter.summary().files += 1; + self.files_done += 1; } fn account_crash( @@ -777,11 +768,8 @@ impl<'a> Coordinator<'a> { bstr::BStr::new(self.rel_path(file_idx)), detail, ); - self.reporter.summary().fail += 1; - self.reporter.summary().files += 1; - self.mark_crashed(file_idx, bun_core::time::milli_timestamp() - dispatched_at); - self.files_done += 1; - if self.bail > 0 && self.reporter.summary().fail >= self.bail { + self.fail_crashed_file(file_idx, bun_core::time::milli_timestamp() - dispatched_at); + if self.bail > 0 && self.reporter.summary().fail() >= self.bail { self.bail_out(); } } @@ -898,7 +886,11 @@ impl<'a> Coordinator<'a> { )), bstr::BStr::new(reason), ); - self.reporter.summary().fail += 1; + self.reporter.fail_file( + FileFailure::NotDispatched, + FailureSite::Coordinator(self.test_records.get_mut(idx as usize)), + || None, + ); self.reporter.summary().files += 1; self.files_done += 1; } diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 9d3a2cc5e06b..cf72acc1165d 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -574,7 +574,6 @@ impl<'a> WorkerLoop<'a> { self.cmds.send(wf.finish()); let before = *self.reporter.summary(); - let before_unhandled = self.reporter.jest.unhandled_errors_between_tests; let started_ns = bun_core::Timespec::now(bun_core::TimespecMockMode::ForceRealTime).ns(); @@ -611,13 +610,13 @@ impl<'a> WorkerLoop<'a> { for v in [ idx, after.pass - before.pass, - after.fail - before.fail, + after.fail() - before.fail(), after.skip - before.skip, after.todo - before.todo, after.expectations - before.expectations, after.skipped_because_label - before.skipped_because_label, after.files - before.files, - self.reporter.jest.unhandled_errors_between_tests - before_unhandled, + after.unhandled_errors() - before.unhandled_errors(), ] { wf.u32(v); } diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 5a5f98b6e721..5f55f5351d52 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2,6 +2,7 @@ use bun_io::Write as _; use crate::cli::Command; use crate::cli::test::changed_files_filter as ChangedFilesFilter; +use crate::cli::test::parallel::coordinator::FileTestRecords; use crate::cli::test::parallel_runner as ParallelRunner; use crate::cli::test::scanner::{self, Scanner}; use crate::cli::test::timings::Timings; @@ -59,7 +60,7 @@ use coverage::{ByteRangeMapping, CodeCoverageReport, Fraction}; // `crate::test_runner::*`; the façade below adapts the body's nested-path // usage (`bun_test::Execution::Result`, `bun_test::BasicResult`, …) without a // 2k-line body rewrite. -use crate::test_runner::jest::{self, FileColumns as _, Summary, TestRunner}; +use crate::test_runner::jest::{self, FileColumns as _, FileFailure, Summary, TestRunner}; use crate::test_runner::snapshot::Snapshots; use bun_collections::index_sort; @@ -208,7 +209,7 @@ pub(crate) fn describe_chain<'a>(child: &'a bun_test::BaseScope) -> Vec<(&'a [u8 scopes } -/// Where the testcase of a failure that is not a finished test goes. Each variant starts with the file, which this process runs. +/// Where the testcase of a failure that is not a finished test goes. The first three variants start with the file, which this process runs. pub(crate) enum FailureSite<'a> { /// Under no `describe` block. File(&'a [u8]), @@ -216,6 +217,8 @@ pub(crate) enum FailureSite<'a> { Describe(&'a [u8], &'a bun_test::DescribeScope), /// Beside the one test that runs now, which keeps the suite of its `describe` block in one piece. BesideTest(&'a [u8], &'a bun_test::ExecutionEntry), + /// A file of the `--parallel` coordinator: the records of its worker, `None` without a structured reporter. + Coordinator(Option<&'a mut FileTestRecords>), } /// The testcase the `--parallel` coordinator records for a file itself. @@ -224,6 +227,20 @@ pub(crate) struct FileFailureRecord { pub failure: Option, } +impl FileFailure { + /// `None`: the failure is counted and gets no testcase. + fn testcase_name(self) -> Option<&'static [u8]> { + match self { + FileFailure::Load { .. } => Some(b"(load error)"), + FileFailure::DescribeCallback => Some(b"(describe callback)"), + FileFailure::Unhandled => Some(b"(unhandled error)"), + FileFailure::WorkerCrashed => Some(b"(worker crashed)"), + FileFailure::Aborted => Some(b"(aborted)"), + FileFailure::NotDispatched => None, + } + } +} + /// Append `input` to `out`, dropping CSI sequences (`ESC '[' ... final`), so a /// matcher message built with colour does not reach the report as SGR residue. fn push_stripping_ansi(out: &mut Vec, input: &[u8]) { @@ -1284,18 +1301,33 @@ impl CommandLineReporter { &mut self.jest.summary } - /// Records a failure that is not a finished test as a failed testcase, when a structured reporter is on. - #[cold] + /// The name of the testcase `fail_file` records for `kind` at `site`, `None` when it records nothing. + pub(crate) fn file_failure_testcase( + &self, + kind: FileFailure, + site: &FailureSite<'_>, + ) -> Option<&'static [u8]> { + let recorded = match site { + FailureSite::Coordinator(records) => records.is_some(), + _ => self.jest.test_options.reporters.junit, + }; + if recorded { kind.testcase_name() } else { None } + } + + /// The record half of `fail_file`, which counts the failure first. pub(crate) fn record_file_failure( &mut self, name: &'static [u8], site: FailureSite<'_>, failure: Option, ) { - if !self.jest.test_options.reporters.junit { - return; - } let (file, scopes, line_number) = match site { + FailureSite::Coordinator(records) => { + if let Some(records) = records { + records.failure = Some(Box::new(FileFailureRecord { name, failure })); + } + return; + } FailureSite::File(file) => (file, Vec::new(), 0), FailureSite::Describe(file, scope) => { let mut scopes = describe_chain(&scope.base); @@ -1466,9 +1498,9 @@ impl CommandLineReporter { | R::FailBecauseTimeoutWithDoneCallback | R::FailBecauseHookTimeout | R::FailBecauseHookTimeoutWithDoneCallback => { - this.summary().fail += 1; + this.summary().count_failed_test(); - if this.summary().fail == this.jest.bail { + if this.summary().fail() == this.jest.bail { this.print_summary(); pretty_error!( "\nBailed out after {} failure{}\n", @@ -1490,7 +1522,7 @@ impl CommandLineReporter { pub(crate) fn print_summary(&mut self) { let summary_ = self.summary(); - let tests = summary_.fail + summary_.pass + summary_.skip + summary_.todo; + let tests = summary_.fail() + summary_.pass + summary_.skip + summary_.todo; let files = summary_.files; pretty_error!( @@ -1934,7 +1966,6 @@ impl TestCommand { // SAFETY: lifetime-erase to `'static`; `ctx` is the // process-lifetime CLI context and `exec()` never returns. test_options: unsafe { bun_ptr::detach_lifetime_ref(&ctx.test_options) }, - unhandled_errors_between_tests: 0, summary: Summary::default(), node_test_used: false, }, @@ -2479,12 +2510,12 @@ impl TestCommand { let _ = error_writer.write_all(&reporter.todos_to_repeat_buf); } - if reporter.summary().fail > 0 { + if reporter.summary().fail() > 0 { if reporter.summary().skip > 0 || reporter.summary().todo > 0 { pretty_error!("\n"); } - pretty_error!("\n{} tests failed:\n", reporter.summary().fail); + pretty_error!("\n{} tests failed:\n", reporter.summary().fail()); Output::flush(); let error_writer = Output::error_writer(); @@ -2576,8 +2607,8 @@ impl TestCommand { // `reporter.summary()` doesn't span the whole printing block and // conflict with the `reporter.jest.*` reads below. let summary: Summary = *reporter.summary(); - let did_label_filter_out_all_tests = summary.did_label_filter_out_all_tests() - && reporter.jest.unhandled_errors_between_tests == 0; + let did_label_filter_out_all_tests = + summary.did_label_filter_out_all_tests() && summary.unhandled_errors() == 0; if !did_label_filter_out_all_tests { struct DotIndenter { @@ -2625,19 +2656,19 @@ impl TestCommand { pretty_error!("{}{:5>} todo\n", &indenter, summary.todo); } - if summary.fail > 0 { + if summary.fail() > 0 { pretty_error!(""); } else { pretty_error!(""); } - pretty_error!("{}{:5>} fail\n", &indenter, summary.fail); - if reporter.jest.unhandled_errors_between_tests > 0 { + pretty_error!("{}{:5>} fail\n", &indenter, summary.fail()); + if summary.unhandled_errors() > 0 { pretty_error!( "{}{:5>} error{}\n", &indenter, - reporter.jest.unhandled_errors_between_tests, - if reporter.jest.unhandled_errors_between_tests > 1 { + summary.unhandled_errors(), + if summary.unhandled_errors() > 1 { "s" } else { "" @@ -2731,12 +2762,12 @@ impl TestCommand { let should_fail_on_no_tests = !ctx.test_options.pass_with_no_tests && (failed_to_find_any_tests || summary.did_label_filter_out_all_tests()); if should_fail_on_no_tests - || summary.fail > 0 + || summary.fail() > 0 || (coverage_options.enabled && coverage_options.fractions.failing && coverage_options.fail_on_low_coverage) || !write_snapshots_success - || reporter.jest.unhandled_errors_between_tests > 0 + || summary.unhandled_errors() > 0 { vm.exit_handler.exit_code = 1; } @@ -3002,19 +3033,18 @@ impl TestCommand { let (result, promise_js) = (p.result(global.vm()), p.to_js()); reporter.load_failure_pending = true; vm.unhandled_rejection(global, result, promise_js); - reporter.summary().fail += 1; if core::mem::take(&mut reporter.load_failure_pending) { - reporter.record_file_failure( - b"(load error)", + reporter.fail_file( + FileFailure::Load { reported: false }, FailureSite::File(file_path), - None, + || None, ); } if let Some(junit) = reporter.reporters.junit.as_mut() { let _ = junit.end_file(None); } - if reporter.jest.bail == reporter.summary().fail { + if reporter.jest.bail == reporter.summary().fail() { reporter.print_summary(); pretty_error!( "\nBailed out after {} failure{}\n", diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index fdd19ca422c7..a26cc0fe403b 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -9,7 +9,7 @@ use bun_jsc::{self as jsc, CallFrame, GlobalRef, JSGlobalObject, JSValue, JsResu use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::js_promise::Status as PromiseStatus; use bun_ptr::RefPtr; -use super::jest::{Jest, FileId, FileColumns as _}; +use super::jest::{Jest, FileFailure, FileId, FileColumns as _}; use crate::timer::{EventLoopTimer, EventLoopTimerState, EventLoopTimerTag, ElTimespec}; use crate::cli::test_command::{CommandLineReporter, FailureSite, TestFailure}; use super::execution::TimespecExt as _; @@ -1317,7 +1317,6 @@ impl BunTest { // `NonNull` carries write provenance from // `enter_file`'s `&mut`; single-threaded, no other borrow live. let reporter = unsafe { &mut *self.reporter.unwrap().as_ptr() }; - reporter.jest.unhandled_errors_between_tests += 1; is_load_failure = core::mem::take(&mut reporter.load_failure_pending); bun_core::pretty_errorln!( "\n# Unhandled error between tests\n-------------------------------\n", @@ -1343,40 +1342,40 @@ impl BunTest { Output::flush(); - // After the flush: a `--parallel` worker sends the record behind the text it belongs to. - if unhandled && !failure_ctx.is_null() { - // SAFETY: as for the count above. + // Counted after the flush: a `--parallel` worker sends the record behind the text it belongs to. + if unhandled { + // SAFETY: as for the marker above. let reporter = unsafe { &mut *self.reporter.unwrap().as_ptr() }; let file = reporter.jest.files.items_source()[self.file_id as usize].path.text; let in_describe = handle_status == HandleUncaughtExceptionResult::ShowUnhandledErrorInDescribe; - let (name, site) = self.unhandled_error_record(file, is_load_failure, in_describe); - reporter.record_file_failure(name, site, unhandled_detail); + let (kind, site) = self.unhandled_failure(file, is_load_failure, in_describe); + reporter.fail_file(kind, site, || unhandled_detail); } } - /// The testcase name and the place of the record of an error that no running test owns. - fn unhandled_error_record<'a>( + /// The kind and the place of the record of an error that no running test owns. + fn unhandled_failure<'a>( &'a mut self, file: &'a [u8], is_load_failure: bool, in_describe: bool, - ) -> (&'static [u8], FailureSite<'a>) { + ) -> (FileFailure, FailureSite<'a>) { if is_load_failure { - return (b"(load error)", FailureSite::File(file)); + return (FileFailure::Load { reported: true }, FailureSite::File(file)); } if in_describe { let scope = self.collection.active_scope(); if !core::ptr::eq(scope, &*self.collection.root_scope) { - return (b"(describe callback)", FailureSite::Describe(file, scope)); + return (FileFailure::DescribeCallback, FailureSite::Describe(file, scope)); } } else if self.phase == Phase::Execution && let Some(sequence) = self.get_current_state_data().sequence(self) && let Some(entry) = sequence.test_entry.or(sequence.first_entry) { // SAFETY: entries are owned by the collection tree, which lives as long as `self`. - return (b"(unhandled error)", FailureSite::BesideTest(file, unsafe { entry.as_ref() })); + return (FileFailure::Unhandled, FailureSite::BesideTest(file, unsafe { entry.as_ref() })); } - (b"(unhandled error)", FailureSite::File(file)) + (FileFailure::Unhandled, FailureSite::File(file)) } } diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index 2b3052d0a8e6..9aae4f8020fd 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -142,7 +142,6 @@ pub(crate) struct TestRunner<'a> { /// `*mut` at the use site would launder shared provenance into a write (UB). pub(crate) filter_regex: Option>, - pub(crate) unhandled_errors_between_tests: u32, pub(crate) summary: Summary, /// Set once any `node:test` registration API is called; gates `process.on('exit')` dispatch at the end of the run. @@ -258,21 +257,96 @@ impl<'a> TestRunner<'a> { // through `crate::timer` (see src/runtime/timer/mod.rs). use crate::timer::EventLoopTimerState as TimerState; -#[derive(Default, Clone, Copy)] -pub(crate) struct Summary { - pub(crate) pass: u32, - pub(crate) expectations: u32, - pub(crate) skip: u32, - pub(crate) todo: u32, - pub(crate) fail: u32, - pub(crate) files: u32, - pub(crate) skipped_because_label: u32, -} +pub(crate) use failures::{FileFailure, Summary}; + +/// The two counters that fail a run are private to this module: a failure that is not a finished test is counted only by `fail_file`. +mod failures { + use crate::cli::test_command::{CommandLineReporter, FailureSite, TestFailure}; + + #[derive(Default, Clone, Copy)] + pub(crate) struct Summary { + pub(crate) pass: u32, + pub(crate) expectations: u32, + pub(crate) skip: u32, + pub(crate) todo: u32, + fail: u32, + unhandled_errors: u32, + pub(crate) files: u32, + pub(crate) skipped_because_label: u32, + } + + impl Summary { + #[inline] + pub(crate) fn fail(&self) -> u32 { + self.fail + } + + /// Errors printed outside of a test: the `N errors` line of the summary. + #[inline] + pub(crate) fn unhandled_errors(&self) -> u32 { + self.unhandled_errors + } -impl Summary { - pub(crate) fn did_label_filter_out_all_tests(&self) -> bool { - self.skipped_because_label > 0 - && (self.pass + self.skip + self.todo + self.fail + self.expectations) == 0 + #[inline] + pub(crate) fn count_failed_test(&mut self) { + self.fail += 1; + } + + /// What a `--parallel` worker counted for one file. The records came as `TestDone` frames. + pub(crate) fn merge_worker_counts(&mut self, fail: u32, unhandled_errors: u32) { + self.fail += fail; + self.unhandled_errors += unhandled_errors; + } + + pub(crate) fn did_label_filter_out_all_tests(&self) -> bool { + self.skipped_because_label > 0 + && (self.pass + self.skip + self.todo + self.fail + self.expectations) == 0 + } + } + + /// A failure that is not a finished test. + #[derive(Clone, Copy, PartialEq, Eq)] + pub(crate) enum FileFailure { + /// The module of the file rejected while it loaded. `reported`: it was printed as an unhandled error, which `N errors` counts. + Load { reported: bool }, + /// A `describe` callback threw or rejected. + DescribeCallback, + /// An error that no running test owns. + Unhandled, + /// `--parallel`: the worker died while it ran the file. + WorkerCrashed, + /// `--parallel`: the file was running when another worker panicked. + Aborted, + /// `--parallel`: the run stopped before a worker took the file. + NotDispatched, + } + + impl CommandLineReporter { + /// Counts a failure that is not a finished test, then leaves its record with the structured reporter. + #[cold] + pub(crate) fn fail_file( + &mut self, + kind: FileFailure, + site: FailureSite<'_>, + detail: impl FnOnce() -> Option, + ) { + let summary = &mut self.jest.summary; + match kind { + FileFailure::Load { reported } => { + summary.fail += 1; + summary.unhandled_errors += u32::from(reported); + } + FileFailure::DescribeCallback | FileFailure::Unhandled => { + summary.unhandled_errors += 1; + } + FileFailure::WorkerCrashed | FileFailure::Aborted | FileFailure::NotDispatched => { + summary.fail += 1; + } + } + if let Some(name) = self.file_failure_testcase(kind, &site) { + self.record_file_failure(name, site, detail()); + } + } } }