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::{DiffView, style};
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 && 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#[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 #[must_use]
276 pub fn show_passed(mut self, show: bool) -> Self {
277 self.show_passed = show;
278 self
279 }
280 #[must_use]
282 pub fn show_output(mut self, show: bool) -> Self {
283 self.show_output = show;
284 self
285 }
286 #[must_use]
288 pub fn diff_context(mut self, lines: usize) -> Self {
289 self.diff_context = lines;
290 self
291 }
292 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 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}