1use crate::golden::GoldenMismatch;
6use crate::platform::Platform;
7use chrono::{DateTime, Utc};
8use std::path::PathBuf;
9use std::time::Duration;
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "UPPERCASE")]
15pub enum TestStatus {
16 Passed,
18 Failed,
20 Skipped,
22 TimedOut,
24}
25
26impl std::fmt::Display for TestStatus {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 TestStatus::Passed => write!(f, "PASSED"),
30 TestStatus::Failed => write!(f, "FAILED"),
31 TestStatus::Skipped => write!(f, "SKIPPED"),
32 TestStatus::TimedOut => write!(f, "TIMED_OUT"),
33 }
34 }
35}
36
37#[derive(Debug, Clone)]
39pub struct TestExecution {
40 pub id: Uuid,
42 pub fixture: String,
44 pub platform: Platform,
46 pub started_at: DateTime<Utc>,
48 pub ended_at: Option<DateTime<Utc>>,
50 pub container_id: Option<String>,
52}
53
54impl TestExecution {
55 pub fn new(fixture: &str, platform: Platform) -> Self {
57 TestExecution {
58 id: Uuid::new_v4(),
59 fixture: fixture.to_string(),
60 platform,
61 started_at: Utc::now(),
62 ended_at: None,
63 container_id: None,
64 }
65 }
66
67 pub fn finish(&mut self) {
69 self.ended_at = Some(Utc::now());
70 }
71
72 pub fn duration(&self) -> Option<Duration> {
74 self.ended_at.map(|end| {
75 let duration = end.signed_duration_since(self.started_at);
76 Duration::from_secs(duration.num_seconds() as u64)
77 })
78 }
79
80 pub fn with_container_id(mut self, id: String) -> Self {
82 self.container_id = Some(id);
83 self
84 }
85}
86
87#[derive(Debug)]
89pub struct TestResult {
90 pub execution: TestExecution,
92 pub status: TestStatus,
94 pub generated_files: Vec<PathBuf>,
96 pub mismatches: Vec<GoldenMismatch>,
98 pub logs: String,
100 pub error: Option<String>,
102}
103
104impl TestResult {
105 pub fn passed(execution: TestExecution, files: Vec<PathBuf>) -> Self {
107 TestResult {
108 execution,
109 status: TestStatus::Passed,
110 generated_files: files,
111 mismatches: Vec::new(),
112 logs: String::new(),
113 error: None,
114 }
115 }
116
117 pub fn failed(execution: TestExecution, error: String, logs: String) -> Self {
119 TestResult {
120 execution,
121 status: TestStatus::Failed,
122 generated_files: Vec::new(),
123 mismatches: Vec::new(),
124 logs,
125 error: Some(error),
126 }
127 }
128
129 pub fn timed_out(execution: TestExecution, timeout: Duration, logs: String) -> Self {
131 TestResult {
132 execution,
133 status: TestStatus::TimedOut,
134 generated_files: Vec::new(),
135 mismatches: Vec::new(),
136 logs,
137 error: Some(format!("Test timed out after {:?}", timeout)),
138 }
139 }
140
141 pub fn skipped(execution: TestExecution, reason: String) -> Self {
143 TestResult {
144 execution,
145 status: TestStatus::Skipped,
146 generated_files: Vec::new(),
147 mismatches: Vec::new(),
148 logs: reason.clone(),
149 error: Some(reason),
150 }
151 }
152
153 pub fn add_mismatch(&mut self, mismatch: GoldenMismatch) {
155 self.mismatches.push(mismatch);
156 self.status = TestStatus::Failed;
157 }
158
159 pub fn is_success(&self) -> bool {
161 self.status == TestStatus::Passed
162 }
163
164 pub fn summary(&self) -> String {
166 format!(
167 "[{}] {} on {} ({})",
168 self.status,
169 self.execution.fixture,
170 self.execution.platform,
171 self.execution
172 .duration()
173 .map(|d| format!("{:?}", d))
174 .unwrap_or_else(|| "in progress".to_string())
175 )
176 }
177
178 pub fn to_junit_xml(&self) -> String {
180 let duration_secs = self
181 .execution
182 .duration()
183 .map(|d| d.as_secs_f64())
184 .unwrap_or(0.0);
185
186 let status_str = match self.status {
187 TestStatus::Passed => "passed",
188 TestStatus::Failed => "failed",
189 TestStatus::Skipped => "skipped",
190 TestStatus::TimedOut => "error",
191 };
192
193 let mut xml = format!(
194 r#"<testcase name="{}" classname="ggen_e2e.{}" time="{}" status="{}">"#,
195 self.execution.fixture, self.execution.platform, duration_secs, status_str
196 );
197
198 if let Some(error_msg) = &self.error {
199 xml.push_str(&format!(
200 r#"<failure message="{}">{}</failure>"#,
201 error_msg, error_msg
202 ));
203 }
204
205 if !self.logs.is_empty() {
206 xml.push_str(&format!(
207 r#"<system-out>{}</system-out>"#,
208 escape_xml(&self.logs)
209 ));
210 }
211
212 for mismatch in &self.mismatches {
213 xml.push_str(&format!(
214 r#"<failure type="golden_mismatch" message="{}{}">
215{}</failure>"#,
216 mismatch.file.display(),
217 "",
218 escape_xml(&mismatch.diff)
219 ));
220 }
221
222 xml.push_str("</testcase>");
223 xml
224 }
225}
226
227impl std::fmt::Display for TestResult {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 write!(f, "{}", self.summary())
230 }
231}
232
233fn escape_xml(s: &str) -> String {
235 s.replace('&', "&")
236 .replace('<', "<")
237 .replace('>', ">")
238 .replace('"', """)
239 .replace('\'', "'")
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn test_test_status_display() {
248 assert_eq!(TestStatus::Passed.to_string(), "PASSED");
249 assert_eq!(TestStatus::Failed.to_string(), "FAILED");
250 assert_eq!(TestStatus::Skipped.to_string(), "SKIPPED");
251 assert_eq!(TestStatus::TimedOut.to_string(), "TIMED_OUT");
252 }
253
254 #[test]
255 fn test_execution_creation() {
256 let platform = Platform {
257 name: "test".to_string(),
258 os: crate::platform::Os::Linux,
259 arch: crate::platform::Arch::X86_64,
260 docker_available: true,
261 };
262 let exec = TestExecution::new("test_fixture", platform);
263
264 assert_eq!(exec.fixture, "test_fixture");
265 assert!(exec.ended_at.is_none());
266 assert!(exec.container_id.is_none());
267 }
268
269 #[test]
270 fn test_execution_duration() {
271 let platform = Platform {
272 name: "test".to_string(),
273 os: crate::platform::Os::Linux,
274 arch: crate::platform::Arch::X86_64,
275 docker_available: true,
276 };
277 let mut exec = TestExecution::new("test", platform);
278 assert!(exec.duration().is_none());
279
280 exec.finish();
281 assert!(exec.duration().is_some());
282 }
283
284 #[test]
285 fn test_result_passed() {
286 let platform = Platform {
287 name: "test".to_string(),
288 os: crate::platform::Os::Linux,
289 arch: crate::platform::Arch::X86_64,
290 docker_available: true,
291 };
292 let exec = TestExecution::new("test", platform);
293 let files = vec![PathBuf::from("output.txt")];
294 let result = TestResult::passed(exec, files);
295
296 assert_eq!(result.status, TestStatus::Passed);
297 assert!(result.is_success());
298 assert_eq!(result.generated_files.len(), 1);
299 }
300
301 #[test]
302 fn test_result_failed() {
303 let platform = Platform {
304 name: "test".to_string(),
305 os: crate::platform::Os::Linux,
306 arch: crate::platform::Arch::X86_64,
307 docker_available: true,
308 };
309 let exec = TestExecution::new("test", platform);
310 let result = TestResult::failed(exec, "error".to_string(), "logs".to_string());
311
312 assert_eq!(result.status, TestStatus::Failed);
313 assert!(!result.is_success());
314 }
315
316 #[test]
317 fn test_escape_xml() {
318 assert_eq!(escape_xml("a&b"), "a&b");
319 assert_eq!(escape_xml("a<b>c"), "a<b>c");
320 assert_eq!(escape_xml(r#"a"b'c"#), "a"b'c");
321 }
322
323 #[test]
324 fn test_to_junit_xml() {
325 let platform = Platform {
326 name: "test".to_string(),
327 os: crate::platform::Os::Linux,
328 arch: crate::platform::Arch::X86_64,
329 docker_available: true,
330 };
331 let exec = TestExecution::new("test_case", platform);
332 let result = TestResult::passed(exec, vec![]);
333
334 let xml = result.to_junit_xml();
335 assert!(xml.contains("test_case"));
336 assert!(xml.contains("testcase"));
337 }
338}