Skip to main content

runifold_testkit/
golden_trace.rs

1//! Stable execution traces with nondeterministic identities removed.
2
3use runifold_core::RunEvent;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use thiserror::Error;
7
8/// Versioned, serializable execution trace for behavioral regression tests.
9#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
10pub struct GoldenTrace {
11    /// Normalization contract version.
12    pub schema_version: u32,
13    /// Canonical events with generated identities and wall-clock values scrubbed.
14    pub events: Vec<Value>,
15}
16
17impl GoldenTrace {
18    /// Current normalization contract version.
19    pub const SCHEMA_VERSION: u32 = 1;
20
21    /// Normalizes canonical runtime events for deterministic comparison.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`serde_json::Error`] only if a public event cannot be encoded.
26    pub fn from_events(events: &[RunEvent]) -> Result<Self, serde_json::Error> {
27        let events = events
28            .iter()
29            .map(serde_json::to_value)
30            .map(|value| value.map(normalize_value))
31            .collect::<Result<Vec<_>, _>>()?;
32        Ok(Self {
33            schema_version: Self::SCHEMA_VERSION,
34            events,
35        })
36    }
37
38    /// Compares two normalized traces and reports the first divergent event.
39    ///
40    /// # Errors
41    ///
42    /// Returns [`GoldenTraceMismatch`] for a schema or event difference.
43    pub fn assert_matches(&self, expected: &Self) -> Result<(), GoldenTraceMismatch> {
44        if self.schema_version != expected.schema_version {
45            return Err(GoldenTraceMismatch::SchemaVersion {
46                expected: expected.schema_version,
47                actual: self.schema_version,
48            });
49        }
50        let limit = self.events.len().max(expected.events.len());
51        for index in 0..limit {
52            let actual = self.events.get(index);
53            let expected = expected.events.get(index);
54            if actual != expected {
55                return Err(GoldenTraceMismatch::Event {
56                    index,
57                    expected: expected.cloned(),
58                    actual: actual.cloned(),
59                });
60            }
61        }
62        Ok(())
63    }
64}
65
66fn normalize_value(mut value: Value) -> Value {
67    normalize_at(None, &mut value);
68    value
69}
70
71fn normalize_at(key: Option<&str>, value: &mut Value) {
72    if key.is_some_and(is_nondeterministic_key) {
73        *value = Value::String("<normalized>".into());
74        return;
75    }
76    match value {
77        Value::Array(values) => {
78            for value in values {
79                normalize_at(None, value);
80            }
81        }
82        Value::Object(values) => {
83            for (key, value) in values {
84                normalize_at(Some(key), value);
85            }
86        }
87        _ => {}
88    }
89}
90
91fn is_nondeterministic_key(key: &str) -> bool {
92    key == "timestamp_ms"
93        || key == "event_id"
94        || key == "run_id"
95        || key == "parent_run_id"
96        || key == "caused_by"
97        || key == "invocation_id"
98        || key == "effect_id"
99        || key == "child_run_id"
100}
101
102/// Typed golden-trace comparison failure.
103#[derive(Clone, Debug, Error, PartialEq)]
104#[non_exhaustive]
105pub enum GoldenTraceMismatch {
106    /// The normalization contracts differ.
107    #[error("golden trace schema mismatch: expected {expected}, got {actual}")]
108    SchemaVersion {
109        /// Expected version.
110        expected: u32,
111        /// Actual version.
112        actual: u32,
113    },
114    /// One event differs or is missing.
115    #[error("golden trace diverged at event {index}")]
116    Event {
117        /// Zero-based event index.
118        index: usize,
119        /// Expected event, or `None` when the actual trace has an extra event.
120        expected: Option<Value>,
121        /// Actual event, or `None` when the actual trace ended early.
122        actual: Option<Value>,
123    },
124}
125
126#[cfg(test)]
127mod tests {
128    use runifold_core::Budget;
129
130    use crate::RunScenario;
131
132    use super::{GoldenTrace, GoldenTraceMismatch};
133
134    #[test]
135    fn generated_ids_and_timestamps_do_not_destabilize_golden_traces() {
136        let first = RunScenario::new(Budget::default());
137        let first_started = first.start();
138        first.complete(serde_json::json!({"ok": true}), &first_started);
139        let second = RunScenario::new(Budget::default());
140        let second_started = second.start();
141        second.complete(serde_json::json!({"ok": true}), &second_started);
142
143        let actual = GoldenTrace::from_events(&first.recorded_events()).unwrap();
144        let expected = GoldenTrace::from_events(&second.recorded_events()).unwrap();
145
146        actual.assert_matches(&expected).unwrap();
147    }
148
149    #[test]
150    fn divergent_event_payload_reports_the_exact_index() {
151        let first = RunScenario::new(Budget::default());
152        let first_started = first.start();
153        first.complete(serde_json::json!({"ok": true}), &first_started);
154        let second = RunScenario::new(Budget::default());
155        let second_started = second.start();
156        second.complete(serde_json::json!({"ok": false}), &second_started);
157
158        let actual = GoldenTrace::from_events(&first.recorded_events()).unwrap();
159        let expected = GoldenTrace::from_events(&second.recorded_events()).unwrap();
160        let error = actual.assert_matches(&expected).unwrap_err();
161
162        assert!(matches!(error, GoldenTraceMismatch::Event { index: 1, .. }));
163    }
164}