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    helpers::{DisplayTestInstance, plural},
7    list::TestInstanceId,
8    reporter::{
9        displayer::formatters::DisplayBracketedHhMmSs,
10        events::*,
11        helpers::{Styles, print_lines_in_chunks},
12    },
13};
14use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
15use nextest_metadata::RustBinaryId;
16use owo_colors::OwoColorize;
17use std::{
18    cmp::{max, min},
19    env, fmt,
20    io::IsTerminal,
21    str::FromStr,
22    time::{Duration, Instant},
23};
24use swrite::{SWrite, swrite};
25use tracing::debug;
26
27/// The refresh rate for the progress bar, set to a minimal value.
28///
29/// For progress, during each tick, two things happen:
30///
31/// - We update the message, calling self.bar.set_message.
32/// - We print any buffered output.
33///
34/// We want both of these updates to be combined into one terminal flush, so we
35/// set *this* to a minimal value (so self.bar.set_message doesn't do a redraw),
36/// and rely on ProgressBar::print_and_flush_buffer to always flush the
37/// terminal.
38const PROGRESS_REFRESH_RATE_HZ: u8 = 1;
39
40/// The maximum number of running tests to display with
41/// `--show-progress=running` or `only`.
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub enum MaxProgressRunning {
44    /// Show a specific maximum number of running tests.
45    /// If 0, running tests (including the overflow summary) aren't displayed.
46    Count(usize),
47
48    /// Show all running tests (no limit).
49    Infinite,
50}
51
52impl MaxProgressRunning {
53    /// The default value (8 tests).
54    pub const DEFAULT_VALUE: Self = Self::Count(8);
55}
56
57impl Default for MaxProgressRunning {
58    fn default() -> Self {
59        Self::DEFAULT_VALUE
60    }
61}
62
63impl FromStr for MaxProgressRunning {
64    type Err = String;
65
66    fn from_str(s: &str) -> Result<Self, Self::Err> {
67        if s.eq_ignore_ascii_case("infinite") {
68            return Ok(Self::Infinite);
69        }
70
71        match s.parse::<usize>() {
72            Err(e) => Err(format!("Error: {e} parsing {s}")),
73            Ok(n) => Ok(Self::Count(n)),
74        }
75    }
76}
77
78impl fmt::Display for MaxProgressRunning {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::Infinite => write!(f, "infinite"),
82            Self::Count(n) => write!(f, "{n}"),
83        }
84    }
85}
86
87/// How to show progress.
88#[derive(Default, Clone, Copy, Debug)]
89pub enum ShowProgress {
90    /// Automatically decide based on environment.
91    #[default]
92    Auto,
93
94    /// No progress display.
95    None,
96
97    /// Show a counter on each line.
98    Counter,
99
100    /// Show a progress bar and the running tests
101    Running,
102}
103
104#[derive(Debug)]
105pub(super) enum RunningTestStatus {
106    Running,
107    Slow,
108    Delay(Duration),
109    Retry,
110}
111
112#[derive(Debug)]
113pub(super) struct RunningTest {
114    binary_id: RustBinaryId,
115    test_name: String,
116    status: RunningTestStatus,
117    start_time: Instant,
118    paused_for: Duration,
119}
120
121impl RunningTest {
122    fn message(&self, now: Instant, width: usize, styles: &Styles) -> String {
123        let mut elapsed = (now - self.start_time).saturating_sub(self.paused_for);
124        let status = match self.status {
125            RunningTestStatus::Running => "     ".to_owned(),
126            RunningTestStatus::Slow => " SLOW".style(styles.skip).to_string(),
127            RunningTestStatus::Delay(d) => {
128                // The elapsed might be greater than the delay duration in case
129                // we ticked past the delay duration without receiving a
130                // notification that the test retry started.
131                elapsed = d.saturating_sub(elapsed);
132                "DELAY".style(styles.retry).to_string()
133            }
134            RunningTestStatus::Retry => "RETRY".style(styles.retry).to_string(),
135        };
136        let elapsed = format!(
137            "{:0>2}:{:0>2}:{:0>2}",
138            elapsed.as_secs() / 3600,
139            elapsed.as_secs() / 60,
140            elapsed.as_secs() % 60,
141        );
142        let max_width = width.saturating_sub(25);
143        let test = DisplayTestInstance::new(
144            None,
145            None,
146            TestInstanceId {
147                binary_id: &self.binary_id,
148
149                test_name: &self.test_name,
150            },
151            &styles.list_styles,
152        )
153        .with_max_width(max_width);
154        format!("       {} [{:>9}] {}", status, elapsed, test)
155    }
156}
157
158#[derive(Debug)]
159pub(super) struct ProgressBarState {
160    bar: ProgressBar,
161    stats: RunStats,
162    running: usize,
163    max_progress_running: MaxProgressRunning,
164    // Keep track of the maximum number of lines used. This allows to adapt the
165    // size of the 'viewport' to what we are using, and not just to the maximum
166    // number of tests that can be run in parallel
167    max_running_displayed: usize,
168    // None when the running tests are not displayed
169    running_tests: Option<Vec<RunningTest>>,
170    buffer: String,
171    // Size in bytes for chunking println calls. Configurable via the
172    // undocumented __NEXTEST_PROGRESS_PRINTLN_CHUNK_SIZE env var.
173    println_chunk_size: usize,
174    // Reasons for hiding the progress bar. We show the progress bar if none of
175    // these are set and hide it if any of them are set.
176    //
177    // indicatif cannot handle this kind of "stacked" state management, so it
178    // falls on us to do so.
179    //
180    // The current draw target is a pure function of these three booleans: if
181    // any of them are set, the draw target is hidden, otherwise it's stderr. If
182    // this changes, we'll need to track those other inputs.
183    hidden_no_capture: bool,
184    hidden_run_paused: bool,
185    hidden_info_response: bool,
186}
187
188impl ProgressBarState {
189    pub(super) fn new(
190        test_count: usize,
191        progress_chars: &str,
192        max_progress_running: MaxProgressRunning,
193    ) -> Self {
194        let bar = ProgressBar::new(test_count as u64);
195        let test_count_width = format!("{test_count}").len();
196        // Create the template using the width as input. This is a
197        // little confusing -- {{foo}} is what's passed into the
198        // ProgressBar, while {bar} is inserted by the format!()
199        // statement.
200        let template = format!(
201            "{{prefix:>12}} [{{elapsed_precise:>9}}] {{wide_bar}} \
202            {{pos:>{test_count_width}}}/{{len:{test_count_width}}}: {{msg}}"
203        );
204        bar.set_style(
205            ProgressStyle::default_bar()
206                .progress_chars(progress_chars)
207                .template(&template)
208                .expect("template is known to be valid"),
209        );
210
211        let running_tests =
212            (!matches!(max_progress_running, MaxProgressRunning::Count(0))).then(Vec::new);
213
214        // The println chunk size defaults to a value chosen by experimentation,
215        // locally and over SSH. This controls how often the progress bar
216        // refreshes during large output bursts.
217        let println_chunk_size = env::var("__NEXTEST_PROGRESS_PRINTLN_CHUNK_SIZE")
218            .ok()
219            .and_then(|s| s.parse::<usize>().ok())
220            .unwrap_or(4096);
221
222        Self {
223            bar,
224            stats: RunStats::default(),
225            running: 0,
226            max_progress_running,
227            max_running_displayed: 0,
228            running_tests,
229            buffer: String::new(),
230            println_chunk_size,
231            hidden_no_capture: false,
232            hidden_run_paused: false,
233            hidden_info_response: false,
234        }
235    }
236
237    pub(super) fn tick(&mut self, styles: &Styles) {
238        self.update_message(styles);
239        self.print_and_clear_buffer();
240    }
241
242    fn print_and_clear_buffer(&mut self) {
243        self.print_and_force_redraw();
244        self.buffer.clear();
245    }
246
247    /// Prints the contents of the buffer, and always forces a redraw.
248    fn print_and_force_redraw(&self) {
249        if self.buffer.is_empty() {
250            // Force a redraw as part of our contract. See the documentation for
251            // `PROGRESS_REFRESH_RATE_HZ`.
252            self.bar.force_draw();
253            return;
254        }
255
256        // println below also forces a redraw, so we don't need to call
257        // force_draw in this case.
258
259        // ProgressBar::println is only called if there's something in the
260        // buffer, for two reasons:
261        //
262        // 1. If passed in nothing at all, it prints an empty line.
263        // 2. It forces a full redraw.
264        //
265        // But if self.buffer is too large, we can overwhelm the terminal with
266        // large amounts of non-progress-bar output, causing the progress bar to
267        // flicker in and out. To avoid those issues, we chunk the output to
268        // maintain progress bar visibility by redrawing it regularly.
269        print_lines_in_chunks(&self.buffer, self.println_chunk_size, |chunk| {
270            self.bar.println(chunk);
271        });
272    }
273
274    fn update_message(&mut self, styles: &Styles) {
275        let mut msg = progress_bar_msg(&self.stats, self.running, styles);
276        msg += "     ";
277
278        if let Some(running_tests) = &self.running_tests {
279            let (_, width) = console::Term::stderr().size();
280            let width = max(width as usize, 40);
281            let now = Instant::now();
282            let mut count = match self.max_progress_running {
283                MaxProgressRunning::Count(count) => min(running_tests.len(), count),
284                MaxProgressRunning::Infinite => running_tests.len(),
285            };
286            for running_test in &running_tests[..count] {
287                msg.push('\n');
288                msg.push_str(&running_test.message(now, width, styles));
289            }
290            if count < running_tests.len() {
291                let overflow_count = running_tests.len() - count;
292                msg.push_str(&format!(
293                    "\n             ... and {} more {} running",
294                    overflow_count.style(styles.count),
295                    plural::tests_str(overflow_count),
296                ));
297                count += 1;
298            }
299            self.max_running_displayed = max(self.max_running_displayed, count);
300            msg.push_str(&"\n".to_string().repeat(self.max_running_displayed - count));
301        }
302        self.bar.set_message(msg);
303    }
304
305    pub(super) fn update_progress_bar(&mut self, event: &TestEvent<'_>, styles: &Styles) {
306        let before_should_hide = self.should_hide();
307
308        match &event.kind {
309            TestEventKind::StressSubRunStarted { .. } => {
310                self.bar.reset();
311            }
312            TestEventKind::StressSubRunFinished { .. } => {
313                // Clear all test bars to remove empty lines of output between
314                // sub-runs.
315                self.bar.finish_and_clear();
316            }
317            TestEventKind::SetupScriptStarted { no_capture, .. } => {
318                // Hide the progress bar if either stderr or stdout are being passed through.
319                if *no_capture {
320                    self.hidden_no_capture = true;
321                }
322            }
323            TestEventKind::SetupScriptFinished { no_capture, .. } => {
324                // Restore the progress bar if it was hidden.
325                if *no_capture {
326                    self.hidden_no_capture = false;
327                }
328            }
329            TestEventKind::TestStarted {
330                current_stats,
331                running,
332                test_instance,
333                ..
334            } => {
335                self.running = *running;
336
337                self.bar.set_prefix(progress_bar_prefix(
338                    current_stats,
339                    current_stats.cancel_reason,
340                    styles,
341                ));
342                // If there are skipped tests, the initial run count will be lower than when constructed
343                // in ProgressBar::new.
344                self.bar.set_length(current_stats.initial_run_count as u64);
345                self.bar.set_position(current_stats.finished_count as u64);
346
347                if let Some(running_tests) = &mut self.running_tests {
348                    running_tests.push(RunningTest {
349                        binary_id: test_instance.id().binary_id.clone(),
350                        test_name: test_instance.id().test_name.to_owned(),
351                        status: RunningTestStatus::Running,
352                        start_time: Instant::now(),
353                        paused_for: Duration::ZERO,
354                    });
355                }
356            }
357            TestEventKind::TestFinished {
358                current_stats,
359                running,
360                test_instance,
361                ..
362            } => {
363                self.running = *running;
364                self.remove_test(&test_instance.id());
365
366                self.bar.set_prefix(progress_bar_prefix(
367                    current_stats,
368                    current_stats.cancel_reason,
369                    styles,
370                ));
371                // If there are skipped tests, the initial run count will be lower than when constructed
372                // in ProgressBar::new.
373                self.bar.set_length(current_stats.initial_run_count as u64);
374                self.bar.set_position(current_stats.finished_count as u64);
375            }
376            TestEventKind::TestAttemptFailedWillRetry {
377                test_instance,
378                delay_before_next_attempt,
379                ..
380            } => {
381                self.remove_test(&test_instance.id());
382                if let Some(running_tests) = &mut self.running_tests {
383                    running_tests.push(RunningTest {
384                        binary_id: test_instance.id().binary_id.clone(),
385                        test_name: test_instance.id().test_name.to_owned(),
386                        status: RunningTestStatus::Delay(*delay_before_next_attempt),
387                        start_time: Instant::now(),
388                        paused_for: Duration::ZERO,
389                    });
390                }
391            }
392            TestEventKind::TestRetryStarted { test_instance, .. } => {
393                self.remove_test(&test_instance.id());
394                if let Some(running_tests) = &mut self.running_tests {
395                    running_tests.push(RunningTest {
396                        binary_id: test_instance.id().binary_id.clone(),
397                        test_name: test_instance.id().test_name.to_owned(),
398                        status: RunningTestStatus::Retry,
399                        start_time: Instant::now(),
400                        paused_for: Duration::ZERO,
401                    });
402                }
403            }
404            TestEventKind::TestSlow { test_instance, .. } => {
405                if let Some(running_tests) = &mut self.running_tests {
406                    running_tests
407                        .iter_mut()
408                        .find(|rt| {
409                            &rt.binary_id == test_instance.id().binary_id
410                                && rt.test_name == test_instance.id().test_name
411                        })
412                        .expect("a slow test to be already running")
413                        .status = RunningTestStatus::Slow;
414                }
415            }
416            TestEventKind::InfoStarted { .. } => {
417                // While info is being displayed, hide the progress bar to avoid
418                // it interrupting the info display.
419                self.hidden_info_response = true;
420            }
421            TestEventKind::InfoFinished { .. } => {
422                // Restore the progress bar if it was hidden.
423                self.hidden_info_response = false;
424            }
425            TestEventKind::RunPaused { .. } => {
426                // Pausing the run should hide the progress bar since we'll exit
427                // to the terminal immediately after.
428                self.hidden_run_paused = true;
429            }
430            TestEventKind::RunContinued { .. } => {
431                // Continuing the run should show the progress bar since we'll
432                // continue to output to it.
433                self.hidden_run_paused = false;
434                let current_global_elapsed = self.bar.elapsed();
435                self.bar.set_elapsed(event.elapsed);
436
437                if let Some(running_tests) = &mut self.running_tests {
438                    let delta = current_global_elapsed.saturating_sub(event.elapsed);
439                    for running_test in running_tests {
440                        running_test.paused_for += delta;
441                    }
442                }
443            }
444            TestEventKind::RunBeginCancel { current_stats, .. }
445            | TestEventKind::RunBeginKill { current_stats, .. } => {
446                self.bar.set_prefix(progress_bar_cancel_prefix(
447                    current_stats.cancel_reason,
448                    styles,
449                ));
450            }
451            _ => {}
452        }
453
454        let after_should_hide = self.should_hide();
455
456        match (before_should_hide, after_should_hide) {
457            (false, true) => self.bar.set_draw_target(Self::hidden_target()),
458            (true, false) => self.bar.set_draw_target(Self::stderr_target()),
459            _ => {}
460        }
461    }
462
463    fn remove_test(&mut self, test_instance: &TestInstanceId) {
464        if let Some(running_tests) = &mut self.running_tests {
465            running_tests.remove(
466                running_tests
467                    .iter()
468                    .position(|e| {
469                        &e.binary_id == test_instance.binary_id
470                            && e.test_name == test_instance.test_name
471                    })
472                    .expect("finished test to have started"),
473            );
474        }
475    }
476
477    pub(super) fn write_buf(&mut self, buf: &str) {
478        self.buffer.push_str(buf);
479    }
480
481    #[inline]
482    pub(super) fn finish_and_clear(&self) {
483        self.print_and_force_redraw();
484        self.bar.finish_and_clear();
485    }
486
487    fn stderr_target() -> ProgressDrawTarget {
488        ProgressDrawTarget::stderr_with_hz(PROGRESS_REFRESH_RATE_HZ)
489    }
490
491    fn hidden_target() -> ProgressDrawTarget {
492        ProgressDrawTarget::hidden()
493    }
494
495    fn should_hide(&self) -> bool {
496        self.hidden_no_capture || self.hidden_run_paused || self.hidden_info_response
497    }
498
499    pub(super) fn is_hidden(&self) -> bool {
500        self.bar.is_hidden()
501    }
502}
503
504/// OSC 9 terminal progress reporting.
505#[derive(Default)]
506pub(super) struct TerminalProgress {
507    last_value: TerminalProgressValue,
508}
509
510impl TerminalProgress {
511    const ENV: &str = "CARGO_TERM_PROGRESS_TERM_INTEGRATION";
512
513    pub(super) fn new(configs: &CargoConfigs, stream: &dyn IsTerminal) -> Option<Self> {
514        // See whether terminal integration is enabled in Cargo.
515        for config in configs.discovered_configs() {
516            match config {
517                DiscoveredConfig::CliOption { config, source } => {
518                    if let Some(v) = config.term.progress.term_integration {
519                        if v {
520                            debug!("enabling terminal progress reporting based on {source:?}");
521                            return Some(Self::default());
522                        } else {
523                            debug!("disabling terminal progress reporting based on {source:?}");
524                            return None;
525                        }
526                    }
527                }
528                DiscoveredConfig::Env => {
529                    if let Some(v) = env::var_os(Self::ENV) {
530                        if v == "true" {
531                            debug!(
532                                "enabling terminal progress reporting based on \
533                                 CARGO_TERM_PROGRESS_TERM_INTEGRATION environment variable"
534                            );
535                            return Some(Self::default());
536                        } else if v == "false" {
537                            debug!(
538                                "disabling terminal progress reporting based on \
539                                 CARGO_TERM_PROGRESS_TERM_INTEGRATION environment variable"
540                            );
541                            return None;
542                        } else {
543                            debug!(
544                                "invalid value for CARGO_TERM_PROGRESS_TERM_INTEGRATION \
545                                 environment variable: {v:?}, ignoring"
546                            );
547                        }
548                    }
549                }
550                DiscoveredConfig::File { config, source } => {
551                    if let Some(v) = config.term.progress.term_integration {
552                        if v {
553                            debug!("enabling terminal progress reporting based on {source:?}");
554                            return Some(Self::default());
555                        } else {
556                            debug!("disabling terminal progress reporting based on {source:?}");
557                            return None;
558                        }
559                    }
560                }
561            }
562        }
563
564        supports_osc_9_4(stream).then(Self::default)
565    }
566
567    pub(super) fn update_progress(&mut self, event: &TestEvent<'_>) {
568        let value = match &event.kind {
569            TestEventKind::RunStarted { .. }
570            | TestEventKind::StressSubRunStarted { .. }
571            | TestEventKind::StressSubRunFinished { .. }
572            | TestEventKind::SetupScriptStarted { .. }
573            | TestEventKind::SetupScriptSlow { .. }
574            | TestEventKind::SetupScriptFinished { .. } => TerminalProgressValue::None,
575            TestEventKind::TestStarted { current_stats, .. }
576            | TestEventKind::TestFinished { current_stats, .. } => {
577                let percentage = (current_stats.finished_count as f64
578                    / current_stats.initial_run_count as f64)
579                    * 100.0;
580                if current_stats.has_failures() || current_stats.cancel_reason.is_some() {
581                    TerminalProgressValue::Error(percentage)
582                } else {
583                    TerminalProgressValue::Value(percentage)
584                }
585            }
586            TestEventKind::TestSlow { .. }
587            | TestEventKind::TestAttemptFailedWillRetry { .. }
588            | TestEventKind::TestRetryStarted { .. }
589            | TestEventKind::TestSkipped { .. }
590            | TestEventKind::InfoStarted { .. }
591            | TestEventKind::InfoResponse { .. }
592            | TestEventKind::InfoFinished { .. }
593            | TestEventKind::InputEnter { .. } => TerminalProgressValue::None,
594            TestEventKind::RunBeginCancel { current_stats, .. }
595            | TestEventKind::RunBeginKill { current_stats, .. } => {
596                // In this case, always indicate an error.
597                let percentage = (current_stats.finished_count as f64
598                    / current_stats.initial_run_count as f64)
599                    * 100.0;
600                TerminalProgressValue::Error(percentage)
601            }
602            TestEventKind::RunPaused { .. }
603            | TestEventKind::RunContinued { .. }
604            | TestEventKind::RunFinished { .. } => {
605                // Reset the terminal state to nothing, since nextest is giving
606                // up control of the terminal at this point.
607                TerminalProgressValue::Remove
608            }
609        };
610
611        self.last_value = value;
612    }
613
614    pub(super) fn last_value(&self) -> &TerminalProgressValue {
615        &self.last_value
616    }
617}
618
619/// Determines whether the terminal supports ANSI OSC 9;4.
620fn supports_osc_9_4(stream: &dyn IsTerminal) -> bool {
621    if !stream.is_terminal() {
622        debug!(
623            "autodetect terminal progress reporting: disabling since \
624             passed-in stream (usually stderr) is not a terminal"
625        );
626        return false;
627    }
628    if std::env::var_os("WT_SESSION").is_some() {
629        debug!("autodetect terminal progress reporting: enabling since WT_SESSION is set");
630        return true;
631    };
632    if std::env::var_os("ConEmuANSI").is_some_and(|term| term == "ON") {
633        debug!("autodetect terminal progress reporting: enabling since ConEmuANSI is ON");
634        return true;
635    }
636    if let Ok(term) = std::env::var("TERM_PROGRAM")
637        && (term == "WezTerm" || term == "ghostty")
638    {
639        debug!("autodetect terminal progress reporting: enabling since TERM_PROGRAM is {term}");
640        return true;
641    }
642
643    false
644}
645
646/// A progress status value printable as an ANSI OSC 9;4 escape code.
647///
648/// Adapted from Cargo 1.87.
649#[derive(PartialEq, Debug, Default)]
650pub(super) enum TerminalProgressValue {
651    /// No output.
652    #[default]
653    None,
654    /// Remove progress.
655    Remove,
656    /// Progress value (0-100).
657    Value(f64),
658    /// Indeterminate state (no bar, just animation)
659    ///
660    /// We don't use this yet, but might in the future.
661    #[expect(dead_code)]
662    Indeterminate,
663    /// Progress value in an error state (0-100).
664    Error(f64),
665}
666
667impl fmt::Display for TerminalProgressValue {
668    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
669        // From https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
670        // ESC ] 9 ; 4 ; st ; pr ST
671        // When st is 0: remove progress.
672        // When st is 1: set progress value to pr (number, 0-100).
673        // When st is 2: set error state in taskbar, pr is optional.
674        // When st is 3: set indeterminate state, pr is ignored.
675        // When st is 4: set paused state, pr is optional.
676        let (state, progress) = match self {
677            Self::None => return Ok(()), // No output
678            Self::Remove => (0, 0.0),
679            Self::Value(v) => (1, *v),
680            Self::Indeterminate => (3, 0.0),
681            Self::Error(v) => (2, *v),
682        };
683        write!(f, "\x1b]9;4;{state};{progress:.0}\x1b\\")
684    }
685}
686
687/// Returns a summary of current progress.
688pub(super) fn progress_str(
689    elapsed: Duration,
690    current_stats: &RunStats,
691    running: usize,
692    styles: &Styles,
693) -> String {
694    // First, show the prefix.
695    let mut s = progress_bar_prefix(current_stats, current_stats.cancel_reason, styles);
696
697    // Then, the time elapsed, test counts, and message.
698    swrite!(
699        s,
700        " {}{}/{}: {}",
701        DisplayBracketedHhMmSs(elapsed),
702        current_stats.finished_count,
703        current_stats.initial_run_count,
704        progress_bar_msg(current_stats, running, styles)
705    );
706
707    s
708}
709
710pub(super) fn write_summary_str(run_stats: &RunStats, styles: &Styles, out: &mut String) {
711    // Written in this style to ensure new fields are accounted for.
712    let &RunStats {
713        initial_run_count: _,
714        finished_count: _,
715        setup_scripts_initial_count: _,
716        setup_scripts_finished_count: _,
717        setup_scripts_passed: _,
718        setup_scripts_failed: _,
719        setup_scripts_exec_failed: _,
720        setup_scripts_timed_out: _,
721        passed,
722        passed_slow,
723        passed_timed_out: _,
724        flaky,
725        failed,
726        failed_slow: _,
727        failed_timed_out,
728        leaky,
729        leaky_failed,
730        exec_failed,
731        skipped,
732        cancel_reason: _,
733    } = run_stats;
734
735    swrite!(
736        out,
737        "{} {}",
738        passed.style(styles.count),
739        "passed".style(styles.pass)
740    );
741
742    if passed_slow > 0 || flaky > 0 || leaky > 0 {
743        let mut text = Vec::with_capacity(3);
744        if passed_slow > 0 {
745            text.push(format!(
746                "{} {}",
747                passed_slow.style(styles.count),
748                "slow".style(styles.skip),
749            ));
750        }
751        if flaky > 0 {
752            text.push(format!(
753                "{} {}",
754                flaky.style(styles.count),
755                "flaky".style(styles.skip),
756            ));
757        }
758        if leaky > 0 {
759            text.push(format!(
760                "{} {}",
761                leaky.style(styles.count),
762                "leaky".style(styles.skip),
763            ));
764        }
765        swrite!(out, " ({})", text.join(", "));
766    }
767    swrite!(out, ", ");
768
769    if failed > 0 {
770        swrite!(
771            out,
772            "{} {}",
773            failed.style(styles.count),
774            "failed".style(styles.fail),
775        );
776        if leaky_failed > 0 {
777            swrite!(
778                out,
779                " ({} due to being {})",
780                leaky_failed.style(styles.count),
781                "leaky".style(styles.fail),
782            );
783        }
784        swrite!(out, ", ");
785    }
786
787    if exec_failed > 0 {
788        swrite!(
789            out,
790            "{} {}, ",
791            exec_failed.style(styles.count),
792            "exec failed".style(styles.fail),
793        );
794    }
795
796    if failed_timed_out > 0 {
797        swrite!(
798            out,
799            "{} {}, ",
800            failed_timed_out.style(styles.count),
801            "timed out".style(styles.fail),
802        );
803    }
804
805    swrite!(
806        out,
807        "{} {}",
808        skipped.style(styles.count),
809        "skipped".style(styles.skip),
810    );
811}
812
813fn progress_bar_cancel_prefix(reason: Option<CancelReason>, styles: &Styles) -> String {
814    let status = match reason {
815        Some(CancelReason::SetupScriptFailure)
816        | Some(CancelReason::TestFailure)
817        | Some(CancelReason::ReportError)
818        | Some(CancelReason::GlobalTimeout)
819        | Some(CancelReason::TestFailureImmediate)
820        | Some(CancelReason::Signal)
821        | Some(CancelReason::Interrupt)
822        | None => "Cancelling",
823        Some(CancelReason::SecondSignal) => "Killing",
824    };
825    format!("{:>12}", status.style(styles.fail))
826}
827
828fn progress_bar_prefix(
829    run_stats: &RunStats,
830    cancel_reason: Option<CancelReason>,
831    styles: &Styles,
832) -> String {
833    if let Some(reason) = cancel_reason {
834        return progress_bar_cancel_prefix(Some(reason), styles);
835    }
836
837    let style = if run_stats.has_failures() {
838        styles.fail
839    } else {
840        styles.pass
841    };
842
843    format!("{:>12}", "Running".style(style))
844}
845
846pub(super) fn progress_bar_msg(
847    current_stats: &RunStats,
848    running: usize,
849    styles: &Styles,
850) -> String {
851    let mut s = format!("{} running, ", running.style(styles.count));
852    write_summary_str(current_stats, styles, &mut s);
853    s
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859
860    #[test]
861    fn test_progress_bar_prefix() {
862        let mut styles = Styles::default();
863        styles.colorize();
864
865        for (name, stats) in run_stats_test_failure_examples() {
866            let prefix = progress_bar_prefix(&stats, Some(CancelReason::TestFailure), &styles);
867            assert_eq!(
868                prefix,
869                "  Cancelling".style(styles.fail).to_string(),
870                "{name} matches"
871            );
872        }
873        for (name, stats) in run_stats_setup_script_failure_examples() {
874            let prefix =
875                progress_bar_prefix(&stats, Some(CancelReason::SetupScriptFailure), &styles);
876            assert_eq!(
877                prefix,
878                "  Cancelling".style(styles.fail).to_string(),
879                "{name} matches"
880            );
881        }
882
883        let prefix = progress_bar_prefix(&RunStats::default(), Some(CancelReason::Signal), &styles);
884        assert_eq!(prefix, "  Cancelling".style(styles.fail).to_string());
885
886        let prefix = progress_bar_prefix(&RunStats::default(), None, &styles);
887        assert_eq!(prefix, "     Running".style(styles.pass).to_string());
888
889        for (name, stats) in run_stats_test_failure_examples() {
890            let prefix = progress_bar_prefix(&stats, None, &styles);
891            assert_eq!(
892                prefix,
893                "     Running".style(styles.fail).to_string(),
894                "{name} matches"
895            );
896        }
897        for (name, stats) in run_stats_setup_script_failure_examples() {
898            let prefix = progress_bar_prefix(&stats, None, &styles);
899            assert_eq!(
900                prefix,
901                "     Running".style(styles.fail).to_string(),
902                "{name} matches"
903            );
904        }
905    }
906
907    #[test]
908    fn progress_str_snapshots() {
909        let mut styles = Styles::default();
910        styles.colorize();
911
912        // This elapsed time is arbitrary but reasonably large.
913        let elapsed = Duration::from_secs(123456);
914        let running = 10;
915
916        for (name, stats) in run_stats_test_failure_examples() {
917            let s = progress_str(elapsed, &stats, running, &styles);
918            insta::assert_snapshot!(format!("{name}_with_cancel_reason"), s);
919
920            let mut stats = stats;
921            stats.cancel_reason = None;
922            let s = progress_str(elapsed, &stats, running, &styles);
923            insta::assert_snapshot!(format!("{name}_without_cancel_reason"), s);
924        }
925
926        for (name, stats) in run_stats_setup_script_failure_examples() {
927            let s = progress_str(elapsed, &stats, running, &styles);
928            insta::assert_snapshot!(format!("{name}_with_cancel_reason"), s);
929
930            let mut stats = stats;
931            stats.cancel_reason = None;
932            let s = progress_str(elapsed, &stats, running, &styles);
933            insta::assert_snapshot!(format!("{name}_without_cancel_reason"), s);
934        }
935    }
936
937    #[test]
938    fn running_test_snapshots() {
939        let styles = Styles::default();
940        let now = Instant::now();
941
942        for (name, running_test) in running_test_examples(now) {
943            let msg = running_test.message(now, 80, &styles);
944            insta::assert_snapshot!(name, msg);
945        }
946    }
947
948    fn running_test_examples(now: Instant) -> Vec<(&'static str, RunningTest)> {
949        let binary_id = RustBinaryId::new("my-binary");
950        let test_name = "test::my_test".to_string();
951        let start_time = now - Duration::from_secs(125); // 2 minutes 5 seconds ago
952
953        vec![
954            (
955                "running_status",
956                RunningTest {
957                    binary_id: binary_id.clone(),
958                    test_name: test_name.clone(),
959                    status: RunningTestStatus::Running,
960                    start_time,
961                    paused_for: Duration::ZERO,
962                },
963            ),
964            (
965                "slow_status",
966                RunningTest {
967                    binary_id: binary_id.clone(),
968                    test_name: test_name.clone(),
969                    status: RunningTestStatus::Slow,
970                    start_time,
971                    paused_for: Duration::ZERO,
972                },
973            ),
974            (
975                "delay_status",
976                RunningTest {
977                    binary_id: binary_id.clone(),
978                    test_name: test_name.clone(),
979                    status: RunningTestStatus::Delay(Duration::from_secs(130)),
980                    start_time,
981                    paused_for: Duration::ZERO,
982                },
983            ),
984            (
985                "delay_status_underflow",
986                RunningTest {
987                    binary_id: binary_id.clone(),
988                    test_name: test_name.clone(),
989                    status: RunningTestStatus::Delay(Duration::from_secs(124)),
990                    start_time,
991                    paused_for: Duration::ZERO,
992                },
993            ),
994            (
995                "retry_status",
996                RunningTest {
997                    binary_id: binary_id.clone(),
998                    test_name: test_name.clone(),
999                    status: RunningTestStatus::Retry,
1000                    start_time,
1001                    paused_for: Duration::ZERO,
1002                },
1003            ),
1004            (
1005                "with_paused_duration",
1006                RunningTest {
1007                    binary_id: binary_id.clone(),
1008                    test_name: test_name.clone(),
1009                    status: RunningTestStatus::Running,
1010                    start_time,
1011                    paused_for: Duration::from_secs(30),
1012                },
1013            ),
1014        ]
1015    }
1016
1017    fn run_stats_test_failure_examples() -> Vec<(&'static str, RunStats)> {
1018        vec![
1019            (
1020                "one_failed",
1021                RunStats {
1022                    initial_run_count: 20,
1023                    finished_count: 1,
1024                    failed: 1,
1025                    cancel_reason: Some(CancelReason::TestFailure),
1026                    ..RunStats::default()
1027                },
1028            ),
1029            (
1030                "one_failed_one_passed",
1031                RunStats {
1032                    initial_run_count: 20,
1033                    finished_count: 2,
1034                    failed: 1,
1035                    passed: 1,
1036                    cancel_reason: Some(CancelReason::TestFailure),
1037                    ..RunStats::default()
1038                },
1039            ),
1040            (
1041                "one_exec_failed",
1042                RunStats {
1043                    initial_run_count: 20,
1044                    finished_count: 10,
1045                    exec_failed: 1,
1046                    cancel_reason: Some(CancelReason::TestFailure),
1047                    ..RunStats::default()
1048                },
1049            ),
1050            (
1051                "one_timed_out",
1052                RunStats {
1053                    initial_run_count: 20,
1054                    finished_count: 10,
1055                    failed_timed_out: 1,
1056                    cancel_reason: Some(CancelReason::TestFailure),
1057                    ..RunStats::default()
1058                },
1059            ),
1060        ]
1061    }
1062
1063    fn run_stats_setup_script_failure_examples() -> Vec<(&'static str, RunStats)> {
1064        vec![
1065            (
1066                "one_setup_script_failed",
1067                RunStats {
1068                    initial_run_count: 30,
1069                    setup_scripts_failed: 1,
1070                    cancel_reason: Some(CancelReason::SetupScriptFailure),
1071                    ..RunStats::default()
1072                },
1073            ),
1074            (
1075                "one_setup_script_exec_failed",
1076                RunStats {
1077                    initial_run_count: 35,
1078                    setup_scripts_exec_failed: 1,
1079                    cancel_reason: Some(CancelReason::SetupScriptFailure),
1080                    ..RunStats::default()
1081                },
1082            ),
1083            (
1084                "one_setup_script_timed_out",
1085                RunStats {
1086                    initial_run_count: 40,
1087                    setup_scripts_timed_out: 1,
1088                    cancel_reason: Some(CancelReason::SetupScriptFailure),
1089                    ..RunStats::default()
1090                },
1091            ),
1092        ]
1093    }
1094}