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