Skip to main content

nextest_runner/reporter/displayer/
status_level.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Status levels: filters for which test statuses are displayed.
5//!
6//! Status levels play a role that's similar to log levels in typical loggers.
7
8use super::TestOutputDisplay;
9use crate::reporter::events::{CancelReason, ExecutionResultDescription};
10use serde::Deserialize;
11
12/// Status level to show in the reporter output.
13///
14/// Status levels are incremental: each level causes all the statuses listed above it to be output. For example,
15/// [`Slow`](Self::Slow) implies [`Retry`](Self::Retry) and [`Fail`](Self::Fail).
16#[derive(Copy, Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize)]
17#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
18#[cfg_attr(test, derive(test_strategy::Arbitrary))]
19#[serde(rename_all = "kebab-case")]
20#[non_exhaustive]
21pub enum StatusLevel {
22    /// No output.
23    None,
24
25    /// Only output test failures.
26    Fail,
27
28    /// Output retries and failures.
29    Retry,
30
31    /// Output information about slow tests, and all variants above.
32    Slow,
33
34    /// Output information about leaky tests, and all variants above.
35    Leak,
36
37    /// Output passing tests in addition to all variants above.
38    Pass,
39
40    /// Output skipped tests in addition to all variants above.
41    Skip,
42
43    /// Currently has the same meaning as [`Skip`](Self::Skip).
44    All,
45}
46
47/// Status level to show at the end of test runs in the reporter output.
48///
49/// Status levels are incremental.
50///
51/// This differs from [`StatusLevel`] in two ways:
52/// * It has a "flaky" test indicator that's different from "retry" (though "retry" works as an alias.)
53/// * It has a different ordering: skipped tests are prioritized over passing ones.
54#[derive(Copy, Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize)]
55#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
56#[cfg_attr(test, derive(test_strategy::Arbitrary))]
57#[serde(rename_all = "kebab-case")]
58#[non_exhaustive]
59pub enum FinalStatusLevel {
60    /// No output.
61    None,
62
63    /// Only output test failures.
64    Fail,
65
66    /// Output flaky tests.
67    #[serde(alias = "retry")]
68    Flaky,
69
70    /// Output information about slow tests, and all variants above.
71    Slow,
72
73    /// Output skipped tests in addition to all variants above.
74    Skip,
75
76    /// Output leaky tests in addition to all variants above.
77    Leak,
78
79    /// Output passing tests in addition to all variants above.
80    Pass,
81
82    /// Currently has the same meaning as [`Pass`](Self::Pass).
83    All,
84}
85
86pub(crate) struct StatusLevels {
87    pub(crate) status_level: StatusLevel,
88    pub(crate) final_status_level: FinalStatusLevel,
89}
90
91impl StatusLevels {
92    pub(super) fn compute_output_on_test_finished(
93        &self,
94        display: TestOutputDisplay,
95        cancel_status: Option<CancelReason>,
96        test_status_level: StatusLevel,
97        test_final_status_level: FinalStatusLevel,
98        execution_result: &ExecutionResultDescription,
99    ) -> OutputOnTestFinished {
100        let write_status_line = self.status_level >= test_status_level;
101
102        let is_immediate = display.is_immediate();
103        // We store entries in the final output map if either the final status level is high enough or
104        // if `display` says we show the output at the end.
105        let is_final = display.is_final() || self.final_status_level >= test_final_status_level;
106
107        // Check if this test was terminated by nextest during immediate termination mode.
108        // This is a heuristic: we check if the test failed with SIGTERM (Unix) or JobObject (Windows)
109        // during TestFailureImmediate cancellation. This suppresses output spam from tests we killed.
110        let terminated_by_nextest = cancel_status == Some(CancelReason::TestFailureImmediate)
111            && execution_result.is_termination_failure();
112
113        // This table is tested below. The basic invariant is that we generally follow what
114        // is_immediate and is_final suggests, except:
115        //
116        // - if the run is cancelled due to a non-interrupt signal, we display test output at most
117        //   once.
118        // - if the run is cancelled due to an interrupt, we hide the output because dumping a bunch
119        //   of output at the end is likely to not be helpful (though in the future we may want to
120        //   at least dump outputs into files and write their names out, or whenever nextest gains
121        //   the ability to replay test runs to be able to display it then.)
122        // - if the run is cancelled due to immediate test failure termination, we hide output for
123        //   tests that were terminated by nextest (via SIGTERM/job object), but still show output
124        //   for tests that failed naturally (e.g. due to assertion failures or other exit codes).
125        //
126        // is_immediate  is_final      cancel_status     terminated_by_nextest  |  show_immediate  store_final
127        //
128        //     false      false          <= Signal                *             |      false          false
129        //     false       true          <= Signal                *             |      false           true  [1]
130        //      true      false          <= Signal                *             |       true          false  [1]
131        //      true       true           < Signal                *             |       true           true
132        //      true       true             Signal                *             |       true          false  [2]
133        //       *          *            Interrupt                *             |      false          false  [3]
134        //       *          *       TestFailureImmediate         true           |      false          false  [4]
135        //       *          *       TestFailureImmediate        false           |  (use rules above)  [5]
136        //
137        // [1] In non-interrupt cases, we want to display output if specified once.
138        //
139        // [2] If there's a signal, we shouldn't display output twice at the end since it's
140        //     redundant -- instead, just show the output as part of the immediate display.
141        //
142        // [3] For interrupts, hide all output to avoid spam.
143        //
144        // [4] For tests terminated by nextest during immediate mode, hide output to avoid spam.
145        //
146        // [5] For tests that failed naturally during immediate mode (race condition), show output
147        //     normally since these are real failures.
148        let show_immediate =
149            is_immediate && cancel_status <= Some(CancelReason::Signal) && !terminated_by_nextest;
150
151        let store_final = if cancel_status == Some(CancelReason::Interrupt) || terminated_by_nextest
152        {
153            // Hide output completely for interrupt and nextest-initiated termination.
154            OutputStoreFinal::No
155        } else if is_final && cancel_status < Some(CancelReason::Signal)
156            || !is_immediate && is_final && cancel_status == Some(CancelReason::Signal)
157        {
158            OutputStoreFinal::Yes {
159                display_output: display.is_final(),
160            }
161        } else if is_immediate && is_final && cancel_status == Some(CancelReason::Signal) {
162            // In this special case, we already display the output once as the test is being
163            // cancelled, so don't display it again at the end since that's redundant.
164            OutputStoreFinal::Yes {
165                display_output: false,
166            }
167        } else {
168            OutputStoreFinal::No
169        };
170
171        OutputOnTestFinished {
172            write_status_line,
173            show_immediate,
174            store_final,
175        }
176    }
177}
178
179#[derive(Debug, PartialEq, Eq)]
180pub(super) struct OutputOnTestFinished {
181    pub(super) write_status_line: bool,
182    pub(super) show_immediate: bool,
183    pub(super) store_final: OutputStoreFinal,
184}
185
186#[derive(Debug, PartialEq, Eq)]
187pub(super) enum OutputStoreFinal {
188    /// Do not store the output.
189    No,
190
191    /// Store the output. display_output controls whether stdout and stderr should actually be
192    /// displayed at the end.
193    Yes { display_output: bool },
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::{
200        output_spec::RecordingSpec,
201        record::{LoadOutput, OutputEventKind},
202        reporter::{
203            displayer::{OutputLoadDecider, unit_output::OutputDisplayOverrides},
204            events::ExecutionStatuses,
205        },
206    };
207    use test_strategy::{Arbitrary, proptest};
208
209    // ---
210    // The proptests here are probabilistically exhaustive, and it's just easier to express them
211    // as property-based tests. We could also potentially use a model checker like Kani here.
212    // ---
213
214    #[proptest(cases = 64)]
215    fn on_test_finished_dont_write_status_line(
216        display: TestOutputDisplay,
217        cancel_status: Option<CancelReason>,
218        #[filter(StatusLevel::Pass < #test_status_level)] test_status_level: StatusLevel,
219        test_final_status_level: FinalStatusLevel,
220    ) {
221        let status_levels = StatusLevels {
222            status_level: StatusLevel::Pass,
223            final_status_level: FinalStatusLevel::Fail,
224        };
225
226        let actual = status_levels.compute_output_on_test_finished(
227            display,
228            cancel_status,
229            test_status_level,
230            test_final_status_level,
231            &ExecutionResultDescription::Pass,
232        );
233
234        assert!(!actual.write_status_line);
235    }
236
237    #[proptest(cases = 64)]
238    fn on_test_finished_write_status_line(
239        display: TestOutputDisplay,
240        cancel_status: Option<CancelReason>,
241        #[filter(StatusLevel::Pass >= #test_status_level)] test_status_level: StatusLevel,
242        test_final_status_level: FinalStatusLevel,
243    ) {
244        let status_levels = StatusLevels {
245            status_level: StatusLevel::Pass,
246            final_status_level: FinalStatusLevel::Fail,
247        };
248
249        let actual = status_levels.compute_output_on_test_finished(
250            display,
251            cancel_status,
252            test_status_level,
253            test_final_status_level,
254            &ExecutionResultDescription::Pass,
255        );
256        assert!(actual.write_status_line);
257    }
258
259    #[proptest(cases = 64)]
260    fn on_test_finished_with_interrupt(
261        // We always hide output on interrupt.
262        display: TestOutputDisplay,
263        // cancel_status is fixed to Interrupt.
264
265        // In this case, the status levels are not relevant for is_immediate and is_final.
266        test_status_level: StatusLevel,
267        test_final_status_level: FinalStatusLevel,
268    ) {
269        let status_levels = StatusLevels {
270            status_level: StatusLevel::Pass,
271            final_status_level: FinalStatusLevel::Fail,
272        };
273
274        let actual = status_levels.compute_output_on_test_finished(
275            display,
276            Some(CancelReason::Interrupt),
277            test_status_level,
278            test_final_status_level,
279            &ExecutionResultDescription::Pass,
280        );
281        assert!(!actual.show_immediate);
282        assert_eq!(actual.store_final, OutputStoreFinal::No);
283    }
284
285    #[proptest(cases = 64)]
286    fn on_test_finished_dont_show_immediate(
287        #[filter(!#display.is_immediate())] display: TestOutputDisplay,
288        cancel_status: Option<CancelReason>,
289        // The status levels are not relevant for show_immediate.
290        test_status_level: StatusLevel,
291        test_final_status_level: FinalStatusLevel,
292    ) {
293        let status_levels = StatusLevels {
294            status_level: StatusLevel::Pass,
295            final_status_level: FinalStatusLevel::Fail,
296        };
297
298        let actual = status_levels.compute_output_on_test_finished(
299            display,
300            cancel_status,
301            test_status_level,
302            test_final_status_level,
303            &ExecutionResultDescription::Pass,
304        );
305        assert!(!actual.show_immediate);
306    }
307
308    #[proptest(cases = 64)]
309    fn on_test_finished_show_immediate(
310        #[filter(#display.is_immediate())] display: TestOutputDisplay,
311        #[filter(#cancel_status <= Some(CancelReason::Signal))] cancel_status: Option<CancelReason>,
312        // The status levels are not relevant for show_immediate.
313        test_status_level: StatusLevel,
314        test_final_status_level: FinalStatusLevel,
315    ) {
316        let status_levels = StatusLevels {
317            status_level: StatusLevel::Pass,
318            final_status_level: FinalStatusLevel::Fail,
319        };
320
321        let actual = status_levels.compute_output_on_test_finished(
322            display,
323            cancel_status,
324            test_status_level,
325            test_final_status_level,
326            &ExecutionResultDescription::Pass,
327        );
328        assert!(actual.show_immediate);
329    }
330
331    // Where we don't store final output: if display.is_final() is false, and if the test final
332    // status level is too high.
333    #[proptest(cases = 64)]
334    fn on_test_finished_dont_store_final(
335        #[filter(!#display.is_final())] display: TestOutputDisplay,
336        cancel_status: Option<CancelReason>,
337        // The status level is not relevant for store_final.
338        test_status_level: StatusLevel,
339        // But the final status level is.
340        #[filter(FinalStatusLevel::Fail < #test_final_status_level)]
341        test_final_status_level: FinalStatusLevel,
342    ) {
343        let status_levels = StatusLevels {
344            status_level: StatusLevel::Pass,
345            final_status_level: FinalStatusLevel::Fail,
346        };
347
348        let actual = status_levels.compute_output_on_test_finished(
349            display,
350            cancel_status,
351            test_status_level,
352            test_final_status_level,
353            &ExecutionResultDescription::Pass,
354        );
355        assert_eq!(actual.store_final, OutputStoreFinal::No);
356    }
357
358    // Case 1 where we store final output: if display is exactly TestOutputDisplay::Final, and if
359    // the cancel status is not Interrupt.
360    #[proptest(cases = 64)]
361    fn on_test_finished_store_final_1(
362        #[filter(#cancel_status <= Some(CancelReason::Signal))] cancel_status: Option<CancelReason>,
363        // In this case, it isn't relevant what test_status_level and test_final_status_level are.
364        test_status_level: StatusLevel,
365        test_final_status_level: FinalStatusLevel,
366    ) {
367        let status_levels = StatusLevels {
368            status_level: StatusLevel::Pass,
369            final_status_level: FinalStatusLevel::Fail,
370        };
371
372        let actual = status_levels.compute_output_on_test_finished(
373            TestOutputDisplay::Final,
374            cancel_status,
375            test_status_level,
376            test_final_status_level,
377            &ExecutionResultDescription::Pass,
378        );
379        assert_eq!(
380            actual.store_final,
381            OutputStoreFinal::Yes {
382                display_output: true
383            }
384        );
385    }
386
387    // Case 2 where we store final output: if display is TestOutputDisplay::ImmediateFinal and the
388    // cancel status is not Signal or Interrupt
389    #[proptest(cases = 64)]
390    fn on_test_finished_store_final_2(
391        #[filter(#cancel_status < Some(CancelReason::Signal))] cancel_status: Option<CancelReason>,
392        test_status_level: StatusLevel,
393        test_final_status_level: FinalStatusLevel,
394    ) {
395        let status_levels = StatusLevels {
396            status_level: StatusLevel::Pass,
397            final_status_level: FinalStatusLevel::Fail,
398        };
399
400        let actual = status_levels.compute_output_on_test_finished(
401            TestOutputDisplay::ImmediateFinal,
402            cancel_status,
403            test_status_level,
404            test_final_status_level,
405            &ExecutionResultDescription::Pass,
406        );
407        assert_eq!(
408            actual.store_final,
409            OutputStoreFinal::Yes {
410                display_output: true
411            }
412        );
413    }
414
415    // Case 3 where we store final output: if display is TestOutputDisplay::ImmediateFinal and the
416    // cancel status is exactly Signal. In this special case, we don't display the output.
417    #[proptest(cases = 64)]
418    fn on_test_finished_store_final_3(
419        test_status_level: StatusLevel,
420        test_final_status_level: FinalStatusLevel,
421    ) {
422        let status_levels = StatusLevels {
423            status_level: StatusLevel::Pass,
424            final_status_level: FinalStatusLevel::Fail,
425        };
426
427        let actual = status_levels.compute_output_on_test_finished(
428            TestOutputDisplay::ImmediateFinal,
429            Some(CancelReason::Signal),
430            test_status_level,
431            test_final_status_level,
432            &ExecutionResultDescription::Pass,
433        );
434        assert_eq!(
435            actual.store_final,
436            OutputStoreFinal::Yes {
437                display_output: false,
438            }
439        );
440    }
441
442    // Case 4: if display.is_final() is *false* but the test_final_status_level is low enough.
443    #[proptest(cases = 64)]
444    fn on_test_finished_store_final_4(
445        #[filter(!#display.is_final())] display: TestOutputDisplay,
446        #[filter(#cancel_status <= Some(CancelReason::Signal))] cancel_status: Option<CancelReason>,
447        // The status level is not relevant for store_final.
448        test_status_level: StatusLevel,
449        // But the final status level is.
450        #[filter(FinalStatusLevel::Fail >= #test_final_status_level)]
451        test_final_status_level: FinalStatusLevel,
452    ) {
453        let status_levels = StatusLevels {
454            status_level: StatusLevel::Pass,
455            final_status_level: FinalStatusLevel::Fail,
456        };
457
458        let actual = status_levels.compute_output_on_test_finished(
459            display,
460            cancel_status,
461            test_status_level,
462            test_final_status_level,
463            &ExecutionResultDescription::Pass,
464        );
465        assert_eq!(
466            actual.store_final,
467            OutputStoreFinal::Yes {
468                display_output: false,
469            }
470        );
471    }
472
473    #[test]
474    fn on_test_finished_terminated_by_nextest() {
475        use crate::reporter::events::{AbortDescription, FailureDescription, SIGTERM};
476
477        let status_levels = StatusLevels {
478            status_level: StatusLevel::Pass,
479            final_status_level: FinalStatusLevel::Fail,
480        };
481
482        // Test 1: Terminated by nextest (SIGTERM) during TestFailureImmediate - should hide
483        {
484            let execution_result = ExecutionResultDescription::Fail {
485                failure: FailureDescription::Abort {
486                    abort: AbortDescription::UnixSignal {
487                        signal: SIGTERM,
488                        name: Some("TERM".into()),
489                    },
490                },
491                leaked: false,
492            };
493
494            let actual = status_levels.compute_output_on_test_finished(
495                TestOutputDisplay::ImmediateFinal,
496                Some(CancelReason::TestFailureImmediate),
497                StatusLevel::Fail,
498                FinalStatusLevel::Fail,
499                &execution_result,
500            );
501
502            assert!(
503                !actual.show_immediate,
504                "should not show immediate for SIGTERM during TestFailureImmediate"
505            );
506            assert_eq!(
507                actual.store_final,
508                OutputStoreFinal::No,
509                "should not store final for SIGTERM during TestFailureImmediate"
510            );
511        }
512
513        // Test 2: Terminated by nextest (JobObject) during TestFailureImmediate - should hide
514        {
515            let execution_result = ExecutionResultDescription::Fail {
516                failure: FailureDescription::Abort {
517                    abort: AbortDescription::WindowsJobObject,
518                },
519                leaked: false,
520            };
521
522            let actual = status_levels.compute_output_on_test_finished(
523                TestOutputDisplay::ImmediateFinal,
524                Some(CancelReason::TestFailureImmediate),
525                StatusLevel::Fail,
526                FinalStatusLevel::Fail,
527                &execution_result,
528            );
529
530            assert!(
531                !actual.show_immediate,
532                "should not show immediate for JobObject during TestFailureImmediate"
533            );
534            assert_eq!(
535                actual.store_final,
536                OutputStoreFinal::No,
537                "should not store final for JobObject during TestFailureImmediate"
538            );
539        }
540
541        // Test 3: Natural failure (exit code) during TestFailureImmediate - should show
542        let execution_result = ExecutionResultDescription::Fail {
543            failure: FailureDescription::ExitCode { code: 1 },
544            leaked: false,
545        };
546
547        let actual = status_levels.compute_output_on_test_finished(
548            TestOutputDisplay::ImmediateFinal,
549            Some(CancelReason::TestFailureImmediate),
550            StatusLevel::Fail,
551            FinalStatusLevel::Fail,
552            &execution_result,
553        );
554
555        assert!(
556            actual.show_immediate,
557            "should show immediate for natural failure during TestFailureImmediate"
558        );
559        assert_eq!(
560            actual.store_final,
561            OutputStoreFinal::Yes {
562                display_output: true
563            },
564            "should store final for natural failure"
565        );
566
567        // Test 4: SIGTERM but not during TestFailureImmediate (user sent signal) - should show
568        {
569            let execution_result = ExecutionResultDescription::Fail {
570                failure: FailureDescription::Abort {
571                    abort: AbortDescription::UnixSignal {
572                        signal: SIGTERM,
573                        name: Some("TERM".into()),
574                    },
575                },
576                leaked: false,
577            };
578
579            let actual = status_levels.compute_output_on_test_finished(
580                TestOutputDisplay::ImmediateFinal,
581                Some(CancelReason::Signal), // Regular signal, not TestFailureImmediate
582                StatusLevel::Fail,
583                FinalStatusLevel::Fail,
584                &execution_result,
585            );
586
587            assert!(
588                actual.show_immediate,
589                "should show immediate for user-initiated SIGTERM"
590            );
591            assert_eq!(
592                actual.store_final,
593                OutputStoreFinal::Yes {
594                    display_output: false
595                },
596                "should store but not display final"
597            );
598        }
599    }
600
601    // --- OutputLoadDecider safety invariant tests ---
602    //
603    // If OutputLoadDecider returns Skip, we ensure that the reporter's display
604    // logic will never show output. (This is a one-directional invariant -- the
605    // decider errs towards loading more than strictly necessary.)
606    //
607    // The invariants established below are:
608    //
609    // 1. OutputLoadDecider conservatively returns Load whenever output
610    //    might be shown.
611    // 2. The cancellation_only_hides_output test verifies that
612    //    cancellation never causes output to appear that wouldn't appear
613    //    without cancellation. This justifies the decider ignoring
614    //    cancel_status.
615    // 3. The test-finished tests verify that if the decider says Skip,
616    //    compute_output_on_test_finished (the displayer's oracle) with
617    //    cancel_status=None produces no output.
618    //
619    // Together, they imply that if we skip loading, then there's no output.
620
621    /// Cancellation can only hide output, never show more than the baseline
622    /// (cancel_status = None).
623    ///
624    /// The `OutputLoadDecider` relies on this property.
625    #[proptest(cases = 512)]
626    fn cancellation_only_hides_output(
627        display: TestOutputDisplay,
628        cancel_status: Option<CancelReason>,
629        test_status_level: StatusLevel,
630        test_final_status_level: FinalStatusLevel,
631        execution_result: ExecutionResultDescription,
632        status_level: StatusLevel,
633        final_status_level: FinalStatusLevel,
634    ) {
635        let status_levels = StatusLevels {
636            status_level,
637            final_status_level,
638        };
639
640        let baseline = status_levels.compute_output_on_test_finished(
641            display,
642            None,
643            test_status_level,
644            test_final_status_level,
645            &execution_result,
646        );
647
648        let with_cancel = status_levels.compute_output_on_test_finished(
649            display,
650            cancel_status,
651            test_status_level,
652            test_final_status_level,
653            &execution_result,
654        );
655
656        // Cancellation must never show MORE output than the baseline.
657        if !baseline.show_immediate {
658            assert!(
659                !with_cancel.show_immediate,
660                "cancel_status={cancel_status:?} caused immediate output that \
661                 wouldn't appear without cancellation"
662            );
663        }
664
665        // For store_final, monotonicity has two dimensions:
666        // 1. An entry stored (No -> Yes is an escalation).
667        // 2. Output bytes displayed (display_output: false -> true is an
668        //    escalation).
669        //
670        // All 9 combinations are enumerated so that adding a new
671        // OutputStoreFinal variant forces an update here.
672        match (&baseline.store_final, &with_cancel.store_final) {
673            // Cancellation caused storage that wouldn't happen without it.
674            (OutputStoreFinal::No, OutputStoreFinal::Yes { display_output }) => {
675                panic!(
676                    "cancel_status={cancel_status:?} caused final output storage \
677                     (display_output={display_output}) that wouldn't happen \
678                     without cancellation"
679                );
680            }
681            // Cancellation caused output bytes to be displayed when they
682            // wouldn't be without it.
683            (
684                OutputStoreFinal::Yes {
685                    display_output: false,
686                },
687                OutputStoreFinal::Yes {
688                    display_output: true,
689                },
690            ) => {
691                panic!(
692                    "cancel_status={cancel_status:?} caused final output display \
693                     that wouldn't happen without cancellation"
694                );
695            }
696
697            // Same or reduced visibility is all right.
698            (OutputStoreFinal::No, OutputStoreFinal::No)
699            | (
700                OutputStoreFinal::Yes {
701                    display_output: false,
702                },
703                OutputStoreFinal::No,
704            )
705            | (
706                OutputStoreFinal::Yes {
707                    display_output: false,
708                },
709                OutputStoreFinal::Yes {
710                    display_output: false,
711                },
712            )
713            | (
714                OutputStoreFinal::Yes {
715                    display_output: true,
716                },
717                _,
718            ) => {}
719        }
720    }
721
722    // --- should_load_for_test_finished with real ExecutionStatuses ---
723    //
724    // These tests use ExecutionStatuses<RecordingSpec> which naturally
725    // covers flaky runs (multi-attempt with last passing), is_slow
726    // interactions (is_slow changes final_status_level), and multi-attempt
727    // scenarios.
728
729    #[derive(Debug, Arbitrary)]
730    struct TestFinishedLoadDeciderInput {
731        status_level: StatusLevel,
732        final_status_level: FinalStatusLevel,
733        success_output: TestOutputDisplay,
734        failure_output: TestOutputDisplay,
735        force_success_output: Option<TestOutputDisplay>,
736        force_failure_output: Option<TestOutputDisplay>,
737        force_exec_fail_output: Option<TestOutputDisplay>,
738        run_statuses: ExecutionStatuses<RecordingSpec>,
739    }
740
741    /// If the decider returns Skip for a TestFinished event, the displayer's
742    /// `compute_output_on_test_finished` must never access output bytes. The
743    /// cancellation_only_hides test above ensures this extends to all
744    /// cancel_status values.
745    ///
746    /// The invariant is one-directional: Skip implies no output byte access.
747    /// The displayer may still store a final entry for the status line, which
748    /// is fine if display_output is false.
749    ///
750    /// This test exercises the full `should_load_for_test_finished` path
751    /// with real `ExecutionStatuses`.
752    #[proptest(cases = 512)]
753    fn load_decider_test_finished_skip_implies_no_output(input: TestFinishedLoadDeciderInput) {
754        let TestFinishedLoadDeciderInput {
755            status_level,
756            final_status_level,
757            success_output,
758            failure_output,
759            force_success_output,
760            force_failure_output,
761            force_exec_fail_output,
762            run_statuses,
763        } = input;
764
765        let decider = OutputLoadDecider {
766            status_level,
767            overrides: OutputDisplayOverrides {
768                force_success_output,
769                force_failure_output,
770                force_exec_fail_output,
771            },
772        };
773
774        let load_decision =
775            decider.should_load_for_test_finished(success_output, failure_output, &run_statuses);
776
777        if load_decision == LoadOutput::Skip {
778            // Derive the same inputs the displayer would compute.
779            let describe = run_statuses.describe();
780            let last_status = describe.last_status();
781
782            let display =
783                decider
784                    .overrides
785                    .resolve_for_describe(success_output, failure_output, &describe);
786
787            let test_status_level = describe.status_level();
788            let test_final_status_level = describe.final_status_level();
789
790            let status_levels = StatusLevels {
791                status_level,
792                final_status_level,
793            };
794
795            let output = status_levels.compute_output_on_test_finished(
796                display,
797                None, // cancel status
798                test_status_level,
799                test_final_status_level,
800                &last_status.result,
801            );
802
803            assert!(
804                !output.show_immediate,
805                "load decider returned Skip but displayer would show immediate output \
806                 (display={display:?}, test_status_level={test_status_level:?}, \
807                 test_final_status_level={test_final_status_level:?})"
808            );
809            // The displayer may still store an entry for the status line,
810            // but it must not display output bytes (display_output: false).
811            if let OutputStoreFinal::Yes {
812                display_output: true,
813            } = output.store_final
814            {
815                panic!(
816                    "load decider returned Skip but displayer would display final output \
817                     (display={display:?}, test_status_level={test_status_level:?}, \
818                     test_final_status_level={test_final_status_level:?})"
819                );
820            }
821        }
822    }
823
824    /// For TestAttemptFailedWillRetry, the decider's Load/Skip decision
825    /// must exactly match whether the displayer would show retry output.
826    ///
827    /// The displayer shows retry output iff both conditions hold:
828    ///
829    /// 1. `status_level >= Retry` (the retry line is printed at all)
830    /// 2. `resolved_failure_output.is_immediate()` (output is shown inline)
831    ///
832    /// The decider must return Load for exactly these cases and Skip
833    /// otherwise.
834    ///
835    /// ```text
836    /// status_level >= Retry   resolved.is_immediate()   displayer shows   decider
837    ///       false                    false                   no             Skip
838    ///       false                    true                    no             Skip
839    ///       true                     false                   no             Skip
840    ///       true                     true                    yes            Load
841    /// ```
842    #[proptest(cases = 64)]
843    fn load_decider_matches_retry_output(
844        status_level: StatusLevel,
845        failure_output: TestOutputDisplay,
846        force_failure_output: Option<TestOutputDisplay>,
847    ) {
848        let decider = OutputLoadDecider {
849            status_level,
850            overrides: OutputDisplayOverrides {
851                force_success_output: None,
852                force_failure_output,
853                force_exec_fail_output: None,
854            },
855        };
856
857        let resolved = decider.overrides.failure_output(failure_output);
858        let displayer_would_show = resolved.is_immediate() && status_level >= StatusLevel::Retry;
859
860        let expected = if displayer_would_show {
861            LoadOutput::Load
862        } else {
863            LoadOutput::Skip
864        };
865
866        let actual = OutputLoadDecider::should_load_for_retry(resolved, status_level);
867        assert_eq!(actual, expected);
868    }
869
870    /// For SetupScriptFinished: the decider returns Load iff the result
871    /// is not a success (the displayer always shows output for failures).
872    #[proptest(cases = 64)]
873    fn load_decider_matches_setup_script_output(execution_result: ExecutionResultDescription) {
874        let expected = if execution_result.is_success() {
875            LoadOutput::Skip
876        } else {
877            LoadOutput::Load
878        };
879        let actual = OutputLoadDecider::should_load_for_setup_script(&execution_result);
880        assert_eq!(actual, expected);
881    }
882
883    // --- Wiring test for should_load_output ---
884    //
885    // The public entry point should_load_output dispatches to the
886    // individual helper methods. This test verifies the dispatch is
887    // correct: a wiring error (e.g. passing success_output where
888    // failure_output is intended) would be caught.
889
890    /// `should_load_output` must produce the same result as calling the
891    /// corresponding helper method for each `OutputEventKind` variant.
892    #[proptest(cases = 256)]
893    fn should_load_output_consistent_with_helpers(
894        status_level: StatusLevel,
895        force_success_output: Option<TestOutputDisplay>,
896        force_failure_output: Option<TestOutputDisplay>,
897        force_exec_fail_output: Option<TestOutputDisplay>,
898        event_kind: OutputEventKind<RecordingSpec>,
899    ) {
900        let decider = OutputLoadDecider {
901            status_level,
902            overrides: OutputDisplayOverrides {
903                force_success_output,
904                force_failure_output,
905                force_exec_fail_output,
906            },
907        };
908
909        let actual = decider.should_load_output(&event_kind);
910
911        let expected = match &event_kind {
912            OutputEventKind::SetupScriptFinished { run_status, .. } => {
913                OutputLoadDecider::should_load_for_setup_script(&run_status.result)
914            }
915            OutputEventKind::TestAttemptFailedWillRetry { failure_output, .. } => {
916                let display = decider.overrides.failure_output(*failure_output);
917                OutputLoadDecider::should_load_for_retry(display, status_level)
918            }
919            OutputEventKind::TestFinished {
920                success_output,
921                failure_output,
922                run_statuses,
923                ..
924            } => decider.should_load_for_test_finished(
925                *success_output,
926                *failure_output,
927                run_statuses,
928            ),
929        };
930
931        assert_eq!(
932            actual, expected,
933            "should_load_output disagrees with individual helper for event kind"
934        );
935    }
936}