runifold_testkit/
golden_trace.rs1use runifold_core::RunEvent;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use thiserror::Error;
7
8#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
10pub struct GoldenTrace {
11 pub schema_version: u32,
13 pub events: Vec<Value>,
15}
16
17impl GoldenTrace {
18 pub const SCHEMA_VERSION: u32 = 1;
20
21 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 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#[derive(Clone, Debug, Error, PartialEq)]
104#[non_exhaustive]
105pub enum GoldenTraceMismatch {
106 #[error("golden trace schema mismatch: expected {expected}, got {actual}")]
108 SchemaVersion {
109 expected: u32,
111 actual: u32,
113 },
114 #[error("golden trace diverged at event {index}")]
116 Event {
117 index: usize,
119 expected: Option<Value>,
121 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}