Skip to main content

relux_runtime/observe/structured/
failure.rs

1use serde::Deserialize;
2use serde::Serialize;
3use ts_rs::TS;
4
5use super::MatchContext;
6use super::SourceLocation;
7use super::event::EventSeq;
8use super::event::TimeoutValue;
9use super::span::SpanId;
10
11#[derive(Debug, Clone, Serialize, Deserialize, TS)]
12#[cfg_attr(
13    feature = "ts-export",
14    ts(export, export_to = "../../../viewer/src/types/")
15)]
16pub struct StackFrame {
17    pub span: SpanId,
18    /// Span-kind discriminator name (e.g. `"fn-call"`, `"shell-block"`).
19    pub kind: String,
20    /// Function name or effect name, when applicable.
21    pub name: Option<String>,
22    pub args: Vec<(String, String)>,
23    /// User-supplied alias bound at start time (`start FX as Alias`).
24    /// Only effect-setup frames carry one today.
25    pub alias: Option<String>,
26    pub location: Option<SourceLocation>,
27}
28
29impl StackFrame {
30    /// Whether this frame is a function call - an impure `fn` (`"fn-call"`)
31    /// or a compile-time `pure fn` (`"pure-fn-call"`). Both render the
32    /// failing match's context as `MatchContext::Fn`.
33    pub fn is_fn_call(&self) -> bool {
34        self.kind == "fn-call" || self.kind == "pure-fn-call"
35    }
36}
37
38/// Self-contained record of a test failure. Variant-specific fields plus
39/// pre-computed convenience fields (`call_stack`, `buffer_tail`,
40/// `vars_in_scope`) lifted from the `FailureContext` captured at the
41/// failure site. `FailureContext::Vm` populates every field; the
42/// `PreVm` variant (effect resolution, pre-VM init) carries only the
43/// surrounding span and empty stack / empty buffer tail - the artifact
44/// stays well-formed. Pure-match failures now travel via
45/// `FailureContext::Pure` and land with a real `event_seq` and the
46/// scope vars snapshotted at the failure site.
47#[derive(Debug, Clone, Serialize, Deserialize, TS)]
48#[cfg_attr(
49    feature = "ts-export",
50    ts(export, export_to = "../../../viewer/src/types/")
51)]
52#[serde(tag = "type", rename_all = "kebab-case")]
53pub enum FailureRecord {
54    MatchTimeout {
55        span: SpanId,
56        event_seq: EventSeq,
57        shell: String,
58        pattern: String,
59        /// The timeout that fired.
60        effective: TimeoutValue,
61        call_stack: Vec<StackFrame>,
62        buffer_tail: String,
63        vars_in_scope: Vec<(String, String)>,
64    },
65    FailPatternMatched {
66        span: SpanId,
67        event_seq: EventSeq,
68        shell: String,
69        pattern: String,
70        matched_line: String,
71        call_stack: Vec<StackFrame>,
72        buffer_tail: String,
73        vars_in_scope: Vec<(String, String)>,
74    },
75    ShellExited {
76        span: SpanId,
77        event_seq: EventSeq,
78        shell: String,
79        exit_code: Option<i32>,
80        call_stack: Vec<StackFrame>,
81        buffer_tail: String,
82        vars_in_scope: Vec<(String, String)>,
83    },
84    Runtime {
85        span: Option<SpanId>,
86        event_seq: Option<EventSeq>,
87        shell: Option<String>,
88        message: String,
89        call_stack: Vec<StackFrame>,
90        vars_in_scope: Vec<(String, String)>,
91    },
92    PureMatch {
93        span: SpanId,
94        event_seq: EventSeq,
95        match_context: MatchContext,
96        value: String,
97        pattern: String,
98        is_regex: bool,
99        call_stack: Vec<StackFrame>,
100        vars_in_scope: Vec<(String, String)>,
101    },
102    MultiMatch {
103        span: SpanId,
104        event_seq: EventSeq,
105        shell: String,
106        /// All patterns in source order (mirrors `MultiMatchStart.patterns`).
107        patterns: Vec<super::event::MultiMatchPattern>,
108        /// Indices into `patterns` that matched before the block timed out.
109        /// The unmatched indices are the complement.
110        matched: Vec<usize>,
111        /// The block-level timeout that fired.
112        effective: TimeoutValue,
113        call_stack: Vec<StackFrame>,
114        buffer_tail: String,
115        vars_in_scope: Vec<(String, String)>,
116    },
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::observe::structured::event::MultiMatchPattern;
123
124    #[test]
125    fn failure_record_multimatch_round_trips_serde() {
126        let original = FailureRecord::MultiMatch {
127            span: 3,
128            event_seq: 11,
129            shell: "default".into(),
130            patterns: vec![
131                MultiMatchPattern {
132                    pattern: "^a$".into(),
133                    is_regex: true,
134                },
135                MultiMatchPattern {
136                    pattern: "b".into(),
137                    is_regex: false,
138                },
139            ],
140            matched: vec![1],
141            effective: TimeoutValue::Assertion {
142                duration: "5s".into(),
143                source: None,
144            },
145            call_stack: vec![],
146            buffer_tail: "tail".into(),
147            vars_in_scope: vec![("k".into(), "v".into())],
148        };
149        let json = serde_json::to_string(&original).unwrap();
150        assert!(json.contains("\"type\":\"multi-match\""), "json: {json}");
151        let parsed: FailureRecord = serde_json::from_str(&json).unwrap();
152        match parsed {
153            FailureRecord::MultiMatch {
154                matched, patterns, ..
155            } => {
156                assert_eq!(matched, vec![1usize]);
157                assert_eq!(patterns.len(), 2);
158            }
159            other => panic!("expected MultiMatch, got {other:?}"),
160        }
161    }
162
163    fn stack_frame(kind: &str) -> StackFrame {
164        StackFrame {
165            span: 1,
166            kind: kind.to_string(),
167            name: None,
168            args: vec![],
169            alias: None,
170            location: None,
171        }
172    }
173
174    #[test]
175    fn is_fn_call_true_for_fn_call() {
176        assert!(stack_frame("fn-call").is_fn_call());
177    }
178
179    #[test]
180    fn is_fn_call_true_for_pure_fn_call() {
181        assert!(stack_frame("pure-fn-call").is_fn_call());
182    }
183
184    #[test]
185    fn is_fn_call_false_for_other_kinds() {
186        assert!(!stack_frame("shell-block").is_fn_call());
187    }
188}
189
190/// Self-contained record of a test cancellation. Distinct from
191/// `FailureRecord` because cancellation is not a failure: the test was
192/// running fine when an external event (the per-test watchdog, the
193/// suite-wide watchdog, fail-fast, SIGINT) cut it short.
194#[derive(Debug, Clone, Serialize, Deserialize, TS)]
195#[cfg_attr(
196    feature = "ts-export",
197    ts(export, export_to = "../../../viewer/src/types/")
198)]
199pub struct CancellationRecord {
200    pub reason: super::event::CancelReasonRecord,
201    pub span: Option<SpanId>,
202    pub event_seq: Option<EventSeq>,
203    pub shell: Option<String>,
204    pub call_stack: Vec<StackFrame>,
205}