Skip to main content

nextest_runner/config/elements/
retry_policy.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use serde::{Deserialize, Serialize};
5use std::{cmp::Ordering, fmt, time::Duration};
6
7/// Type for the retry config key.
8#[derive(Debug, Copy, Clone, PartialEq, Eq)]
9pub enum RetryPolicy {
10    /// Fixed backoff.
11    Fixed {
12        /// Maximum retry count.
13        count: u32,
14
15        /// Delay between retries.
16        delay: Duration,
17
18        /// If set to true, randomness will be added to the delay on each retry attempt.
19        jitter: bool,
20    },
21
22    /// Exponential backoff.
23    Exponential {
24        /// Maximum retry count.
25        count: u32,
26
27        /// Delay between retries. Not optional for exponential backoff.
28        delay: Duration,
29
30        /// If set to true, randomness will be added to the delay on each retry attempt.
31        jitter: bool,
32
33        /// If set, limits the delay between retries.
34        max_delay: Option<Duration>,
35    },
36}
37
38impl Default for RetryPolicy {
39    #[inline]
40    fn default() -> Self {
41        Self::new_without_delay(0)
42    }
43}
44
45impl RetryPolicy {
46    /// Create new policy with no delay between retries.
47    pub fn new_without_delay(count: u32) -> Self {
48        Self::Fixed {
49            count,
50            delay: Duration::ZERO,
51            jitter: false,
52        }
53    }
54
55    /// Returns the number of retries.
56    pub fn count(&self) -> u32 {
57        match self {
58            Self::Fixed { count, .. } | Self::Exponential { count, .. } => *count,
59        }
60    }
61}
62
63/// Controls whether a flaky test is treated as a pass or a failure.
64#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
65#[serde(rename_all = "kebab-case")]
66#[cfg_attr(test, derive(test_strategy::Arbitrary))]
67pub enum FlakyResult {
68    /// The test is marked as failed.
69    Fail,
70
71    /// The test is marked as passed.
72    #[default]
73    Pass,
74}
75
76impl FlakyResult {
77    /// Returns a message describing a flaky failure, or `None` if the result is
78    /// `Pass`.
79    ///
80    /// Used by both JUnit and Chrome trace output to produce a consistent
81    /// message.
82    pub fn fail_message(self, attempt: u32, total_attempts: u32) -> Option<String> {
83        match self {
84            Self::Fail => Some(format!(
85                "test passed on attempt {attempt}/{total_attempts} \
86                 but is configured to fail when flaky",
87            )),
88            Self::Pass => None,
89        }
90    }
91}
92
93/// Serde-compatible intermediate type for the `retries` config field. After
94/// deserialization, this is converted into a `RetryPolicy`.
95#[derive(Debug, Copy, Clone, Deserialize)]
96#[serde(tag = "backoff", rename_all = "kebab-case", deny_unknown_fields)]
97enum RetryPolicySerde {
98    #[serde(rename_all = "kebab-case")]
99    Fixed {
100        count: u32,
101        #[serde(default, with = "humantime_serde")]
102        delay: Duration,
103        #[serde(default)]
104        jitter: bool,
105    },
106    #[serde(rename_all = "kebab-case")]
107    Exponential {
108        count: u32,
109        #[serde(with = "humantime_serde")]
110        delay: Duration,
111        #[serde(default)]
112        jitter: bool,
113        #[serde(default, with = "humantime_serde")]
114        max_delay: Option<Duration>,
115    },
116}
117
118impl RetryPolicySerde {
119    fn into_policy(self) -> RetryPolicy {
120        match self {
121            RetryPolicySerde::Fixed {
122                count,
123                delay,
124                jitter,
125            } => RetryPolicy::Fixed {
126                count,
127                delay,
128                jitter,
129            },
130            RetryPolicySerde::Exponential {
131                count,
132                delay,
133                jitter,
134                max_delay,
135            } => RetryPolicy::Exponential {
136                count,
137                delay,
138                jitter,
139                max_delay,
140            },
141        }
142    }
143}
144
145pub(in crate::config) fn deserialize_retry_policy<'de, D>(
146    deserializer: D,
147) -> Result<Option<RetryPolicy>, D::Error>
148where
149    D: serde::Deserializer<'de>,
150{
151    struct V;
152
153    impl<'de2> serde::de::Visitor<'de2> for V {
154        type Value = Option<RetryPolicy>;
155
156        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
157            write!(
158                formatter,
159                "a table ({{ backoff = \"fixed\", count = 5 }}) or a number (5)"
160            )
161        }
162
163        // Note that TOML uses i64, not u64.
164        fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
165        where
166            E: serde::de::Error,
167        {
168            match v.cmp(&0) {
169                Ordering::Greater | Ordering::Equal => {
170                    let v = u32::try_from(v).map_err(|_| {
171                        serde::de::Error::invalid_value(
172                            serde::de::Unexpected::Signed(v),
173                            &"a positive u32",
174                        )
175                    })?;
176                    Ok(Some(RetryPolicy::new_without_delay(v)))
177                }
178                Ordering::Less => Err(serde::de::Error::invalid_value(
179                    serde::de::Unexpected::Signed(v),
180                    &self,
181                )),
182            }
183        }
184
185        fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
186        where
187            A: serde::de::MapAccess<'de2>,
188        {
189            RetryPolicySerde::deserialize(serde::de::value::MapAccessDeserializer::new(map))
190                .map(|s| Some(s.into_policy()))
191        }
192    }
193
194    // Post-deserialize validation of retry policy.
195    let policy = deserializer.deserialize_any(V)?;
196    match &policy {
197        Some(RetryPolicy::Fixed {
198            count: _,
199            delay,
200            jitter,
201        })
202            // Jitter can't be specified if delay is 0.
203            if delay.is_zero() && *jitter =>
204        {
205            return Err(serde::de::Error::custom(
206                "`jitter` cannot be true if `delay` isn't specified or is zero",
207            ));
208        }
209        Some(RetryPolicy::Fixed { .. }) => {}
210        Some(RetryPolicy::Exponential {
211            count,
212            delay,
213            jitter: _,
214            max_delay,
215        }) => {
216            // Count can't be zero.
217            if *count == 0 {
218                return Err(serde::de::Error::custom(
219                    "`count` cannot be zero with exponential backoff",
220                ));
221            }
222            // Delay can't be zero.
223            if delay.is_zero() {
224                return Err(serde::de::Error::custom(
225                    "`delay` cannot be zero with exponential backoff",
226                ));
227            }
228            // Max delay, if specified, can't be zero.
229            if max_delay.is_some_and(|f| f.is_zero()) {
230                return Err(serde::de::Error::custom(
231                    "`max-delay` cannot be zero with exponential backoff",
232                ));
233            }
234            // Max delay can't be less than delay.
235            if max_delay.is_some_and(|max_delay| max_delay < *delay) {
236                return Err(serde::de::Error::custom(
237                    "`max-delay` cannot be less than delay with exponential backoff",
238                ));
239            }
240        }
241        None => {}
242    }
243
244    Ok(policy)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::{
251        config::{core::NextestConfig, utils::test_helpers::*},
252        errors::ConfigParseErrorKind,
253        run_mode::NextestRunMode,
254    };
255    use camino_tempfile::tempdir;
256    use config::ConfigError;
257    use guppy::graph::cargo::BuildPlatform;
258    use indoc::indoc;
259    use nextest_filtering::{ParseContext, TestQuery};
260    use nextest_metadata::TestCaseName;
261    use test_case::test_case;
262
263    #[test]
264    fn parse_retries_valid() {
265        let config_contents = indoc! {r#"
266            [profile.default]
267            retries = { backoff = "fixed", count = 3 }
268
269            [profile.no-retries]
270            retries = 0
271
272            [profile.fixed-with-delay]
273            retries = { backoff = "fixed", count = 3, delay = "1s" }
274
275            [profile.exp]
276            retries = { backoff = "exponential", count = 4, delay = "2s" }
277
278            [profile.exp-with-max-delay]
279            retries = { backoff = "exponential", count = 5, delay = "3s", max-delay = "10s" }
280
281            [profile.exp-with-max-delay-and-jitter]
282            retries = { backoff = "exponential", count = 6, delay = "4s", max-delay = "1m", jitter = true }
283
284            [profile.with-flaky-result-fail]
285            retries = { backoff = "fixed", count = 2 }
286            flaky-result = "fail"
287
288            [profile.with-flaky-result-pass]
289            retries = { backoff = "fixed", count = 2 }
290            flaky-result = "pass"
291
292            [profile.exp-with-flaky-result-fail]
293            retries = { backoff = "exponential", count = 3, delay = "1s" }
294            flaky-result = "fail"
295
296            [profile.flaky-result-only]
297            flaky-result = "fail"
298        "#};
299
300        let workspace_dir = tempdir().unwrap();
301
302        let graph = temp_workspace(&workspace_dir, config_contents);
303        let pcx = ParseContext::new(&graph);
304
305        let config = NextestConfig::from_sources(
306            graph.workspace().root(),
307            &pcx,
308            None,
309            [],
310            &Default::default(),
311        )
312        .expect("config is valid");
313
314        let default_profile = config
315            .profile("default")
316            .expect("default profile exists")
317            .apply_build_platforms(&build_platforms());
318        assert_eq!(
319            default_profile.retries(),
320            RetryPolicy::Fixed {
321                count: 3,
322                delay: Duration::ZERO,
323                jitter: false,
324            },
325            "default retries matches"
326        );
327        assert_eq!(
328            default_profile.flaky_result(),
329            FlakyResult::Pass,
330            "default flaky_result matches"
331        );
332
333        assert_eq!(
334            config
335                .profile("no-retries")
336                .expect("profile exists")
337                .apply_build_platforms(&build_platforms())
338                .retries(),
339            RetryPolicy::new_without_delay(0),
340            "no-retries retries matches"
341        );
342
343        assert_eq!(
344            config
345                .profile("fixed-with-delay")
346                .expect("profile exists")
347                .apply_build_platforms(&build_platforms())
348                .retries(),
349            RetryPolicy::Fixed {
350                count: 3,
351                delay: Duration::from_secs(1),
352                jitter: false,
353            },
354            "fixed-with-delay retries matches"
355        );
356
357        assert_eq!(
358            config
359                .profile("exp")
360                .expect("profile exists")
361                .apply_build_platforms(&build_platforms())
362                .retries(),
363            RetryPolicy::Exponential {
364                count: 4,
365                delay: Duration::from_secs(2),
366                jitter: false,
367                max_delay: None,
368            },
369            "exp retries matches"
370        );
371
372        assert_eq!(
373            config
374                .profile("exp-with-max-delay")
375                .expect("profile exists")
376                .apply_build_platforms(&build_platforms())
377                .retries(),
378            RetryPolicy::Exponential {
379                count: 5,
380                delay: Duration::from_secs(3),
381                jitter: false,
382                max_delay: Some(Duration::from_secs(10)),
383            },
384            "exp-with-max-delay retries matches"
385        );
386
387        assert_eq!(
388            config
389                .profile("exp-with-max-delay-and-jitter")
390                .expect("profile exists")
391                .apply_build_platforms(&build_platforms())
392                .retries(),
393            RetryPolicy::Exponential {
394                count: 6,
395                delay: Duration::from_secs(4),
396                jitter: true,
397                max_delay: Some(Duration::from_secs(60)),
398            },
399            "exp-with-max-delay-and-jitter retries matches"
400        );
401
402        let with_flaky_result_fail = config
403            .profile("with-flaky-result-fail")
404            .expect("profile exists")
405            .apply_build_platforms(&build_platforms());
406        assert_eq!(
407            with_flaky_result_fail.retries(),
408            RetryPolicy::new_without_delay(2),
409            "with-flaky-result-fail retries matches"
410        );
411        assert_eq!(
412            with_flaky_result_fail.flaky_result(),
413            FlakyResult::Fail,
414            "with-flaky-result-fail flaky_result matches"
415        );
416
417        let with_flaky_result_pass = config
418            .profile("with-flaky-result-pass")
419            .expect("profile exists")
420            .apply_build_platforms(&build_platforms());
421        assert_eq!(
422            with_flaky_result_pass.retries(),
423            RetryPolicy::new_without_delay(2),
424            "with-flaky-result-pass retries matches"
425        );
426        assert_eq!(
427            with_flaky_result_pass.flaky_result(),
428            FlakyResult::Pass,
429            "with-flaky-result-pass flaky_result matches"
430        );
431
432        let exp_with_flaky_result_fail = config
433            .profile("exp-with-flaky-result-fail")
434            .expect("profile exists")
435            .apply_build_platforms(&build_platforms());
436        assert_eq!(
437            exp_with_flaky_result_fail.retries(),
438            RetryPolicy::Exponential {
439                count: 3,
440                delay: Duration::from_secs(1),
441                jitter: false,
442                max_delay: None,
443            },
444            "exp-with-flaky-result-fail retries matches"
445        );
446        assert_eq!(
447            exp_with_flaky_result_fail.flaky_result(),
448            FlakyResult::Fail,
449            "exp-with-flaky-result-fail flaky_result matches"
450        );
451
452        // flaky-result-only: retries inherited from default (count=3), flaky
453        // result set to fail.
454        let flaky_result_only = config
455            .profile("flaky-result-only")
456            .expect("profile exists")
457            .apply_build_platforms(&build_platforms());
458        assert_eq!(
459            flaky_result_only.retries(),
460            RetryPolicy::Fixed {
461                count: 3,
462                delay: Duration::ZERO,
463                jitter: false,
464            },
465            "flaky-result-only retries inherited from default"
466        );
467        assert_eq!(
468            flaky_result_only.flaky_result(),
469            FlakyResult::Fail,
470            "flaky-result-only flaky_result matches"
471        );
472    }
473
474    #[test_case(
475        indoc!{r#"
476            [profile.default]
477            retries = { backoff = "foo" }
478        "#},
479        ConfigErrorKind::Message,
480        "unknown variant `foo`, expected `fixed` or `exponential`"
481        ; "invalid value for backoff")]
482    #[test_case(
483        indoc!{r#"
484            [profile.default]
485            retries = { backoff = "fixed" }
486        "#},
487        ConfigErrorKind::NotFound,
488        "profile.default.retries.count"
489        ; "fixed specified without count")]
490    #[test_case(
491        indoc!{r#"
492            [profile.default]
493            retries = { backoff = "fixed", count = 1, delay = "foobar" }
494        "#},
495        ConfigErrorKind::Message,
496        "invalid value: string \"foobar\", expected a duration"
497        ; "delay is not a valid duration")]
498    #[test_case(
499        indoc!{r#"
500            [profile.default]
501            retries = { backoff = "fixed", count = 1, jitter = true }
502        "#},
503        ConfigErrorKind::Message,
504        "`jitter` cannot be true if `delay` isn't specified or is zero"
505        ; "jitter specified without delay")]
506    #[test_case(
507        indoc!{r#"
508            [profile.default]
509            retries = { backoff = "fixed", count = 1, max-delay = "10s" }
510        "#},
511        ConfigErrorKind::Message,
512        "unknown field `max-delay`, expected one of `count`, `delay`, `jitter`"
513        ; "max-delay is incompatible with fixed backoff")]
514    #[test_case(
515        indoc!{r#"
516            [profile.default]
517            retries = { backoff = "exponential", count = 1 }
518        "#},
519        ConfigErrorKind::NotFound,
520        "profile.default.retries.delay"
521        ; "exponential backoff must specify delay")]
522    #[test_case(
523        indoc!{r#"
524            [profile.default]
525            retries = { backoff = "exponential", delay = "1s" }
526        "#},
527        ConfigErrorKind::NotFound,
528        "profile.default.retries.count"
529        ; "exponential backoff must specify count")]
530    #[test_case(
531        indoc!{r#"
532            [profile.default]
533            retries = { backoff = "exponential", count = 0, delay = "1s" }
534        "#},
535        ConfigErrorKind::Message,
536        "`count` cannot be zero with exponential backoff"
537        ; "exponential backoff must have a non-zero count")]
538    #[test_case(
539        indoc!{r#"
540            [profile.default]
541            retries = { backoff = "exponential", count = 1, delay = "0s" }
542        "#},
543        ConfigErrorKind::Message,
544        "`delay` cannot be zero with exponential backoff"
545        ; "exponential backoff must have a non-zero delay")]
546    #[test_case(
547        indoc!{r#"
548            [profile.default]
549            retries = { backoff = "exponential", count = 1, delay = "1s", max-delay = "0s" }
550        "#},
551        ConfigErrorKind::Message,
552        "`max-delay` cannot be zero with exponential backoff"
553        ; "exponential backoff must have a non-zero max delay")]
554    #[test_case(
555        indoc!{r#"
556            [profile.default]
557            retries = { backoff = "exponential", count = 1, delay = "4s", max-delay = "2s", jitter = true }
558        "#},
559        ConfigErrorKind::Message,
560        "`max-delay` cannot be less than delay"
561        ; "max-delay greater than delay")]
562    #[test_case(
563        indoc!{r#"
564            [profile.default]
565            flaky-result = "unknown"
566        "#},
567        ConfigErrorKind::Message,
568        "enum FlakyResult does not have variant constructor unknown"
569        ; "invalid flaky-result value")]
570    fn parse_retries_invalid(
571        config_contents: &str,
572        expected_kind: ConfigErrorKind,
573        expected_message: &str,
574    ) {
575        let workspace_dir = tempdir().unwrap();
576
577        let graph = temp_workspace(&workspace_dir, config_contents);
578        let pcx = ParseContext::new(&graph);
579
580        let config_err = NextestConfig::from_sources(
581            graph.workspace().root(),
582            &pcx,
583            None,
584            [],
585            &Default::default(),
586        )
587        .expect_err("config expected to be invalid");
588
589        let message = match config_err.kind() {
590            ConfigParseErrorKind::DeserializeError(path_error) => {
591                match (path_error.inner(), expected_kind) {
592                    (ConfigError::Message(message), ConfigErrorKind::Message) => message,
593                    (ConfigError::NotFound(message), ConfigErrorKind::NotFound) => message,
594                    (other, expected) => {
595                        panic!(
596                            "for config error {config_err:?}, expected \
597                             ConfigErrorKind::{expected:?} for inner error {other:?}"
598                        );
599                    }
600                }
601            }
602            other => {
603                panic!(
604                    "for config error {other:?}, expected ConfigParseErrorKind::DeserializeError"
605                );
606            }
607        };
608
609        assert!(
610            message.contains(expected_message),
611            "expected message \"{message}\" to contain \"{expected_message}\""
612        );
613    }
614
615    #[test_case(
616        indoc! {r#"
617            [[profile.default.overrides]]
618            filter = "test(=my_test)"
619            retries = 2
620
621            [profile.ci]
622        "#},
623        BuildPlatform::Target,
624        RetryPolicy::new_without_delay(2)
625
626        ; "my_test matches exactly"
627    )]
628    #[test_case(
629        indoc! {r#"
630            [[profile.default.overrides]]
631            filter = "!test(=my_test)"
632            retries = 2
633
634            [profile.ci]
635        "#},
636        BuildPlatform::Target,
637        RetryPolicy::new_without_delay(0)
638
639        ; "not match"
640    )]
641    #[test_case(
642        indoc! {r#"
643            [[profile.default.overrides]]
644            filter = "test(=my_test)"
645
646            [profile.ci]
647        "#},
648        BuildPlatform::Target,
649        RetryPolicy::new_without_delay(0)
650
651        ; "no retries specified"
652    )]
653    #[test_case(
654        indoc! {r#"
655            [[profile.default.overrides]]
656            filter = "test(test)"
657            retries = 2
658
659            [[profile.default.overrides]]
660            filter = "test(=my_test)"
661            retries = 3
662
663            [profile.ci]
664        "#},
665        BuildPlatform::Target,
666        RetryPolicy::new_without_delay(2)
667
668        ; "earlier configs override later ones"
669    )]
670    #[test_case(
671        indoc! {r#"
672            [[profile.default.overrides]]
673            filter = "test(test)"
674            retries = 2
675
676            [profile.ci]
677
678            [[profile.ci.overrides]]
679            filter = "test(=my_test)"
680            retries = 3
681        "#},
682        BuildPlatform::Target,
683        RetryPolicy::new_without_delay(3)
684
685        ; "profile-specific configs override default ones"
686    )]
687    #[test_case(
688        indoc! {r#"
689            [[profile.default.overrides]]
690            filter = "(!package(test-package)) and test(test)"
691            retries = 2
692
693            [profile.ci]
694
695            [[profile.ci.overrides]]
696            filter = "!test(=my_test_2)"
697            retries = 3
698        "#},
699        BuildPlatform::Target,
700        RetryPolicy::new_without_delay(3)
701
702        ; "no overrides match my_test exactly"
703    )]
704    #[test_case(
705        indoc! {r#"
706            [[profile.default.overrides]]
707            platform = "x86_64-unknown-linux-gnu"
708            filter = "test(test)"
709            retries = 2
710
711            [[profile.default.overrides]]
712            filter = "test(=my_test)"
713            retries = 3
714
715            [profile.ci]
716        "#},
717        BuildPlatform::Host,
718        RetryPolicy::new_without_delay(2)
719
720        ; "earlier config applied because it matches host triple"
721    )]
722    #[test_case(
723        indoc! {r#"
724            [[profile.default.overrides]]
725            platform = "aarch64-apple-darwin"
726            filter = "test(test)"
727            retries = 2
728
729            [[profile.default.overrides]]
730            filter = "test(=my_test)"
731            retries = 3
732
733            [profile.ci]
734        "#},
735        BuildPlatform::Host,
736        RetryPolicy::new_without_delay(3)
737
738        ; "earlier config ignored because it doesn't match host triple"
739    )]
740    #[test_case(
741        indoc! {r#"
742            [[profile.default.overrides]]
743            platform = "aarch64-apple-darwin"
744            filter = "test(test)"
745            retries = 2
746
747            [[profile.default.overrides]]
748            filter = "test(=my_test)"
749            retries = 3
750
751            [profile.ci]
752        "#},
753        BuildPlatform::Target,
754        RetryPolicy::new_without_delay(2)
755
756        ; "earlier config applied because it matches target triple"
757    )]
758    #[test_case(
759        indoc! {r#"
760            [[profile.default.overrides]]
761            platform = "x86_64-unknown-linux-gnu"
762            filter = "test(test)"
763            retries = 2
764
765            [[profile.default.overrides]]
766            filter = "test(=my_test)"
767            retries = 3
768
769            [profile.ci]
770        "#},
771        BuildPlatform::Target,
772        RetryPolicy::new_without_delay(3)
773
774        ; "earlier config ignored because it doesn't match target triple"
775    )]
776    #[test_case(
777        indoc! {r#"
778            [[profile.default.overrides]]
779            platform = 'cfg(target_os = "macos")'
780            filter = "test(test)"
781            retries = 2
782
783            [[profile.default.overrides]]
784            filter = "test(=my_test)"
785            retries = 3
786
787            [profile.ci]
788        "#},
789        BuildPlatform::Target,
790        RetryPolicy::new_without_delay(2)
791
792        ; "earlier config applied because it matches target cfg expr"
793    )]
794    #[test_case(
795        indoc! {r#"
796            [[profile.default.overrides]]
797            platform = 'cfg(target_arch = "x86_64")'
798            filter = "test(test)"
799            retries = 2
800
801            [[profile.default.overrides]]
802            filter = "test(=my_test)"
803            retries = 3
804
805            [profile.ci]
806        "#},
807        BuildPlatform::Target,
808        RetryPolicy::new_without_delay(3)
809
810        ; "earlier config ignored because it doesn't match target cfg expr"
811    )]
812    fn overrides_retries(
813        config_contents: &str,
814        build_platform: BuildPlatform,
815        retries: RetryPolicy,
816    ) {
817        let workspace_dir = tempdir().unwrap();
818
819        let graph = temp_workspace(&workspace_dir, config_contents);
820        let package_id = graph.workspace().iter().next().unwrap().id();
821        let pcx = ParseContext::new(&graph);
822
823        let config = NextestConfig::from_sources(
824            graph.workspace().root(),
825            &pcx,
826            None,
827            &[][..],
828            &Default::default(),
829        )
830        .unwrap();
831        let binary_query = binary_query(&graph, package_id, "lib", "my-binary", build_platform);
832        let test_name = TestCaseName::new("my_test");
833        let query = TestQuery {
834            binary_query: binary_query.to_query(),
835            test_name: &test_name,
836        };
837        let profile = config
838            .profile("ci")
839            .expect("ci profile is defined")
840            .apply_build_platforms(&build_platforms());
841        let settings_for = profile.settings_for(NextestRunMode::Test, &query);
842        assert_eq!(
843            settings_for.retries(),
844            retries,
845            "actual retries don't match expected retries"
846        );
847    }
848
849    #[test]
850    fn overrides_flaky_result() {
851        let config_contents = indoc! {r#"
852            [[profile.default.overrides]]
853            filter = "test(=my_test)"
854            retries = { backoff = "fixed", count = 3 }
855            flaky-result = "fail"
856
857            [[profile.default.overrides]]
858            filter = "test(=other_test)"
859            retries = 2
860
861            [profile.ci]
862        "#};
863        let workspace_dir = tempdir().unwrap();
864
865        let graph = temp_workspace(&workspace_dir, config_contents);
866        let package_id = graph.workspace().iter().next().unwrap().id();
867        let pcx = ParseContext::new(&graph);
868
869        let config = NextestConfig::from_sources(
870            graph.workspace().root(),
871            &pcx,
872            None,
873            &[][..],
874            &Default::default(),
875        )
876        .unwrap();
877
878        let profile = config
879            .profile("ci")
880            .expect("ci profile is defined")
881            .apply_build_platforms(&build_platforms());
882
883        // my_test has flaky-result = "fail" set explicitly.
884        let binary_query = binary_query(
885            &graph,
886            package_id,
887            "lib",
888            "my-binary",
889            BuildPlatform::Target,
890        );
891        let test_name = TestCaseName::new("my_test");
892        let query = TestQuery {
893            binary_query: binary_query.to_query(),
894            test_name: &test_name,
895        };
896        let settings = profile.settings_for(NextestRunMode::Test, &query);
897        assert_eq!(
898            settings.flaky_result(),
899            FlakyResult::Fail,
900            "my_test flaky_result is fail"
901        );
902
903        // other_test uses shorthand retries = 2, which does not set
904        // flaky-result.
905        let test_name = TestCaseName::new("other_test");
906        let query = TestQuery {
907            binary_query: binary_query.to_query(),
908            test_name: &test_name,
909        };
910        let settings = profile.settings_for(NextestRunMode::Test, &query);
911        assert_eq!(
912            settings.flaky_result(),
913            FlakyResult::Pass,
914            "other_test flaky_result defaults to pass"
915        );
916    }
917
918    /// Test that retries and flaky_result resolve independently through the
919    /// override chain. An override that sets only retries should not override
920    /// a flaky_result set by a later (lower-priority) override.
921    #[test]
922    fn overrides_flaky_result_independent_resolution() {
923        let config_contents = indoc! {r#"
924            # Override 1: sets retries count only.
925            [[profile.default.overrides]]
926            filter = "test(=my_test)"
927            retries = 5
928
929            # Override 2: sets retries with flaky-result = "fail".
930            [[profile.default.overrides]]
931            filter = "all()"
932            retries = { backoff = "fixed", count = 2 }
933            flaky-result = "fail"
934
935            [profile.ci]
936        "#};
937        let workspace_dir = tempdir().unwrap();
938
939        let graph = temp_workspace(&workspace_dir, config_contents);
940        let package_id = graph.workspace().iter().next().unwrap().id();
941        let pcx = ParseContext::new(&graph);
942
943        let config = NextestConfig::from_sources(
944            graph.workspace().root(),
945            &pcx,
946            None,
947            &[][..],
948            &Default::default(),
949        )
950        .unwrap();
951
952        let profile = config
953            .profile("ci")
954            .expect("ci profile is defined")
955            .apply_build_platforms(&build_platforms());
956
957        let binary_query = binary_query(
958            &graph,
959            package_id,
960            "lib",
961            "my-binary",
962            BuildPlatform::Target,
963        );
964        let test_name = TestCaseName::new("my_test");
965        let query = TestQuery {
966            binary_query: binary_query.to_query(),
967            test_name: &test_name,
968        };
969        let settings = profile.settings_for(NextestRunMode::Test, &query);
970
971        // Retries count comes from override 1 (higher priority).
972        assert_eq!(
973            settings.retries(),
974            RetryPolicy::new_without_delay(5),
975            "retries count from first override"
976        );
977        // Flaky result comes from override 2 (first override didn't set it).
978        assert_eq!(
979            settings.flaky_result(),
980            FlakyResult::Fail,
981            "flaky_result from second override"
982        );
983    }
984
985    /// Test that `flaky-result = "fail"` (without retries) sets only the flaky
986    /// result, with the retry policy inherited from a lower-priority override.
987    #[test]
988    fn overrides_flaky_result_only() {
989        let config_contents = indoc! {r#"
990            # Override 1: sets only flaky-result, no retry policy.
991            [[profile.default.overrides]]
992            filter = "test(=my_test)"
993            flaky-result = "fail"
994
995            # Override 2: sets retries count for all tests.
996            [[profile.default.overrides]]
997            filter = "all()"
998            retries = 3
999
1000            [profile.ci]
1001        "#};
1002        let workspace_dir = tempdir().unwrap();
1003
1004        let graph = temp_workspace(&workspace_dir, config_contents);
1005        let package_id = graph.workspace().iter().next().unwrap().id();
1006        let pcx = ParseContext::new(&graph);
1007
1008        let config = NextestConfig::from_sources(
1009            graph.workspace().root(),
1010            &pcx,
1011            None,
1012            &[][..],
1013            &Default::default(),
1014        )
1015        .unwrap();
1016
1017        let profile = config
1018            .profile("ci")
1019            .expect("ci profile is defined")
1020            .apply_build_platforms(&build_platforms());
1021
1022        let binary_query = binary_query(
1023            &graph,
1024            package_id,
1025            "lib",
1026            "my-binary",
1027            BuildPlatform::Target,
1028        );
1029        let test_name = TestCaseName::new("my_test");
1030        let query = TestQuery {
1031            binary_query: binary_query.to_query(),
1032            test_name: &test_name,
1033        };
1034        let settings = profile.settings_for(NextestRunMode::Test, &query);
1035
1036        // Retries come from override 2 (override 1 didn't set a policy).
1037        assert_eq!(
1038            settings.retries(),
1039            RetryPolicy::new_without_delay(3),
1040            "retries from second override"
1041        );
1042        // Flaky result comes from override 1.
1043        assert_eq!(
1044            settings.flaky_result(),
1045            FlakyResult::Fail,
1046            "flaky_result from first override"
1047        );
1048
1049        // For a test that doesn't match override 1, flaky_result defaults to
1050        // pass.
1051        let test_name = TestCaseName::new("other_test");
1052        let query = TestQuery {
1053            binary_query: binary_query.to_query(),
1054            test_name: &test_name,
1055        };
1056        let settings = profile.settings_for(NextestRunMode::Test, &query);
1057        assert_eq!(
1058            settings.retries(),
1059            RetryPolicy::new_without_delay(3),
1060            "other_test retries from second override"
1061        );
1062        assert_eq!(
1063            settings.flaky_result(),
1064            FlakyResult::Pass,
1065            "other_test flaky_result defaults to pass"
1066        );
1067    }
1068}