1use std::io::Write;
2use std::path::Path;
3use std::path::PathBuf;
4use std::time::Duration;
5
6use colored::Colorize;
7
8use relux_core::diagnostics::IrSpan;
9
10#[derive(Debug, Clone)]
11pub enum Failure {
12 MatchTimeout {
13 pattern: String,
14 span: IrSpan,
15 shell: String,
16 },
17 FailPatternMatched {
18 pattern: String,
19 matched_line: String,
20 span: IrSpan,
21 shell: String,
22 },
23 ShellExited {
24 shell: String,
25 exit_code: Option<i32>,
26 span: IrSpan,
27 },
28 Runtime {
29 message: String,
30 span: Option<IrSpan>,
31 shell: Option<String>,
32 },
33 Cancelled {
34 span: Option<IrSpan>,
35 shell: Option<String>,
36 },
37}
38
39impl Failure {
40 pub fn summary(&self) -> String {
41 match self {
42 Failure::MatchTimeout { pattern, shell, .. } => {
43 format!("match timeout in shell '{shell}': timed out waiting for {pattern}")
44 }
45 Failure::FailPatternMatched {
46 pattern,
47 matched_line,
48 shell,
49 ..
50 } => {
51 format!(
52 "fail pattern matched in shell '{shell}': pattern {pattern} triggered, matched: \"{matched_line}\""
53 )
54 }
55 Failure::ShellExited {
56 shell,
57 exit_code: Some(code),
58 ..
59 } => {
60 format!("shell '{shell}' exited unexpectedly with exit code {code}")
61 }
62 Failure::ShellExited {
63 shell,
64 exit_code: None,
65 ..
66 } => {
67 format!("shell '{shell}' exited unexpectedly without an exit code")
68 }
69 Failure::Runtime {
70 message,
71 shell: Some(shell),
72 ..
73 } => {
74 format!("runtime error in shell '{shell}': {message}")
75 }
76 Failure::Runtime {
77 message,
78 shell: None,
79 ..
80 } => {
81 format!("runtime error: {message}")
82 }
83 Failure::Cancelled {
84 shell: Some(shell), ..
85 } => {
86 format!("cancelled in shell '{shell}'")
87 }
88 Failure::Cancelled { shell: None, .. } => "cancelled".to_string(),
89 }
90 }
91
92 pub fn failure_type(&self) -> &'static str {
93 match self {
94 Failure::MatchTimeout { .. } => "MatchTimeout",
95 Failure::FailPatternMatched { .. } => "FailPatternMatched",
96 Failure::ShellExited { .. } => "ShellExited",
97 Failure::Runtime { .. } => "Runtime",
98 Failure::Cancelled { .. } => "Cancelled",
99 }
100 }
101}
102
103impl From<&Failure> for relux_core::error::DiagnosticReport {
104 fn from(failure: &Failure) -> Self {
105 use relux_core::error::DiagnosticReport;
106 use relux_core::error::Severity;
107 match failure {
108 Failure::MatchTimeout {
109 pattern,
110 span,
111 shell,
112 } => DiagnosticReport {
113 severity: Severity::Error,
114 message: format!("match timeout in shell `{shell}`"),
115 labels: vec![(span.clone(), format!("timed out waiting for `{pattern}`")).into()],
116 help: None,
117 note: None,
118 },
119 Failure::FailPatternMatched {
120 pattern,
121 matched_line,
122 span,
123 shell,
124 } => DiagnosticReport {
125 severity: Severity::Error,
126 message: format!("fail pattern matched in shell `{shell}`"),
127 labels: vec![(span.clone(), format!("pattern `{pattern}` triggered here")).into()],
128 help: None,
129 note: Some(format!("matched output: {matched_line}")),
130 },
131 Failure::ShellExited {
132 shell,
133 exit_code,
134 span,
135 } => {
136 let code_msg = match exit_code {
137 Some(c) => format!("with exit code {c}"),
138 None => "without an exit code".to_string(),
139 };
140 DiagnosticReport {
141 severity: Severity::Error,
142 message: format!("shell `{shell}` exited unexpectedly"),
143 labels: vec![(span.clone(), code_msg).into()],
144 help: None,
145 note: None,
146 }
147 }
148 Failure::Runtime {
149 message,
150 span,
151 shell,
152 } => {
153 let msg = match shell {
154 Some(s) => format!("runtime error in shell `{s}`"),
155 None => "runtime error".to_string(),
156 };
157 let first_line = message.lines().next().unwrap_or(message);
158 let has_detail = message.contains('\n');
159 match span {
160 Some(span) => DiagnosticReport {
161 severity: Severity::Error,
162 message: msg,
163 labels: vec![(span.clone(), first_line.to_string()).into()],
164 help: None,
165 note: if has_detail {
166 Some(message.clone())
167 } else {
168 None
169 },
170 },
171 None => DiagnosticReport {
172 severity: Severity::Error,
173 message: format!("{msg}: {first_line}"),
174 labels: vec![],
175 help: None,
176 note: if has_detail {
177 Some(message.clone())
178 } else {
179 None
180 },
181 },
182 }
183 }
184 Failure::Cancelled { span, shell } => {
185 let msg = match shell {
186 Some(s) => format!("cancelled in shell `{s}`"),
187 None => "cancelled".to_string(),
188 };
189 match span {
190 Some(span) => DiagnosticReport {
191 severity: Severity::Error,
192 message: msg,
193 labels: vec![(span.clone(), "cancelled here".to_string()).into()],
194 help: None,
195 note: None,
196 },
197 None => DiagnosticReport {
198 severity: Severity::Error,
199 message: msg,
200 labels: vec![],
201 help: None,
202 note: None,
203 },
204 }
205 }
206 }
207 }
208}
209
210pub fn log_link(run_dir: &Path, result: &TestResult) -> Option<String> {
211 let log_dir = result.log_dir.as_ref()?;
212 let relative = log_dir.strip_prefix(run_dir).ok()?;
213 Some(format!("{}/event.html", relative.display()))
214}
215
216#[derive(Debug, Clone)]
217pub struct TestResult {
218 pub test_name: String,
219 pub test_path: String,
220 pub outcome: Outcome,
221 pub duration: Duration,
222 pub progress: String,
223 pub log_dir: Option<PathBuf>,
224 pub warnings: Vec<crate::effect::Warning>,
225 pub flaky_retries: u32,
226}
227
228impl TestResult {
229 pub fn is_failure(&self) -> bool {
230 matches!(self.outcome, Outcome::Fail(_))
231 }
232}
233
234#[derive(Debug, Clone)]
235pub enum Outcome {
236 Pass,
237 Fail(Failure),
238 Skipped(String),
239 Invalid(String),
240}
241
242pub struct RunReport<'a> {
245 pub results: &'a [TestResult],
246 pub run_dir: &'a Path,
247 pub wall_duration: Duration,
248 pub jobs: usize,
249}
250
251impl RunReport<'_> {
252 pub fn eprint(&self) {
253 let mut passed = 0usize;
254 let mut failed = 0usize;
255 let mut skipped = 0usize;
256 let mut invalid = 0usize;
257 let mut flaky_retries = 0u32;
258 let mut total_duration = Duration::ZERO;
259
260 for result in self.results {
261 total_duration += result.duration;
262 flaky_retries += result.flaky_retries;
263 match &result.outcome {
264 Outcome::Pass => passed += 1,
265 Outcome::Fail(_) => failed += 1,
266 Outcome::Skipped(_) => skipped += 1,
267 Outcome::Invalid(_) => invalid += 1,
268 }
269 }
270
271 let has_problems = failed > 0 || invalid > 0;
272 let status = if has_problems {
273 "FAILED".red().to_string()
274 } else {
275 "ok".green().to_string()
276 };
277
278 let mut summary = format!("\ntest result: {status}. {passed} passed; {failed} failed");
279 if invalid > 0 {
280 summary.push_str(&format!("; {invalid} invalid"));
281 }
282 if skipped > 0 {
283 summary.push_str(&format!("; {skipped} skipped"));
284 }
285 if flaky_retries > 0 {
286 summary.push_str(&format!("; {flaky_retries} flaky retries"));
287 }
288 if self.jobs > 1 {
289 summary.push_str(&format!(
290 "; finished in {} ({} cumulative)\n",
291 format_duration(self.wall_duration),
292 format_duration(total_duration)
293 ));
294 } else {
295 summary.push_str(&format!(
296 "; finished in {}\n",
297 format_duration(self.wall_duration)
298 ));
299 }
300 eprint!("{summary}");
301 eprintln!(
302 " Test logs: file://{}",
303 self.run_dir.join("index.html").display()
304 );
305 let _ = std::io::stderr().flush();
306 }
307}
308
309pub fn format_duration(d: Duration) -> String {
310 let total_ms = d.as_secs_f64() * 1000.0;
311 if total_ms < 1000.0 {
312 format!("{:.1} ms", total_ms)
313 } else {
314 format!("{:.1} s", total_ms / 1000.0)
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use std::path::Path;
322
323 fn dummy_span() -> IrSpan {
324 IrSpan::synthetic()
325 }
326
327 #[test]
328 fn summary_match_timeout() {
329 let f = Failure::MatchTimeout {
330 pattern: "/ready/".into(),
331 shell: "default".into(),
332 span: dummy_span(),
333 };
334 assert_eq!(
335 f.summary(),
336 "match timeout in shell 'default': timed out waiting for /ready/"
337 );
338 }
339
340 #[test]
341 fn summary_fail_pattern_matched() {
342 let f = Failure::FailPatternMatched {
343 pattern: "/error/".into(),
344 matched_line: "error: connection refused".into(),
345 shell: "default".into(),
346 span: dummy_span(),
347 };
348 assert_eq!(
349 f.summary(),
350 "fail pattern matched in shell 'default': pattern /error/ triggered, matched: \"error: connection refused\""
351 );
352 }
353
354 #[test]
355 fn summary_shell_exited_with_code() {
356 let f = Failure::ShellExited {
357 shell: "default".into(),
358 exit_code: Some(1),
359 span: dummy_span(),
360 };
361 assert_eq!(
362 f.summary(),
363 "shell 'default' exited unexpectedly with exit code 1"
364 );
365 }
366
367 #[test]
368 fn summary_shell_exited_without_code() {
369 let f = Failure::ShellExited {
370 shell: "default".into(),
371 exit_code: None,
372 span: dummy_span(),
373 };
374 assert_eq!(
375 f.summary(),
376 "shell 'default' exited unexpectedly without an exit code"
377 );
378 }
379
380 #[test]
381 fn summary_runtime_with_shell() {
382 let f = Failure::Runtime {
383 message: "something broke".into(),
384 shell: Some("default".into()),
385 span: None,
386 };
387 assert_eq!(
388 f.summary(),
389 "runtime error in shell 'default': something broke"
390 );
391 }
392
393 #[test]
394 fn summary_runtime_without_shell() {
395 let f = Failure::Runtime {
396 message: "something broke".into(),
397 shell: None,
398 span: None,
399 };
400 assert_eq!(f.summary(), "runtime error: something broke");
401 }
402
403 #[test]
404 fn log_link_with_log_dir() {
405 let run_dir = Path::new("/tmp/runs/run-001");
406 let result = TestResult {
407 test_name: "my_test".into(),
408 test_path: "tests/my_test.relux".into(),
409 outcome: Outcome::Pass,
410 duration: Duration::from_millis(100),
411
412 progress: String::new(),
413 log_dir: Some(PathBuf::from("/tmp/runs/run-001/my_test")),
414 warnings: Vec::new(),
415 flaky_retries: 0,
416 };
417 assert_eq!(
418 log_link(run_dir, &result),
419 Some("my_test/event.html".to_string())
420 );
421 }
422
423 #[test]
424 fn log_link_without_log_dir() {
425 let run_dir = Path::new("/tmp/runs/run-001");
426 let result = TestResult {
427 test_name: "my_test".into(),
428 test_path: "tests/my_test.relux".into(),
429 outcome: Outcome::Pass,
430 duration: Duration::from_millis(100),
431
432 progress: String::new(),
433 log_dir: None,
434 warnings: Vec::new(),
435 flaky_retries: 0,
436 };
437 assert_eq!(log_link(run_dir, &result), None);
438 }
439}