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