1pub 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
29fn inert(text: &str) -> String {
31 sanitize_terminal_and_bidi_controls(text)
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub enum Status {
37 Passed,
38 Failed,
39 Skipped,
40 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#[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 pub message: Option<String>,
72 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 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 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 pub fn is_failure(&self) -> bool {
107 matches!(self.status, Status::Failed | Status::Errored)
108 }
109}
110
111#[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#[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 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 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
162pub struct TestRun {
163 pub suites: Vec<Suite>,
164 pub duration: Option<Duration>,
165}
166
167#[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
181pub trait TestAdapter {
184 fn name(&self) -> &'static str;
186 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 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 pub fn is_success(&self) -> bool {
207 let t = self.totals();
208 t.failed == 0 && t.errored == 0
209 }
210 pub fn to_junit_xml(&self) -> String {
212 junit::write(self)
213 }
214}
215
216pub(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
224pub(crate) fn seconds(d: Duration) -> String {
226 format!("{:.3}", d.as_secs_f64())
227}
228
229pub(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#[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 pub fn show_passed(mut self, show: bool) -> Self {
277 self.show_passed = show;
278 self
279 }
280 pub fn show_output(mut self, show: bool) -> Self {
282 self.show_output = show;
283 self
284 }
285 pub fn diff_context(mut self, lines: usize) -> Self {
287 self.diff_context = lines;
288 self
289 }
290 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 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}