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;
9use relux_ir::IrTimeout;
10
11use crate::cancel::CancelReason;
12use crate::observe::structured::EventSeq;
13use crate::observe::structured::MatchContext;
14use crate::observe::structured::SpanId;
15use crate::observe::structured::StackFrame;
16use crate::observe::structured::StructuredLogBuilder;
17use crate::observe::structured::log_sink::LogSink;
18
19/// Diagnostic context captured at failure-construction time. Travels with
20/// every `Failure` so that downstream consumers (structured-log artifact,
21/// console error renderer) can render the call site, what arrived in the
22/// shell, and which user vars were live - without needing to reach back
23/// into a VM that is about to be dropped.
24///
25/// The variant makes the failure's provenance explicit. `Vm` carries the
26/// full diagnostic picture; `PreVm` represents failures raised outside any
27/// VM (effect resolution, shell-block lookup, cleanup-shell spawn,
28/// pre-init PTY spawn) and carries only the surrounding span, when one is
29/// known. The structured-log builder flattens both variants into a single
30/// on-disk shape via the accessor methods.
31#[derive(Debug, Clone)]
32pub enum FailureContext {
33    /// Captured by a running VM at failure-construction time.
34    Vm {
35        span: SpanId,
36        event_seq: EventSeq,
37        call_stack: Vec<StackFrame>,
38        buffer_tail: String,
39        vars_in_scope: Vec<(String, String)>,
40    },
41    /// Failure raised from pure evaluation at a pre-VM boundary (test/effect
42    /// preamble, overlay, or a pure-fn body reached from one). Unlike
43    /// `PreVm`, a pure-match failure always emitted its event trio and knows
44    /// its scope vars, so `event_seq` and `vars_in_scope` are non-optional
45    /// and real. No buffer (a pure match reads no shell).
46    Pure {
47        span: SpanId,
48        event_seq: EventSeq,
49        call_stack: Vec<StackFrame>,
50        vars_in_scope: Vec<(String, String)>,
51    },
52    /// Failure raised before/around any VM. `span` points at the
53    /// surrounding span when one is known (effect-setup span,
54    /// shell-block span, cleanup-block span), `None` otherwise. The
55    /// `call_stack` carries pure-fn frames when the failure came out of
56    /// pure evaluation; empty for every other pre-VM failure.
57    PreVm {
58        span: Option<SpanId>,
59        call_stack: Vec<StackFrame>,
60    },
61}
62
63impl FailureContext {
64    /// Construct a `PreVm` context with no surrounding span.
65    pub fn pre_vm() -> Self {
66        Self::PreVm {
67            span: None,
68            call_stack: vec![],
69        }
70    }
71
72    /// Construct a `PreVm` context tied to a known surrounding span.
73    pub fn pre_vm_with_span(span: SpanId) -> Self {
74        Self::PreVm {
75            span: Some(span),
76            call_stack: vec![],
77        }
78    }
79
80    /// Construct a `PreVm` context tied to a known surrounding span and a
81    /// resolved call stack (pure-fn frames).
82    pub fn pre_vm_with_frames(span: Option<SpanId>, call_stack: Vec<StackFrame>) -> Self {
83        Self::PreVm { span, call_stack }
84    }
85
86    /// Construct a `Pure` context for a pre-VM pure-evaluation failure.
87    pub fn pure(
88        span: SpanId,
89        event_seq: EventSeq,
90        call_stack: Vec<StackFrame>,
91        vars_in_scope: Vec<(String, String)>,
92    ) -> Self {
93        Self::Pure {
94            span,
95            event_seq,
96            call_stack,
97            vars_in_scope,
98        }
99    }
100
101    pub fn span(&self) -> Option<SpanId> {
102        match self {
103            Self::Vm { span, .. } => Some(*span),
104            Self::Pure { span, .. } => Some(*span),
105            Self::PreVm { span, .. } => *span,
106        }
107    }
108
109    pub fn event_seq(&self) -> Option<EventSeq> {
110        match self {
111            Self::Vm { event_seq, .. } => Some(*event_seq),
112            Self::Pure { event_seq, .. } => Some(*event_seq),
113            Self::PreVm { .. } => None,
114        }
115    }
116
117    pub fn call_stack(&self) -> &[StackFrame] {
118        match self {
119            Self::Vm { call_stack, .. } => call_stack,
120            Self::Pure { call_stack, .. } => call_stack,
121            Self::PreVm { call_stack, .. } => call_stack,
122        }
123    }
124
125    pub fn buffer_tail(&self) -> &str {
126        match self {
127            Self::Vm { buffer_tail, .. } => buffer_tail,
128            Self::Pure { .. } | Self::PreVm { .. } => "",
129        }
130    }
131
132    pub fn vars_in_scope(&self) -> &[(String, String)] {
133        match self {
134            Self::Vm { vars_in_scope, .. } => vars_in_scope,
135            Self::Pure { vars_in_scope, .. } => vars_in_scope,
136            Self::PreVm { .. } => &[],
137        }
138    }
139}
140
141#[derive(Debug, Clone, thiserror::Error)]
142pub enum Failure {
143    #[error("match timeout in shell '{shell}': timed out waiting for {pattern}")]
144    MatchTimeout {
145        pattern: String,
146        span: IrSpan,
147        shell: String,
148        /// The timeout that fired. Boxed to keep `Failure`'s variant size
149        /// comparable to the others (avoids `clippy::large_enum_variant`).
150        effective: Box<IrTimeout>,
151        context: FailureContext,
152    },
153    #[error(
154        "fail pattern matched in shell '{shell}': pattern {pattern} triggered, matched: \"{matched_line}\""
155    )]
156    FailPatternMatched {
157        pattern: String,
158        matched_line: String,
159        span: IrSpan,
160        shell: String,
161        context: FailureContext,
162    },
163    #[error(
164        "shell '{shell}' exited unexpectedly{}",
165        match exit_code {
166            Some(code) => format!(" with exit code {code}"),
167            None => " without an exit code".to_string(),
168        }
169    )]
170    ShellExited {
171        shell: String,
172        exit_code: Option<i32>,
173        span: IrSpan,
174        context: FailureContext,
175    },
176    #[error(
177        "{}",
178        match shell {
179            Some(s) => format!("runtime error in shell '{s}': {message}"),
180            None => format!("runtime error: {message}"),
181        }
182    )]
183    Runtime {
184        message: String,
185        span: IrSpan,
186        shell: Option<String>,
187        context: FailureContext,
188    },
189    #[error(
190        "pure match in {match_context} did not satisfy pattern {pattern}: value {value:?} did not match"
191    )]
192    PureMatch {
193        value: String,
194        pattern: String,
195        is_regex: bool,
196        span: IrSpan,
197        match_context: MatchContext,
198        context: FailureContext,
199    },
200    #[error(
201        "multimatch did not satisfy all patterns in shell '{shell}' ({matched_count}/{total} matched)",
202        matched_count = matched.len(),
203        total = patterns.len(),
204    )]
205    MultiMatch {
206        shell: String,
207        /// All patterns in source order.
208        patterns: Vec<crate::observe::structured::event::MultiMatchPattern>,
209        /// Indices into `patterns` that matched before the block timed out.
210        matched: Vec<usize>,
211        span: IrSpan,
212        /// The block-level timeout that fired. Boxed to keep variant size in
213        /// line with the other failures (see clippy::large_enum_variant).
214        effective: Box<IrTimeout>,
215        context: FailureContext,
216    },
217}
218
219/// Resolve the pure-fn call chain from the innermost still-open pure-fn
220/// span. On the pure-eval error path `leave_pure_fn` is skipped by `?`
221/// propagation, so those spans stay open and carry the chain. Empty when
222/// no pure fn is on the stack (a direct pure-match failure).
223pub fn resolve_pure_stack(sink: &LogSink, log: &StructuredLogBuilder) -> Vec<StackFrame> {
224    sink.deepest_open_span()
225        .map(|leaf| log.resolve_stack(leaf))
226        .unwrap_or_default()
227}
228
229/// Build the `ExecError` for a pure-eval failure at a pre-VM boundary
230/// (test-level / effect-level `let` or pure-match, overlay). Resolves the
231/// pure-fn chain and wraps it in a `Pure` failure context carrying the
232/// real current seq and a snapshot of the scope vars. When the failure
233/// surfaced from a pure-fn frame, the match context names that fn;
234/// otherwise it is the caller-supplied enclosing context (test/effect
235/// preamble or overlay).
236pub fn pure_eval_failure(
237    err: relux_ir::PureEvalError,
238    span: SpanId,
239    enclosing_context: MatchContext,
240    vars_in_scope: Vec<(String, String)>,
241    sink: &LogSink,
242    log: &StructuredLogBuilder,
243) -> ExecError {
244    let call_stack = resolve_pure_stack(sink, log);
245    let match_context = match call_stack.last() {
246        Some(f) if f.is_fn_call() => MatchContext::Fn {
247            name: f.name.clone().unwrap_or_default(),
248        },
249        _ => enclosing_context,
250    };
251    let event_seq = log.current_seq();
252    Failure::from_pure_eval(
253        err,
254        match_context,
255        FailureContext::pure(span, event_seq, call_stack, vars_in_scope),
256    )
257    .into()
258}
259
260/// The single authority for the malformed-pure-regex failure wording,
261/// shared by the shell-body pure-match path (`vm`) and the pre-VM
262/// `from_pure_eval` path.
263pub(crate) fn invalid_regex_message(reason: &str) -> String {
264    format!("invalid regex: {reason}")
265}
266
267impl Failure {
268    /// Build a `Failure` from a pure-evaluation error. A failed pure match
269    /// inside a `pure fn` body becomes `Failure::PureMatch`; a malformed
270    /// interpolated regex becomes a runtime error naming the bad pattern.
271    /// `match_context` names where the pure match ran (fn, test/effect
272    /// preamble, overlay, or shell); a non-shell context carries no shell
273    /// on the derived `Runtime` failure.
274    pub fn from_pure_eval(
275        err: relux_ir::PureEvalError,
276        match_context: MatchContext,
277        context: FailureContext,
278    ) -> Self {
279        match err {
280            relux_ir::PureEvalError::PureMatchFailed {
281                value,
282                pattern,
283                is_regex,
284                span,
285            } => Failure::PureMatch {
286                value,
287                pattern,
288                is_regex,
289                span,
290                match_context,
291                context,
292            },
293            relux_ir::PureEvalError::MalformedPattern {
294                pattern: _,
295                reason,
296                span,
297            } => Failure::Runtime {
298                message: invalid_regex_message(&reason),
299                span,
300                shell: match_context.shell_name_ref().map(str::to_string),
301                context,
302            },
303        }
304    }
305
306    pub fn summary(&self) -> String {
307        self.to_string()
308    }
309
310    pub fn failure_type(&self) -> &'static str {
311        match self {
312            Failure::MatchTimeout { .. } => "MatchTimeout",
313            Failure::FailPatternMatched { .. } => "FailPatternMatched",
314            Failure::ShellExited { .. } => "ShellExited",
315            Failure::Runtime { .. } => "Runtime",
316            Failure::PureMatch { .. } => "PureMatch",
317            Failure::MultiMatch { .. } => "MultiMatch",
318        }
319    }
320
321    pub fn context(&self) -> &FailureContext {
322        match self {
323            Failure::MatchTimeout { context, .. }
324            | Failure::FailPatternMatched { context, .. }
325            | Failure::ShellExited { context, .. }
326            | Failure::Runtime { context, .. }
327            | Failure::PureMatch { context, .. }
328            | Failure::MultiMatch { context, .. } => context,
329        }
330    }
331}
332
333impl From<&Failure> for relux_core::error::DiagnosticReport {
334    fn from(failure: &Failure) -> Self {
335        use relux_core::error::DiagnosticReport;
336        use relux_core::error::Severity;
337        match failure {
338            Failure::MatchTimeout {
339                pattern,
340                span,
341                shell,
342                ..
343            } => DiagnosticReport {
344                severity: Severity::Error,
345                message: format!("match timeout in shell `{shell}`"),
346                labels: vec![(span.clone(), format!("timed out waiting for `{pattern}`")).into()],
347                help: None,
348                note: None,
349            },
350            Failure::FailPatternMatched {
351                pattern,
352                matched_line,
353                span,
354                shell,
355                ..
356            } => DiagnosticReport {
357                severity: Severity::Error,
358                message: format!("fail pattern matched in shell `{shell}`"),
359                labels: vec![(span.clone(), format!("pattern `{pattern}` triggered here")).into()],
360                help: None,
361                note: Some(format!("matched output: {matched_line}")),
362            },
363            Failure::ShellExited {
364                shell,
365                exit_code,
366                span,
367                ..
368            } => {
369                let code_msg = match exit_code {
370                    Some(c) => format!("with exit code {c}"),
371                    None => "without an exit code".to_string(),
372                };
373                DiagnosticReport {
374                    severity: Severity::Error,
375                    message: format!("shell `{shell}` exited unexpectedly"),
376                    labels: vec![(span.clone(), code_msg).into()],
377                    help: None,
378                    note: None,
379                }
380            }
381            Failure::Runtime {
382                message,
383                span,
384                shell,
385                ..
386            } => {
387                let msg = match shell {
388                    Some(s) => format!("runtime error in shell `{s}`"),
389                    None => "runtime error".to_string(),
390                };
391                let first_line = message.lines().next().unwrap_or(message);
392                let has_detail = message.contains('\n');
393                DiagnosticReport {
394                    severity: Severity::Error,
395                    message: msg,
396                    labels: vec![(span.clone(), first_line.to_string()).into()],
397                    help: None,
398                    note: if has_detail {
399                        Some(message.clone())
400                    } else {
401                        None
402                    },
403                }
404            }
405            Failure::PureMatch {
406                value,
407                pattern,
408                is_regex,
409                span,
410                match_context,
411                ..
412            } => {
413                let op = if *is_regex { "?" } else { "=" };
414                DiagnosticReport {
415                    severity: Severity::Error,
416                    message: format!(
417                        "pure match in {} did not match",
418                        match_context.backtick_label()
419                    ),
420                    labels: vec![
421                        (
422                            span.clone(),
423                            format!("value did not satisfy `{op} {pattern}`"),
424                        )
425                            .into(),
426                    ],
427                    help: None,
428                    note: Some(format!("value: {value}")),
429                }
430            }
431            Failure::MultiMatch {
432                shell,
433                patterns,
434                matched,
435                span,
436                ..
437            } => {
438                let matched_set: std::collections::HashSet<usize> =
439                    matched.iter().copied().collect();
440                let total = patterns.len();
441                let hit_count = matched_set.len();
442                let header = format!(
443                    "multimatch did not satisfy all patterns ({hit_count} matched, {} timed out)",
444                    total.saturating_sub(hit_count),
445                );
446                let mut lines = String::with_capacity(header.len() + patterns.len() * 48);
447                lines.push_str(&header);
448                lines.push('\n');
449                for (i, p) in patterns.iter().enumerate() {
450                    let label = if matched_set.contains(&i) {
451                        "matched:"
452                    } else {
453                        "timed out:"
454                    };
455                    let kind = if p.is_regex { "?" } else { "=" };
456                    let line = format!("{label:<13}{kind} {pat}", pat = p.pattern);
457                    lines.push_str(&line);
458                    lines.push('\n');
459                }
460                DiagnosticReport {
461                    severity: Severity::Error,
462                    message: format!("multimatch in shell `{shell}` did not satisfy all patterns"),
463                    labels: vec![
464                        (span.clone(), "multimatch block timed out here".to_string()).into(),
465                    ],
466                    help: None,
467                    note: Some(lines.trim_end().to_string()),
468                }
469            }
470        }
471    }
472}
473
474pub fn log_link(run_dir: &Path, result: &TestResult) -> Option<String> {
475    let log_dir = result.log_dir.as_ref()?;
476    let relative = log_dir.strip_prefix(run_dir).ok()?;
477    Some(format!("{}/event.html", relative.display()))
478}
479
480/// Companion to `log_link` for the canonical structured artifact.
481/// Returns `<log_dir>/events.json` relative to `run_dir`. Machine
482/// consumers (custom reporters, dashboards) prefer this over the
483/// human-targeted `event.html`.
484pub fn events_json_link(run_dir: &Path, result: &TestResult) -> Option<String> {
485    let log_dir = result.log_dir.as_ref()?;
486    let relative = log_dir.strip_prefix(run_dir).ok()?;
487    Some(format!("{}/events.json", relative.display()))
488}
489
490/// Top-level marker that the test was interrupted before completing.
491/// Distinct from `Failure` because the test did not misbehave - it was
492/// stopped by an external event (the per-test watchdog, the suite-wide
493/// watchdog, fail-fast, or SIGINT).
494#[derive(Debug, Clone, thiserror::Error)]
495#[error(
496    "{}",
497    match reason {
498        CancelReason::TestTimeout { duration } => format!("cancelled: test timed out after {duration:?}"),
499        CancelReason::SuiteTimeout { duration } => format!("cancelled: suite timed out after {duration:?}"),
500        CancelReason::FailFast { trigger_test } => format!("cancelled: suite stopped after `{trigger_test}` failed (fail-fast)"),
501        CancelReason::Sigint => "cancelled: interrupted (SIGINT)".to_string(),
502    }
503)]
504pub struct Cancellation {
505    pub reason: CancelReason,
506    pub context: FailureContext,
507}
508
509impl Cancellation {
510    pub fn summary(&self) -> String {
511        self.to_string()
512    }
513
514    pub fn reason_tag(&self) -> &'static str {
515        match &self.reason {
516            CancelReason::TestTimeout { .. } => "test-timeout",
517            CancelReason::SuiteTimeout { .. } => "suite-timeout",
518            CancelReason::FailFast { .. } => "fail-fast",
519            CancelReason::Sigint => "sigint",
520        }
521    }
522}
523
524impl From<&Cancellation> for relux_core::error::DiagnosticReport {
525    fn from(c: &Cancellation) -> Self {
526        relux_core::error::DiagnosticReport {
527            severity: relux_core::error::Severity::Error,
528            message: c.summary(),
529            labels: vec![],
530            help: None,
531            note: None,
532        }
533    }
534}
535
536/// Internal error type used by the VM / BIF / effect machinery while a test
537/// is running. `Failure` is "the test misbehaved"; `Cancelled` is "we were
538/// stopped from the outside". `run_test` maps each variant onto the
539/// corresponding `Outcome`.
540#[derive(Debug, Clone, thiserror::Error)]
541pub enum ExecError {
542    #[error(transparent)]
543    Failure(#[from] Failure),
544    #[error(transparent)]
545    Cancelled(#[from] Cancellation),
546}
547
548impl ExecError {
549    pub fn summary(&self) -> String {
550        self.to_string()
551    }
552}
553
554#[derive(Debug, Clone)]
555pub struct TestResult {
556    pub test_name: String,
557    pub test_path: String,
558    pub outcome: Outcome,
559    pub duration: Duration,
560    pub progress: String,
561    pub log_dir: Option<PathBuf>,
562    pub warnings: Vec<crate::effect::Warning>,
563    pub flaky_retries: u32,
564}
565
566impl TestResult {
567    pub fn is_failure(&self) -> bool {
568        matches!(self.outcome, Outcome::Fail(_))
569    }
570
571    pub fn is_cancelled(&self) -> bool {
572        matches!(self.outcome, Outcome::Cancelled(_))
573    }
574}
575
576#[derive(Debug, Clone)]
577pub enum Outcome {
578    Pass,
579    Fail(Failure),
580    Cancelled(Cancellation),
581    Skipped(String),
582    Invalid(String),
583}
584
585impl Outcome {
586    pub fn is_failure(&self) -> bool {
587        matches!(self, Outcome::Fail(_))
588    }
589
590    pub fn is_cancelled(&self) -> bool {
591        matches!(self, Outcome::Cancelled(_))
592    }
593
594    pub fn is_nonzero_outcome(&self) -> bool {
595        matches!(
596            self,
597            Outcome::Fail(_) | Outcome::Cancelled(_) | Outcome::Invalid(_)
598        )
599    }
600
601    /// Whether the flaky-retry loop should retry on this outcome. Real
602    /// failures and per-test-timeout cancellations are retryable (those are
603    /// the test's own clock running out - exactly what flaky retries with
604    /// scaled timeouts target). External cancellations (suite-timeout,
605    /// fail-fast, SIGINT) are not retryable: rerunning the same test isn't
606    /// going to make the external trigger disappear.
607    pub fn is_retryable(&self) -> bool {
608        match self {
609            Outcome::Fail(_) => true,
610            Outcome::Cancelled(c) => {
611                matches!(c.reason, crate::cancel::CancelReason::TestTimeout { .. })
612            }
613            _ => false,
614        }
615    }
616}
617
618// --- Run Report ------------------------------------------
619
620pub struct RunReport<'a> {
621    pub results: &'a [TestResult],
622    pub run_dir: &'a Path,
623    pub wall_duration: Duration,
624    pub jobs: usize,
625}
626
627impl RunReport<'_> {
628    pub fn eprint(&self) {
629        let mut passed = 0usize;
630        let mut failed = 0usize;
631        let mut cancelled = 0usize;
632        let mut skipped = 0usize;
633        let mut invalid = 0usize;
634        let mut flaky_retries = 0u32;
635        let mut total_duration = Duration::ZERO;
636
637        for result in self.results {
638            total_duration += result.duration;
639            flaky_retries += result.flaky_retries;
640            match &result.outcome {
641                Outcome::Pass => passed += 1,
642                Outcome::Fail(_) => failed += 1,
643                Outcome::Cancelled(_) => cancelled += 1,
644                Outcome::Skipped(_) => skipped += 1,
645                Outcome::Invalid(_) => invalid += 1,
646            }
647        }
648
649        let has_problems = failed > 0 || cancelled > 0 || invalid > 0;
650        let status = if has_problems {
651            "FAILED".red().to_string()
652        } else {
653            "ok".green().to_string()
654        };
655
656        let mut summary = format!("\ntest result: {status}. {passed} passed; {failed} failed");
657        if cancelled > 0 {
658            summary.push_str(&format!("; {cancelled} cancelled"));
659        }
660        if invalid > 0 {
661            summary.push_str(&format!("; {invalid} invalid"));
662        }
663        if skipped > 0 {
664            summary.push_str(&format!("; {skipped} skipped"));
665        }
666        if flaky_retries > 0 {
667            summary.push_str(&format!("; {flaky_retries} flaky retries"));
668        }
669        if self.jobs > 1 {
670            summary.push_str(&format!(
671                "; finished in {} ({} cumulative)\n",
672                format_duration(self.wall_duration),
673                format_duration(total_duration)
674            ));
675        } else {
676            summary.push_str(&format!(
677                "; finished in {}\n",
678                format_duration(self.wall_duration)
679            ));
680        }
681        eprint!("{summary}");
682        eprintln!(
683            "  Test logs: file://{}",
684            self.run_dir.join("index.html").display()
685        );
686        let _ = std::io::stderr().flush();
687    }
688}
689
690pub fn format_duration(d: Duration) -> String {
691    let total_ms = d.as_secs_f64() * 1000.0;
692    if total_ms < 1000.0 {
693        format!("{:.1} ms", total_ms)
694    } else {
695        format!("{:.1} s", total_ms / 1000.0)
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use std::path::Path;
703
704    fn dummy_span() -> IrSpan {
705        IrSpan::synthetic()
706    }
707
708    #[test]
709    fn invalid_regex_message_wording() {
710        assert_eq!(
711            super::invalid_regex_message("unclosed group"),
712            "invalid regex: unclosed group"
713        );
714    }
715
716    #[test]
717    fn summary_match_timeout() {
718        let f = Failure::MatchTimeout {
719            pattern: "/ready/".into(),
720            shell: "default".into(),
721            span: dummy_span(),
722            effective: Box::new(IrTimeout::tolerance(std::time::Duration::from_secs(5))),
723            context: FailureContext::pre_vm(),
724        };
725        assert_eq!(
726            f.summary(),
727            "match timeout in shell 'default': timed out waiting for /ready/"
728        );
729    }
730
731    #[test]
732    fn summary_fail_pattern_matched() {
733        let f = Failure::FailPatternMatched {
734            pattern: "/error/".into(),
735            matched_line: "error: connection refused".into(),
736            shell: "default".into(),
737            span: dummy_span(),
738            context: FailureContext::pre_vm(),
739        };
740        assert_eq!(
741            f.summary(),
742            "fail pattern matched in shell 'default': pattern /error/ triggered, matched: \"error: connection refused\""
743        );
744    }
745
746    #[test]
747    fn summary_shell_exited_with_code() {
748        let f = Failure::ShellExited {
749            shell: "default".into(),
750            exit_code: Some(1),
751            span: dummy_span(),
752            context: FailureContext::pre_vm(),
753        };
754        assert_eq!(
755            f.summary(),
756            "shell 'default' exited unexpectedly with exit code 1"
757        );
758    }
759
760    #[test]
761    fn summary_shell_exited_without_code() {
762        let f = Failure::ShellExited {
763            shell: "default".into(),
764            exit_code: None,
765            span: dummy_span(),
766            context: FailureContext::pre_vm(),
767        };
768        assert_eq!(
769            f.summary(),
770            "shell 'default' exited unexpectedly without an exit code"
771        );
772    }
773
774    #[test]
775    fn diagnostic_report_runtime_renders_source_span_label() {
776        use relux_core::error::DiagnosticReport;
777        use relux_core::table::FileId;
778        // A real (non-synthetic) source span pointing at the offending
779        // identifier. Every `Failure::Runtime` now carries one, so the
780        // rendered report must surface it as exactly one diagnostic label.
781        let file = FileId::new(std::path::PathBuf::from("tests/auth/login.relux"));
782        let span = IrSpan::new(file.clone(), relux_core::Span::new(12, 24));
783        let f = Failure::Runtime {
784            message: "effect alias `db` does not expose shell `psql`".into(),
785            shell: None,
786            span: span.clone(),
787            context: FailureContext::pre_vm(),
788        };
789        let rep: DiagnosticReport = (&f).into();
790        assert_eq!(
791            rep.labels.len(),
792            1,
793            "a Runtime failure must render exactly one source label"
794        );
795        let label = &rep.labels[0];
796        assert_eq!(label.span.file(), &file, "label points at the source file");
797        assert_eq!(
798            label.span.span(),
799            span.span(),
800            "label carries the exact byte span passed on the failure"
801        );
802    }
803
804    #[test]
805    fn summary_runtime_with_shell() {
806        let f = Failure::Runtime {
807            message: "something broke".into(),
808            shell: Some("default".into()),
809            span: IrSpan::synthetic(),
810            context: FailureContext::pre_vm(),
811        };
812        assert_eq!(
813            f.summary(),
814            "runtime error in shell 'default': something broke"
815        );
816    }
817
818    #[test]
819    fn summary_runtime_without_shell() {
820        let f = Failure::Runtime {
821            message: "something broke".into(),
822            shell: None,
823            span: IrSpan::synthetic(),
824            context: FailureContext::pre_vm(),
825        };
826        assert_eq!(f.summary(), "runtime error: something broke");
827    }
828
829    #[test]
830    fn summary_multimatch() {
831        use crate::observe::structured::event::MultiMatchPattern;
832        let f = Failure::MultiMatch {
833            shell: "default".into(),
834            patterns: vec![
835                MultiMatchPattern {
836                    pattern: "^a$".into(),
837                    is_regex: true,
838                },
839                MultiMatchPattern {
840                    pattern: "b".into(),
841                    is_regex: false,
842                },
843            ],
844            matched: vec![0],
845            span: dummy_span(),
846            effective: Box::new(IrTimeout::tolerance(std::time::Duration::from_secs(5))),
847            context: FailureContext::pre_vm(),
848        };
849        let summary = f.summary();
850        assert!(
851            summary.starts_with("multimatch did not satisfy all patterns"),
852            "got: {summary}"
853        );
854    }
855
856    #[test]
857    fn failure_type_multimatch() {
858        use crate::observe::structured::event::MultiMatchPattern;
859        let f = Failure::MultiMatch {
860            shell: "default".into(),
861            patterns: vec![MultiMatchPattern {
862                pattern: "^a$".into(),
863                is_regex: true,
864            }],
865            matched: vec![],
866            span: dummy_span(),
867            effective: Box::new(IrTimeout::tolerance(std::time::Duration::from_secs(5))),
868            context: FailureContext::pre_vm(),
869        };
870        assert_eq!(f.failure_type(), "MultiMatch");
871    }
872
873    #[test]
874    fn diagnostic_report_multimatch_lists_per_pattern_status() {
875        use crate::observe::structured::event::MultiMatchPattern;
876        use relux_core::error::DiagnosticReport;
877        let f = Failure::MultiMatch {
878            shell: "default".into(),
879            patterns: vec![
880                MultiMatchPattern {
881                    pattern: "^a$".into(),
882                    is_regex: true,
883                },
884                MultiMatchPattern {
885                    pattern: "^b$".into(),
886                    is_regex: true,
887                },
888                MultiMatchPattern {
889                    pattern: "^c$".into(),
890                    is_regex: true,
891                },
892            ],
893            matched: vec![0, 2],
894            span: dummy_span(),
895            effective: Box::new(IrTimeout::tolerance(std::time::Duration::from_secs(5))),
896            context: FailureContext::pre_vm(),
897        };
898        let rep: DiagnosticReport = (&f).into();
899        let note = rep
900            .note
901            .expect("multimatch DiagnosticReport must carry a per-pattern note");
902        assert!(note.contains("matched:"), "matched label present: {note}");
903        assert!(
904            note.contains("timed out:"),
905            "timed-out label present: {note}"
906        );
907        assert!(note.contains("^a$"), "pattern 0 listed");
908        assert!(note.contains("^b$"), "pattern 1 listed");
909        assert!(note.contains("^c$"), "pattern 2 listed");
910        assert!(note.is_ascii(), "diagnostic note must be ASCII-only");
911    }
912
913    #[test]
914    fn diagnostic_report_pure_match_carries_value_and_pattern() {
915        use relux_core::error::DiagnosticReport;
916        for (is_regex, op) in [(false, "="), (true, "?")] {
917            let f = Failure::PureMatch {
918                value: "hello world".into(),
919                pattern: "goodbye".into(),
920                is_regex,
921                match_context: MatchContext::Shell {
922                    name: "default".into(),
923                },
924                span: dummy_span(),
925                context: FailureContext::pre_vm(),
926            };
927            let rep: DiagnosticReport = (&f).into();
928            assert!(
929                rep.message.contains("pure match"),
930                "message names the failure: {}",
931                rep.message
932            );
933            assert!(
934                rep.message.contains("shell `default`"),
935                "message names the match context: {}",
936                rep.message
937            );
938            let label_text = rep
939                .labels
940                .first()
941                .map(|l| l.message.clone())
942                .expect("pure-match DiagnosticReport must carry a label");
943            assert!(
944                label_text.contains("goodbye"),
945                "label carries the pattern: {label_text}"
946            );
947            assert!(
948                label_text.contains(op),
949                "label carries the `{op}` operator: {label_text}"
950            );
951            let note = rep
952                .note
953                .expect("pure-match DiagnosticReport must carry a value note");
954            assert!(
955                note.contains("hello world"),
956                "note carries the value: {note}"
957            );
958            // A pure match has no shell buffer; the report must not fabricate
959            // a buffer-tail section (DiagnosticReport has no buffer field, and
960            // nothing should smuggle one into the note).
961            assert!(
962                !note.to_lowercase().contains("buffer"),
963                "pure-match report must not carry a buffer tail: {note}"
964            );
965        }
966    }
967
968    #[test]
969    fn from_pure_eval_malformed_pattern_non_shell_carries_no_shell() {
970        // A malformed interpolated regex in a non-shell context (here a test
971        // preamble) must produce a `Runtime` failure with `shell: None`:
972        // the old empty-string special-case is gone, and only a real shell
973        // context contributes a shell name.
974        let err = relux_ir::PureEvalError::MalformedPattern {
975            pattern: "(".into(),
976            reason: "unclosed group".into(),
977            span: dummy_span(),
978        };
979        let f = Failure::from_pure_eval(
980            err,
981            MatchContext::TestPreamble {
982                name: "login".into(),
983            },
984            FailureContext::pure(1, 2, vec![], vec![]),
985        );
986        match f {
987            Failure::Runtime { shell, message, .. } => {
988                assert_eq!(shell, None, "non-shell context carries no shell");
989                assert!(message.contains("invalid regex"), "message: {message}");
990            }
991            other => panic!("expected Runtime, got {other:?}"),
992        }
993    }
994
995    #[test]
996    fn from_pure_eval_malformed_pattern_shell_carries_shell() {
997        // The shell context is the only one that surfaces a shell name.
998        let err = relux_ir::PureEvalError::MalformedPattern {
999            pattern: "(".into(),
1000            reason: "unclosed group".into(),
1001            span: dummy_span(),
1002        };
1003        let f = Failure::from_pure_eval(
1004            err,
1005            MatchContext::Shell {
1006                name: "default".into(),
1007            },
1008            FailureContext::pure(1, 2, vec![], vec![]),
1009        );
1010        match f {
1011            Failure::Runtime { shell, .. } => {
1012                assert_eq!(shell, Some("default".to_string()));
1013            }
1014            other => panic!("expected Runtime, got {other:?}"),
1015        }
1016    }
1017
1018    #[test]
1019    fn pure_context_exposes_real_seq_and_vars() {
1020        let ctx = FailureContext::pure(7, 42, vec![], vec![("v".into(), "abc".into())]);
1021        assert_eq!(ctx.span(), Some(7));
1022        assert_eq!(ctx.event_seq(), Some(42));
1023        assert_eq!(ctx.buffer_tail(), "");
1024        assert_eq!(ctx.vars_in_scope(), &[("v".to_string(), "abc".to_string())]);
1025    }
1026
1027    #[test]
1028    fn log_link_with_log_dir() {
1029        let run_dir = Path::new("/tmp/runs/run-001");
1030        let result = TestResult {
1031            test_name: "my_test".into(),
1032            test_path: "tests/my_test.relux".into(),
1033            outcome: Outcome::Pass,
1034            duration: Duration::from_millis(100),
1035
1036            progress: String::new(),
1037            log_dir: Some(PathBuf::from("/tmp/runs/run-001/my_test")),
1038            warnings: Vec::new(),
1039            flaky_retries: 0,
1040        };
1041        assert_eq!(
1042            log_link(run_dir, &result),
1043            Some("my_test/event.html".to_string())
1044        );
1045    }
1046
1047    #[test]
1048    fn cancellation_summary_test_timeout() {
1049        let c = Cancellation {
1050            reason: CancelReason::TestTimeout {
1051                duration: Duration::from_millis(300),
1052            },
1053            context: FailureContext::pre_vm(),
1054        };
1055        assert_eq!(c.reason_tag(), "test-timeout");
1056        assert!(c.summary().starts_with("cancelled: test timed out after"));
1057    }
1058
1059    #[test]
1060    fn cancellation_summary_fail_fast() {
1061        let c = Cancellation {
1062            reason: CancelReason::FailFast {
1063                trigger_test: "foo".into(),
1064            },
1065            context: FailureContext::pre_vm(),
1066        };
1067        assert_eq!(c.reason_tag(), "fail-fast");
1068        assert!(c.summary().contains("`foo`"));
1069    }
1070
1071    #[test]
1072    fn exec_error_from_conversions() {
1073        let f = Failure::Runtime {
1074            message: "x".into(),
1075            span: IrSpan::synthetic(),
1076            shell: None,
1077            context: FailureContext::pre_vm(),
1078        };
1079        let e: ExecError = f.into();
1080        assert!(matches!(e, ExecError::Failure(_)));
1081
1082        let c = Cancellation {
1083            reason: CancelReason::Sigint,
1084            context: FailureContext::pre_vm(),
1085        };
1086        let e: ExecError = c.into();
1087        assert!(matches!(e, ExecError::Cancelled(_)));
1088    }
1089
1090    #[test]
1091    fn log_link_without_log_dir() {
1092        let run_dir = Path::new("/tmp/runs/run-001");
1093        let result = TestResult {
1094            test_name: "my_test".into(),
1095            test_path: "tests/my_test.relux".into(),
1096            outcome: Outcome::Pass,
1097            duration: Duration::from_millis(100),
1098
1099            progress: String::new(),
1100            log_dir: None,
1101            warnings: Vec::new(),
1102            flaky_retries: 0,
1103        };
1104        assert_eq!(log_link(run_dir, &result), None);
1105    }
1106}