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::{style, DiffView};
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        if let Some((expected, rest)) = rest.split_once("> but was:<") {
238            if let Some(actual) = rest.rfind('>').map(|i| &rest[..i]) {
239                return Some((expected.to_string(), actual.to_string()));
240            }
241        }
242    }
243    let mut expected = None;
244    let mut actual = None;
245    for line in text.lines() {
246        let line = line.trim();
247        if let Some(v) = line.strip_prefix("Expected:") {
248            expected.get_or_insert_with(|| v.trim().to_string());
249        } else if let Some(v) = line.strip_prefix("Received:") {
250            actual.get_or_insert_with(|| v.trim().to_string());
251        }
252    }
253    expected.zip(actual)
254}
255
256/// A renderable test report: failures first, a per-suite summary table and
257/// a total line.
258#[derive(Clone, Debug)]
259pub struct TestReport {
260    run: TestRun,
261    show_passed: bool,
262    show_output: bool,
263    diff_context: usize,
264}
265
266impl TestReport {
267    pub fn new(run: TestRun) -> Self {
268        TestReport {
269            run,
270            show_passed: false,
271            show_output: true,
272            diff_context: 3,
273        }
274    }
275    /// List passing and skipped cases after the failures (default off).
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    pub fn show_output(mut self, show: bool) -> Self {
282        self.show_output = show;
283        self
284    }
285    /// Context lines in expected/actual diffs (default 3).
286    pub fn diff_context(mut self, lines: usize) -> Self {
287        self.diff_context = lines;
288        self
289    }
290    /// The run shown.
291    pub fn run(&self) -> &TestRun {
292        &self.run
293    }
294
295    fn case_title(&self, console: &Console, suite: &Suite, case: &Case) -> Text {
296        let mut text = Text::new("");
297        text.append(
298            &format!("{} ", case.status.label()),
299            Some(style(console, case.status.key()).into()),
300        );
301        if !suite.name.is_empty() {
302            text.append(
303                &format!("{} > ", sanitize_single_line(&suite.name)),
304                Some(style(console, "diff.line_number").into()),
305            );
306        }
307        text.append(
308            &sanitize_single_line(&case.full_name()),
309            Some(style(console, "diff.header").into()),
310        );
311        if let Some(d) = case.duration {
312            text.append(
313                &format!(" ({}s)", seconds(d)),
314                Some(style(console, "diff.line_number").into()),
315            );
316        }
317        text
318    }
319
320    fn indented(console: &Console, text: &Text, width: usize, indent: usize) -> Vec<Vec<Segment>> {
321        let pad = Segment::new(" ".repeat(indent), None);
322        wrap_text(
323            console,
324            text,
325            &Style::new(),
326            width.saturating_sub(indent).max(1),
327            true,
328        )
329        .into_iter()
330        .map(|line| {
331            let mut row = vec![pad.clone()];
332            row.extend(line);
333            trim_end(row)
334        })
335        .collect()
336    }
337
338    fn failure_rows(
339        &self,
340        console: &Console,
341        suite: &Suite,
342        case: &Case,
343        width: usize,
344    ) -> Vec<Vec<Segment>> {
345        let mut rows = Self::indented(console, &self.case_title(console, suite, case), width, 0);
346        let dim = style(console, "diff.line_number");
347        if let Some(message) = &case.message {
348            rows.extend(Self::indented(
349                console,
350                &Text::new(inert(message.trim_end())),
351                width,
352                2,
353            ));
354        }
355        if let (Some(expected), Some(actual)) = (&case.expected, &case.actual) {
356            let mut e = inert(expected);
357            let mut a = inert(actual);
358            e.push('\n');
359            a.push('\n');
360            let view = DiffView::new(&e, &a)
361                .titles("expected", "actual")
362                .context(self.diff_context);
363            let opts = console
364                .options()
365                .update_width(width.saturating_sub(2).max(1));
366            let pad = Segment::new("  ", None);
367            for row in Segment::split_lines(&view.rich_render(console, &opts)) {
368                let mut r = vec![pad.clone()];
369                r.extend(row);
370                rows.push(trim_end(r));
371            }
372        } else if let Some(details) = &case.details {
373            if case.message.as_deref().map(str::trim) != Some(details.trim()) {
374                rows.extend(Self::indented(
375                    console,
376                    &Text::new(inert(details.trim_end())),
377                    width,
378                    2,
379                ));
380            }
381        }
382        if self.show_output {
383            for (label, output) in [("stdout", &case.stdout), ("stderr", &case.stderr)] {
384                let Some(output) = output.as_deref().filter(|o| !o.trim().is_empty()) else {
385                    continue;
386                };
387                rows.extend(banner(&format!("  captured {label}:"), dim.clone(), width));
388                rows.extend(Self::indented(
389                    console,
390                    &Text::new(inert(output.trim_end())),
391                    width,
392                    4,
393                ));
394            }
395        }
396        rows
397    }
398
399    fn table(&self) -> Table {
400        let mut table = Table::new();
401        for h in ["Suite", "Passed", "Failed", "Skipped", "Time"] {
402            table.add_column(h);
403        }
404        for suite in &self.run.suites {
405            let t = suite.totals();
406            let time = suite
407                .time()
408                .map(|d| format!("{}s", seconds(d)))
409                .unwrap_or_default();
410            // Suite names are data: pytest ids such as `test_x[a]` stay literal.
411            table.add_row_text(vec![
412                Text::new(sanitize_single_line(&suite.name)),
413                Text::new(t.passed.to_string()),
414                Text::new((t.failed + t.errored).to_string()),
415                Text::new(t.skipped.to_string()),
416                Text::new(time),
417            ]);
418        }
419        table
420    }
421}
422
423impl Renderable for TestReport {
424    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
425        let width = options.max_width;
426        if width == 0 || options.height == Some(0) {
427            return Vec::new();
428        }
429        let mut rows: Vec<Vec<Segment>> = Vec::new();
430        for suite in &self.run.suites {
431            for case in suite.cases.iter().filter(|c| c.is_failure()) {
432                rows.extend(self.failure_rows(console, suite, case, width));
433                rows.push(Vec::new());
434            }
435        }
436        if self.show_passed {
437            let mut any = false;
438            for suite in &self.run.suites {
439                for case in suite.cases.iter().filter(|c| !c.is_failure()) {
440                    rows.extend(Self::indented(
441                        console,
442                        &self.case_title(console, suite, case),
443                        width,
444                        0,
445                    ));
446                    any = true;
447                }
448            }
449            if any {
450                rows.push(Vec::new());
451            }
452        }
453        if !self.run.suites.is_empty() {
454            let table = self.table();
455            let rendered = table.rich_render(console, &options.update_width(width));
456            rows.extend(Segment::split_lines(&rendered).into_iter().map(trim_end));
457        }
458        let t = self.run.totals();
459        let mut total = Text::new("");
460        let mut part = |n: usize, label: &str, key: &str, always: bool| {
461            if n == 0 && !always {
462                return;
463            }
464            if !total.is_empty() {
465                total.append(", ", None);
466            }
467            let st = if n > 0 {
468                style(console, key)
469            } else {
470                Style::new()
471            };
472            total.append(&format!("{n} {label}"), Some(st.into()));
473        };
474        part(t.passed, "passed", "test.passed", true);
475        part(t.failed, "failed", "test.failed", true);
476        part(t.errored, "errored", "test.errored", false);
477        part(t.skipped, "skipped", "test.skipped", false);
478        if let Some(d) = self.run.time() {
479            total.append(&format!(" in {}s", seconds(d)), None);
480        }
481        rows.extend(Self::indented(console, &total, width, 0));
482        join(rows, options.height)
483    }
484}