Skip to main content

relux_runtime/report/
result.rs

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
242// ─── Run Report ─────────────────────────────────────────────
243
244pub 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}