Skip to main content

test_better_runner/
lib.rs

1//! `test-better-runner`: optional pretty runner.
2//!
3//! Library half of the `cargo-test-better` subcommand. It wraps `cargo test`,
4//! forwarding every argument and propagating the exit code; it also groups
5//! failures by their context chain.
6//!
7//! # The structured-output channel
8//!
9//! The runner never parses rendered failure text. It consumes the structured
10//! `StructuredError` form (`test-better`'s owned, serializable mirror of
11//! `TestError`), and the channel that carries it is a **marker-wrapped JSON
12//! line in the test's own captured output** (the emitting side lives in
13//! `test-better-core`'s `runner` module):
14//!
15//! - The runner exports [`RUNNER_ENV`]`=1` into the `cargo test` it spawns.
16//! - When that variable is set, a failing `test-better` test prints one line
17//!   of the form `<STRUCTURED_MARKER><json><STRUCTURED_MARKER>` to stdout, in
18//!   addition to its normal human-readable failure.
19//! - `cargo test` already captures test output and replays it for *failing*
20//!   tests, which is exactly when the runner needs it, so no side-channel file
21//!   and no `--nocapture` is required.
22//! - A failure with no marker line (a plain `panic!`, or non-`test-better`
23//!   code) is shown ungrouped and labelled "unstructured"; the runner never
24//!   falls back to parsing prose.
25//!
26//! # Grouping
27//!
28//! [`run`] pipes the wrapped `cargo test`'s stdout, tees every non-marker line
29//! straight through, and feeds the stream to [`scan_output`], which builds a
30//! [`GroupedReport`]: structured failures bucketed by their top
31//! [`ContextFrame`](test_better::ContextFrame) message, plus a flat list of
32//! unstructured ones. [`run`] prints that report after the wrapped build exits.
33//!
34//! # Progress and summary
35//!
36//! [`scan_output`] also reads libtest's own `test result:` lines into a
37//! [`RunSummary`] (passed/failed/ignored counts, summed across every test
38//! binary), which [`run`] prints as a one-line summary table once the build
39//! exits, alongside the wall-clock duration it measured itself.
40//!
41//! While the build runs, [`run`] keeps a live progress counter. It is gated
42//! on stderr being a TTY: on a terminal the per-test `... ok` lines are
43//! replaced by an updating `running: done/total` line on stderr; piped or
44//! redirected, the output is the plain `cargo test` stream, unchanged.
45
46use std::ffi::{OsStr, OsString};
47use std::io::{BufRead, BufReader, IsTerminal, Write};
48use std::process::{Command, Stdio};
49use std::time::{Duration, Instant};
50
51use test_better::StructuredError;
52pub use test_better::{RUNNER_ENV, STRUCTURED_MARKER};
53
54/// The subcommand name cargo passes as the first argument when this binary is
55/// invoked as `cargo test-better`.
56const SUBCOMMAND: &str = "test-better";
57
58/// The bucket label for a structured failure whose error carries no context
59/// chain at all.
60const NO_CONTEXT: &str = "(no context)";
61
62/// Builds the `cargo test` invocation the runner wraps.
63///
64/// `args` is the runner's own arguments with the program name already removed.
65/// Cargo runs an external subcommand as `cargo-test-better test-better ...`, so
66/// a leading `test-better` argument is dropped here and everything after it is
67/// forwarded to `cargo test` verbatim. The structured-output channel is opened
68/// by exporting [`RUNNER_ENV`].
69#[must_use]
70pub fn cargo_test_command<I, S>(args: I) -> Command
71where
72    I: IntoIterator<Item = S>,
73    S: AsRef<OsStr>,
74{
75    let mut forwarded: Vec<OsString> = args
76        .into_iter()
77        .map(|arg| arg.as_ref().to_os_string())
78        .collect();
79    if forwarded.first().is_some_and(|arg| arg == SUBCOMMAND) {
80        forwarded.remove(0);
81    }
82    // Respect the `CARGO` cargo sets for its subprocesses, so the wrapped build
83    // uses the same toolchain that launched the runner.
84    let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
85    let mut command = Command::new(cargo);
86    command.arg("test").args(forwarded).env(RUNNER_ENV, "1");
87    command
88}
89
90/// Runs the wrapped `cargo test` to completion and returns the exit code to
91/// propagate, after printing the grouped failure report and the run summary.
92///
93/// Stdout is piped so the runner can pick out the structured-error marker
94/// lines; every other line is teed straight through, so the wrapped build
95/// still looks like an ordinary `cargo test` run. Stderr is inherited
96/// untouched. While the build runs, a live progress counter is shown on stderr
97/// when stderr is a TTY (and the per-test `... ok` lines are then folded into
98/// it instead of being teed). The grouped report and summary table are printed
99/// once the child has exited.
100///
101/// A process ended by a signal reports no exit code; that maps to `101`, the
102/// code cargo itself uses for an abnormally terminated test binary, so the
103/// runner's exit code still means "something went wrong".
104///
105/// # Errors
106///
107/// Returns the [`std::io::Error`] from spawning `cargo` if the process could
108/// not be started at all (for example, `cargo` is not on `PATH`).
109pub fn run<I, S>(args: I) -> std::io::Result<i32>
110where
111    I: IntoIterator<Item = S>,
112    S: AsRef<OsStr>,
113{
114    let mut command = cargo_test_command(args);
115    command.stdout(Stdio::piped());
116
117    let started = Instant::now();
118    let mut child = command.spawn()?;
119
120    // Drain the child's stdout while it runs: scan for marker lines, drive the
121    // live progress counter, and tee everything else to our own stdout. `take`
122    // leaves `None` behind, so the borrow ends before `wait`.
123    let report = match child.stdout.take() {
124        Some(stdout) => {
125            let lines = BufReader::new(stdout).lines().map_while(Result::ok);
126            let mut progress = Progress::new(std::io::stderr().is_terminal());
127            let report = scan_output(lines, |line| {
128                let event = progress_event(line);
129                // On a TTY the per-test `... ok` lines are the progress
130                // counter's job; everywhere else they are teed as usual.
131                if !(progress.enabled && matches!(event, Some(ProgressEvent::Completed))) {
132                    println!("{line}");
133                }
134                if let Some(event) = event {
135                    progress.observe(event);
136                }
137            });
138            progress.clear();
139            report
140        }
141        None => GroupedReport::default(),
142    };
143
144    let status = child.wait()?;
145    print_report(&report);
146    print_summary(&report.summary, started.elapsed());
147    Ok(status.code().unwrap_or(101))
148}
149
150/// One structured failure: the test that produced it and its structured error.
151#[derive(Debug, Clone)]
152pub struct StructuredFailure {
153    /// The libtest name of the failing test (`module::path::test_name`).
154    pub test: String,
155    /// The structured error the test emitted on the channel.
156    pub error: StructuredError,
157}
158
159/// Structured failures that share a top context-frame message.
160#[derive(Debug, Clone)]
161pub struct ContextGroup {
162    /// The shared top context-frame message, or `(no context)` when the errors
163    /// carry no context chain.
164    pub context: String,
165    /// The failures in this group, in the order they were scanned.
166    pub failures: Vec<StructuredFailure>,
167}
168
169/// The pass/fail/ignored tallies of a wrapped `cargo test` run, summed across
170/// every test binary (libtest prints one `test result:` line per binary, and
171/// [`scan_output`] adds them up).
172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
173pub struct RunSummary {
174    /// Tests that passed.
175    pub passed: usize,
176    /// Tests that failed.
177    pub failed: usize,
178    /// Tests skipped with `#[ignore]` or filtered out by name.
179    pub ignored: usize,
180    /// Benchmarks measured (libtest's `measured` count; zero for `cargo test`).
181    pub measured: usize,
182    /// Tests excluded by a name filter (`cargo test <filter>`).
183    pub filtered_out: usize,
184}
185
186/// The result of scanning a wrapped `cargo test` run: structured failures
187/// bucketed by feature area, the unstructured ones left ungrouped, and the
188/// run's pass/fail/ignored summary.
189#[derive(Debug, Clone, Default)]
190pub struct GroupedReport {
191    /// Structured failures, grouped by their top context frame.
192    pub groups: Vec<ContextGroup>,
193    /// Names of failing tests that emitted no structured payload (a plain
194    /// `panic!`, or non-`test-better` code). Shown ungrouped, never parsed.
195    pub unstructured: Vec<String>,
196    /// The pass/fail/ignored tallies, summed across every test binary.
197    pub summary: RunSummary,
198}
199
200/// Scans a wrapped `cargo test`'s stdout, line by line, into a [`GroupedReport`].
201///
202/// `echo` is called with every line that is *not* a structured-error marker,
203/// so the caller can tee the ordinary `cargo test` output through unchanged.
204/// Marker lines are consumed silently: they are tooling traffic, and the
205/// human-readable failure they accompany has already been echoed.
206///
207/// The scan is a small state machine over libtest's failure-replay format. A
208/// `---- <name> stdout ----` header starts a test section; a marker line seen
209/// inside one attaches a structured error to that test. Any test that opened a
210/// section but emitted no parseable marker is recorded as unstructured. Each
211/// `test result:` line is parsed and its counts added into the summary.
212#[must_use]
213pub fn scan_output<L, E>(lines: L, mut echo: E) -> GroupedReport
214where
215    L: IntoIterator<Item = String>,
216    E: FnMut(&str),
217{
218    let mut current_test: Option<String> = None;
219    let mut structured: Vec<StructuredFailure> = Vec::new();
220    let mut sectioned: Vec<String> = Vec::new();
221    let mut with_marker: Vec<String> = Vec::new();
222    let mut summary = RunSummary::default();
223
224    for line in lines {
225        if let Some(payload) = marker_payload(&line) {
226            if let (Some(test), Ok(error)) = (
227                current_test.as_ref(),
228                serde_json::from_str::<StructuredError>(payload),
229            ) {
230                structured.push(StructuredFailure {
231                    test: test.clone(),
232                    error,
233                });
234                with_marker.push(test.clone());
235            }
236            // A marker line is tooling traffic, not part of the human output.
237            continue;
238        }
239        if let Some(name) = test_section_header(&line) {
240            current_test = Some(name.to_string());
241            if !sectioned.iter().any(|seen| seen == name) {
242                sectioned.push(name.to_string());
243            }
244        }
245        if let Some(line_summary) = parse_result_line(&line) {
246            summary.passed += line_summary.passed;
247            summary.failed += line_summary.failed;
248            summary.ignored += line_summary.ignored;
249            summary.measured += line_summary.measured;
250            summary.filtered_out += line_summary.filtered_out;
251        }
252        echo(&line);
253    }
254
255    let unstructured = sectioned
256        .into_iter()
257        .filter(|test| !with_marker.contains(test))
258        .collect();
259    GroupedReport {
260        groups: group(structured),
261        unstructured,
262        summary,
263    }
264}
265
266/// Buckets structured failures by their top context-frame message, preserving
267/// first-seen order both of the groups and of the failures within each.
268fn group(failures: Vec<StructuredFailure>) -> Vec<ContextGroup> {
269    let mut groups: Vec<ContextGroup> = Vec::new();
270    for failure in failures {
271        let context = failure
272            .error
273            .context
274            .first()
275            .map_or_else(|| NO_CONTEXT.to_string(), |frame| frame.message.clone());
276        match groups
277            .iter_mut()
278            .find(|existing| existing.context == context)
279        {
280            Some(existing) => existing.failures.push(failure),
281            None => groups.push(ContextGroup {
282                context,
283                failures: vec![failure],
284            }),
285        }
286    }
287    groups
288}
289
290/// If `line` is a structured-error marker line, returns the JSON payload
291/// between the two markers; otherwise returns `None`.
292fn marker_payload(line: &str) -> Option<&str> {
293    line.trim()
294        .strip_prefix(STRUCTURED_MARKER)?
295        .strip_suffix(STRUCTURED_MARKER)
296}
297
298/// If `line` is a libtest `---- <name> stdout ----` section header, returns the
299/// test name; otherwise returns `None`.
300fn test_section_header(line: &str) -> Option<&str> {
301    line.strip_prefix("---- ")?.strip_suffix(" stdout ----")
302}
303
304/// The count `segment` reports for `label`, if it ends with that label.
305///
306/// libtest's `test result:` line is a `;`-separated list of segments like
307/// `5 passed` or `0 filtered out`; the count is the last whitespace-delimited
308/// token before the label.
309fn segment_count(segment: &str, label: &str) -> Option<usize> {
310    segment
311        .trim()
312        .strip_suffix(label)?
313        .trim_end()
314        .rsplit(' ')
315        .next()
316        .and_then(|count| count.parse().ok())
317}
318
319/// If `line` is a libtest `test result:` summary line, parses its
320/// passed/failed/ignored/measured/filtered tallies into a [`RunSummary`].
321///
322/// libtest prints one such line per test binary; [`scan_output`] sums them.
323fn parse_result_line(line: &str) -> Option<RunSummary> {
324    let line = line.trim();
325    if !line.starts_with("test result:") {
326        return None;
327    }
328    let mut summary = RunSummary::default();
329    let mut matched = false;
330    for segment in line.split(';') {
331        if let Some(count) = segment_count(segment, "passed") {
332            summary.passed = count;
333            matched = true;
334        } else if let Some(count) = segment_count(segment, "failed") {
335            summary.failed = count;
336            matched = true;
337        } else if let Some(count) = segment_count(segment, "ignored") {
338            summary.ignored = count;
339            matched = true;
340        } else if let Some(count) = segment_count(segment, "measured") {
341            summary.measured = count;
342            matched = true;
343        } else if let Some(count) = segment_count(segment, "filtered out") {
344            summary.filtered_out = count;
345            matched = true;
346        }
347    }
348    matched.then_some(summary)
349}
350
351/// A step in the wrapped run's progress, recovered from one line of libtest
352/// output by [`progress_event`].
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum ProgressEvent {
355    /// A `running N tests` line: `N` more tests are about to run.
356    Discovered(usize),
357    /// A `test <name> ... <outcome>` line: one more test has finished.
358    Completed,
359}
360
361/// Classifies one line of libtest output as a [`ProgressEvent`], or `None` if
362/// it is neither a test-count announcement nor a per-test outcome line.
363#[must_use]
364pub fn progress_event(line: &str) -> Option<ProgressEvent> {
365    let line = line.trim();
366    if let Some(rest) = line.strip_prefix("running ") {
367        // `running 5 tests` / `running 1 test`.
368        let count = rest.split(' ').next()?.parse().ok()?;
369        return Some(ProgressEvent::Discovered(count));
370    }
371    // `test <name> ... ok` / `... FAILED` / `... ignored`. The `test result:`
372    // summary line also starts with `test `, so it is excluded explicitly.
373    if line.starts_with("test ") && !line.starts_with("test result:") && line.contains(" ... ") {
374        return Some(ProgressEvent::Completed);
375    }
376    None
377}
378
379/// A live `running: done/total` counter, shown on stderr while the wrapped
380/// build runs. Disabled (every method a no-op) when stderr is not a TTY.
381struct Progress {
382    /// Whether the counter renders; false when stderr is not a terminal.
383    enabled: bool,
384    /// Tests announced by `running N tests` lines so far.
385    total: usize,
386    /// Tests finished so far.
387    done: usize,
388}
389
390impl Progress {
391    /// Creates a counter, rendering only when `enabled`.
392    fn new(enabled: bool) -> Self {
393        Self {
394            enabled,
395            total: 0,
396            done: 0,
397        }
398    }
399
400    /// Folds one [`ProgressEvent`] into the counter and repaints it.
401    fn observe(&mut self, event: ProgressEvent) {
402        match event {
403            ProgressEvent::Discovered(count) => self.total += count,
404            ProgressEvent::Completed => self.done += 1,
405        }
406        if self.enabled {
407            // `\r` returns to the line start; the trailing spaces overwrite a
408            // previously longer count. Errors writing the bar are ignored: it
409            // is cosmetic, and the real output goes to stdout regardless.
410            let mut stderr = std::io::stderr();
411            let _ = write!(stderr, "\r  running: {}/{} tests   ", self.done, self.total);
412            let _ = stderr.flush();
413        }
414    }
415
416    /// Erases the counter line, so the final report starts on a clean line.
417    fn clear(&self) {
418        if self.enabled {
419            let mut stderr = std::io::stderr();
420            let _ = write!(stderr, "\r\u{1b}[K");
421            let _ = stderr.flush();
422        }
423    }
424}
425
426/// Prints the grouped failure report to stdout, after the wrapped build's own
427/// output. Nothing is printed when there were no failures at all.
428fn print_report(report: &GroupedReport) {
429    if report.groups.is_empty() && report.unstructured.is_empty() {
430        return;
431    }
432    println!();
433    println!("test-better: grouped failures");
434    for group in &report.groups {
435        println!();
436        println!("  {}", group.context);
437        for failure in &group.failures {
438            let summary = failure
439                .error
440                .message
441                .as_deref()
442                .unwrap_or_else(|| failure.error.kind.headline());
443            println!("    {}: {summary}", failure.test);
444            println!(
445                "      at {}:{}:{}",
446                failure.error.location.file,
447                failure.error.location.line,
448                failure.error.location.column,
449            );
450        }
451    }
452    if !report.unstructured.is_empty() {
453        println!();
454        println!("  unstructured (no test-better failure data)");
455        for test in &report.unstructured {
456            println!("    {test}");
457        }
458    }
459}
460
461/// Prints the one-line run summary table to stdout, after the grouped report:
462/// the pass/fail/ignored tallies and the wall-clock `duration` the runner
463/// measured around the wrapped build.
464fn print_summary(summary: &RunSummary, duration: Duration) {
465    println!();
466    println!("test-better: summary");
467    println!(
468        "  {} passed, {} failed, {} ignored",
469        summary.passed, summary.failed, summary.ignored,
470    );
471    println!("  finished in {:.2}s", duration.as_secs_f64());
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use test_better::prelude::*;
478    use test_better::{ErrorKind, SourceLocation, StructuredContextFrame};
479
480    /// Builds a minimal `StructuredError` with the given kind, message, and
481    /// context chain (outermost-first, like the real one).
482    fn structured_error(kind: ErrorKind, message: &str, context: &[&str]) -> StructuredError {
483        StructuredError {
484            kind,
485            message: Some(message.to_string()),
486            location: SourceLocation {
487                file: "src/lib.rs".to_string(),
488                line: 7,
489                column: 5,
490            },
491            context: context
492                .iter()
493                .map(|frame| StructuredContextFrame {
494                    message: (*frame).to_string(),
495                    location: None,
496                })
497                .collect(),
498            trace: Vec::new(),
499            payload: None,
500        }
501    }
502
503    /// A libtest section header line for `test`.
504    fn header(test: &str) -> String {
505        format!("---- {test} stdout ----")
506    }
507
508    /// A structured-error marker line carrying `error`.
509    fn marker_line(error: &StructuredError) -> TestResult<String> {
510        let json = serde_json::to_string(error).or_fail_with("serialize structured error")?;
511        Ok(format!("{STRUCTURED_MARKER}{json}{STRUCTURED_MARKER}"))
512    }
513
514    #[test]
515    fn forwards_args_after_dropping_the_subcommand() -> TestResult {
516        let command = cargo_test_command(["test-better", "--release", "-p", "mycrate"]);
517        let args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
518        check!(args).satisfies(eq(vec![
519            OsString::from("test"),
520            OsString::from("--release"),
521            OsString::from("-p"),
522            OsString::from("mycrate"),
523        ]))
524    }
525
526    #[test]
527    fn keeps_args_when_there_is_no_subcommand_prefix() -> TestResult {
528        let command = cargo_test_command(["--lib"]);
529        let args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
530        check!(args).satisfies(eq(vec![OsString::from("test"), OsString::from("--lib")]))
531    }
532
533    #[test]
534    fn opens_the_structured_output_channel() -> TestResult {
535        let command = cargo_test_command(["test-better"]);
536        let opened = command
537            .get_envs()
538            .any(|(key, value)| key == OsStr::new(RUNNER_ENV) && value == Some(OsStr::new("1")));
539        check!(opened).satisfies(is_true())
540    }
541
542    #[test]
543    fn groups_structured_failures_by_top_context_frame() -> TestResult {
544        let db_one = structured_error(
545            ErrorKind::Assertion,
546            "row count differs",
547            &["the user store"],
548        );
549        let db_two = structured_error(ErrorKind::Setup, "no connection", &["the user store"]);
550        let http = structured_error(ErrorKind::Assertion, "status was 500", &["the http layer"]);
551        let lines = vec![
552            header("store::counts_match"),
553            marker_line(&db_one)?,
554            header("store::connects"),
555            marker_line(&db_two)?,
556            header("http::returns_ok"),
557            marker_line(&http)?,
558        ];
559
560        let report = scan_output(lines, |_| {});
561
562        check!(report.groups.len()).satisfies(eq(2))?;
563        check!(report.groups[0].context.as_str()).satisfies(eq("the user store"))?;
564        check!(report.groups[0].failures.len()).satisfies(eq(2))?;
565        check!(report.groups[0].failures[0].test.as_str()).satisfies(eq("store::counts_match"))?;
566        check!(report.groups[1].context.as_str()).satisfies(eq("the http layer"))?;
567        check!(report.groups[1].failures.len()).satisfies(eq(1))?;
568        check!(report.unstructured.is_empty()).satisfies(is_true())
569    }
570
571    #[test]
572    fn keeps_failures_without_a_marker_as_unstructured() -> TestResult {
573        let lines = vec![
574            header("math::adds"),
575            "thread 'math::adds' panicked at src/lib.rs:3:5:".to_string(),
576            "assertion `left == right` failed".to_string(),
577        ];
578
579        let report = scan_output(lines, |_| {});
580
581        check!(report.groups.is_empty()).satisfies(is_true())?;
582        check!(report.unstructured).satisfies(eq(vec!["math::adds".to_string()]))
583    }
584
585    #[test]
586    fn echoes_every_non_marker_line() -> TestResult {
587        let error = structured_error(ErrorKind::Assertion, "boom", &["an area"]);
588        let lines = vec![
589            "running 1 test".to_string(),
590            header("suite::case"),
591            marker_line(&error)?,
592            "test result: FAILED. 0 passed; 1 failed".to_string(),
593        ];
594
595        let mut echoed: Vec<String> = Vec::new();
596        let _ = scan_output(lines, |line| echoed.push(line.to_string()));
597
598        // The marker line is swallowed; everything else passes through.
599        check!(echoed).satisfies(eq(vec![
600            "running 1 test".to_string(),
601            header("suite::case"),
602            "test result: FAILED. 0 passed; 1 failed".to_string(),
603        ]))
604    }
605
606    #[test]
607    fn an_unparseable_marker_leaves_the_test_unstructured() -> TestResult {
608        let lines = vec![
609            header("suite::case"),
610            format!("{STRUCTURED_MARKER}not json{STRUCTURED_MARKER}"),
611        ];
612
613        let report = scan_output(lines, |_| {});
614
615        check!(report.groups.is_empty()).satisfies(is_true())?;
616        check!(report.unstructured).satisfies(eq(vec!["suite::case".to_string()]))
617    }
618
619    #[test]
620    fn errors_without_context_land_in_the_no_context_bucket() -> TestResult {
621        let bare = structured_error(ErrorKind::Custom, "something off", &[]);
622        let lines = vec![header("suite::case"), marker_line(&bare)?];
623
624        let report = scan_output(lines, |_| {});
625
626        check!(report.groups.len()).satisfies(eq(1))?;
627        check!(report.groups[0].context.as_str()).satisfies(eq(NO_CONTEXT))
628    }
629
630    #[test]
631    fn parses_a_test_result_line_into_a_summary() -> TestResult {
632        let summary = parse_result_line(
633            "test result: FAILED. 5 passed; 2 failed; 1 ignored; 0 measured; 3 filtered out; \
634             finished in 0.42s",
635        )
636        .or_fail_with("the line is a test result line")?;
637        check!(summary).satisfies(eq(RunSummary {
638            passed: 5,
639            failed: 2,
640            ignored: 1,
641            measured: 0,
642            filtered_out: 3,
643        }))
644    }
645
646    #[test]
647    fn a_non_result_line_is_not_a_summary() -> TestResult {
648        check!(parse_result_line("running 3 tests").is_none()).satisfies(is_true())?;
649        check!(parse_result_line("test math::adds ... ok").is_none()).satisfies(is_true())
650    }
651
652    #[test]
653    fn scan_output_sums_the_summary_across_test_binaries() -> TestResult {
654        let lines = vec![
655            "test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; \
656             finished in 0.01s"
657                .to_string(),
658            "test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; \
659             finished in 0.02s"
660                .to_string(),
661        ];
662
663        let report = scan_output(lines, |_| {});
664
665        check!(report.summary).satisfies(eq(RunSummary {
666            passed: 4,
667            failed: 2,
668            ignored: 1,
669            measured: 0,
670            filtered_out: 0,
671        }))
672    }
673
674    #[test]
675    fn classifies_progress_events() -> TestResult {
676        check!(progress_event("running 5 tests"))
677            .satisfies(eq(Some(ProgressEvent::Discovered(5))))?;
678        check!(progress_event("running 1 test"))
679            .satisfies(eq(Some(ProgressEvent::Discovered(1))))?;
680        check!(progress_event("test math::adds ... ok"))
681            .satisfies(eq(Some(ProgressEvent::Completed)))?;
682        check!(progress_event("test math::divides ... FAILED"))
683            .satisfies(eq(Some(ProgressEvent::Completed)))?;
684        check!(progress_event("test math::slow ... ignored"))
685            .satisfies(eq(Some(ProgressEvent::Completed)))?;
686        // The `test result:` summary line is not a per-test outcome.
687        check!(progress_event("test result: ok. 1 passed; 0 failed")).satisfies(eq(None))?;
688        check!(progress_event("some other line")).satisfies(eq(None))
689    }
690}