Skip to main content

rich_ext/diff/test_report/
mod.rs

1//! Test results: a format-neutral model, JUnit XML and libtest JSON
2//! adapters, a renderable [`TestReport`] and JUnit export.
3//!
4//! [`TestReport`] shows failures first — each with its message, captured
5//! output and, when both are known, a [`DiffView`] of
6//! expected against actual — then a per-suite summary table and a total
7//! line. It is an ordinary renderable, so console HTML and SVG export apply;
8//! [`TestRun::to_junit_xml`] writes normalized JUnit for CI.
9//!
10//! The parsers decode escapes (JUnit's ``, JSON's `\u001b`), so a
11//! [`TestRun`] holds names, messages and output exactly as the report gave
12//! them, control characters included. [`TestReport`] shows every such field
13//! with terminal and bidi controls made visible (`␛[31m`), so a report
14//! cannot move the cursor, recolour or reorder the terminal it is shown on.
15
16pub mod junit;
17pub mod libtest;
18
19use std::fmt;
20use std::time::Duration;
21
22use rich::{Console, ConsoleOptions, Renderable, Segment, Style, Table, Text};
23use serde::{Deserialize, Serialize};
24
25use super::render::{banner, join, trim_end, wrap_text};
26use super::{DiffView, style};
27use crate::sanitize::{sanitize_single_line, sanitize_terminal_and_bidi_controls};
28
29/// Multi-line report text (a message, a trace, captured output), inert.
30fn inert(text: &str) -> String {
31    sanitize_terminal_and_bidi_controls(text)
32}
33
34/// How a test case ended.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub enum Status {
37    Passed,
38    Failed,
39    Skipped,
40    /// An error outside the assertion (JUnit `<error>`).
41    Errored,
42}
43
44impl Status {
45    fn key(self) -> &'static str {
46        match self {
47            Status::Passed => "test.passed",
48            Status::Failed => "test.failed",
49            Status::Skipped => "test.skipped",
50            Status::Errored => "test.errored",
51        }
52    }
53    fn label(self) -> &'static str {
54        match self {
55            Status::Passed => "PASSED",
56            Status::Failed => "FAILED",
57            Status::Skipped => "SKIPPED",
58            Status::Errored => "ERROR",
59        }
60    }
61}
62
63/// One test case.
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Case {
66    pub name: String,
67    pub classname: String,
68    pub status: Status,
69    pub duration: Option<Duration>,
70    /// The failure, error or skip message.
71    pub message: Option<String>,
72    /// The failure body (a stack trace or assertion detail).
73    pub details: Option<String>,
74    pub stdout: Option<String>,
75    pub stderr: Option<String>,
76    pub expected: Option<String>,
77    pub actual: Option<String>,
78}
79
80impl Case {
81    /// A case with no message, output or duration.
82    pub fn new(name: impl Into<String>, classname: impl Into<String>, status: Status) -> Self {
83        Case {
84            name: name.into(),
85            classname: classname.into(),
86            status,
87            duration: None,
88            message: None,
89            details: None,
90            stdout: None,
91            stderr: None,
92            expected: None,
93            actual: None,
94        }
95    }
96    /// `classname.name`, or the name alone when it already includes the
97    /// class (libtest paths, jest-junit titles).
98    pub fn full_name(&self) -> String {
99        if self.classname.is_empty() || self.name.contains(&self.classname) {
100            self.name.clone()
101        } else {
102            format!("{}.{}", self.classname, self.name)
103        }
104    }
105    /// Whether this case failed or errored.
106    pub fn is_failure(&self) -> bool {
107        matches!(self.status, Status::Failed | Status::Errored)
108    }
109}
110
111/// A suite of cases.
112#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
113pub struct Suite {
114    pub name: String,
115    pub cases: Vec<Case>,
116    pub duration: Option<Duration>,
117}
118
119/// Counts of each status.
120#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
121pub struct Totals {
122    pub passed: usize,
123    pub failed: usize,
124    pub errored: usize,
125    pub skipped: usize,
126}
127
128impl Totals {
129    fn add(&mut self, status: Status) {
130        match status {
131            Status::Passed => self.passed += 1,
132            Status::Failed => self.failed += 1,
133            Status::Errored => self.errored += 1,
134            Status::Skipped => self.skipped += 1,
135        }
136    }
137    /// All cases.
138    pub fn total(&self) -> usize {
139        self.passed + self.failed + self.errored + self.skipped
140    }
141}
142
143impl Suite {
144    pub fn totals(&self) -> Totals {
145        let mut t = Totals::default();
146        for case in &self.cases {
147            t.add(case.status);
148        }
149        t
150    }
151    /// The suite's duration, else the sum of its cases'.
152    pub fn time(&self) -> Option<Duration> {
153        self.duration.or_else(|| {
154            let times: Vec<Duration> = self.cases.iter().filter_map(|c| c.duration).collect();
155            (!times.is_empty()).then(|| times.iter().sum())
156        })
157    }
158}
159
160/// A whole test run.
161#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
162pub struct TestRun {
163    pub suites: Vec<Suite>,
164    pub duration: Option<Duration>,
165}
166
167/// Why test results did not parse.
168#[derive(Clone, Debug, PartialEq, Eq)]
169pub struct TestParseError {
170    pub format: &'static str,
171    pub message: String,
172}
173
174impl fmt::Display for TestParseError {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        write!(f, "{}: {}", self.format, self.message)
177    }
178}
179impl std::error::Error for TestParseError {}
180
181/// A test-result format. Implement it to feed [`TestReport`] from another
182/// runner.
183pub trait TestAdapter {
184    /// The format's name.
185    fn name(&self) -> &'static str;
186    /// Parse a whole report.
187    fn parse(&self, input: &str) -> Result<TestRun, TestParseError>;
188}
189
190impl TestRun {
191    pub fn totals(&self) -> Totals {
192        let mut t = Totals::default();
193        for case in self.suites.iter().flat_map(|s| &s.cases) {
194            t.add(case.status);
195        }
196        t
197    }
198    /// The run's duration, else the sum of its suites'.
199    pub fn time(&self) -> Option<Duration> {
200        self.duration.or_else(|| {
201            let times: Vec<Duration> = self.suites.iter().filter_map(Suite::time).collect();
202            (!times.is_empty()).then(|| times.iter().sum())
203        })
204    }
205    /// Whether nothing failed or errored.
206    pub fn is_success(&self) -> bool {
207        let t = self.totals();
208        t.failed == 0 && t.errored == 0
209    }
210    /// Normalized JUnit XML, which [`junit::parse`] reads back.
211    pub fn to_junit_xml(&self) -> String {
212        junit::write(self)
213    }
214}
215
216/// A duration from seconds, rounded to the nanosecond; `None` when negative
217/// or not finite.
218pub(crate) fn duration(secs: f64) -> Option<Duration> {
219    (0.0..1e9)
220        .contains(&secs)
221        .then(|| Duration::from_nanos((secs * 1e9).round() as u64))
222}
223
224/// Seconds with millisecond precision, as JUnit writes them.
225pub(crate) fn seconds(d: Duration) -> String {
226    format!("{:.3}", d.as_secs_f64())
227}
228
229/// `expected` and `actual` from common assertion messages: libtest's
230/// `left`/`right`, JUnit 4's `expected:<a> but was:<b>` and Jest's
231/// `Expected:`/`Received:` lines.
232pub(crate) fn expected_actual(text: &str) -> Option<(String, String)> {
233    if let Some(found) = libtest::left_right(text) {
234        return Some(found);
235    }
236    if let Some(rest) = text.split("expected:<").nth(1)
237        && let Some((expected, rest)) = rest.split_once("> but was:<")
238        && let Some(actual) = rest.rfind('>').map(|i| &rest[..i])
239    {
240        return Some((expected.to_string(), actual.to_string()));
241    }
242    let mut expected = None;
243    let mut actual = None;
244    for line in text.lines() {
245        let line = line.trim();
246        if let Some(v) = line.strip_prefix("Expected:") {
247            expected.get_or_insert_with(|| v.trim().to_string());
248        } else if let Some(v) = line.strip_prefix("Received:") {
249            actual.get_or_insert_with(|| v.trim().to_string());
250        }
251    }
252    expected.zip(actual)
253}
254
255/// A renderable test report: failures first, a per-suite summary table and
256/// a total line.
257#[derive(Clone, Debug)]
258pub struct TestReport {
259    run: TestRun,
260    show_passed: bool,
261    show_output: bool,
262    diff_context: usize,
263}
264
265impl TestReport {
266    pub fn new(run: TestRun) -> Self {
267        TestReport {
268            run,
269            show_passed: false,
270            show_output: true,
271            diff_context: 3,
272        }
273    }
274    /// List passing and skipped cases after the failures (default off).
275    #[must_use]
276    pub fn show_passed(mut self, show: bool) -> Self {
277        self.show_passed = show;
278        self
279    }
280    /// Show captured output under failures (default on).
281    #[must_use]
282    pub fn show_output(mut self, show: bool) -> Self {
283        self.show_output = show;
284        self
285    }
286    /// Context lines in expected/actual diffs (default 3).
287    #[must_use]
288    pub fn diff_context(mut self, lines: usize) -> Self {
289        self.diff_context = lines;
290        self
291    }
292    /// The run shown.
293    pub fn run(&self) -> &TestRun {
294        &self.run
295    }
296
297    fn case_title(&self, console: &Console, suite: &Suite, case: &Case) -> Text {
298        let mut text = Text::new("");
299        text.append(
300            &format!("{} ", case.status.label()),
301            Some(style(console, case.status.key()).into()),
302        );
303        if !suite.name.is_empty() {
304            text.append(
305                &format!("{} > ", sanitize_single_line(&suite.name)),
306                Some(style(console, "diff.line_number").into()),
307            );
308        }
309        text.append(
310            &sanitize_single_line(&case.full_name()),
311            Some(style(console, "diff.header").into()),
312        );
313        if let Some(d) = case.duration {
314            text.append(
315                &format!(" ({}s)", seconds(d)),
316                Some(style(console, "diff.line_number").into()),
317            );
318        }
319        text
320    }
321
322    fn indented(console: &Console, text: &Text, width: usize, indent: usize) -> Vec<Vec<Segment>> {
323        let pad = Segment::new(" ".repeat(indent), None);
324        wrap_text(
325            console,
326            text,
327            &Style::new(),
328            width.saturating_sub(indent).max(1),
329            true,
330        )
331        .into_iter()
332        .map(|line| {
333            let mut row = vec![pad.clone()];
334            row.extend(line);
335            trim_end(row)
336        })
337        .collect()
338    }
339
340    fn failure_rows(
341        &self,
342        console: &Console,
343        suite: &Suite,
344        case: &Case,
345        width: usize,
346    ) -> Vec<Vec<Segment>> {
347        let mut rows = Self::indented(console, &self.case_title(console, suite, case), width, 0);
348        let dim = style(console, "diff.line_number");
349        if let Some(message) = &case.message {
350            rows.extend(Self::indented(
351                console,
352                &Text::new(inert(message.trim_end())),
353                width,
354                2,
355            ));
356        }
357        if let (Some(expected), Some(actual)) = (&case.expected, &case.actual) {
358            let mut e = inert(expected);
359            let mut a = inert(actual);
360            e.push('\n');
361            a.push('\n');
362            let view = DiffView::new(&e, &a)
363                .titles("expected", "actual")
364                .context(self.diff_context);
365            let opts = console
366                .options()
367                .update_width(width.saturating_sub(2).max(1));
368            let pad = Segment::new("  ", None);
369            for row in Segment::split_lines(&view.rich_render(console, &opts)) {
370                let mut r = vec![pad.clone()];
371                r.extend(row);
372                rows.push(trim_end(r));
373            }
374        } else if let Some(details) = &case.details
375            && case.message.as_deref().map(str::trim) != Some(details.trim())
376        {
377            rows.extend(Self::indented(
378                console,
379                &Text::new(inert(details.trim_end())),
380                width,
381                2,
382            ));
383        }
384        if self.show_output {
385            for (label, output) in [("stdout", &case.stdout), ("stderr", &case.stderr)] {
386                let Some(output) = output.as_deref().filter(|o| !o.trim().is_empty()) else {
387                    continue;
388                };
389                rows.extend(banner(&format!("  captured {label}:"), dim.clone(), width));
390                rows.extend(Self::indented(
391                    console,
392                    &Text::new(inert(output.trim_end())),
393                    width,
394                    4,
395                ));
396            }
397        }
398        rows
399    }
400
401    fn table(&self) -> Table {
402        let mut table = Table::new();
403        for h in ["Suite", "Passed", "Failed", "Skipped", "Time"] {
404            table.add_column(h);
405        }
406        for suite in &self.run.suites {
407            let t = suite.totals();
408            let time = suite
409                .time()
410                .map(|d| format!("{}s", seconds(d)))
411                .unwrap_or_default();
412            // Suite names are data: pytest ids such as `test_x[a]` stay literal.
413            table.add_row_text(vec![
414                Text::new(sanitize_single_line(&suite.name)),
415                Text::new(t.passed.to_string()),
416                Text::new((t.failed + t.errored).to_string()),
417                Text::new(t.skipped.to_string()),
418                Text::new(time),
419            ]);
420        }
421        table
422    }
423}
424
425impl Renderable for TestReport {
426    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
427        let width = options.max_width;
428        if width == 0 || options.height == Some(0) {
429            return Vec::new();
430        }
431        let mut rows: Vec<Vec<Segment>> = Vec::new();
432        for suite in &self.run.suites {
433            for case in suite.cases.iter().filter(|c| c.is_failure()) {
434                rows.extend(self.failure_rows(console, suite, case, width));
435                rows.push(Vec::new());
436            }
437        }
438        if self.show_passed {
439            let mut any = false;
440            for suite in &self.run.suites {
441                for case in suite.cases.iter().filter(|c| !c.is_failure()) {
442                    rows.extend(Self::indented(
443                        console,
444                        &self.case_title(console, suite, case),
445                        width,
446                        0,
447                    ));
448                    any = true;
449                }
450            }
451            if any {
452                rows.push(Vec::new());
453            }
454        }
455        if !self.run.suites.is_empty() {
456            let table = self.table();
457            let rendered = table.rich_render(console, &options.update_width(width));
458            rows.extend(Segment::split_lines(&rendered).into_iter().map(trim_end));
459        }
460        let t = self.run.totals();
461        let mut total = Text::new("");
462        let mut part = |n: usize, label: &str, key: &str, always: bool| {
463            if n == 0 && !always {
464                return;
465            }
466            if !total.is_empty() {
467                total.append(", ", None);
468            }
469            let st = if n > 0 {
470                style(console, key)
471            } else {
472                Style::new()
473            };
474            total.append(&format!("{n} {label}"), Some(st.into()));
475        };
476        part(t.passed, "passed", "test.passed", true);
477        part(t.failed, "failed", "test.failed", true);
478        part(t.errored, "errored", "test.errored", false);
479        part(t.skipped, "skipped", "test.skipped", false);
480        if let Some(d) = self.run.time() {
481            total.append(&format!(" in {}s", seconds(d)), None);
482        }
483        rows.extend(Self::indented(console, &total, width, 0));
484        join(rows, options.height)
485    }
486}