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///
89/// In the `Auto` variant, the progress display is chosen based on the
90/// environment: a progress bar in interactive terminals, a counter otherwise.
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum ShowProgress {
93    /// Automatically decide based on environment.
94    ///
95    /// When `suppress_success` is true and a progress bar is shown,
96    /// successful test output is suppressed (status level defaults to
97    /// `Slow`, final status level defaults to `None`). In non-interactive
98    /// contexts, output behaves identically to `suppress_success: false`:
99    /// all test results are displayed normally.
100    Auto {
101        /// Whether to hide successful test output when a progress bar is
102        /// shown.
103        suppress_success: bool,
104    },
105
106    /// No progress display.
107    None,
108
109    /// Show a counter on each line.
110    Counter,
111
112    /// Show a progress bar and the running tests.
113    Running,
114}
115
116impl Default for ShowProgress {
117    fn default() -> Self {
118        ShowProgress::Auto {
119            suppress_success: false,
120        }
121    }
122}
123
124#[derive(Debug)]
125pub(super) enum RunningTestStatus {
126    Running,
127    Slow,
128    Delay(Duration),
129    Retry,
130}
131
132#[derive(Debug)]
133pub(super) struct RunningTest {
134    binary_id: RustBinaryId,
135    test_name: TestCaseName,
136    status: RunningTestStatus,
137    start_time: Instant,
138    paused_for: Duration,
139}
140
141impl RunningTest {
142    fn message(&self, now: Instant, width: usize, styles: &Styles) -> String {
143        let mut elapsed = (now - self.start_time).saturating_sub(self.paused_for);
144        let status = match self.status {
145            RunningTestStatus::Running => "     ".to_owned(),
146            RunningTestStatus::Slow => " SLOW".style(styles.skip).to_string(),
147            RunningTestStatus::Delay(d) => {
148                // The elapsed might be greater than the delay duration in case
149                // we ticked past the delay duration without receiving a
150                // notification that the test retry started.
151                elapsed = d.saturating_sub(elapsed);
152                "DELAY".style(styles.retry).to_string()
153            }
154            RunningTestStatus::Retry => "RETRY".style(styles.retry).to_string(),
155        };
156        let elapsed = format!(
157            "{:0>2}:{:0>2}:{:0>2}",
158            elapsed.as_secs() / 3600,
159            elapsed.as_secs() / 60,
160            elapsed.as_secs() % 60,
161        );
162        let max_width = width.saturating_sub(25);
163        let test = DisplayTestInstance::new(
164            None,
165            None,
166            TestInstanceId {
167                binary_id: &self.binary_id,
168
169                test_name: &self.test_name,
170            },
171            &styles.list_styles,
172        )
173        .with_max_width(max_width);
174        format!("       {} [{:>9}] {}", status, elapsed, test)
175    }
176}
177
178#[derive(Debug)]
179pub(super) struct ProgressBarState {
180    bar: ProgressBar,
181    mode: NextestRunMode,
182    stats: RunStats,
183    running: usize,
184    max_progress_running: MaxProgressRunning,
185    // Keep track of the maximum number of lines used. This allows to adapt the
186    // size of the 'viewport' to what we are using, and not just to the maximum
187    // number of tests that can be run in parallel
188    max_running_displayed: usize,
189    // None when the running tests are not displayed
190    running_tests: Option<Vec<RunningTest>>,
191    buffer: String,
192    // Size in bytes for chunking println calls. Configurable via the
193    // undocumented __NEXTEST_PROGRESS_PRINTLN_CHUNK_SIZE env var.
194    println_chunk_size: usize,
195    // Reasons for hiding the progress bar. We show the progress bar if none of
196    // these are set and hide it if any of them are set.
197    //
198    // indicatif cannot handle this kind of "stacked" state management, so it
199    // falls on us to do so.
200    //
201    // The current draw target is a pure function of these three booleans: if
202    // any of them are set, the draw target is hidden, otherwise it's stderr. If
203    // this changes, we'll need to track those other inputs.
204    hidden_no_capture: bool,
205    hidden_run_paused: bool,
206    hidden_info_response: bool,
207}
208
209impl ProgressBarState {
210    pub(super) fn new(
211        mode: NextestRunMode,
212        run_count: usize,
213        progress_chars: &str,
214        max_progress_running: MaxProgressRunning,
215    ) -> Self {
216        let bar = ProgressBar::new(run_count as u64);
217        let run_count_width = format!("{run_count}").len();
218        // Create the template using the width as input. This is a
219        // little confusing -- {{foo}} is what's passed into the
220        // ProgressBar, while {bar} is inserted by the format!()
221        // statement.
222        let template = format!(
223            "{{prefix:>12}} [{{elapsed_precise:>9}}] {{wide_bar}} \
224            {{pos:>{run_count_width}}}/{{len:{run_count_width}}}: {{msg}}"
225        );
226        bar.set_style(
227            ProgressStyle::default_bar()
228                .progress_chars(progress_chars)
229                .template(&template)
230                .expect("template is known to be valid"),
231        );
232
233        let running_tests =
234            (!matches!(max_progress_running, MaxProgressRunning::Count(0))).then(Vec::new);
235
236        // The println chunk size defaults to a value chosen by experimentation,
237        // locally and over SSH. This controls how often the progress bar
238        // refreshes during large output bursts.
239        let println_chunk_size = env::var("__NEXTEST_PROGRESS_PRINTLN_CHUNK_SIZE")
240            .ok()
241            .and_then(|s| s.parse::<usize>().ok())
242            .unwrap_or(4096);
243
244        Self {
245            bar,
246            mode,
247            stats: RunStats::default(),
248            running: 0,
249            max_progress_running,
250            max_running_displayed: 0,
251            running_tests,
252            buffer: String::new(),
253            println_chunk_size,
254            hidden_no_capture: false,
255            hidden_run_paused: false,
256            hidden_info_response: false,
257        }
258    }
259
260    pub(super) fn tick(&mut self, styles: &Styles) {
261        self.update_message(styles);
262        self.print_and_clear_buffer();
263    }
264
265    fn print_and_clear_buffer(&mut self) {
266        self.print_and_force_redraw();
267        self.buffer.clear();
268    }
269
270    /// Prints the contents of the buffer, and always forces a redraw.
271    fn print_and_force_redraw(&self) {
272        if self.buffer.is_empty() {
273            // Force a redraw as part of our contract. See the documentation for
274            // `PROGRESS_REFRESH_RATE_HZ`.
275            self.bar.force_draw();
276            return;
277        }
278
279        // println below also forces a redraw, so we don't need to call
280        // force_draw in this case.
281
282        // ProgressBar::println is only called if there's something in the
283        // buffer, for two reasons:
284        //
285        // 1. If passed in nothing at all, it prints an empty line.
286        // 2. It forces a full redraw.
287        //
288        // But if self.buffer is too large, we can overwhelm the terminal with
289        // large amounts of non-progress-bar output, causing the progress bar to
290        // flicker in and out. To avoid those issues, we chunk the output to
291        // maintain progress bar visibility by redrawing it regularly.
292        print_lines_in_chunks(&self.buffer, self.println_chunk_size, |chunk| {
293            self.bar.println(chunk);
294        });
295    }
296
297    fn update_message(&mut self, styles: &Styles) {
298        let mut msg = self.progress_bar_msg(styles);
299        msg += "     ";
300
301        if let Some(running_tests) = &self.running_tests {
302            let (_, width) = console::Term::stderr().size();
303            let width = max(width as usize, 40);
304            let now = Instant::now();
305            let mut count = match self.max_progress_running {
306                MaxProgressRunning::Count(count) => min(running_tests.len(), count),
307                MaxProgressRunning::Infinite => running_tests.len(),
308            };
309            for running_test in &running_tests[..count] {
310                msg.push('\n');
311                msg.push_str(&running_test.message(now, width, styles));
312            }
313            if count < running_tests.len() {
314                let overflow_count = running_tests.len() - count;
315                swrite!(
316                    msg,
317                    "\n             ... and {} more {} running",
318                    overflow_count.style(styles.count),
319                    plural::tests_str(self.mode, overflow_count),
320                );
321                count += 1;
322            }
323            self.max_running_displayed = max(self.max_running_displayed, count);
324            msg.push_str(&"\n".to_string().repeat(self.max_running_displayed - count));
325        }
326        self.bar.set_message(msg);
327    }
328
329    fn progress_bar_msg(&self, styles: &Styles) -> String {
330        progress_bar_msg(&self.stats, self.running, styles)
331    }
332
333    pub(super) fn update_progress_bar(&mut self, event: &TestEvent<'_>, styles: &Styles) {
334        let before_should_hide = self.should_hide();
335
336        match &event.kind {
337            TestEventKind::StressSubRunStarted { .. } => {
338                self.bar.reset();
339            }
340            TestEventKind::StressSubRunFinished { .. } => {
341                // Clear all test bars to remove empty lines of output between
342                // sub-runs.
343                self.bar.finish_and_clear();
344            }
345            // Hide the progress bar if either stderr or stdout are being passed through.
346            TestEventKind::SetupScriptStarted { no_capture, .. } if *no_capture => {
347                self.hidden_no_capture = true;
348            }
349            // Restore the progress bar if it was hidden.
350            TestEventKind::SetupScriptFinished { no_capture, .. } if *no_capture => {
351                self.hidden_no_capture = false;
352            }
353            TestEventKind::TestStarted {
354                current_stats,
355                running,
356                test_instance,
357                ..
358            } => {
359                self.running = *running;
360                self.stats = *current_stats;
361
362                self.bar.set_prefix(progress_bar_prefix(
363                    current_stats,
364                    current_stats.cancel_reason,
365                    styles,
366                ));
367                self.bar.set_length(current_stats.initial_run_count as u64);
368                self.bar.set_position(current_stats.finished_count as u64);
369
370                if let Some(running_tests) = &mut self.running_tests {
371                    running_tests.push(RunningTest {
372                        binary_id: test_instance.binary_id.clone(),
373                        test_name: test_instance.test_name.to_owned(),
374                        status: RunningTestStatus::Running,
375                        start_time: Instant::now(),
376                        paused_for: Duration::ZERO,
377                    });
378                }
379            }
380            TestEventKind::TestFinished {
381                current_stats,
382                running,
383                test_instance,
384                ..
385            } => {
386                self.running = *running;
387                self.stats = *current_stats;
388                self.remove_test(test_instance);
389
390                self.bar.set_prefix(progress_bar_prefix(
391                    current_stats,
392                    current_stats.cancel_reason,
393                    styles,
394                ));
395                self.bar.set_length(current_stats.initial_run_count as u64);
396                self.bar.set_position(current_stats.finished_count as u64);
397            }
398            TestEventKind::TestAttemptFailedWillRetry {
399                test_instance,
400                delay_before_next_attempt,
401                ..
402            } => {
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::Delay(*delay_before_next_attempt),
409                        start_time: Instant::now(),
410                        paused_for: Duration::ZERO,
411                    });
412                }
413            }
414            TestEventKind::TestRetryStarted { test_instance, .. } => {
415                self.remove_test(test_instance);
416                if let Some(running_tests) = &mut self.running_tests {
417                    running_tests.push(RunningTest {
418                        binary_id: test_instance.binary_id.clone(),
419                        test_name: test_instance.test_name.to_owned(),
420                        status: RunningTestStatus::Retry,
421                        start_time: Instant::now(),
422                        paused_for: Duration::ZERO,
423                    });
424                }
425            }
426            TestEventKind::TestSlow { test_instance, .. } => {
427                if let Some(running_tests) = &mut self.running_tests {
428                    running_tests
429                        .iter_mut()
430                        .find(|rt| {
431                            &rt.binary_id == test_instance.binary_id
432                                && &rt.test_name == test_instance.test_name
433                        })
434                        .expect("a slow test to be already running")
435                        .status = RunningTestStatus::Slow;
436                }
437            }
438            TestEventKind::InfoStarted { .. } => {
439                // While info is being displayed, hide the progress bar to avoid
440                // it interrupting the info display.
441                self.hidden_info_response = true;
442            }
443            TestEventKind::InfoFinished { .. } => {
444                // Restore the progress bar if it was hidden.
445                self.hidden_info_response = false;
446            }
447            TestEventKind::RunPaused { .. } => {
448                // Pausing the run should hide the progress bar since we'll exit
449                // to the terminal immediately after.
450                self.hidden_run_paused = true;
451            }
452            TestEventKind::RunContinued { .. } => {
453                // Continuing the run should show the progress bar since we'll
454                // continue to output to it.
455                self.hidden_run_paused = false;
456                let current_global_elapsed = self.bar.elapsed();
457                self.bar.set_elapsed(event.elapsed);
458
459                if let Some(running_tests) = &mut self.running_tests {
460                    let delta = current_global_elapsed.saturating_sub(event.elapsed);
461                    for running_test in running_tests {
462                        running_test.paused_for += delta;
463                    }
464                }
465            }
466            TestEventKind::RunBeginCancel {
467                current_stats,
468                running,
469                ..
470            }
471            | TestEventKind::RunBeginKill {
472                current_stats,
473                running,
474                ..
475            } => {
476                self.running = *running;
477                self.stats = *current_stats;
478                self.bar.set_prefix(progress_bar_cancel_prefix(
479                    current_stats.cancel_reason,
480                    styles,
481                ));
482            }
483            _ => {}
484        }
485
486        let after_should_hide = self.should_hide();
487
488        match (before_should_hide, after_should_hide) {
489            (false, true) => self.bar.set_draw_target(Self::hidden_target()),
490            (true, false) => self.bar.set_draw_target(Self::stderr_target()),
491            _ => {}
492        }
493    }
494
495    fn remove_test(&mut self, test_instance: &TestInstanceId) {
496        if let Some(running_tests) = &mut self.running_tests {
497            running_tests.remove(
498                running_tests
499                    .iter()
500                    .position(|e| {
501                        &e.binary_id == test_instance.binary_id
502                            && &e.test_name == test_instance.test_name
503                    })
504                    .expect("finished test to have started"),
505            );
506        }
507    }
508
509    pub(super) fn write_buf(&mut self, buf: &str) {
510        self.buffer.push_str(buf);
511    }
512
513    #[inline]
514    pub(super) fn finish_and_clear(&self) {
515        self.print_and_force_redraw();
516        self.bar.finish_and_clear();
517    }
518
519    fn stderr_target() -> ProgressDrawTarget {
520        ProgressDrawTarget::stderr_with_hz(PROGRESS_REFRESH_RATE_HZ)
521    }
522
523    fn hidden_target() -> ProgressDrawTarget {
524        ProgressDrawTarget::hidden()
525    }
526
527    fn should_hide(&self) -> bool {
528        self.hidden_no_capture || self.hidden_run_paused || self.hidden_info_response
529    }
530}
531
532/// Whether to show OSC 9;4 terminal progress.
533#[derive(Clone, Copy, Debug, Eq, PartialEq)]
534pub enum ShowTerminalProgress {
535    /// Show terminal progress.
536    Yes,
537
538    /// Do not show terminal progress.
539    No,
540}
541
542impl ShowTerminalProgress {
543    const ENV: &str = "CARGO_TERM_PROGRESS_TERM_INTEGRATION";
544
545    /// Determines whether to show terminal progress based on Cargo configs and
546    /// whether the output is a terminal.
547    pub fn from_cargo_configs(configs: &CargoConfigs, is_terminal: bool) -> Self {
548        // See whether terminal integration is enabled in Cargo.
549        for config in configs.discovered_configs() {
550            match config {
551                DiscoveredConfig::CliOption { config, source } => {
552                    if let Some(v) = config.term.progress.term_integration {
553                        if v {
554                            debug!("enabling terminal progress reporting based on {source:?}");
555                            return Self::Yes;
556                        } else {
557                            debug!("disabling terminal progress reporting based on {source:?}");
558                            return Self::No;
559                        }
560                    }
561                }
562                DiscoveredConfig::Env => {
563                    if let Some(v) = env::var_os(Self::ENV) {
564                        if v == "true" {
565                            debug!(
566                                "enabling terminal progress reporting based on \
567                                 CARGO_TERM_PROGRESS_TERM_INTEGRATION environment variable"
568                            );
569                            return Self::Yes;
570                        } else if v == "false" {
571                            debug!(
572                                "disabling terminal progress reporting based on \
573                                 CARGO_TERM_PROGRESS_TERM_INTEGRATION environment variable"
574                            );
575                            return Self::No;
576                        } else {
577                            debug!(
578                                "invalid value for CARGO_TERM_PROGRESS_TERM_INTEGRATION \
579                                 environment variable: {v:?}, ignoring"
580                            );
581                        }
582                    }
583                }
584                DiscoveredConfig::File { config, source } => {
585                    if let Some(v) = config.term.progress.term_integration {
586                        if v {
587                            debug!("enabling terminal progress reporting based on {source:?}");
588                            return Self::Yes;
589                        } else {
590                            debug!("disabling terminal progress reporting based on {source:?}");
591                            return Self::No;
592                        }
593                    }
594                }
595            }
596        }
597
598        if supports_osc_9_4(is_terminal) {
599            Self::Yes
600        } else {
601            Self::No
602        }
603    }
604}
605
606/// OSC 9 terminal progress reporting.
607#[derive(Default)]
608pub(super) struct TerminalProgress {
609    last_value: TerminalProgressValue,
610}
611
612impl TerminalProgress {
613    pub(super) fn new(show: ShowTerminalProgress) -> Option<Self> {
614        match show {
615            ShowTerminalProgress::Yes => Some(Self::default()),
616            ShowTerminalProgress::No => None,
617        }
618    }
619
620    pub(super) fn update_progress(&mut self, event: &TestEvent<'_>) {
621        let value = match &event.kind {
622            TestEventKind::RunStarted { .. }
623            | TestEventKind::StressSubRunStarted { .. }
624            | TestEventKind::StressSubRunFinished { .. }
625            | TestEventKind::SetupScriptStarted { .. }
626            | TestEventKind::SetupScriptSlow { .. }
627            | TestEventKind::SetupScriptFinished { .. } => TerminalProgressValue::None,
628            TestEventKind::TestStarted { current_stats, .. }
629            | TestEventKind::TestFinished { current_stats, .. } => {
630                let percentage = (current_stats.finished_count as f64
631                    / current_stats.initial_run_count as f64)
632                    * 100.0;
633                if current_stats.has_failures() || current_stats.cancel_reason.is_some() {
634                    TerminalProgressValue::Error(percentage)
635                } else {
636                    TerminalProgressValue::Value(percentage)
637                }
638            }
639            TestEventKind::TestSlow { .. }
640            | TestEventKind::TestAttemptFailedWillRetry { .. }
641            | TestEventKind::TestRetryStarted { .. }
642            | TestEventKind::TestSkipped { .. }
643            | TestEventKind::InfoStarted { .. }
644            | TestEventKind::InfoResponse { .. }
645            | TestEventKind::InfoFinished { .. }
646            | TestEventKind::InputEnter { .. } => TerminalProgressValue::None,
647            TestEventKind::RunBeginCancel { current_stats, .. }
648            | TestEventKind::RunBeginKill { current_stats, .. } => {
649                // In this case, always indicate an error.
650                let percentage = (current_stats.finished_count as f64
651                    / current_stats.initial_run_count as f64)
652                    * 100.0;
653                TerminalProgressValue::Error(percentage)
654            }
655            TestEventKind::RunPaused { .. }
656            | TestEventKind::RunContinued { .. }
657            | TestEventKind::RunFinished { .. } => {
658                // Reset the terminal state to nothing, since nextest is giving
659                // up control of the terminal at this point.
660                TerminalProgressValue::Remove
661            }
662        };
663
664        self.last_value = value;
665    }
666
667    pub(super) fn last_value(&self) -> &TerminalProgressValue {
668        &self.last_value
669    }
670}
671
672/// Determines whether the terminal supports ANSI OSC 9;4.
673fn supports_osc_9_4(is_terminal: bool) -> bool {
674    if !is_terminal {
675        debug!(
676            "autodetect terminal progress reporting: disabling since \
677             passed-in stream (usually stderr) is not a terminal"
678        );
679        return false;
680    }
681    if std::env::var_os("WT_SESSION").is_some() {
682        debug!("autodetect terminal progress reporting: enabling since WT_SESSION is set");
683        return true;
684    };
685    if std::env::var_os("ConEmuANSI").is_some_and(|term| term == "ON") {
686        debug!("autodetect terminal progress reporting: enabling since ConEmuANSI is ON");
687        return true;
688    }
689    if let Ok(term) = std::env::var("TERM_PROGRAM")
690        && (term == "WezTerm" || term == "ghostty" || term == "iTerm.app")
691    {
692        debug!("autodetect terminal progress reporting: enabling since TERM_PROGRAM is {term}");
693        return true;
694    }
695
696    false
697}
698
699/// A progress status value printable as an ANSI OSC 9;4 escape code.
700///
701/// Adapted from Cargo 1.87.
702#[derive(PartialEq, Debug, Default)]
703pub(super) enum TerminalProgressValue {
704    /// No output.
705    #[default]
706    None,
707    /// Remove progress.
708    Remove,
709    /// Progress value (0-100).
710    Value(f64),
711    /// Indeterminate state (no bar, just animation)
712    ///
713    /// We don't use this yet, but might in the future.
714    #[expect(dead_code)]
715    Indeterminate,
716    /// Progress value in an error state (0-100).
717    Error(f64),
718}
719
720impl fmt::Display for TerminalProgressValue {
721    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
722        // From https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
723        // ESC ] 9 ; 4 ; st ; pr ST
724        // When st is 0: remove progress.
725        // When st is 1: set progress value to pr (number, 0-100).
726        // When st is 2: set error state in taskbar, pr is optional.
727        // When st is 3: set indeterminate state, pr is ignored.
728        // When st is 4: set paused state, pr is optional.
729        let (state, progress) = match self {
730            Self::None => return Ok(()), // No output
731            Self::Remove => (0, 0.0),
732            Self::Value(v) => (1, *v),
733            Self::Indeterminate => (3, 0.0),
734            Self::Error(v) => (2, *v),
735        };
736        write!(f, "\x1b]9;4;{state};{progress:.0}\x1b\\")
737    }
738}
739
740/// Returns a summary of current progress.
741pub(super) fn progress_str(
742    elapsed: Duration,
743    current_stats: &RunStats,
744    running: usize,
745    styles: &Styles,
746) -> String {
747    // First, show the prefix.
748    let mut s = progress_bar_prefix(current_stats, current_stats.cancel_reason, styles);
749
750    // Then, the time elapsed, test counts, and message.
751    swrite!(
752        s,
753        " {}{}/{}: {}",
754        DisplayBracketedHhMmSs(elapsed),
755        current_stats.finished_count,
756        current_stats.initial_run_count,
757        progress_bar_msg(current_stats, running, styles)
758    );
759
760    s
761}
762
763pub(super) fn write_summary_str(run_stats: &RunStats, styles: &Styles, out: &mut String) {
764    // Written in this style to ensure new fields are accounted for.
765    let &RunStats {
766        initial_run_count: _,
767        finished_count: _,
768        setup_scripts_initial_count: _,
769        setup_scripts_finished_count: _,
770        setup_scripts_passed: _,
771        setup_scripts_failed: _,
772        setup_scripts_exec_failed: _,
773        setup_scripts_timed_out: _,
774        passed,
775        passed_slow,
776        passed_timed_out: _,
777        flaky,
778        failed,
779        failed_slow: _,
780        failed_timed_out,
781        leaky,
782        leaky_failed,
783        exec_failed,
784        skipped,
785        cancel_reason: _,
786    } = run_stats;
787
788    swrite!(
789        out,
790        "{} {}",
791        passed.style(styles.count),
792        "passed".style(styles.pass)
793    );
794
795    if passed_slow > 0 || flaky > 0 || leaky > 0 {
796        let mut text = Vec::with_capacity(3);
797        if passed_slow > 0 {
798            text.push(format!(
799                "{} {}",
800                passed_slow.style(styles.count),
801                "slow".style(styles.skip),
802            ));
803        }
804        if flaky > 0 {
805            text.push(format!(
806                "{} {}",
807                flaky.style(styles.count),
808                "flaky".style(styles.skip),
809            ));
810        }
811        if leaky > 0 {
812            text.push(format!(
813                "{} {}",
814                leaky.style(styles.count),
815                "leaky".style(styles.skip),
816            ));
817        }
818        swrite!(out, " ({})", text.join(", "));
819    }
820    swrite!(out, ", ");
821
822    if failed > 0 {
823        swrite!(
824            out,
825            "{} {}",
826            failed.style(styles.count),
827            "failed".style(styles.fail),
828        );
829        if leaky_failed > 0 {
830            swrite!(
831                out,
832                " ({} due to being {})",
833                leaky_failed.style(styles.count),
834                "leaky".style(styles.fail),
835            );
836        }
837        swrite!(out, ", ");
838    }
839
840    if exec_failed > 0 {
841        swrite!(
842            out,
843            "{} {}, ",
844            exec_failed.style(styles.count),
845            "exec failed".style(styles.fail),
846        );
847    }
848
849    if failed_timed_out > 0 {
850        swrite!(
851            out,
852            "{} {}, ",
853            failed_timed_out.style(styles.count),
854            "timed out".style(styles.fail),
855        );
856    }
857
858    swrite!(
859        out,
860        "{} {}",
861        skipped.style(styles.count),
862        "skipped".style(styles.skip),
863    );
864}
865
866fn progress_bar_cancel_prefix(reason: Option<CancelReason>, styles: &Styles) -> String {
867    let status = match reason {
868        Some(CancelReason::SetupScriptFailure)
869        | Some(CancelReason::TestFailure)
870        | Some(CancelReason::ReportError)
871        | Some(CancelReason::GlobalTimeout)
872        | Some(CancelReason::TestFailureImmediate)
873        | Some(CancelReason::Signal)
874        | Some(CancelReason::Interrupt)
875        | None => "Cancelling",
876        Some(CancelReason::SecondSignal) => "Killing",
877    };
878    format!("{:>12}", status.style(styles.fail))
879}
880
881fn progress_bar_prefix(
882    run_stats: &RunStats,
883    cancel_reason: Option<CancelReason>,
884    styles: &Styles,
885) -> String {
886    if let Some(reason) = cancel_reason {
887        return progress_bar_cancel_prefix(Some(reason), styles);
888    }
889
890    let style = if run_stats.has_failures() {
891        styles.fail
892    } else {
893        styles.pass
894    };
895
896    format!("{:>12}", "Running".style(style))
897}
898
899pub(super) fn progress_bar_msg(
900    current_stats: &RunStats,
901    running: usize,
902    styles: &Styles,
903) -> String {
904    let mut s = format!("{} running, ", running.style(styles.count));
905    write_summary_str(current_stats, styles, &mut s);
906    s
907}
908
909#[cfg(test)]
910mod tests {
911    use super::*;
912    use crate::{
913        config::elements::{FlakyResult, JunitFlakyFailStatus},
914        output_spec::LiveSpec,
915        reporter::{TestOutputDisplay, test_helpers::global_slot_assignment},
916        test_output::{ChildExecutionOutput, ChildOutput, ChildSplitOutput},
917    };
918    use bytes::Bytes;
919    use chrono::Local;
920
921    #[test]
922    fn test_progress_bar_prefix() {
923        let mut styles = Styles::default();
924        styles.colorize();
925
926        for (name, stats) in run_stats_test_failure_examples() {
927            let prefix = progress_bar_prefix(&stats, Some(CancelReason::TestFailure), &styles);
928            assert_eq!(
929                prefix,
930                "  Cancelling".style(styles.fail).to_string(),
931                "{name} matches"
932            );
933        }
934        for (name, stats) in run_stats_setup_script_failure_examples() {
935            let prefix =
936                progress_bar_prefix(&stats, Some(CancelReason::SetupScriptFailure), &styles);
937            assert_eq!(
938                prefix,
939                "  Cancelling".style(styles.fail).to_string(),
940                "{name} matches"
941            );
942        }
943
944        let prefix = progress_bar_prefix(&RunStats::default(), Some(CancelReason::Signal), &styles);
945        assert_eq!(prefix, "  Cancelling".style(styles.fail).to_string());
946
947        let prefix = progress_bar_prefix(&RunStats::default(), None, &styles);
948        assert_eq!(prefix, "     Running".style(styles.pass).to_string());
949
950        for (name, stats) in run_stats_test_failure_examples() {
951            let prefix = progress_bar_prefix(&stats, None, &styles);
952            assert_eq!(
953                prefix,
954                "     Running".style(styles.fail).to_string(),
955                "{name} matches"
956            );
957        }
958        for (name, stats) in run_stats_setup_script_failure_examples() {
959            let prefix = progress_bar_prefix(&stats, None, &styles);
960            assert_eq!(
961                prefix,
962                "     Running".style(styles.fail).to_string(),
963                "{name} matches"
964            );
965        }
966    }
967
968    #[test]
969    fn progress_str_snapshots() {
970        let mut styles = Styles::default();
971        styles.colorize();
972
973        // This elapsed time is arbitrary but reasonably large.
974        let elapsed = Duration::from_secs(123456);
975        let running = 10;
976
977        for (name, stats) in run_stats_test_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        for (name, stats) in run_stats_setup_script_failure_examples() {
988            let s = progress_str(elapsed, &stats, running, &styles);
989            insta::assert_snapshot!(format!("{name}_with_cancel_reason"), s);
990
991            let mut stats = stats;
992            stats.cancel_reason = None;
993            let s = progress_str(elapsed, &stats, running, &styles);
994            insta::assert_snapshot!(format!("{name}_without_cancel_reason"), s);
995        }
996    }
997
998    #[test]
999    fn running_test_snapshots() {
1000        let styles = Styles::default();
1001        let now = Instant::now();
1002
1003        for (name, running_test) in running_test_examples(now) {
1004            let msg = running_test.message(now, 80, &styles);
1005            insta::assert_snapshot!(name, msg);
1006        }
1007    }
1008
1009    fn running_test_examples(now: Instant) -> Vec<(&'static str, RunningTest)> {
1010        let binary_id = RustBinaryId::new("my-binary");
1011        let test_name = TestCaseName::new("test::my_test");
1012        let start_time = now - Duration::from_secs(125); // 2 minutes 5 seconds ago
1013
1014        vec![
1015            (
1016                "running_status",
1017                RunningTest {
1018                    binary_id: binary_id.clone(),
1019                    test_name: test_name.clone(),
1020                    status: RunningTestStatus::Running,
1021                    start_time,
1022                    paused_for: Duration::ZERO,
1023                },
1024            ),
1025            (
1026                "slow_status",
1027                RunningTest {
1028                    binary_id: binary_id.clone(),
1029                    test_name: test_name.clone(),
1030                    status: RunningTestStatus::Slow,
1031                    start_time,
1032                    paused_for: Duration::ZERO,
1033                },
1034            ),
1035            (
1036                "delay_status",
1037                RunningTest {
1038                    binary_id: binary_id.clone(),
1039                    test_name: test_name.clone(),
1040                    status: RunningTestStatus::Delay(Duration::from_secs(130)),
1041                    start_time,
1042                    paused_for: Duration::ZERO,
1043                },
1044            ),
1045            (
1046                "delay_status_underflow",
1047                RunningTest {
1048                    binary_id: binary_id.clone(),
1049                    test_name: test_name.clone(),
1050                    status: RunningTestStatus::Delay(Duration::from_secs(124)),
1051                    start_time,
1052                    paused_for: Duration::ZERO,
1053                },
1054            ),
1055            (
1056                "retry_status",
1057                RunningTest {
1058                    binary_id: binary_id.clone(),
1059                    test_name: test_name.clone(),
1060                    status: RunningTestStatus::Retry,
1061                    start_time,
1062                    paused_for: Duration::ZERO,
1063                },
1064            ),
1065            (
1066                "with_paused_duration",
1067                RunningTest {
1068                    binary_id: binary_id.clone(),
1069                    test_name: test_name.clone(),
1070                    status: RunningTestStatus::Running,
1071                    start_time,
1072                    paused_for: Duration::from_secs(30),
1073                },
1074            ),
1075        ]
1076    }
1077
1078    fn run_stats_test_failure_examples() -> Vec<(&'static str, RunStats)> {
1079        vec![
1080            (
1081                "one_failed",
1082                RunStats {
1083                    initial_run_count: 20,
1084                    finished_count: 1,
1085                    failed: 1,
1086                    cancel_reason: Some(CancelReason::TestFailure),
1087                    ..RunStats::default()
1088                },
1089            ),
1090            (
1091                "one_failed_one_passed",
1092                RunStats {
1093                    initial_run_count: 20,
1094                    finished_count: 2,
1095                    failed: 1,
1096                    passed: 1,
1097                    cancel_reason: Some(CancelReason::TestFailure),
1098                    ..RunStats::default()
1099                },
1100            ),
1101            (
1102                "one_exec_failed",
1103                RunStats {
1104                    initial_run_count: 20,
1105                    finished_count: 10,
1106                    exec_failed: 1,
1107                    cancel_reason: Some(CancelReason::TestFailure),
1108                    ..RunStats::default()
1109                },
1110            ),
1111            (
1112                "one_timed_out",
1113                RunStats {
1114                    initial_run_count: 20,
1115                    finished_count: 10,
1116                    failed_timed_out: 1,
1117                    cancel_reason: Some(CancelReason::TestFailure),
1118                    ..RunStats::default()
1119                },
1120            ),
1121        ]
1122    }
1123
1124    fn run_stats_setup_script_failure_examples() -> Vec<(&'static str, RunStats)> {
1125        vec![
1126            (
1127                "one_setup_script_failed",
1128                RunStats {
1129                    initial_run_count: 30,
1130                    setup_scripts_failed: 1,
1131                    cancel_reason: Some(CancelReason::SetupScriptFailure),
1132                    ..RunStats::default()
1133                },
1134            ),
1135            (
1136                "one_setup_script_exec_failed",
1137                RunStats {
1138                    initial_run_count: 35,
1139                    setup_scripts_exec_failed: 1,
1140                    cancel_reason: Some(CancelReason::SetupScriptFailure),
1141                    ..RunStats::default()
1142                },
1143            ),
1144            (
1145                "one_setup_script_timed_out",
1146                RunStats {
1147                    initial_run_count: 40,
1148                    setup_scripts_timed_out: 1,
1149                    cancel_reason: Some(CancelReason::SetupScriptFailure),
1150                    ..RunStats::default()
1151                },
1152            ),
1153        ]
1154    }
1155
1156    /// Test that `update_progress_bar` correctly updates `self.stats` when
1157    /// processing `TestStarted` and `TestFinished` events.
1158    ///
1159    /// This test verifies both:
1160    ///
1161    /// 1. State: `self.stats` equals the event's `current_stats` after processing.
1162    /// 2. Output: `state.progress_bar_msg()` reflects the updated stats.
1163    #[test]
1164    fn update_progress_bar_updates_stats() {
1165        let styles = Styles::default();
1166        let binary_id = RustBinaryId::new("test-binary");
1167        let test_name = TestCaseName::new("test_name");
1168
1169        // Create ProgressBarState with initial (default) stats.
1170        let mut state = ProgressBarState::new(
1171            NextestRunMode::Test,
1172            10,
1173            "=> ",
1174            MaxProgressRunning::default(),
1175        );
1176
1177        // Verify the initial state.
1178        assert_eq!(state.stats, RunStats::default());
1179        assert_eq!(state.running, 0);
1180
1181        // Create a TestStarted event.
1182        let started_stats = RunStats {
1183            initial_run_count: 10,
1184            passed: 5,
1185            ..RunStats::default()
1186        };
1187        let started_event = TestEvent {
1188            timestamp: Local::now().fixed_offset(),
1189            elapsed: Duration::ZERO,
1190            kind: TestEventKind::TestStarted {
1191                stress_index: None,
1192                test_instance: TestInstanceId {
1193                    binary_id: &binary_id,
1194                    test_name: &test_name,
1195                },
1196                slot_assignment: global_slot_assignment(0),
1197                current_stats: started_stats,
1198                running: 3,
1199                command_line: vec![],
1200            },
1201        };
1202
1203        state.update_progress_bar(&started_event, &styles);
1204
1205        // Verify the state was updated.
1206        assert_eq!(
1207            state.stats, started_stats,
1208            "stats should be updated on TestStarted"
1209        );
1210        assert_eq!(state.running, 3, "running should be updated on TestStarted");
1211
1212        // Verify that the output reflects the updated stats.
1213        let msg = state.progress_bar_msg(&styles);
1214        insta::assert_snapshot!(msg, @"3 running, 5 passed, 0 skipped");
1215
1216        // Create a TestFinished event with different stats.
1217        let finished_stats = RunStats {
1218            initial_run_count: 10,
1219            finished_count: 1,
1220            passed: 8,
1221            ..RunStats::default()
1222        };
1223        let finished_event = TestEvent {
1224            timestamp: Local::now().fixed_offset(),
1225            elapsed: Duration::ZERO,
1226            kind: TestEventKind::TestFinished {
1227                stress_index: None,
1228                test_instance: TestInstanceId {
1229                    binary_id: &binary_id,
1230                    test_name: &test_name,
1231                },
1232                success_output: TestOutputDisplay::Never,
1233                failure_output: TestOutputDisplay::Never,
1234                junit_store_success_output: false,
1235                junit_store_failure_output: false,
1236                junit_flaky_fail_status: JunitFlakyFailStatus::default(),
1237                run_statuses: ExecutionStatuses::new(
1238                    vec![ExecuteStatus {
1239                        retry_data: RetryData {
1240                            attempt: 1,
1241                            total_attempts: 1,
1242                        },
1243                        output: make_test_output(),
1244                        result: ExecutionResultDescription::Pass,
1245                        start_time: Local::now().fixed_offset(),
1246                        time_taken: Duration::from_secs(1),
1247                        is_slow: false,
1248                        delay_before_start: Duration::ZERO,
1249                        error_summary: None,
1250                        output_error_slice: None,
1251                    }],
1252                    FlakyResult::default(),
1253                ),
1254                current_stats: finished_stats,
1255                running: 2,
1256            },
1257        };
1258
1259        state.update_progress_bar(&finished_event, &styles);
1260
1261        // Verify the state was updated.
1262        assert_eq!(
1263            state.stats, finished_stats,
1264            "stats should be updated on TestFinished"
1265        );
1266        assert_eq!(
1267            state.running, 2,
1268            "running should be updated on TestFinished"
1269        );
1270
1271        // Verify that the output reflects the updated stats.
1272        let msg = state.progress_bar_msg(&styles);
1273        insta::assert_snapshot!(msg, @"2 running, 8 passed, 0 skipped");
1274
1275        // Create a RunBeginCancel event.
1276        let cancel_stats = RunStats {
1277            initial_run_count: 10,
1278            finished_count: 3,
1279            passed: 2,
1280            failed: 1,
1281            cancel_reason: Some(CancelReason::TestFailure),
1282            ..RunStats::default()
1283        };
1284        let cancel_event = TestEvent {
1285            timestamp: Local::now().fixed_offset(),
1286            elapsed: Duration::ZERO,
1287            kind: TestEventKind::RunBeginCancel {
1288                setup_scripts_running: 0,
1289                current_stats: cancel_stats,
1290                running: 4,
1291            },
1292        };
1293
1294        state.update_progress_bar(&cancel_event, &styles);
1295
1296        // Verify the state was updated.
1297        assert_eq!(
1298            state.stats, cancel_stats,
1299            "stats should be updated on RunBeginCancel"
1300        );
1301        assert_eq!(
1302            state.running, 4,
1303            "running should be updated on RunBeginCancel"
1304        );
1305
1306        // Verify that the output reflects the updated stats.
1307        let msg = state.progress_bar_msg(&styles);
1308        insta::assert_snapshot!(msg, @"4 running, 2 passed, 1 failed, 0 skipped");
1309
1310        // Create a RunBeginKill event with different stats.
1311        let kill_stats = RunStats {
1312            initial_run_count: 10,
1313            finished_count: 5,
1314            passed: 3,
1315            failed: 2,
1316            cancel_reason: Some(CancelReason::Signal),
1317            ..RunStats::default()
1318        };
1319        let kill_event = TestEvent {
1320            timestamp: Local::now().fixed_offset(),
1321            elapsed: Duration::ZERO,
1322            kind: TestEventKind::RunBeginKill {
1323                setup_scripts_running: 0,
1324                current_stats: kill_stats,
1325                running: 2,
1326            },
1327        };
1328
1329        state.update_progress_bar(&kill_event, &styles);
1330
1331        // Verify the state was updated.
1332        assert_eq!(
1333            state.stats, kill_stats,
1334            "stats should be updated on RunBeginKill"
1335        );
1336        assert_eq!(
1337            state.running, 2,
1338            "running should be updated on RunBeginKill"
1339        );
1340
1341        // Verify that the output reflects the updated stats.
1342        let msg = state.progress_bar_msg(&styles);
1343        insta::assert_snapshot!(msg, @"2 running, 3 passed, 2 failed, 0 skipped");
1344    }
1345
1346    // Helper to create minimal output for ExecuteStatus.
1347    fn make_test_output() -> ChildExecutionOutputDescription<LiveSpec> {
1348        ChildExecutionOutput::Output {
1349            result: Some(ExecutionResult::Pass),
1350            output: ChildOutput::Split(ChildSplitOutput {
1351                stdout: Some(Bytes::new().into()),
1352                stderr: Some(Bytes::new().into()),
1353            }),
1354            errors: None,
1355        }
1356        .into()
1357    }
1358}