nextest_runner/reporter/displayer/
progress.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{
5    cargo_config::{CargoConfigs, DiscoveredConfig},
6    reporter::{displayer::formatters::DisplayBracketedHhMmSs, events::*, helpers::Styles},
7};
8use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
9use owo_colors::OwoColorize;
10use std::{
11    env, fmt,
12    io::{self, IsTerminal, Write},
13    time::Duration,
14};
15use swrite::{SWrite, swrite};
16use tracing::debug;
17
18/// How to show progress.
19#[derive(Default, Clone, Copy, Debug)]
20pub enum ShowProgress {
21    /// Automatically decide based on environment.
22    #[default]
23    Auto,
24
25    /// No progress display.
26    None,
27
28    /// Show a progress bar.
29    Bar,
30
31    /// Show a counter on each line.
32    Counter,
33}
34
35#[derive(Debug)]
36pub(super) struct ProgressBarState {
37    bar: ProgressBar,
38    // Reasons for hiding the progress bar. We show the progress bar if none of
39    // these are set and hide it if any of them are set.
40    //
41    // indicatif cannot handle this kind of "stacked" state management, so it
42    // falls on us to do so.
43    //
44    // The current draw target is a pure function of these three booleans: if
45    // any of them are set, the draw target is hidden, otherwise it's stderr. If
46    // this changes, we'll need to track those other inputs.
47    hidden_no_capture: bool,
48    hidden_run_paused: bool,
49    hidden_info_response: bool,
50    hidden_between_sub_runs: bool,
51}
52
53impl ProgressBarState {
54    pub(super) fn new(test_count: usize, progress_chars: &str) -> Self {
55        let bar = ProgressBar::new(test_count as u64);
56
57        let test_count_width = format!("{test_count}").len();
58        // Create the template using the width as input. This is a
59        // little confusing -- {{foo}} is what's passed into the
60        // ProgressBar, while {bar} is inserted by the format!()
61        // statement.
62        let template = format!(
63            "{{prefix:>12}} [{{elapsed_precise:>9}}] {{wide_bar}} \
64            {{pos:>{test_count_width}}}/{{len:{test_count_width}}}: {{msg}}     "
65        );
66        bar.set_style(
67            ProgressStyle::default_bar()
68                .progress_chars(progress_chars)
69                .template(&template)
70                .expect("template is known to be valid"),
71        );
72
73        // NOTE: set_draw_target must be called before enable_steady_tick to avoid a
74        // spurious extra line from being printed as the draw target changes.
75        bar.set_draw_target(Self::stderr_target());
76        // Enable a steady tick 10 times a second.
77        bar.enable_steady_tick(Duration::from_millis(100));
78
79        Self {
80            bar,
81            hidden_no_capture: false,
82            hidden_run_paused: false,
83            hidden_info_response: false,
84            hidden_between_sub_runs: false,
85        }
86    }
87
88    pub(super) fn update_progress_bar(&mut self, event: &TestEvent<'_>, styles: &Styles) {
89        let before_should_hide = self.should_hide();
90
91        match &event.kind {
92            TestEventKind::StressSubRunFinished { .. } => {
93                // Hide the progress bar between sub runs to avoid a spurious
94                // progress bar.
95                self.hidden_between_sub_runs = true;
96            }
97            TestEventKind::SetupScriptStarted { no_capture, .. } => {
98                // Hide the progress bar if either stderr or stdout are being passed through.
99                if *no_capture {
100                    self.hidden_no_capture = true;
101                }
102                self.hidden_between_sub_runs = false;
103            }
104            TestEventKind::SetupScriptFinished { no_capture, .. } => {
105                // Restore the progress bar if it was hidden.
106                if *no_capture {
107                    self.hidden_no_capture = false;
108                }
109                self.hidden_between_sub_runs = false;
110            }
111            TestEventKind::TestStarted {
112                current_stats,
113                running,
114                ..
115            }
116            | TestEventKind::TestFinished {
117                current_stats,
118                running,
119                ..
120            } => {
121                self.hidden_between_sub_runs = false;
122                self.bar.set_prefix(progress_bar_prefix(
123                    current_stats,
124                    current_stats.cancel_reason,
125                    styles,
126                ));
127                self.bar
128                    .set_message(progress_bar_msg(current_stats, *running, styles));
129                // If there are skipped tests, the initial run count will be lower than when constructed
130                // in ProgressBar::new.
131                self.bar.set_length(current_stats.initial_run_count as u64);
132                self.bar.set_position(current_stats.finished_count as u64);
133            }
134            TestEventKind::InfoStarted { .. } => {
135                // While info is being displayed, hide the progress bar to avoid
136                // it interrupting the info display.
137                self.hidden_info_response = true;
138            }
139            TestEventKind::InfoFinished { .. } => {
140                // Restore the progress bar if it was hidden.
141                self.hidden_info_response = false;
142            }
143            TestEventKind::RunPaused { .. } => {
144                // Pausing the run should hide the progress bar since we'll exit
145                // to the terminal immediately after.
146                self.hidden_run_paused = true;
147            }
148            TestEventKind::RunContinued { .. } => {
149                // Continuing the run should show the progress bar since we'll
150                // continue to output to it.
151                self.hidden_run_paused = false;
152                // Wish a mutable form of with_elapsed were supported.
153                let bar = std::mem::replace(&mut self.bar, ProgressBar::hidden());
154                self.bar = bar.with_elapsed(event.elapsed);
155            }
156            TestEventKind::RunBeginCancel { current_stats, .. }
157            | TestEventKind::RunBeginKill { current_stats, .. } => {
158                self.bar.set_prefix(progress_bar_cancel_prefix(
159                    current_stats.cancel_reason,
160                    styles,
161                ));
162            }
163            _ => {}
164        }
165
166        let after_should_hide = self.should_hide();
167
168        match (before_should_hide, after_should_hide) {
169            (false, true) => self.bar.set_draw_target(Self::hidden_target()),
170            (true, false) => self.bar.set_draw_target(Self::stderr_target()),
171            _ => {}
172        }
173    }
174
175    pub(super) fn write_buf(&self, buf: &[u8]) -> io::Result<()> {
176        // ProgressBar::println doesn't print status lines if the bar is
177        // hidden. The suspend method prints it in all cases.
178        self.bar.suspend(|| std::io::stderr().write_all(buf))
179    }
180
181    #[inline]
182    pub(super) fn finish_and_clear(&self) {
183        self.bar.finish_and_clear();
184    }
185
186    fn stderr_target() -> ProgressDrawTarget {
187        // This used to be unbuffered, but that option went away from indicatif
188        // 0.17.0. The refresh rate is now 20hz so that it's double the steady
189        // tick rate.
190        ProgressDrawTarget::stderr_with_hz(20)
191    }
192
193    fn hidden_target() -> ProgressDrawTarget {
194        ProgressDrawTarget::hidden()
195    }
196
197    fn should_hide(&self) -> bool {
198        self.hidden_no_capture
199            || self.hidden_run_paused
200            || self.hidden_info_response
201            || self.hidden_between_sub_runs
202    }
203
204    pub(super) fn is_hidden(&self) -> bool {
205        self.bar.is_hidden()
206    }
207}
208
209/// OSC 9 terminal progress reporting.
210pub(super) struct TerminalProgress {}
211
212impl TerminalProgress {
213    const ENV: &str = "CARGO_TERM_PROGRESS_TERM_INTEGRATION";
214
215    pub(super) fn new(configs: &CargoConfigs, stream: &dyn IsTerminal) -> Option<Self> {
216        // See whether terminal integration is enabled in Cargo.
217        for config in configs.discovered_configs() {
218            match config {
219                DiscoveredConfig::CliOption { config, source } => {
220                    if let Some(v) = config.term.progress.term_integration {
221                        if v {
222                            debug!("enabling terminal progress reporting based on {source:?}");
223                            return Some(Self {});
224                        } else {
225                            debug!("disabling terminal progress reporting based on {source:?}");
226                            return None;
227                        }
228                    }
229                }
230                DiscoveredConfig::Env => {
231                    if let Some(v) = env::var_os(Self::ENV) {
232                        if v == "true" {
233                            debug!(
234                                "enabling terminal progress reporting based on \
235                                 CARGO_TERM_PROGRESS_TERM_INTEGRATION environment variable"
236                            );
237                            return Some(Self {});
238                        } else if v == "false" {
239                            debug!(
240                                "disabling terminal progress reporting based on \
241                                 CARGO_TERM_PROGRESS_TERM_INTEGRATION environment variable"
242                            );
243                            return None;
244                        } else {
245                            debug!(
246                                "invalid value for CARGO_TERM_PROGRESS_TERM_INTEGRATION \
247                                 environment variable: {v:?}, ignoring"
248                            );
249                        }
250                    }
251                }
252                DiscoveredConfig::File { config, source } => {
253                    if let Some(v) = config.term.progress.term_integration {
254                        if v {
255                            debug!("enabling terminal progress reporting based on {source:?}");
256                            return Some(Self {});
257                        } else {
258                            debug!("disabling terminal progress reporting based on {source:?}");
259                            return None;
260                        }
261                    }
262                }
263            }
264        }
265
266        supports_osc_9_4(stream).then_some(TerminalProgress {})
267    }
268
269    pub(super) fn update_progress(
270        &self,
271        event: &TestEvent<'_>,
272        writer: &mut dyn Write,
273    ) -> Result<(), io::Error> {
274        let value = match &event.kind {
275            TestEventKind::RunStarted { .. }
276            | TestEventKind::StressSubRunStarted { .. }
277            | TestEventKind::StressSubRunFinished { .. }
278            | TestEventKind::SetupScriptStarted { .. }
279            | TestEventKind::SetupScriptSlow { .. }
280            | TestEventKind::SetupScriptFinished { .. } => TerminalProgressValue::None,
281            TestEventKind::TestStarted { current_stats, .. }
282            | TestEventKind::TestFinished { current_stats, .. } => {
283                let percentage = (current_stats.finished_count as f64
284                    / current_stats.initial_run_count as f64)
285                    * 100.0;
286                if current_stats.has_failures() || current_stats.cancel_reason.is_some() {
287                    TerminalProgressValue::Error(percentage)
288                } else {
289                    TerminalProgressValue::Value(percentage)
290                }
291            }
292            TestEventKind::TestSlow { .. }
293            | TestEventKind::TestAttemptFailedWillRetry { .. }
294            | TestEventKind::TestRetryStarted { .. }
295            | TestEventKind::TestSkipped { .. }
296            | TestEventKind::InfoStarted { .. }
297            | TestEventKind::InfoResponse { .. }
298            | TestEventKind::InfoFinished { .. }
299            | TestEventKind::InputEnter { .. } => TerminalProgressValue::None,
300            TestEventKind::RunBeginCancel { current_stats, .. }
301            | TestEventKind::RunBeginKill { current_stats, .. } => {
302                // In this case, always indicate an error.
303                let percentage = (current_stats.finished_count as f64
304                    / current_stats.initial_run_count as f64)
305                    * 100.0;
306                TerminalProgressValue::Error(percentage)
307            }
308            TestEventKind::RunPaused { .. }
309            | TestEventKind::RunContinued { .. }
310            | TestEventKind::RunFinished { .. } => {
311                // Reset the terminal state to nothing, since nextest is giving
312                // up control of the terminal at this point.
313                TerminalProgressValue::Remove
314            }
315        };
316
317        write!(writer, "{value}")
318    }
319}
320
321/// Determines whether the terminal supports ANSI OSC 9;4.
322fn supports_osc_9_4(stream: &dyn IsTerminal) -> bool {
323    if !stream.is_terminal() {
324        debug!(
325            "autodetect terminal progress reporting: disabling since \
326             passed-in stream (usually stderr) is not a terminal"
327        );
328        return false;
329    }
330    if std::env::var("WT_SESSION").is_ok() {
331        debug!("autodetect terminal progress reporting: enabling since WT_SESSION is set");
332        return true;
333    };
334    if std::env::var("ConEmuANSI").ok() == Some("ON".into()) {
335        debug!("autodetect terminal progress reporting: enabling since ConEmuANSI is ON");
336        return true;
337    }
338    if std::env::var("TERM_PROGRAM").ok() == Some("WezTerm".into()) {
339        debug!("autodetect terminal progress reporting: enabling since TERM_PROGRAM is WezTerm");
340        return true;
341    }
342
343    false
344}
345
346/// A progress status value printable as an ANSI OSC 9;4 escape code.
347///
348/// Adapted from Cargo 1.87.
349#[derive(PartialEq, Debug)]
350enum TerminalProgressValue {
351    /// No output.
352    None,
353    /// Remove progress.
354    Remove,
355    /// Progress value (0-100).
356    Value(f64),
357    /// Indeterminate state (no bar, just animation)
358    ///
359    /// We don't use this yet, but might in the future.
360    #[expect(dead_code)]
361    Indeterminate,
362    /// Progress value in an error state (0-100).
363    Error(f64),
364}
365
366impl fmt::Display for TerminalProgressValue {
367    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368        // From https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
369        // ESC ] 9 ; 4 ; st ; pr ST
370        // When st is 0: remove progress.
371        // When st is 1: set progress value to pr (number, 0-100).
372        // When st is 2: set error state in taskbar, pr is optional.
373        // When st is 3: set indeterminate state, pr is ignored.
374        // When st is 4: set paused state, pr is optional.
375        let (state, progress) = match self {
376            Self::None => return Ok(()), // No output
377            Self::Remove => (0, 0.0),
378            Self::Value(v) => (1, *v),
379            Self::Indeterminate => (3, 0.0),
380            Self::Error(v) => (2, *v),
381        };
382        write!(f, "\x1b]9;4;{state};{progress:.0}\x1b\\")
383    }
384}
385
386/// Returns a summary of current progress.
387pub(super) fn progress_str(
388    elapsed: Duration,
389    current_stats: &RunStats,
390    running: usize,
391    styles: &Styles,
392) -> String {
393    // First, show the prefix.
394    let mut s = progress_bar_prefix(current_stats, current_stats.cancel_reason, styles);
395
396    // Then, the time elapsed, test counts, and message.
397    swrite!(
398        s,
399        " {}{}/{}: {}",
400        DisplayBracketedHhMmSs(elapsed),
401        current_stats.finished_count,
402        current_stats.initial_run_count,
403        progress_bar_msg(current_stats, running, styles)
404    );
405
406    s
407}
408
409pub(super) fn write_summary_str(run_stats: &RunStats, styles: &Styles, out: &mut String) {
410    // Written in this style to ensure new fields are accounted for.
411    let &RunStats {
412        initial_run_count: _,
413        finished_count: _,
414        setup_scripts_initial_count: _,
415        setup_scripts_finished_count: _,
416        setup_scripts_passed: _,
417        setup_scripts_failed: _,
418        setup_scripts_exec_failed: _,
419        setup_scripts_timed_out: _,
420        passed,
421        passed_slow,
422        flaky,
423        failed,
424        failed_slow: _,
425        timed_out,
426        leaky,
427        leaky_failed,
428        exec_failed,
429        skipped,
430        cancel_reason: _,
431    } = run_stats;
432
433    swrite!(
434        out,
435        "{} {}",
436        passed.style(styles.count),
437        "passed".style(styles.pass)
438    );
439
440    if passed_slow > 0 || flaky > 0 || leaky > 0 {
441        let mut text = Vec::with_capacity(3);
442        if passed_slow > 0 {
443            text.push(format!(
444                "{} {}",
445                passed_slow.style(styles.count),
446                "slow".style(styles.skip),
447            ));
448        }
449        if flaky > 0 {
450            text.push(format!(
451                "{} {}",
452                flaky.style(styles.count),
453                "flaky".style(styles.skip),
454            ));
455        }
456        if leaky > 0 {
457            text.push(format!(
458                "{} {}",
459                leaky.style(styles.count),
460                "leaky".style(styles.skip),
461            ));
462        }
463        swrite!(out, " ({})", text.join(", "));
464    }
465    swrite!(out, ", ");
466
467    if failed > 0 {
468        swrite!(
469            out,
470            "{} {}",
471            failed.style(styles.count),
472            "failed".style(styles.fail),
473        );
474        if leaky_failed > 0 {
475            swrite!(
476                out,
477                " ({} due to being {})",
478                leaky_failed.style(styles.count),
479                "leaky".style(styles.fail),
480            );
481        }
482        swrite!(out, ", ");
483    }
484
485    if exec_failed > 0 {
486        swrite!(
487            out,
488            "{} {}, ",
489            exec_failed.style(styles.count),
490            "exec failed".style(styles.fail),
491        );
492    }
493
494    if timed_out > 0 {
495        swrite!(
496            out,
497            "{} {}, ",
498            timed_out.style(styles.count),
499            "timed out".style(styles.fail),
500        );
501    }
502
503    swrite!(
504        out,
505        "{} {}",
506        skipped.style(styles.count),
507        "skipped".style(styles.skip),
508    );
509}
510
511fn progress_bar_cancel_prefix(reason: Option<CancelReason>, styles: &Styles) -> String {
512    let status = match reason {
513        Some(CancelReason::SetupScriptFailure)
514        | Some(CancelReason::TestFailure)
515        | Some(CancelReason::ReportError)
516        | Some(CancelReason::GlobalTimeout)
517        | Some(CancelReason::Signal)
518        | Some(CancelReason::Interrupt)
519        | None => "Cancelling",
520        Some(CancelReason::SecondSignal) => "Killing",
521    };
522    format!("{:>12}", status.style(styles.fail))
523}
524
525fn progress_bar_prefix(
526    run_stats: &RunStats,
527    cancel_reason: Option<CancelReason>,
528    styles: &Styles,
529) -> String {
530    if let Some(reason) = cancel_reason {
531        return progress_bar_cancel_prefix(Some(reason), styles);
532    }
533
534    let style = if run_stats.has_failures() {
535        styles.fail
536    } else {
537        styles.pass
538    };
539
540    format!("{:>12}", "Running".style(style))
541}
542
543pub(super) fn progress_bar_msg(
544    current_stats: &RunStats,
545    running: usize,
546    styles: &Styles,
547) -> String {
548    let mut s = format!("{} running, ", running.style(styles.count));
549    write_summary_str(current_stats, styles, &mut s);
550    s
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn test_progress_bar_prefix() {
559        let mut styles = Styles::default();
560        styles.colorize();
561
562        for (name, stats) in run_stats_test_failure_examples() {
563            let prefix = progress_bar_prefix(&stats, Some(CancelReason::TestFailure), &styles);
564            assert_eq!(
565                prefix,
566                "  Cancelling".style(styles.fail).to_string(),
567                "{name} matches"
568            );
569        }
570        for (name, stats) in run_stats_setup_script_failure_examples() {
571            let prefix =
572                progress_bar_prefix(&stats, Some(CancelReason::SetupScriptFailure), &styles);
573            assert_eq!(
574                prefix,
575                "  Cancelling".style(styles.fail).to_string(),
576                "{name} matches"
577            );
578        }
579
580        let prefix = progress_bar_prefix(&RunStats::default(), Some(CancelReason::Signal), &styles);
581        assert_eq!(prefix, "  Cancelling".style(styles.fail).to_string());
582
583        let prefix = progress_bar_prefix(&RunStats::default(), None, &styles);
584        assert_eq!(prefix, "     Running".style(styles.pass).to_string());
585
586        for (name, stats) in run_stats_test_failure_examples() {
587            let prefix = progress_bar_prefix(&stats, None, &styles);
588            assert_eq!(
589                prefix,
590                "     Running".style(styles.fail).to_string(),
591                "{name} matches"
592            );
593        }
594        for (name, stats) in run_stats_setup_script_failure_examples() {
595            let prefix = progress_bar_prefix(&stats, None, &styles);
596            assert_eq!(
597                prefix,
598                "     Running".style(styles.fail).to_string(),
599                "{name} matches"
600            );
601        }
602    }
603
604    #[test]
605    fn progress_str_snapshots() {
606        let mut styles = Styles::default();
607        styles.colorize();
608
609        // This elapsed time is arbitrary but reasonably large.
610        let elapsed = Duration::from_secs(123456);
611        let running = 10;
612
613        for (name, stats) in run_stats_test_failure_examples() {
614            let s = progress_str(elapsed, &stats, running, &styles);
615            insta::assert_snapshot!(format!("{name}_with_cancel_reason"), s);
616
617            let mut stats = stats;
618            stats.cancel_reason = None;
619            let s = progress_str(elapsed, &stats, running, &styles);
620            insta::assert_snapshot!(format!("{name}_without_cancel_reason"), s);
621        }
622
623        for (name, stats) in run_stats_setup_script_failure_examples() {
624            let s = progress_str(elapsed, &stats, running, &styles);
625            insta::assert_snapshot!(format!("{name}_with_cancel_reason"), s);
626
627            let mut stats = stats;
628            stats.cancel_reason = None;
629            let s = progress_str(elapsed, &stats, running, &styles);
630            insta::assert_snapshot!(format!("{name}_without_cancel_reason"), s);
631        }
632    }
633
634    fn run_stats_test_failure_examples() -> Vec<(&'static str, RunStats)> {
635        vec![
636            (
637                "one_failed",
638                RunStats {
639                    initial_run_count: 20,
640                    finished_count: 1,
641                    failed: 1,
642                    cancel_reason: Some(CancelReason::TestFailure),
643                    ..RunStats::default()
644                },
645            ),
646            (
647                "one_failed_one_passed",
648                RunStats {
649                    initial_run_count: 20,
650                    finished_count: 2,
651                    failed: 1,
652                    passed: 1,
653                    cancel_reason: Some(CancelReason::TestFailure),
654                    ..RunStats::default()
655                },
656            ),
657            (
658                "one_exec_failed",
659                RunStats {
660                    initial_run_count: 20,
661                    finished_count: 10,
662                    exec_failed: 1,
663                    cancel_reason: Some(CancelReason::TestFailure),
664                    ..RunStats::default()
665                },
666            ),
667            (
668                "one_timed_out",
669                RunStats {
670                    initial_run_count: 20,
671                    finished_count: 10,
672                    timed_out: 1,
673                    cancel_reason: Some(CancelReason::TestFailure),
674                    ..RunStats::default()
675                },
676            ),
677        ]
678    }
679
680    fn run_stats_setup_script_failure_examples() -> Vec<(&'static str, RunStats)> {
681        vec![
682            (
683                "one_setup_script_failed",
684                RunStats {
685                    initial_run_count: 30,
686                    setup_scripts_failed: 1,
687                    cancel_reason: Some(CancelReason::SetupScriptFailure),
688                    ..RunStats::default()
689                },
690            ),
691            (
692                "one_setup_script_exec_failed",
693                RunStats {
694                    initial_run_count: 35,
695                    setup_scripts_exec_failed: 1,
696                    cancel_reason: Some(CancelReason::SetupScriptFailure),
697                    ..RunStats::default()
698                },
699            ),
700            (
701                "one_setup_script_timed_out",
702                RunStats {
703                    initial_run_count: 40,
704                    setup_scripts_timed_out: 1,
705                    cancel_reason: Some(CancelReason::SetupScriptFailure),
706                    ..RunStats::default()
707                },
708            ),
709        ]
710    }
711}