Skip to main content

testx/
retry.rs

1use std::time::Duration;
2
3use crate::adapters::{TestCase, TestRunResult, TestStatus, TestSuite};
4
5/// Strategy for computing delay between retries.
6#[derive(Debug, Clone, Default)]
7pub enum BackoffStrategy {
8    /// No delay between retries
9    #[default]
10    None,
11    /// Fixed delay between retries
12    Fixed(Duration),
13    /// Linear backoff: delay * attempt_number
14    Linear(Duration),
15    /// Exponential backoff: delay * 2^attempt_number, capped at max
16    Exponential { base: Duration, max: Duration },
17}
18
19impl BackoffStrategy {
20    /// Compute the delay for given attempt number (0-indexed).
21    pub fn delay_for(&self, attempt: u32) -> Duration {
22        match self {
23            BackoffStrategy::None => Duration::ZERO,
24            BackoffStrategy::Fixed(d) => *d,
25            BackoffStrategy::Linear(base) => *base * attempt,
26            BackoffStrategy::Exponential { base, max } => {
27                let multiplier = 2u64.saturating_pow(attempt);
28                let delay = base.saturating_mul(multiplier as u32);
29                if delay > *max { *max } else { delay }
30            }
31        }
32    }
33}
34
35/// Configuration for retry behavior.
36#[derive(Debug, Clone)]
37pub struct RetryConfig {
38    /// Maximum number of retries (0 = no retries)
39    pub max_retries: u32,
40    /// Backoff strategy between retries
41    pub backoff: BackoffStrategy,
42    /// Whether to stop retrying a test after it passes once
43    pub stop_on_pass: bool,
44    /// Whether to retry only failed tests (vs. retry entire suite)
45    pub retry_failed_only: bool,
46}
47
48impl RetryConfig {
49    /// Create a new retry config with the given max retries.
50    pub fn new(max_retries: u32) -> Self {
51        Self {
52            max_retries,
53            backoff: BackoffStrategy::None,
54            stop_on_pass: true,
55            retry_failed_only: true,
56        }
57    }
58
59    /// Set the backoff strategy.
60    pub fn with_backoff(mut self, backoff: BackoffStrategy) -> Self {
61        self.backoff = backoff;
62        self
63    }
64
65    /// Set whether to stop on first pass.
66    pub fn with_stop_on_pass(mut self, stop: bool) -> Self {
67        self.stop_on_pass = stop;
68        self
69    }
70
71    /// Set whether to retry failed tests only.
72    pub fn with_retry_failed_only(mut self, failed_only: bool) -> Self {
73        self.retry_failed_only = failed_only;
74        self
75    }
76
77    /// Returns true if retries are enabled.
78    pub fn is_enabled(&self) -> bool {
79        self.max_retries > 0
80    }
81}
82
83impl Default for RetryConfig {
84    fn default() -> Self {
85        Self::new(0)
86    }
87}
88
89/// Result of a single retry attempt.
90#[derive(Debug, Clone)]
91pub struct RetryAttempt {
92    /// The attempt number (1-indexed, where 1 = first retry)
93    pub attempt: u32,
94    /// Result of this attempt
95    pub result: TestRunResult,
96    /// How long this attempt took
97    pub duration: Duration,
98}
99
100/// Aggregated result of all retry attempts for a test run.
101#[derive(Debug, Clone)]
102pub struct RetryResult {
103    /// Original (first) run result
104    pub original: TestRunResult,
105    /// Results of each retry attempt
106    pub attempts: Vec<RetryAttempt>,
107    /// Final merged result after all retries
108    pub final_result: TestRunResult,
109    /// Total number of attempts (including original)
110    pub total_attempts: u32,
111}
112
113impl RetryResult {
114    /// How many tests were fixed by retries.
115    pub fn tests_fixed(&self) -> usize {
116        let original_failed = self.original.total_failed();
117        let final_failed = self.final_result.total_failed();
118        original_failed.saturating_sub(final_failed)
119    }
120
121    /// Whether all tests pass after retries.
122    pub fn all_passed(&self) -> bool {
123        self.final_result.total_failed() == 0
124    }
125
126    /// Whether retries changed the outcome.
127    pub fn had_effect(&self) -> bool {
128        self.original.total_failed() != self.final_result.total_failed()
129    }
130}
131
132/// Extract the names of failed tests from a result.
133pub fn extract_failed_tests(result: &TestRunResult) -> Vec<FailedTestInfo> {
134    let mut failed = Vec::new();
135    for suite in &result.suites {
136        for test in &suite.tests {
137            if test.status == TestStatus::Failed {
138                failed.push(FailedTestInfo {
139                    suite_name: suite.name.clone(),
140                    test_name: test.name.clone(),
141                    error_message: test.error.as_ref().map(|e| e.message.clone()),
142                });
143            }
144        }
145    }
146    failed
147}
148
149/// Information about a failed test.
150#[derive(Debug, Clone)]
151pub struct FailedTestInfo {
152    /// Name of the suite containing this test
153    pub suite_name: String,
154    /// Name of the test
155    pub test_name: String,
156    /// Error message if available
157    pub error_message: Option<String>,
158}
159
160impl FailedTestInfo {
161    /// Fully qualified test name (suite::test).
162    pub fn full_name(&self) -> String {
163        format!("{}::{}", self.suite_name, self.test_name)
164    }
165}
166
167/// Merge an original result with a retry result.
168/// Tests that passed in the retry override their failed status in the original.
169pub fn merge_retry_result(original: &TestRunResult, retry: &TestRunResult) -> TestRunResult {
170    let mut suites = Vec::new();
171
172    for orig_suite in &original.suites {
173        // Find matching suite in retry result
174        let retry_suite = retry.suites.iter().find(|s| s.name == orig_suite.name);
175
176        let tests: Vec<TestCase> = orig_suite
177            .tests
178            .iter()
179            .map(|orig_test| {
180                if orig_test.status != TestStatus::Failed {
181                    return orig_test.clone();
182                }
183
184                // Look for this test in the retry result
185                if let Some(rs) = retry_suite
186                    && let Some(retry_test) = rs.tests.iter().find(|t| t.name == orig_test.name)
187                    && retry_test.status == TestStatus::Passed
188                {
189                    // Test was fixed by retry
190                    return retry_test.clone();
191                }
192
193                orig_test.clone()
194            })
195            .collect();
196
197        suites.push(TestSuite {
198            name: orig_suite.name.clone(),
199            tests,
200        });
201    }
202
203    let exit_code = if suites.iter().all(|s| s.failed() == 0) {
204        0
205    } else {
206        original.raw_exit_code
207    };
208
209    TestRunResult {
210        suites,
211        duration: original.duration + retry.duration,
212        raw_exit_code: exit_code,
213    }
214}
215
216/// Merge results from multiple retry attempts progressively.
217pub fn merge_all_retries(original: &TestRunResult, attempts: &[RetryAttempt]) -> TestRunResult {
218    let mut merged = original.clone();
219    for attempt in attempts {
220        merged = merge_retry_result(&merged, &attempt.result);
221    }
222    merged
223}
224
225/// Build a RetryResult from an original run and retry attempts.
226pub fn build_retry_result(original: TestRunResult, attempts: Vec<RetryAttempt>) -> RetryResult {
227    let total_attempts = 1 + attempts.len() as u32;
228    let final_result = merge_all_retries(&original, &attempts);
229
230    RetryResult {
231        original,
232        attempts,
233        final_result,
234        total_attempts,
235    }
236}
237
238/// Determine which tests still need retrying after an attempt.
239pub fn tests_still_failing(
240    current: &TestRunResult,
241    failed_names: &[FailedTestInfo],
242) -> Vec<FailedTestInfo> {
243    let mut still_failing = Vec::new();
244
245    for info in failed_names {
246        let still_failed = current.suites.iter().any(|suite| {
247            suite.name == info.suite_name
248                && suite
249                    .tests
250                    .iter()
251                    .any(|t| t.name == info.test_name && t.status == TestStatus::Failed)
252        });
253
254        if still_failed {
255            still_failing.push(info.clone());
256        }
257    }
258
259    still_failing
260}
261
262/// Create a filter string from failed test names for re-running.
263pub fn failed_tests_as_filter(failed: &[FailedTestInfo]) -> String {
264    failed
265        .iter()
266        .map(|f| f.test_name.as_str())
267        .collect::<Vec<_>>()
268        .join(",")
269}
270
271/// Statistics about a retry session.
272#[derive(Debug, Clone)]
273pub struct RetryStats {
274    /// Total retry attempts made
275    pub total_retries: u32,
276    /// Total tests retried
277    pub tests_retried: usize,
278    /// Tests fixed by retries
279    pub tests_fixed: usize,
280    /// Tests still failing after all retries
281    pub tests_still_failing: usize,
282    /// Total time spent retrying
283    pub total_retry_time: Duration,
284}
285
286/// Compute retry statistics from a RetryResult.
287pub fn compute_retry_stats(result: &RetryResult) -> RetryStats {
288    let original_failed = result.original.total_failed();
289    let final_failed = result.final_result.total_failed();
290    let total_retry_time: Duration = result.attempts.iter().map(|a| a.duration).sum();
291
292    RetryStats {
293        total_retries: result.attempts.len() as u32,
294        tests_retried: original_failed,
295        tests_fixed: original_failed.saturating_sub(final_failed),
296        tests_still_failing: final_failed,
297        total_retry_time,
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn make_test(name: &str, status: TestStatus) -> TestCase {
306        TestCase {
307            name: name.into(),
308            status,
309            duration: Duration::from_millis(10),
310            error: None,
311        }
312    }
313
314    fn make_suite(name: &str, tests: Vec<TestCase>) -> TestSuite {
315        TestSuite {
316            name: name.into(),
317            tests,
318        }
319    }
320
321    fn make_result(suites: Vec<TestSuite>) -> TestRunResult {
322        TestRunResult {
323            suites,
324            duration: Duration::from_millis(100),
325            raw_exit_code: 1,
326        }
327    }
328
329    // ─── BackoffStrategy Tests ──────────────────────────────────────────
330
331    #[test]
332    fn backoff_none() {
333        let b = BackoffStrategy::None;
334        assert_eq!(b.delay_for(0), Duration::ZERO);
335        assert_eq!(b.delay_for(5), Duration::ZERO);
336    }
337
338    #[test]
339    fn backoff_fixed() {
340        let b = BackoffStrategy::Fixed(Duration::from_millis(500));
341        assert_eq!(b.delay_for(0), Duration::from_millis(500));
342        assert_eq!(b.delay_for(3), Duration::from_millis(500));
343    }
344
345    #[test]
346    fn backoff_linear() {
347        let b = BackoffStrategy::Linear(Duration::from_millis(100));
348        assert_eq!(b.delay_for(0), Duration::ZERO);
349        assert_eq!(b.delay_for(1), Duration::from_millis(100));
350        assert_eq!(b.delay_for(3), Duration::from_millis(300));
351    }
352
353    #[test]
354    fn backoff_exponential() {
355        let b = BackoffStrategy::Exponential {
356            base: Duration::from_millis(100),
357            max: Duration::from_secs(5),
358        };
359        assert_eq!(b.delay_for(0), Duration::from_millis(100)); // 100 * 2^0
360        assert_eq!(b.delay_for(1), Duration::from_millis(200)); // 100 * 2^1
361        assert_eq!(b.delay_for(2), Duration::from_millis(400)); // 100 * 2^2
362        assert_eq!(b.delay_for(3), Duration::from_millis(800)); // 100 * 2^3
363    }
364
365    #[test]
366    fn backoff_exponential_cap() {
367        let b = BackoffStrategy::Exponential {
368            base: Duration::from_secs(1),
369            max: Duration::from_secs(10),
370        };
371        assert_eq!(b.delay_for(10), Duration::from_secs(10)); // capped at max
372    }
373
374    // ─── RetryConfig Tests ──────────────────────────────────────────────
375
376    #[test]
377    fn retry_config_default() {
378        let config = RetryConfig::default();
379        assert_eq!(config.max_retries, 0);
380        assert!(!config.is_enabled());
381    }
382
383    #[test]
384    fn retry_config_enabled() {
385        let config = RetryConfig::new(3);
386        assert!(config.is_enabled());
387        assert_eq!(config.max_retries, 3);
388        assert!(config.stop_on_pass);
389        assert!(config.retry_failed_only);
390    }
391
392    #[test]
393    fn retry_config_builder() {
394        let config = RetryConfig::new(2)
395            .with_backoff(BackoffStrategy::Fixed(Duration::from_secs(1)))
396            .with_stop_on_pass(false)
397            .with_retry_failed_only(false);
398
399        assert_eq!(config.max_retries, 2);
400        assert!(!config.stop_on_pass);
401        assert!(!config.retry_failed_only);
402    }
403
404    // ─── Extract Failed Tests ───────────────────────────────────────────
405
406    #[test]
407    fn extract_failed_test_info() {
408        let result = make_result(vec![make_suite(
409            "unit",
410            vec![
411                make_test("test_add", TestStatus::Passed),
412                make_test("test_div", TestStatus::Failed),
413                make_test("test_mul", TestStatus::Passed),
414            ],
415        )]);
416
417        let failed = extract_failed_tests(&result);
418        assert_eq!(failed.len(), 1);
419        assert_eq!(failed[0].test_name, "test_div");
420        assert_eq!(failed[0].suite_name, "unit");
421        assert_eq!(failed[0].full_name(), "unit::test_div");
422    }
423
424    #[test]
425    fn extract_failed_multiple_suites() {
426        let result = make_result(vec![
427            make_suite(
428                "math",
429                vec![
430                    make_test("test_add", TestStatus::Failed),
431                    make_test("test_sub", TestStatus::Passed),
432                ],
433            ),
434            make_suite(
435                "strings",
436                vec![make_test("test_concat", TestStatus::Failed)],
437            ),
438        ]);
439
440        let failed = extract_failed_tests(&result);
441        assert_eq!(failed.len(), 2);
442        assert_eq!(failed[0].test_name, "test_add");
443        assert_eq!(failed[1].test_name, "test_concat");
444    }
445
446    #[test]
447    fn extract_failed_none() {
448        let result = make_result(vec![make_suite(
449            "unit",
450            vec![make_test("test", TestStatus::Passed)],
451        )]);
452
453        let failed = extract_failed_tests(&result);
454        assert!(failed.is_empty());
455    }
456
457    // ─── Merge Tests ────────────────────────────────────────────────────
458
459    #[test]
460    fn merge_retry_fixes_test() {
461        let original = make_result(vec![make_suite(
462            "unit",
463            vec![
464                make_test("test_a", TestStatus::Passed),
465                make_test("test_b", TestStatus::Failed),
466            ],
467        )]);
468
469        let retry = make_result(vec![make_suite(
470            "unit",
471            vec![make_test("test_b", TestStatus::Passed)],
472        )]);
473
474        let merged = merge_retry_result(&original, &retry);
475        assert_eq!(merged.total_passed(), 2);
476        assert_eq!(merged.total_failed(), 0);
477        assert_eq!(merged.raw_exit_code, 0); // changed to 0 because all pass
478    }
479
480    #[test]
481    fn merge_retry_still_fails() {
482        let original = make_result(vec![make_suite(
483            "unit",
484            vec![make_test("test_b", TestStatus::Failed)],
485        )]);
486
487        let retry = make_result(vec![make_suite(
488            "unit",
489            vec![make_test("test_b", TestStatus::Failed)],
490        )]);
491
492        let merged = merge_retry_result(&original, &retry);
493        assert_eq!(merged.total_failed(), 1);
494        assert_eq!(merged.raw_exit_code, 1);
495    }
496
497    #[test]
498    fn merge_retry_partial_fix() {
499        let original = make_result(vec![make_suite(
500            "unit",
501            vec![
502                make_test("test_a", TestStatus::Failed),
503                make_test("test_b", TestStatus::Failed),
504            ],
505        )]);
506
507        let retry = make_result(vec![make_suite(
508            "unit",
509            vec![
510                make_test("test_a", TestStatus::Passed),
511                make_test("test_b", TestStatus::Failed),
512            ],
513        )]);
514
515        let merged = merge_retry_result(&original, &retry);
516        assert_eq!(merged.total_passed(), 1);
517        assert_eq!(merged.total_failed(), 1);
518    }
519
520    #[test]
521    fn merge_no_matching_suite() {
522        let original = make_result(vec![make_suite(
523            "unit",
524            vec![make_test("test_a", TestStatus::Failed)],
525        )]);
526
527        let retry = make_result(vec![make_suite(
528            "other",
529            vec![make_test("test_a", TestStatus::Passed)],
530        )]);
531
532        let merged = merge_retry_result(&original, &retry);
533        // No match, original status preserved
534        assert_eq!(merged.total_failed(), 1);
535    }
536
537    // ─── Merge All Retries ──────────────────────────────────────────────
538
539    #[test]
540    fn merge_all_progressive() {
541        let original = make_result(vec![make_suite(
542            "unit",
543            vec![
544                make_test("test_a", TestStatus::Failed),
545                make_test("test_b", TestStatus::Failed),
546                make_test("test_c", TestStatus::Failed),
547            ],
548        )]);
549
550        let attempt1 = RetryAttempt {
551            attempt: 1,
552            result: make_result(vec![make_suite(
553                "unit",
554                vec![make_test("test_a", TestStatus::Passed)],
555            )]),
556            duration: Duration::from_millis(50),
557        };
558
559        let attempt2 = RetryAttempt {
560            attempt: 2,
561            result: make_result(vec![make_suite(
562                "unit",
563                vec![make_test("test_b", TestStatus::Passed)],
564            )]),
565            duration: Duration::from_millis(50),
566        };
567
568        let merged = merge_all_retries(&original, &[attempt1, attempt2]);
569        assert_eq!(merged.total_passed(), 2);
570        assert_eq!(merged.total_failed(), 1); // test_c still fails
571    }
572
573    // ─── RetryResult Tests ─────────────────────────────────────────────
574
575    #[test]
576    fn retry_result_stats() {
577        let original = make_result(vec![make_suite(
578            "unit",
579            vec![
580                make_test("test_a", TestStatus::Failed),
581                make_test("test_b", TestStatus::Failed),
582            ],
583        )]);
584
585        let attempt = RetryAttempt {
586            attempt: 1,
587            result: make_result(vec![make_suite(
588                "unit",
589                vec![make_test("test_a", TestStatus::Passed)],
590            )]),
591            duration: Duration::from_millis(50),
592        };
593
594        let retry_result = build_retry_result(original, vec![attempt]);
595
596        assert_eq!(retry_result.total_attempts, 2);
597        assert_eq!(retry_result.tests_fixed(), 1);
598        assert!(!retry_result.all_passed());
599        assert!(retry_result.had_effect());
600    }
601
602    #[test]
603    fn retry_result_all_fixed() {
604        let original = make_result(vec![make_suite(
605            "unit",
606            vec![make_test("test_a", TestStatus::Failed)],
607        )]);
608
609        let attempt = RetryAttempt {
610            attempt: 1,
611            result: make_result(vec![make_suite(
612                "unit",
613                vec![make_test("test_a", TestStatus::Passed)],
614            )]),
615            duration: Duration::from_millis(50),
616        };
617
618        let retry_result = build_retry_result(original, vec![attempt]);
619        assert!(retry_result.all_passed());
620        assert!(retry_result.had_effect());
621    }
622
623    #[test]
624    fn retry_result_no_effect() {
625        let original = make_result(vec![make_suite(
626            "unit",
627            vec![make_test("test_a", TestStatus::Failed)],
628        )]);
629
630        let attempt = RetryAttempt {
631            attempt: 1,
632            result: make_result(vec![make_suite(
633                "unit",
634                vec![make_test("test_a", TestStatus::Failed)],
635            )]),
636            duration: Duration::from_millis(50),
637        };
638
639        let retry_result = build_retry_result(original, vec![attempt]);
640        assert!(!retry_result.had_effect());
641    }
642
643    // ─── Still Failing Tests ────────────────────────────────────────────
644
645    #[test]
646    fn tests_still_failing_some() {
647        let current = make_result(vec![make_suite(
648            "unit",
649            vec![
650                make_test("test_a", TestStatus::Passed),
651                make_test("test_b", TestStatus::Failed),
652            ],
653        )]);
654
655        let failed = vec![
656            FailedTestInfo {
657                suite_name: "unit".into(),
658                test_name: "test_a".into(),
659                error_message: None,
660            },
661            FailedTestInfo {
662                suite_name: "unit".into(),
663                test_name: "test_b".into(),
664                error_message: None,
665            },
666        ];
667
668        let still = tests_still_failing(&current, &failed);
669        assert_eq!(still.len(), 1);
670        assert_eq!(still[0].test_name, "test_b");
671    }
672
673    #[test]
674    fn tests_still_failing_none() {
675        let current = make_result(vec![make_suite(
676            "unit",
677            vec![make_test("test_a", TestStatus::Passed)],
678        )]);
679
680        let failed = vec![FailedTestInfo {
681            suite_name: "unit".into(),
682            test_name: "test_a".into(),
683            error_message: None,
684        }];
685
686        let still = tests_still_failing(&current, &failed);
687        assert!(still.is_empty());
688    }
689
690    // ─── Filter String ──────────────────────────────────────────────────
691
692    #[test]
693    fn failed_as_filter_string() {
694        let failed = vec![
695            FailedTestInfo {
696                suite_name: "unit".into(),
697                test_name: "test_a".into(),
698                error_message: None,
699            },
700            FailedTestInfo {
701                suite_name: "unit".into(),
702                test_name: "test_b".into(),
703                error_message: None,
704            },
705        ];
706
707        let filter = failed_tests_as_filter(&failed);
708        assert_eq!(filter, "test_a,test_b");
709    }
710
711    #[test]
712    fn failed_as_filter_empty() {
713        let filter = failed_tests_as_filter(&[]);
714        assert_eq!(filter, "");
715    }
716
717    // ─── Compute Stats ─────────────────────────────────────────────────
718
719    #[test]
720    fn compute_stats_basic() {
721        let original = make_result(vec![make_suite(
722            "unit",
723            vec![
724                make_test("test_a", TestStatus::Failed),
725                make_test("test_b", TestStatus::Failed),
726            ],
727        )]);
728
729        let attempt = RetryAttempt {
730            attempt: 1,
731            result: make_result(vec![make_suite(
732                "unit",
733                vec![make_test("test_a", TestStatus::Passed)],
734            )]),
735            duration: Duration::from_millis(200),
736        };
737
738        let retry_result = build_retry_result(original, vec![attempt]);
739        let stats = compute_retry_stats(&retry_result);
740
741        assert_eq!(stats.total_retries, 1);
742        assert_eq!(stats.tests_retried, 2);
743        assert_eq!(stats.tests_fixed, 1);
744        assert_eq!(stats.tests_still_failing, 1);
745        assert_eq!(stats.total_retry_time, Duration::from_millis(200));
746    }
747
748    #[test]
749    fn compute_stats_multiple_attempts() {
750        let original = make_result(vec![make_suite(
751            "unit",
752            vec![
753                make_test("a", TestStatus::Failed),
754                make_test("b", TestStatus::Failed),
755                make_test("c", TestStatus::Failed),
756            ],
757        )]);
758
759        let a1 = RetryAttempt {
760            attempt: 1,
761            result: make_result(vec![make_suite(
762                "unit",
763                vec![make_test("a", TestStatus::Passed)],
764            )]),
765            duration: Duration::from_millis(100),
766        };
767
768        let a2 = RetryAttempt {
769            attempt: 2,
770            result: make_result(vec![make_suite(
771                "unit",
772                vec![make_test("b", TestStatus::Passed)],
773            )]),
774            duration: Duration::from_millis(100),
775        };
776
777        let retry_result = build_retry_result(original, vec![a1, a2]);
778        let stats = compute_retry_stats(&retry_result);
779
780        assert_eq!(stats.total_retries, 2);
781        assert_eq!(stats.tests_retried, 3);
782        assert_eq!(stats.tests_fixed, 2);
783        assert_eq!(stats.tests_still_failing, 1);
784        assert_eq!(stats.total_retry_time, Duration::from_millis(200));
785    }
786}