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