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#[derive(Debug, Clone)]
32pub enum FailureContext {
33 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 Pure {
47 span: SpanId,
48 event_seq: EventSeq,
49 call_stack: Vec<StackFrame>,
50 vars_in_scope: Vec<(String, String)>,
51 },
52 PreVm {
58 span: Option<SpanId>,
59 call_stack: Vec<StackFrame>,
60 },
61}
62
63impl FailureContext {
64 pub fn pre_vm() -> Self {
66 Self::PreVm {
67 span: None,
68 call_stack: vec![],
69 }
70 }
71
72 pub fn pre_vm_with_span(span: SpanId) -> Self {
74 Self::PreVm {
75 span: Some(span),
76 call_stack: vec![],
77 }
78 }
79
80 pub fn pre_vm_with_frames(span: Option<SpanId>, call_stack: Vec<StackFrame>) -> Self {
83 Self::PreVm { span, call_stack }
84 }
85
86 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 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 patterns: Vec<crate::observe::structured::event::MultiMatchPattern>,
209 matched: Vec<usize>,
211 span: IrSpan,
212 effective: Box<IrTimeout>,
215 context: FailureContext,
216 },
217}
218
219pub 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
229pub 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
260pub(crate) fn invalid_regex_message(reason: &str) -> String {
264 format!("invalid regex: {reason}")
265}
266
267impl Failure {
268 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
480pub 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#[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#[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 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
618pub 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 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 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 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 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}