1use crate::error::Result;
4use crate::models::{FlakinessRecord, StabilityState, TestOutcome};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8use tracing::debug;
9
10impl StabilityState {
11 pub fn transition(self, outcome: &TestOutcome, prev_outcome: Option<&TestOutcome>) -> Self {
15 match self {
16 StabilityState::Unknown => match outcome {
17 TestOutcome::Passed => StabilityState::Stable {
18 consecutive_passes: 1,
19 },
20 TestOutcome::Failed | TestOutcome::Error => StabilityState::Unstable {
21 consecutive_failures: 1,
22 },
23 _ => StabilityState::Unknown,
25 },
26 StabilityState::Stable { consecutive_passes } => match outcome {
27 TestOutcome::Passed => StabilityState::Stable {
28 consecutive_passes: consecutive_passes + 1,
29 },
30 TestOutcome::Failed | TestOutcome::Error => {
31 StabilityState::Flaky { streak_count: 1 }
33 }
34 _ => StabilityState::Unknown,
35 },
36 StabilityState::Unstable {
37 consecutive_failures,
38 } => match outcome {
39 TestOutcome::Passed => {
40 StabilityState::Flaky { streak_count: 1 }
42 }
43 TestOutcome::Failed | TestOutcome::Error => StabilityState::Unstable {
44 consecutive_failures: consecutive_failures + 1,
45 },
46 _ => StabilityState::Unknown,
47 },
48 StabilityState::Flaky { streak_count } => {
49 let is_flip = prev_outcome.map_or(false, |prev| {
51 let prev_is_fail = matches!(prev, TestOutcome::Failed | TestOutcome::Error);
52 let curr_is_fail = matches!(outcome, TestOutcome::Failed | TestOutcome::Error);
53 prev_is_fail != curr_is_fail
54 });
55
56 if is_flip {
57 let new_streak = streak_count + 1;
58 if new_streak >= 3 {
59 StabilityState::ConfirmedFlaky
61 } else {
62 StabilityState::Flaky {
63 streak_count: new_streak,
64 }
65 }
66 } else {
67 match outcome {
69 TestOutcome::Passed => StabilityState::Stable {
70 consecutive_passes: 1,
71 },
72 TestOutcome::Failed | TestOutcome::Error => StabilityState::Unstable {
73 consecutive_failures: 1,
74 },
75 _ => StabilityState::Unknown,
76 }
77 }
78 }
79 StabilityState::ConfirmedFlaky => {
80 match outcome {
82 TestOutcome::Passed => {
83 let prev_was_pass =
85 prev_outcome.map_or(false, |prev| matches!(prev, TestOutcome::Passed));
86 if prev_was_pass {
87 StabilityState::Stable {
89 consecutive_passes: 2, }
91 } else {
92 StabilityState::ConfirmedFlaky
93 }
94 }
95 TestOutcome::Failed | TestOutcome::Error => {
96 let prev_was_fail = prev_outcome.map_or(false, |prev| {
97 matches!(prev, TestOutcome::Failed | TestOutcome::Error)
98 });
99 if prev_was_fail {
100 StabilityState::Unstable {
101 consecutive_failures: 2,
102 }
103 } else {
104 StabilityState::ConfirmedFlaky
105 }
106 }
107 _ => StabilityState::ConfirmedFlaky,
108 }
109 }
110 }
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct FlakinessTracker {
117 records: HashMap<String, FlakinessRecord>,
119 max_outcomes: usize,
121 storage_path: Option<PathBuf>,
123 #[serde(skip)]
125 dirty: bool,
126}
127
128impl FlakinessTracker {
129 pub fn new(storage_path: Option<PathBuf>) -> Self {
131 let mut tracker = FlakinessTracker {
132 records: HashMap::new(),
133 max_outcomes: 20,
134 storage_path,
135 dirty: false,
136 };
137
138 if let Some(ref path) = tracker.storage_path {
140 if path.exists() {
141 let _ = tracker.load();
142 }
143 }
144
145 tracker
146 }
147
148 pub fn record_outcome(&mut self, node_id: &str, outcome: TestOutcome, message: Option<&str>) {
152 let record = self
153 .records
154 .entry(node_id.to_string())
155 .or_insert_with(|| FlakinessRecord {
156 node_id: node_id.to_string(),
157 outcomes: Vec::new(),
158 consecutive_failures: 0,
159 consecutive_passes: 0,
160 flaky_streak: 0,
161 total_runs: 0,
162 last_failure_message: None,
163 stability: StabilityState::Unknown,
164 });
165
166 let prev_outcome = record.outcomes.last().cloned();
167
168 record.outcomes.push(outcome.clone().into());
169
170 if record.outcomes.len() > self.max_outcomes {
172 record.outcomes = record.outcomes[record.outcomes.len() - self.max_outcomes..].to_vec();
173 }
174
175 record.total_runs += 1;
176
177 let prev_for_transition = prev_outcome.as_ref().and_then(|s| match s.as_str() {
179 "passed" => Some(TestOutcome::Passed),
180 "failed" => Some(TestOutcome::Failed),
181 "error" => Some(TestOutcome::Error),
182 "skipped" => Some(TestOutcome::Skipped),
183 "xfail" => Some(TestOutcome::Xfail),
184 "xpass" => Some(TestOutcome::Xpass),
185 _ => None,
186 });
187
188 let old_stability = std::mem::replace(
189 &mut record.stability,
190 StabilityState::Unknown, );
192 record.stability = old_stability.transition(&outcome, prev_for_transition.as_ref());
193
194 match outcome {
196 TestOutcome::Failed | TestOutcome::Error => {
197 record.consecutive_failures += 1;
198 record.consecutive_passes = 0;
199 record.last_failure_message = message.map(|s| s.to_string());
200 }
201 TestOutcome::Passed => {
202 record.consecutive_passes += 1;
203 record.consecutive_failures = 0;
204 }
205 _ => {
206 record.consecutive_failures = 0;
208 record.consecutive_passes = 0;
209 }
210 }
211
212 if let Some(ref prev) = prev_outcome {
214 let prev_str = prev.as_str();
215 let prev_is_fail = prev_str == "failed" || prev_str == "error";
216 let curr_is_fail = matches!(outcome, TestOutcome::Failed | TestOutcome::Error);
217 if prev_is_fail != curr_is_fail {
218 record.flaky_streak += 1;
219 }
220 }
221
222 self.dirty = true;
224 }
225
226 pub fn get_record(&self, node_id: &str) -> Option<&FlakinessRecord> {
228 self.records.get(node_id)
229 }
230
231 pub fn is_flaky(&self, node_id: &str) -> bool {
233 self.records
234 .get(node_id)
235 .map_or(false, |r| r.stability.is_flaky())
236 }
237
238 pub fn stability_state(&self, node_id: &str) -> StabilityState {
240 self.records
241 .get(node_id)
242 .map_or(StabilityState::Unknown, |r| r.stability.clone())
243 }
244
245 fn check_is_flaky(record: &FlakinessRecord) -> bool {
247 if record.outcomes.len() < 3 {
248 return false;
249 }
250 let has_pass = record.outcomes.iter().any(|o| o == "passed");
251 let has_fail = record
252 .outcomes
253 .iter()
254 .any(|o| o == "failed" || o == "error");
255 has_pass && has_fail && record.flaky_streak >= 2
256 }
257
258 pub fn get_failure_rate(&self, node_id: &str) -> f64 {
260 if let Some(record) = self.records.get(node_id) {
261 if record.outcomes.is_empty() {
262 return 0.0;
263 }
264 let failures: usize = record
265 .outcomes
266 .iter()
267 .filter(|o| o.as_str() == "failed" || o.as_str() == "error")
268 .count();
269 failures as f64 / record.outcomes.len() as f64
270 } else {
271 0.0
272 }
273 }
274
275 pub fn get_flaky_tests(&self) -> Vec<&FlakinessRecord> {
277 self.records
278 .values()
279 .filter(|r| r.stability.is_flaky())
280 .collect()
281 }
282
283 pub fn get_unstable_tests(&self) -> Vec<&FlakinessRecord> {
285 self.records
286 .values()
287 .filter(|r| {
288 let has_fail = r
289 .outcomes
290 .iter()
291 .any(|o| o.as_str() == "failed" || o.as_str() == "error");
292 has_fail && !r.stability.is_flaky()
293 })
294 .collect()
295 }
296
297 pub fn stable_count(&self) -> usize {
299 self.records
300 .values()
301 .filter(|r| {
302 !r.stability.is_flaky()
303 && !r
304 .outcomes
305 .iter()
306 .any(|o| o.as_str() == "failed" || o.as_str() == "error")
307 })
308 .count()
309 }
310
311 pub fn total_tracked(&self) -> usize {
313 self.records.len()
314 }
315
316 pub fn save(&self) -> Result<()> {
318 if let Some(ref path) = self.storage_path {
319 if let Some(parent) = path.parent() {
320 std::fs::create_dir_all(parent)?;
321 }
322 let data: Vec<_> = self.records.values().collect();
323 let json = serde_json::to_string_pretty(&data)?;
324 std::fs::write(path, json)?;
325 debug!("Saved {} flakiness records", self.records.len());
326 }
327 Ok(())
328 }
329
330 pub fn flush_if_dirty(&mut self) -> Result<()> {
333 if self.dirty {
334 self.save()?;
335 self.dirty = false;
336 }
337 Ok(())
338 }
339
340 pub fn is_dirty(&self) -> bool {
342 self.dirty
343 }
344
345 pub fn load(&mut self) -> Result<()> {
347 if let Some(ref path) = self.storage_path {
348 if !path.exists() {
349 return Ok(());
350 }
351 let json = std::fs::read_to_string(path)?;
352 let data: Vec<FlakinessRecord> = serde_json::from_str(&json)?;
353 self.records.clear();
354 for record in data {
355 self.records.insert(record.node_id.clone(), record);
356 }
357 debug!("Loaded {} flakiness records", self.records.len());
358 }
359 Ok(())
360 }
361
362 pub fn clear(&mut self) {
364 self.records.clear();
365 if let Some(ref path) = self.storage_path {
366 let _ = std::fs::remove_file(path);
367 }
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use tempfile::TempDir;
375
376 #[test]
377 fn test_stability_state_transitions() {
378 let state = StabilityState::Unknown.transition(&TestOutcome::Passed, None);
380 assert!(matches!(
381 state,
382 StabilityState::Stable {
383 consecutive_passes: 1
384 }
385 ));
386
387 let state = StabilityState::Unknown.transition(&TestOutcome::Failed, None);
389 assert!(matches!(
390 state,
391 StabilityState::Unstable {
392 consecutive_failures: 1
393 }
394 ));
395
396 let state = StabilityState::Stable {
398 consecutive_passes: 3,
399 }
400 .transition(&TestOutcome::Failed, Some(&TestOutcome::Passed));
401 assert!(matches!(state, StabilityState::Flaky { streak_count: 1 }));
402
403 let state = StabilityState::Unstable {
405 consecutive_failures: 2,
406 }
407 .transition(&TestOutcome::Passed, Some(&TestOutcome::Failed));
408 assert!(matches!(state, StabilityState::Flaky { streak_count: 1 }));
409
410 let state = StabilityState::Flaky { streak_count: 1 }
412 .transition(&TestOutcome::Failed, Some(&TestOutcome::Passed));
413 assert!(matches!(state, StabilityState::Flaky { streak_count: 2 }));
414
415 let state = StabilityState::Flaky { streak_count: 2 }
417 .transition(&TestOutcome::Passed, Some(&TestOutcome::Failed));
418 assert!(matches!(state, StabilityState::ConfirmedFlaky));
419
420 let state = StabilityState::Flaky { streak_count: 1 }
422 .transition(&TestOutcome::Passed, Some(&TestOutcome::Passed));
423 assert!(matches!(
424 state,
425 StabilityState::Stable {
426 consecutive_passes: 1
427 }
428 ));
429
430 let state = StabilityState::Flaky { streak_count: 1 }
432 .transition(&TestOutcome::Failed, Some(&TestOutcome::Failed));
433 assert!(matches!(
434 state,
435 StabilityState::Unstable {
436 consecutive_failures: 1
437 }
438 ));
439
440 let state = StabilityState::Stable {
442 consecutive_passes: 5,
443 }
444 .transition(&TestOutcome::Skipped, Some(&TestOutcome::Passed));
445 assert!(matches!(state, StabilityState::Unknown));
446 }
447
448 #[test]
449 fn test_record_outcome() {
450 let dir = TempDir::new().unwrap();
451 let storage_path = dir.path().join("flakiness.json");
452 let mut tracker = FlakinessTracker::new(Some(storage_path));
453
454 tracker.record_outcome("test_a", TestOutcome::Passed, None);
455 tracker.record_outcome("test_a", TestOutcome::Failed, Some("error"));
456
457 assert_eq!(tracker.total_tracked(), 1);
458 assert!(!tracker.is_flaky("test_a")); let state = tracker.stability_state("test_a");
461 assert!(matches!(state, StabilityState::Flaky { streak_count: 1 }));
462 }
463
464 #[test]
465 fn test_flaky_detection() {
466 let dir = TempDir::new().unwrap();
467 let storage_path = dir.path().join("flakiness.json");
468 let mut tracker = FlakinessTracker::new(Some(storage_path));
469
470 tracker.record_outcome("test_a", TestOutcome::Passed, None);
472 tracker.record_outcome("test_a", TestOutcome::Failed, None);
473 tracker.record_outcome("test_a", TestOutcome::Passed, None);
474 tracker.record_outcome("test_a", TestOutcome::Failed, None);
475 tracker.record_outcome("test_a", TestOutcome::Passed, None);
476
477 assert!(tracker.is_flaky("test_a"));
478 assert_eq!(tracker.get_failure_rate("test_a"), 0.4);
479
480 let state = tracker.stability_state("test_a");
481 assert!(matches!(state, StabilityState::ConfirmedFlaky));
482 }
483
484 #[test]
485 fn test_get_flaky_tests() {
486 let dir = TempDir::new().unwrap();
487 let storage_path = dir.path().join("flakiness.json");
488 let mut tracker = FlakinessTracker::new(Some(storage_path));
489
490 tracker.record_outcome("test_flaky", TestOutcome::Passed, None);
491 tracker.record_outcome("test_flaky", TestOutcome::Failed, None);
492 tracker.record_outcome("test_flaky", TestOutcome::Passed, None);
493
494 tracker.record_outcome("test_stable", TestOutcome::Passed, None);
495 tracker.record_outcome("test_stable", TestOutcome::Passed, None);
496
497 let flaky = tracker.get_flaky_tests();
498 assert_eq!(flaky.len(), 1);
499 assert_eq!(flaky[0].node_id, "test_flaky");
500 assert!(flaky[0].stability.is_flaky());
501 }
502
503 #[test]
504 fn test_confirmed_flaky_recovery() {
505 let dir = TempDir::new().unwrap();
506 let storage_path = dir.path().join("flakiness.json");
507 let mut tracker = FlakinessTracker::new(Some(storage_path));
508
509 tracker.record_outcome("test_recover", TestOutcome::Passed, None);
511 tracker.record_outcome("test_recover", TestOutcome::Failed, None);
512 tracker.record_outcome("test_recover", TestOutcome::Passed, None);
513 tracker.record_outcome("test_recover", TestOutcome::Failed, None);
514 tracker.record_outcome("test_recover", TestOutcome::Passed, None);
515
516 assert!(tracker.is_flaky("test_recover"));
517 let state = tracker.stability_state("test_recover");
518 assert!(matches!(state, StabilityState::ConfirmedFlaky));
519
520 tracker.record_outcome("test_recover", TestOutcome::Passed, None);
522 tracker.record_outcome("test_recover", TestOutcome::Passed, None);
523 tracker.record_outcome("test_recover", TestOutcome::Passed, None);
524
525 let state = tracker.stability_state("test_recover");
526 assert!(matches!(
527 state,
528 StabilityState::Stable {
529 consecutive_passes: 4
530 }
531 ));
532 assert!(!tracker.is_flaky("test_recover"));
533 }
534
535 #[test]
536 fn test_stable_count() {
537 let dir = TempDir::new().unwrap();
538 let storage_path = dir.path().join("flakiness.json");
539 let mut tracker = FlakinessTracker::new(Some(storage_path));
540
541 tracker.record_outcome("stable1", TestOutcome::Passed, None);
542 tracker.record_outcome("stable1", TestOutcome::Passed, None);
543
544 tracker.record_outcome("flaky1", TestOutcome::Passed, None);
545 tracker.record_outcome("flaky1", TestOutcome::Failed, None);
546 tracker.record_outcome("flaky1", TestOutcome::Passed, None);
547
548 tracker.record_outcome("unstable1", TestOutcome::Failed, None);
549 tracker.record_outcome("unstable1", TestOutcome::Failed, None);
550
551 assert_eq!(tracker.stable_count(), 1);
552 assert_eq!(tracker.get_flaky_tests().len(), 1);
553 assert_eq!(tracker.get_unstable_tests().len(), 1);
554 }
555}