Skip to main content

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 = self.progress_bar_msg(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                swrite!(
296                    msg,
297                    "\n             ... and {} more {} running",
298                    overflow_count.style(styles.count),
299                    plural::tests_str(self.mode, overflow_count),
300                );
301                count += 1;
302            }
303            self.max_running_displayed = max(self.max_running_displayed, count);
304            msg.push_str(&"\n".to_string().repeat(self.max_running_displayed - count));
305        }
306        self.bar.set_message(msg);
307    }
308
309    fn progress_bar_msg(&self, styles: &Styles) -> String {
310        progress_bar_msg(&self.stats, self.running, styles)
311    }
312
313    pub(super) fn update_progress_bar(&mut self, event: &TestEvent<'_>, styles: &Styles) {
314        let before_should_hide = self.should_hide();
315
316        match &event.kind {
317            TestEventKind::StressSubRunStarted { .. } => {
318                self.bar.reset();
319            }
320            TestEventKind::StressSubRunFinished { .. } => {
321                // Clear all test bars to remove empty lines of output between
322                // sub-runs.
323                self.bar.finish_and_clear();
324            }
325            TestEventKind::SetupScriptStarted { no_capture, .. } => {
326                // Hide the progress bar if either stderr or stdout are being passed through.
327                if *no_capture {
328                    self.hidden_no_capture = true;
329                }
330            }
331            TestEventKind::SetupScriptFinished { no_capture, .. } => {
332                // Restore the progress bar if it was hidden.
333                if *no_capture {
334                    self.hidden_no_capture = false;
335                }
336            }
337            TestEventKind::TestStarted {
338                current_stats,
339                running,
340                test_instance,
341                ..
342            } => {
343                self.running = *running;
344                self.stats = *current_stats;
345
346                self.bar.set_prefix(progress_bar_prefix(
347                    current_stats,
348                    current_stats.cancel_reason,
349                    styles,
350                ));
351                // If there are skipped tests, the initial run count will be lower than when constructed
352                // in ProgressBar::new.
353                self.bar.set_length(current_stats.initial_run_count as u64);
354                self.bar.set_position(current_stats.finished_count as u64);
355
356                if let Some(running_tests) = &mut self.running_tests {
357                    running_tests.push(RunningTest {
358                        binary_id: test_instance.binary_id.clone(),
359                        test_name: test_instance.test_name.to_owned(),
360                        status: RunningTestStatus::Running,
361                        start_time: Instant::now(),
362                        paused_for: Duration::ZERO,
363                    });
364                }
365            }
366            TestEventKind::TestFinished {
367                current_stats,
368                running,
369                test_instance,
370                ..
371            } => {
372                self.running = *running;
373                self.stats = *current_stats;
374                self.remove_test(test_instance);
375
376                self.bar.set_prefix(progress_bar_prefix(
377                    current_stats,
378                    current_stats.cancel_reason,
379                    styles,
380                ));
381                // If there are skipped tests, the initial run count will be lower than when constructed
382                // in ProgressBar::new.
383                self.bar.set_length(current_stats.initial_run_count as u64);
384                self.bar.set_position(current_stats.finished_count as u64);
385            }
386            TestEventKind::TestAttemptFailedWillRetry {
387                test_instance,
388                delay_before_next_attempt,
389                ..
390            } => {
391                self.remove_test(test_instance);
392                if let Some(running_tests) = &mut self.running_tests {
393                    running_tests.push(RunningTest {
394                        binary_id: test_instance.binary_id.clone(),
395                        test_name: test_instance.test_name.to_owned(),
396                        status: RunningTestStatus::Delay(*delay_before_next_attempt),
397                        start_time: Instant::now(),
398                        paused_for: Duration::ZERO,
399                    });
400                }
401            }
402            TestEventKind::TestRetryStarted { test_instance, .. } => {
403                self.remove_test(test_instance);
404                if let Some(running_tests) = &mut self.running_tests {
405                    running_tests.push(RunningTest {
406                        binary_id: test_instance.binary_id.clone(),
407                        test_name: test_instance.test_name.to_owned(),
408                        status: RunningTestStatus::Retry,
409                        start_time: Instant::now(),
410                        paused_for: Duration::ZERO,
411                    });
412                }
413            }
414            TestEventKind::TestSlow { test_instance, .. } => {
415                if let Some(running_tests) = &mut self.running_tests {
416                    running_tests
417                        .iter_mut()
418                        .find(|rt| {
419                            &rt.binary_id == test_instance.binary_id
420                                && &rt.test_name == test_instance.test_name
421                        })
422                        .expect("a slow test to be already running")
423                        .status = RunningTestStatus::Slow;
424                }
425            }
426            TestEventKind::InfoStarted { .. } => {
427                // While info is being displayed, hide the progress bar to avoid
428                // it interrupting the info display.
429                self.hidden_info_response = true;
430            }
431            TestEventKind::InfoFinished { .. } => {
432                // Restore the progress bar if it was hidden.
433                self.hidden_info_response = false;
434            }
435            TestEventKind::RunPaused { .. } => {
436                // Pausing the run should hide the progress bar since we'll exit
437                // to the terminal immediately after.
438                self.hidden_run_paused = true;
439            }
440            TestEventKind::RunContinued { .. } => {
441                // Continuing the run should show the progress bar since we'll
442                // continue to output to it.
443                self.hidden_run_paused = false;
444                let current_global_elapsed = self.bar.elapsed();
445                self.bar.set_elapsed(event.elapsed);
446
447                if let Some(running_tests) = &mut self.running_tests {
448                    let delta = current_global_elapsed.saturating_sub(event.elapsed);
449                    for running_test in running_tests {
450                        running_test.paused_for += delta;
451                    }
452                }
453            }
454            TestEventKind::RunBeginCancel {
455                current_stats,
456                running,
457                ..
458            }
459            | TestEventKind::RunBeginKill {
460                current_stats,
461                running,
462                ..
463            } => {
464                self.running = *running;
465                self.stats = *current_stats;
466                self.bar.set_prefix(progress_bar_cancel_prefix(
467                    current_stats.cancel_reason,
468                    styles,
469                ));
470            }
471            _ => {}
472        }
473
474        let after_should_hide = self.should_hide();
475
476        match (before_should_hide, after_should_hide) {
477            (false, true) => self.bar.set_draw_target(Self::hidden_target()),
478            (true, false) => self.bar.set_draw_target(Self::stderr_target()),
479            _ => {}
480        }
481    }
482
483    fn remove_test(&mut self, test_instance: &TestInstanceId) {
484        if let Some(running_tests) = &mut self.running_tests {
485            running_tests.remove(
486                running_tests
487                    .iter()
488                    .position(|e| {
489                        &e.binary_id == test_instance.binary_id
490                            && &e.test_name == test_instance.test_name
491                    })
492                    .expect("finished test to have started"),
493            );
494        }
495    }
496
497    pub(super) fn write_buf(&mut self, buf: &str) {
498        self.buffer.push_str(buf);
499    }
500
501    #[inline]
502    pub(super) fn finish_and_clear(&self) {
503        self.print_and_force_redraw();
504        self.bar.finish_and_clear();
505    }
506
507    fn stderr_target() -> ProgressDrawTarget {
508        ProgressDrawTarget::stderr_with_hz(PROGRESS_REFRESH_RATE_HZ)
509    }
510
511    fn hidden_target() -> ProgressDrawTarget {
512        ProgressDrawTarget::hidden()
513    }
514
515    fn should_hide(&self) -> bool {
516        self.hidden_no_capture || self.hidden_run_paused || self.hidden_info_response
517    }
518
519    pub(super) fn is_hidden(&self) -> bool {
520        self.bar.is_hidden()
521    }
522}
523
524/// Whether to show OSC 9;4 terminal progress.
525#[derive(Clone, Copy, Debug, Eq, PartialEq)]
526pub enum ShowTerminalProgress {
527    /// Show terminal progress.
528    Yes,
529
530    /// Do not show terminal progress.
531    No,
532}
533
534impl ShowTerminalProgress {
535    const ENV: &str = "CARGO_TERM_PROGRESS_TERM_INTEGRATION";
536
537    /// Determines whether to show terminal progress based on Cargo configs and
538    /// whether the output is a terminal.
539    pub fn from_cargo_configs(configs: &CargoConfigs, is_terminal: bool) -> Self {
540        // See whether terminal integration is enabled in Cargo.
541        for config in configs.discovered_configs() {
542            match config {
543                DiscoveredConfig::CliOption { config, source } => {
544                    if let Some(v) = config.term.progress.term_integration {
545                        if v {
546                            debug!("enabling terminal progress reporting based on {source:?}");
547                            return Self::Yes;
548                        } else {
549                            debug!("disabling terminal progress reporting based on {source:?}");
550                            return Self::No;
551                        }
552                    }
553                }
554                DiscoveredConfig::Env => {
555                    if let Some(v) = env::var_os(Self::ENV) {
556                        if v == "true" {
557                            debug!(
558                                "enabling terminal progress reporting based on \
559                                 CARGO_TERM_PROGRESS_TERM_INTEGRATION environment variable"
560                            );
561                            return Self::Yes;
562                        } else if v == "false" {
563                            debug!(
564                                "disabling terminal progress reporting based on \
565                                 CARGO_TERM_PROGRESS_TERM_INTEGRATION environment variable"
566                            );
567                            return Self::No;
568                        } else {
569                            debug!(
570                                "invalid value for CARGO_TERM_PROGRESS_TERM_INTEGRATION \
571                                 environment variable: {v:?}, ignoring"
572                            );
573                        }
574                    }
575                }
576                DiscoveredConfig::File { config, source } => {
577                    if let Some(v) = config.term.progress.term_integration {
578                        if v {
579                            debug!("enabling terminal progress reporting based on {source:?}");
580                            return Self::Yes;
581                        } else {
582                            debug!("disabling terminal progress reporting based on {source:?}");
583                            return Self::No;
584                        }
585                    }
586                }
587            }
588        }
589
590        if supports_osc_9_4(is_terminal) {
591            Self::Yes
592        } else {
593            Self::No
594        }
595    }
596}
597
598/// OSC 9 terminal progress reporting.
599#[derive(Default)]
600pub(super) struct TerminalProgress {
601    last_value: TerminalProgressValue,
602}
603
604impl TerminalProgress {
605    pub(super) fn new(show: ShowTerminalProgress) -> Option<Self> {
606        match show {
607            ShowTerminalProgress::Yes => Some(Self::default()),
608            ShowTerminalProgress::No => None,
609        }
610    }
611
612    pub(super) fn update_progress(&mut self, event: &TestEvent<'_>) {
613        let value = match &event.kind {
614            TestEventKind::RunStarted { .. }
615            | TestEventKind::StressSubRunStarted { .. }
616            | TestEventKind::StressSubRunFinished { .. }
617            | TestEventKind::SetupScriptStarted { .. }
618            | TestEventKind::SetupScriptSlow { .. }
619            | TestEventKind::SetupScriptFinished { .. } => TerminalProgressValue::None,
620            TestEventKind::TestStarted { current_stats, .. }
621            | TestEventKind::TestFinished { current_stats, .. } => {
622                let percentage = (current_stats.finished_count as f64
623                    / current_stats.initial_run_count as f64)
624                    * 100.0;
625                if current_stats.has_failures() || current_stats.cancel_reason.is_some() {
626                    TerminalProgressValue::Error(percentage)
627                } else {
628                    TerminalProgressValue::Value(percentage)
629                }
630            }
631            TestEventKind::TestSlow { .. }
632            | TestEventKind::TestAttemptFailedWillRetry { .. }
633            | TestEventKind::TestRetryStarted { .. }
634            | TestEventKind::TestSkipped { .. }
635            | TestEventKind::InfoStarted { .. }
636            | TestEventKind::InfoResponse { .. }
637            | TestEventKind::InfoFinished { .. }
638            | TestEventKind::InputEnter { .. } => TerminalProgressValue::None,
639            TestEventKind::RunBeginCancel { current_stats, .. }
640            | TestEventKind::RunBeginKill { current_stats, .. } => {
641                // In this case, always indicate an error.
642                let percentage = (current_stats.finished_count as f64
643                    / current_stats.initial_run_count as f64)
644                    * 100.0;
645                TerminalProgressValue::Error(percentage)
646            }
647            TestEventKind::RunPaused { .. }
648            | TestEventKind::RunContinued { .. }
649            | TestEventKind::RunFinished { .. } => {
650                // Reset the terminal state to nothing, since nextest is giving
651                // up control of the terminal at this point.
652                TerminalProgressValue::Remove
653            }
654        };
655
656        self.last_value = value;
657    }
658
659    pub(super) fn last_value(&self) -> &TerminalProgressValue {
660        &self.last_value
661    }
662}
663
664/// Determines whether the terminal supports ANSI OSC 9;4.
665fn supports_osc_9_4(is_terminal: bool) -> bool {
666    if !is_terminal {
667        debug!(
668            "autodetect terminal progress reporting: disabling since \
669             passed-in stream (usually stderr) is not a terminal"
670        );
671        return false;
672    }
673    if std::env::var_os("WT_SESSION").is_some() {
674        debug!("autodetect terminal progress reporting: enabling since WT_SESSION is set");
675        return true;
676    };
677    if std::env::var_os("ConEmuANSI").is_some_and(|term| term == "ON") {
678        debug!("autodetect terminal progress reporting: enabling since ConEmuANSI is ON");
679        return true;
680    }
681    if let Ok(term) = std::env::var("TERM_PROGRAM")
682        && (term == "WezTerm" || term == "ghostty" || term == "iTerm.app")
683    {
684        debug!("autodetect terminal progress reporting: enabling since TERM_PROGRAM is {term}");
685        return true;
686    }
687
688    false
689}
690
691/// A progress status value printable as an ANSI OSC 9;4 escape code.
692///
693/// Adapted from Cargo 1.87.
694#[derive(PartialEq, Debug, Default)]
695pub(super) enum TerminalProgressValue {
696    /// No output.
697    #[default]
698    None,
699    /// Remove progress.
700    Remove,
701    /// Progress value (0-100).
702    Value(f64),
703    /// Indeterminate state (no bar, just animation)
704    ///
705    /// We don't use this yet, but might in the future.
706    #[expect(dead_code)]
707    Indeterminate,
708    /// Progress value in an error state (0-100).
709    Error(f64),
710}
711
712impl fmt::Display for TerminalProgressValue {
713    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
714        // From https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
715        // ESC ] 9 ; 4 ; st ; pr ST
716        // When st is 0: remove progress.
717        // When st is 1: set progress value to pr (number, 0-100).
718        // When st is 2: set error state in taskbar, pr is optional.
719        // When st is 3: set indeterminate state, pr is ignored.
720        // When st is 4: set paused state, pr is optional.
721        let (state, progress) = match self {
722            Self::None => return Ok(()), // No output
723            Self::Remove => (0, 0.0),
724            Self::Value(v) => (1, *v),
725            Self::Indeterminate => (3, 0.0),
726            Self::Error(v) => (2, *v),
727        };
728        write!(f, "\x1b]9;4;{state};{progress:.0}\x1b\\")
729    }
730}
731
732/// Returns a summary of current progress.
733pub(super) fn progress_str(
734    elapsed: Duration,
735    current_stats: &RunStats,
736    running: usize,
737    styles: &Styles,
738) -> String {
739    // First, show the prefix.
740    let mut s = progress_bar_prefix(current_stats, current_stats.cancel_reason, styles);
741
742    // Then, the time elapsed, test counts, and message.
743    swrite!(
744        s,
745        " {}{}/{}: {}",
746        DisplayBracketedHhMmSs(elapsed),
747        current_stats.finished_count,
748        current_stats.initial_run_count,
749        progress_bar_msg(current_stats, running, styles)
750    );
751
752    s
753}
754
755pub(super) fn write_summary_str(run_stats: &RunStats, styles: &Styles, out: &mut String) {
756    // Written in this style to ensure new fields are accounted for.
757    let &RunStats {
758        initial_run_count: _,
759        finished_count: _,
760        setup_scripts_initial_count: _,
761        setup_scripts_finished_count: _,
762        setup_scripts_passed: _,
763        setup_scripts_failed: _,
764        setup_scripts_exec_failed: _,
765        setup_scripts_timed_out: _,
766        passed,
767        passed_slow,
768        passed_timed_out: _,
769        flaky,
770        failed,
771        failed_slow: _,
772        failed_timed_out,
773        leaky,
774        leaky_failed,
775        exec_failed,
776        skipped,
777        cancel_reason: _,
778    } = run_stats;
779
780    swrite!(
781        out,
782        "{} {}",
783        passed.style(styles.count),
784        "passed".style(styles.pass)
785    );
786
787    if passed_slow > 0 || flaky > 0 || leaky > 0 {
788        let mut text = Vec::with_capacity(3);
789        if passed_slow > 0 {
790            text.push(format!(
791                "{} {}",
792                passed_slow.style(styles.count),
793                "slow".style(styles.skip),
794            ));
795        }
796        if flaky > 0 {
797            text.push(format!(
798                "{} {}",
799                flaky.style(styles.count),
800                "flaky".style(styles.skip),
801            ));
802        }
803        if leaky > 0 {
804            text.push(format!(
805                "{} {}",
806                leaky.style(styles.count),
807                "leaky".style(styles.skip),
808            ));
809        }
810        swrite!(out, " ({})", text.join(", "));
811    }
812    swrite!(out, ", ");
813
814    if failed > 0 {
815        swrite!(
816            out,
817            "{} {}",
818            failed.style(styles.count),
819            "failed".style(styles.fail),
820        );
821        if leaky_failed > 0 {
822            swrite!(
823                out,
824                " ({} due to being {})",
825                leaky_failed.style(styles.count),
826                "leaky".style(styles.fail),
827            );
828        }
829        swrite!(out, ", ");
830    }
831
832    if exec_failed > 0 {
833        swrite!(
834            out,
835            "{} {}, ",
836            exec_failed.style(styles.count),
837            "exec failed".style(styles.fail),
838        );
839    }
840
841    if failed_timed_out > 0 {
842        swrite!(
843            out,
844            "{} {}, ",
845            failed_timed_out.style(styles.count),
846            "timed out".style(styles.fail),
847        );
848    }
849
850    swrite!(
851        out,
852        "{} {}",
853        skipped.style(styles.count),
854        "skipped".style(styles.skip),
855    );
856}
857
858fn progress_bar_cancel_prefix(reason: Option<CancelReason>, styles: &Styles) -> String {
859    let status = match reason {
860        Some(CancelReason::SetupScriptFailure)
861        | Some(CancelReason::TestFailure)
862        | Some(CancelReason::ReportError)
863        | Some(CancelReason::GlobalTimeout)
864        | Some(CancelReason::TestFailureImmediate)
865        | Some(CancelReason::Signal)
866        | Some(CancelReason::Interrupt)
867        | None => "Cancelling",
868        Some(CancelReason::SecondSignal) => "Killing",
869    };
870    format!("{:>12}", status.style(styles.fail))
871}
872
873fn progress_bar_prefix(
874    run_stats: &RunStats,
875    cancel_reason: Option<CancelReason>,
876    styles: &Styles,
877) -> String {
878    if let Some(reason) = cancel_reason {
879        return progress_bar_cancel_prefix(Some(reason), styles);
880    }
881
882    let style = if run_stats.has_failures() {
883        styles.fail
884    } else {
885        styles.pass
886    };
887
888    format!("{:>12}", "Running".style(style))
889}
890
891pub(super) fn progress_bar_msg(
892    current_stats: &RunStats,
893    running: usize,
894    styles: &Styles,
895) -> String {
896    let mut s = format!("{} running, ", running.style(styles.count));
897    write_summary_str(current_stats, styles, &mut s);
898    s
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904    use crate::{
905        reporter::TestOutputDisplay,
906        test_output::{ChildExecutionOutput, ChildOutput, ChildSingleOutput, ChildSplitOutput},
907    };
908    use bytes::Bytes;
909    use chrono::Local;
910
911    #[test]
912    fn test_progress_bar_prefix() {
913        let mut styles = Styles::default();
914        styles.colorize();
915
916        for (name, stats) in run_stats_test_failure_examples() {
917            let prefix = progress_bar_prefix(&stats, Some(CancelReason::TestFailure), &styles);
918            assert_eq!(
919                prefix,
920                "  Cancelling".style(styles.fail).to_string(),
921                "{name} matches"
922            );
923        }
924        for (name, stats) in run_stats_setup_script_failure_examples() {
925            let prefix =
926                progress_bar_prefix(&stats, Some(CancelReason::SetupScriptFailure), &styles);
927            assert_eq!(
928                prefix,
929                "  Cancelling".style(styles.fail).to_string(),
930                "{name} matches"
931            );
932        }
933
934        let prefix = progress_bar_prefix(&RunStats::default(), Some(CancelReason::Signal), &styles);
935        assert_eq!(prefix, "  Cancelling".style(styles.fail).to_string());
936
937        let prefix = progress_bar_prefix(&RunStats::default(), None, &styles);
938        assert_eq!(prefix, "     Running".style(styles.pass).to_string());
939
940        for (name, stats) in run_stats_test_failure_examples() {
941            let prefix = progress_bar_prefix(&stats, None, &styles);
942            assert_eq!(
943                prefix,
944                "     Running".style(styles.fail).to_string(),
945                "{name} matches"
946            );
947        }
948        for (name, stats) in run_stats_setup_script_failure_examples() {
949            let prefix = progress_bar_prefix(&stats, None, &styles);
950            assert_eq!(
951                prefix,
952                "     Running".style(styles.fail).to_string(),
953                "{name} matches"
954            );
955        }
956    }
957
958    #[test]
959    fn progress_str_snapshots() {
960        let mut styles = Styles::default();
961        styles.colorize();
962
963        // This elapsed time is arbitrary but reasonably large.
964        let elapsed = Duration::from_secs(123456);
965        let running = 10;
966
967        for (name, stats) in run_stats_test_failure_examples() {
968            let s = progress_str(elapsed, &stats, running, &styles);
969            insta::assert_snapshot!(format!("{name}_with_cancel_reason"), s);
970
971            let mut stats = stats;
972            stats.cancel_reason = None;
973            let s = progress_str(elapsed, &stats, running, &styles);
974            insta::assert_snapshot!(format!("{name}_without_cancel_reason"), s);
975        }
976
977        for (name, stats) in run_stats_setup_script_failure_examples() {
978            let s = progress_str(elapsed, &stats, running, &styles);
979            insta::assert_snapshot!(format!("{name}_with_cancel_reason"), s);
980
981            let mut stats = stats;
982            stats.cancel_reason = None;
983            let s = progress_str(elapsed, &stats, running, &styles);
984            insta::assert_snapshot!(format!("{name}_without_cancel_reason"), s);
985        }
986    }
987
988    #[test]
989    fn running_test_snapshots() {
990        let styles = Styles::default();
991        let now = Instant::now();
992
993        for (name, running_test) in running_test_examples(now) {
994            let msg = running_test.message(now, 80, &styles);
995            insta::assert_snapshot!(name, msg);
996        }
997    }
998
999    fn running_test_examples(now: Instant) -> Vec<(&'static str, RunningTest)> {
1000        let binary_id = RustBinaryId::new("my-binary");
1001        let test_name = TestCaseName::new("test::my_test");
1002        let start_time = now - Duration::from_secs(125); // 2 minutes 5 seconds ago
1003
1004        vec![
1005            (
1006                "running_status",
1007                RunningTest {
1008                    binary_id: binary_id.clone(),
1009                    test_name: test_name.clone(),
1010                    status: RunningTestStatus::Running,
1011                    start_time,
1012                    paused_for: Duration::ZERO,
1013                },
1014            ),
1015            (
1016                "slow_status",
1017                RunningTest {
1018                    binary_id: binary_id.clone(),
1019                    test_name: test_name.clone(),
1020                    status: RunningTestStatus::Slow,
1021                    start_time,
1022                    paused_for: Duration::ZERO,
1023                },
1024            ),
1025            (
1026                "delay_status",
1027                RunningTest {
1028                    binary_id: binary_id.clone(),
1029                    test_name: test_name.clone(),
1030                    status: RunningTestStatus::Delay(Duration::from_secs(130)),
1031                    start_time,
1032                    paused_for: Duration::ZERO,
1033                },
1034            ),
1035            (
1036                "delay_status_underflow",
1037                RunningTest {
1038                    binary_id: binary_id.clone(),
1039                    test_name: test_name.clone(),
1040                    status: RunningTestStatus::Delay(Duration::from_secs(124)),
1041                    start_time,
1042                    paused_for: Duration::ZERO,
1043                },
1044            ),
1045            (
1046                "retry_status",
1047                RunningTest {
1048                    binary_id: binary_id.clone(),
1049                    test_name: test_name.clone(),
1050                    status: RunningTestStatus::Retry,
1051                    start_time,
1052                    paused_for: Duration::ZERO,
1053                },
1054            ),
1055            (
1056                "with_paused_duration",
1057                RunningTest {
1058                    binary_id: binary_id.clone(),
1059                    test_name: test_name.clone(),
1060                    status: RunningTestStatus::Running,
1061                    start_time,
1062                    paused_for: Duration::from_secs(30),
1063                },
1064            ),
1065        ]
1066    }
1067
1068    fn run_stats_test_failure_examples() -> Vec<(&'static str, RunStats)> {
1069        vec![
1070            (
1071                "one_failed",
1072                RunStats {
1073                    initial_run_count: 20,
1074                    finished_count: 1,
1075                    failed: 1,
1076                    cancel_reason: Some(CancelReason::TestFailure),
1077                    ..RunStats::default()
1078                },
1079            ),
1080            (
1081                "one_failed_one_passed",
1082                RunStats {
1083                    initial_run_count: 20,
1084                    finished_count: 2,
1085                    failed: 1,
1086                    passed: 1,
1087                    cancel_reason: Some(CancelReason::TestFailure),
1088                    ..RunStats::default()
1089                },
1090            ),
1091            (
1092                "one_exec_failed",
1093                RunStats {
1094                    initial_run_count: 20,
1095                    finished_count: 10,
1096                    exec_failed: 1,
1097                    cancel_reason: Some(CancelReason::TestFailure),
1098                    ..RunStats::default()
1099                },
1100            ),
1101            (
1102                "one_timed_out",
1103                RunStats {
1104                    initial_run_count: 20,
1105                    finished_count: 10,
1106                    failed_timed_out: 1,
1107                    cancel_reason: Some(CancelReason::TestFailure),
1108                    ..RunStats::default()
1109                },
1110            ),
1111        ]
1112    }
1113
1114    fn run_stats_setup_script_failure_examples() -> Vec<(&'static str, RunStats)> {
1115        vec![
1116            (
1117                "one_setup_script_failed",
1118                RunStats {
1119                    initial_run_count: 30,
1120                    setup_scripts_failed: 1,
1121                    cancel_reason: Some(CancelReason::SetupScriptFailure),
1122                    ..RunStats::default()
1123                },
1124            ),
1125            (
1126                "one_setup_script_exec_failed",
1127                RunStats {
1128                    initial_run_count: 35,
1129                    setup_scripts_exec_failed: 1,
1130                    cancel_reason: Some(CancelReason::SetupScriptFailure),
1131                    ..RunStats::default()
1132                },
1133            ),
1134            (
1135                "one_setup_script_timed_out",
1136                RunStats {
1137                    initial_run_count: 40,
1138                    setup_scripts_timed_out: 1,
1139                    cancel_reason: Some(CancelReason::SetupScriptFailure),
1140                    ..RunStats::default()
1141                },
1142            ),
1143        ]
1144    }
1145
1146    /// Test that `update_progress_bar` correctly updates `self.stats` when
1147    /// processing `TestStarted` and `TestFinished` events.
1148    ///
1149    /// This test verifies both:
1150    ///
1151    /// 1. State: `self.stats` equals the event's `current_stats` after processing.
1152    /// 2. Output: `state.progress_bar_msg()` reflects the updated stats.
1153    #[test]
1154    fn update_progress_bar_updates_stats() {
1155        let styles = Styles::default();
1156        let binary_id = RustBinaryId::new("test-binary");
1157        let test_name = TestCaseName::new("test_name");
1158
1159        // Create ProgressBarState with initial (default) stats.
1160        let mut state = ProgressBarState::new(
1161            NextestRunMode::Test,
1162            10,
1163            "=> ",
1164            MaxProgressRunning::default(),
1165        );
1166
1167        // Verify the initial state.
1168        assert_eq!(state.stats, RunStats::default());
1169        assert_eq!(state.running, 0);
1170
1171        // Create a TestStarted event.
1172        let started_stats = RunStats {
1173            initial_run_count: 10,
1174            passed: 5,
1175            ..RunStats::default()
1176        };
1177        let started_event = TestEvent {
1178            timestamp: Local::now().fixed_offset(),
1179            elapsed: Duration::ZERO,
1180            kind: TestEventKind::TestStarted {
1181                stress_index: None,
1182                test_instance: TestInstanceId {
1183                    binary_id: &binary_id,
1184                    test_name: &test_name,
1185                },
1186                current_stats: started_stats,
1187                running: 3,
1188                command_line: vec![],
1189            },
1190        };
1191
1192        state.update_progress_bar(&started_event, &styles);
1193
1194        // Verify the state was updated.
1195        assert_eq!(
1196            state.stats, started_stats,
1197            "stats should be updated on TestStarted"
1198        );
1199        assert_eq!(state.running, 3, "running should be updated on TestStarted");
1200
1201        // Verify that the output reflects the updated stats.
1202        let msg = state.progress_bar_msg(&styles);
1203        insta::assert_snapshot!(msg, @"3 running, 5 passed, 0 skipped");
1204
1205        // Create a TestFinished event with different stats.
1206        let finished_stats = RunStats {
1207            initial_run_count: 10,
1208            finished_count: 1,
1209            passed: 8,
1210            ..RunStats::default()
1211        };
1212        let finished_event = TestEvent {
1213            timestamp: Local::now().fixed_offset(),
1214            elapsed: Duration::ZERO,
1215            kind: TestEventKind::TestFinished {
1216                stress_index: None,
1217                test_instance: TestInstanceId {
1218                    binary_id: &binary_id,
1219                    test_name: &test_name,
1220                },
1221                success_output: TestOutputDisplay::Never,
1222                failure_output: TestOutputDisplay::Never,
1223                junit_store_success_output: false,
1224                junit_store_failure_output: false,
1225                run_statuses: ExecutionStatuses::new(vec![ExecuteStatus {
1226                    retry_data: RetryData {
1227                        attempt: 1,
1228                        total_attempts: 1,
1229                    },
1230                    output: make_test_output(),
1231                    result: ExecutionResultDescription::Pass,
1232                    start_time: Local::now().fixed_offset(),
1233                    time_taken: Duration::from_secs(1),
1234                    is_slow: false,
1235                    delay_before_start: Duration::ZERO,
1236                    error_summary: None,
1237                    output_error_slice: None,
1238                }]),
1239                current_stats: finished_stats,
1240                running: 2,
1241            },
1242        };
1243
1244        state.update_progress_bar(&finished_event, &styles);
1245
1246        // Verify the state was updated.
1247        assert_eq!(
1248            state.stats, finished_stats,
1249            "stats should be updated on TestFinished"
1250        );
1251        assert_eq!(
1252            state.running, 2,
1253            "running should be updated on TestFinished"
1254        );
1255
1256        // Verify that the output reflects the updated stats.
1257        let msg = state.progress_bar_msg(&styles);
1258        insta::assert_snapshot!(msg, @"2 running, 8 passed, 0 skipped");
1259
1260        // Create a RunBeginCancel event.
1261        let cancel_stats = RunStats {
1262            initial_run_count: 10,
1263            finished_count: 3,
1264            passed: 2,
1265            failed: 1,
1266            cancel_reason: Some(CancelReason::TestFailure),
1267            ..RunStats::default()
1268        };
1269        let cancel_event = TestEvent {
1270            timestamp: Local::now().fixed_offset(),
1271            elapsed: Duration::ZERO,
1272            kind: TestEventKind::RunBeginCancel {
1273                setup_scripts_running: 0,
1274                current_stats: cancel_stats,
1275                running: 4,
1276            },
1277        };
1278
1279        state.update_progress_bar(&cancel_event, &styles);
1280
1281        // Verify the state was updated.
1282        assert_eq!(
1283            state.stats, cancel_stats,
1284            "stats should be updated on RunBeginCancel"
1285        );
1286        assert_eq!(
1287            state.running, 4,
1288            "running should be updated on RunBeginCancel"
1289        );
1290
1291        // Verify that the output reflects the updated stats.
1292        let msg = state.progress_bar_msg(&styles);
1293        insta::assert_snapshot!(msg, @"4 running, 2 passed, 1 failed, 0 skipped");
1294
1295        // Create a RunBeginKill event with different stats.
1296        let kill_stats = RunStats {
1297            initial_run_count: 10,
1298            finished_count: 5,
1299            passed: 3,
1300            failed: 2,
1301            cancel_reason: Some(CancelReason::Signal),
1302            ..RunStats::default()
1303        };
1304        let kill_event = TestEvent {
1305            timestamp: Local::now().fixed_offset(),
1306            elapsed: Duration::ZERO,
1307            kind: TestEventKind::RunBeginKill {
1308                setup_scripts_running: 0,
1309                current_stats: kill_stats,
1310                running: 2,
1311            },
1312        };
1313
1314        state.update_progress_bar(&kill_event, &styles);
1315
1316        // Verify the state was updated.
1317        assert_eq!(
1318            state.stats, kill_stats,
1319            "stats should be updated on RunBeginKill"
1320        );
1321        assert_eq!(
1322            state.running, 2,
1323            "running should be updated on RunBeginKill"
1324        );
1325
1326        // Verify that the output reflects the updated stats.
1327        let msg = state.progress_bar_msg(&styles);
1328        insta::assert_snapshot!(msg, @"2 running, 3 passed, 2 failed, 0 skipped");
1329    }
1330
1331    // Helper to create minimal output for ExecuteStatus.
1332    fn make_test_output() -> ChildExecutionOutputDescription<ChildSingleOutput> {
1333        ChildExecutionOutput::Output {
1334            result: Some(ExecutionResult::Pass),
1335            output: ChildOutput::Split(ChildSplitOutput {
1336                stdout: Some(Bytes::new().into()),
1337                stderr: Some(Bytes::new().into()),
1338            }),
1339            errors: None,
1340        }
1341        .into()
1342    }
1343}