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                return Err(serde::de::Error::custom(
205                    "`jitter` cannot be true if `delay` isn't specified or is zero",
206                ));
207            }
208        }
209        Some(RetryPolicy::Exponential {
210            count,
211            delay,
212            jitter: _,
213            max_delay,
214        }) => {
215            // Count can't be zero.
216            if *count == 0 {
217                return Err(serde::de::Error::custom(
218                    "`count` cannot be zero with exponential backoff",
219                ));
220            }
221            // Delay can't be zero.
222            if delay.is_zero() {
223                return Err(serde::de::Error::custom(
224                    "`delay` cannot be zero with exponential backoff",
225                ));
226            }
227            // Max delay, if specified, can't be zero.
228            if max_delay.is_some_and(|f| f.is_zero()) {
229                return Err(serde::de::Error::custom(
230                    "`max-delay` cannot be zero with exponential backoff",
231                ));
232            }
233            // Max delay can't be less than delay.
234            if max_delay.is_some_and(|max_delay| max_delay < *delay) {
235                return Err(serde::de::Error::custom(
236                    "`max-delay` cannot be less than delay with exponential backoff",
237                ));
238            }
239        }
240        None => {}
241    }
242
243    Ok(policy)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::{
250        config::{core::NextestConfig, utils::test_helpers::*},
251        errors::ConfigParseErrorKind,
252        run_mode::NextestRunMode,
253    };
254    use camino_tempfile::tempdir;
255    use config::ConfigError;
256    use guppy::graph::cargo::BuildPlatform;
257    use indoc::indoc;
258    use nextest_filtering::{ParseContext, TestQuery};
259    use nextest_metadata::TestCaseName;
260    use test_case::test_case;
261
262    #[test]
263    fn parse_retries_valid() {
264        let config_contents = indoc! {r#"
265            [profile.default]
266            retries = { backoff = "fixed", count = 3 }
267
268            [profile.no-retries]
269            retries = 0
270
271            [profile.fixed-with-delay]
272            retries = { backoff = "fixed", count = 3, delay = "1s" }
273
274            [profile.exp]
275            retries = { backoff = "exponential", count = 4, delay = "2s" }
276
277            [profile.exp-with-max-delay]
278            retries = { backoff = "exponential", count = 5, delay = "3s", max-delay = "10s" }
279
280            [profile.exp-with-max-delay-and-jitter]
281            retries = { backoff = "exponential", count = 6, delay = "4s", max-delay = "1m", jitter = true }
282
283            [profile.with-flaky-result-fail]
284            retries = { backoff = "fixed", count = 2 }
285            flaky-result = "fail"
286
287            [profile.with-flaky-result-pass]
288            retries = { backoff = "fixed", count = 2 }
289            flaky-result = "pass"
290
291            [profile.exp-with-flaky-result-fail]
292            retries = { backoff = "exponential", count = 3, delay = "1s" }
293            flaky-result = "fail"
294
295            [profile.flaky-result-only]
296            flaky-result = "fail"
297        "#};
298
299        let workspace_dir = tempdir().unwrap();
300
301        let graph = temp_workspace(&workspace_dir, config_contents);
302        let pcx = ParseContext::new(&graph);
303
304        let config = NextestConfig::from_sources(
305            graph.workspace().root(),
306            &pcx,
307            None,
308            [],
309            &Default::default(),
310        )
311        .expect("config is valid");
312
313        let default_profile = config
314            .profile("default")
315            .expect("default profile exists")
316            .apply_build_platforms(&build_platforms());
317        assert_eq!(
318            default_profile.retries(),
319            RetryPolicy::Fixed {
320                count: 3,
321                delay: Duration::ZERO,
322                jitter: false,
323            },
324            "default retries matches"
325        );
326        assert_eq!(
327            default_profile.flaky_result(),
328            FlakyResult::Pass,
329            "default flaky_result matches"
330        );
331
332        assert_eq!(
333            config
334                .profile("no-retries")
335                .expect("profile exists")
336                .apply_build_platforms(&build_platforms())
337                .retries(),
338            RetryPolicy::new_without_delay(0),
339            "no-retries retries matches"
340        );
341
342        assert_eq!(
343            config
344                .profile("fixed-with-delay")
345                .expect("profile exists")
346                .apply_build_platforms(&build_platforms())
347                .retries(),
348            RetryPolicy::Fixed {
349                count: 3,
350                delay: Duration::from_secs(1),
351                jitter: false,
352            },
353            "fixed-with-delay retries matches"
354        );
355
356        assert_eq!(
357            config
358                .profile("exp")
359                .expect("profile exists")
360                .apply_build_platforms(&build_platforms())
361                .retries(),
362            RetryPolicy::Exponential {
363                count: 4,
364                delay: Duration::from_secs(2),
365                jitter: false,
366                max_delay: None,
367            },
368            "exp retries matches"
369        );
370
371        assert_eq!(
372            config
373                .profile("exp-with-max-delay")
374                .expect("profile exists")
375                .apply_build_platforms(&build_platforms())
376                .retries(),
377            RetryPolicy::Exponential {
378                count: 5,
379                delay: Duration::from_secs(3),
380                jitter: false,
381                max_delay: Some(Duration::from_secs(10)),
382            },
383            "exp-with-max-delay retries matches"
384        );
385
386        assert_eq!(
387            config
388                .profile("exp-with-max-delay-and-jitter")
389                .expect("profile exists")
390                .apply_build_platforms(&build_platforms())
391                .retries(),
392            RetryPolicy::Exponential {
393                count: 6,
394                delay: Duration::from_secs(4),
395                jitter: true,
396                max_delay: Some(Duration::from_secs(60)),
397            },
398            "exp-with-max-delay-and-jitter retries matches"
399        );
400
401        let with_flaky_result_fail = config
402            .profile("with-flaky-result-fail")
403            .expect("profile exists")
404            .apply_build_platforms(&build_platforms());
405        assert_eq!(
406            with_flaky_result_fail.retries(),
407            RetryPolicy::new_without_delay(2),
408            "with-flaky-result-fail retries matches"
409        );
410        assert_eq!(
411            with_flaky_result_fail.flaky_result(),
412            FlakyResult::Fail,
413            "with-flaky-result-fail flaky_result matches"
414        );
415
416        let with_flaky_result_pass = config
417            .profile("with-flaky-result-pass")
418            .expect("profile exists")
419            .apply_build_platforms(&build_platforms());
420        assert_eq!(
421            with_flaky_result_pass.retries(),
422            RetryPolicy::new_without_delay(2),
423            "with-flaky-result-pass retries matches"
424        );
425        assert_eq!(
426            with_flaky_result_pass.flaky_result(),
427            FlakyResult::Pass,
428            "with-flaky-result-pass flaky_result matches"
429        );
430
431        let exp_with_flaky_result_fail = config
432            .profile("exp-with-flaky-result-fail")
433            .expect("profile exists")
434            .apply_build_platforms(&build_platforms());
435        assert_eq!(
436            exp_with_flaky_result_fail.retries(),
437            RetryPolicy::Exponential {
438                count: 3,
439                delay: Duration::from_secs(1),
440                jitter: false,
441                max_delay: None,
442            },
443            "exp-with-flaky-result-fail retries matches"
444        );
445        assert_eq!(
446            exp_with_flaky_result_fail.flaky_result(),
447            FlakyResult::Fail,
448            "exp-with-flaky-result-fail flaky_result matches"
449        );
450
451        // flaky-result-only: retries inherited from default (count=3), flaky
452        // result set to fail.
453        let flaky_result_only = config
454            .profile("flaky-result-only")
455            .expect("profile exists")
456            .apply_build_platforms(&build_platforms());
457        assert_eq!(
458            flaky_result_only.retries(),
459            RetryPolicy::Fixed {
460                count: 3,
461                delay: Duration::ZERO,
462                jitter: false,
463            },
464            "flaky-result-only retries inherited from default"
465        );
466        assert_eq!(
467            flaky_result_only.flaky_result(),
468            FlakyResult::Fail,
469            "flaky-result-only flaky_result matches"
470        );
471    }
472
473    #[test_case(
474        indoc!{r#"
475            [profile.default]
476            retries = { backoff = "foo" }
477        "#},
478        ConfigErrorKind::Message,
479        "unknown variant `foo`, expected `fixed` or `exponential`"
480        ; "invalid value for backoff")]
481    #[test_case(
482        indoc!{r#"
483            [profile.default]
484            retries = { backoff = "fixed" }
485        "#},
486        ConfigErrorKind::NotFound,
487        "profile.default.retries.count"
488        ; "fixed specified without count")]
489    #[test_case(
490        indoc!{r#"
491            [profile.default]
492            retries = { backoff = "fixed", count = 1, delay = "foobar" }
493        "#},
494        ConfigErrorKind::Message,
495        "invalid value: string \"foobar\", expected a duration"
496        ; "delay is not a valid duration")]
497    #[test_case(
498        indoc!{r#"
499            [profile.default]
500            retries = { backoff = "fixed", count = 1, jitter = true }
501        "#},
502        ConfigErrorKind::Message,
503        "`jitter` cannot be true if `delay` isn't specified or is zero"
504        ; "jitter specified without delay")]
505    #[test_case(
506        indoc!{r#"
507            [profile.default]
508            retries = { backoff = "fixed", count = 1, max-delay = "10s" }
509        "#},
510        ConfigErrorKind::Message,
511        "unknown field `max-delay`, expected one of `count`, `delay`, `jitter`"
512        ; "max-delay is incompatible with fixed backoff")]
513    #[test_case(
514        indoc!{r#"
515            [profile.default]
516            retries = { backoff = "exponential", count = 1 }
517        "#},
518        ConfigErrorKind::NotFound,
519        "profile.default.retries.delay"
520        ; "exponential backoff must specify delay")]
521    #[test_case(
522        indoc!{r#"
523            [profile.default]
524            retries = { backoff = "exponential", delay = "1s" }
525        "#},
526        ConfigErrorKind::NotFound,
527        "profile.default.retries.count"
528        ; "exponential backoff must specify count")]
529    #[test_case(
530        indoc!{r#"
531            [profile.default]
532            retries = { backoff = "exponential", count = 0, delay = "1s" }
533        "#},
534        ConfigErrorKind::Message,
535        "`count` cannot be zero with exponential backoff"
536        ; "exponential backoff must have a non-zero count")]
537    #[test_case(
538        indoc!{r#"
539            [profile.default]
540            retries = { backoff = "exponential", count = 1, delay = "0s" }
541        "#},
542        ConfigErrorKind::Message,
543        "`delay` cannot be zero with exponential backoff"
544        ; "exponential backoff must have a non-zero delay")]
545    #[test_case(
546        indoc!{r#"
547            [profile.default]
548            retries = { backoff = "exponential", count = 1, delay = "1s", max-delay = "0s" }
549        "#},
550        ConfigErrorKind::Message,
551        "`max-delay` cannot be zero with exponential backoff"
552        ; "exponential backoff must have a non-zero max delay")]
553    #[test_case(
554        indoc!{r#"
555            [profile.default]
556            retries = { backoff = "exponential", count = 1, delay = "4s", max-delay = "2s", jitter = true }
557        "#},
558        ConfigErrorKind::Message,
559        "`max-delay` cannot be less than delay"
560        ; "max-delay greater than delay")]
561    #[test_case(
562        indoc!{r#"
563            [profile.default]
564            flaky-result = "unknown"
565        "#},
566        ConfigErrorKind::Message,
567        "enum FlakyResult does not have variant constructor unknown"
568        ; "invalid flaky-result value")]
569    fn parse_retries_invalid(
570        config_contents: &str,
571        expected_kind: ConfigErrorKind,
572        expected_message: &str,
573    ) {
574        let workspace_dir = tempdir().unwrap();
575
576        let graph = temp_workspace(&workspace_dir, config_contents);
577        let pcx = ParseContext::new(&graph);
578
579        let config_err = NextestConfig::from_sources(
580            graph.workspace().root(),
581            &pcx,
582            None,
583            [],
584            &Default::default(),
585        )
586        .expect_err("config expected to be invalid");
587
588        let message = match config_err.kind() {
589            ConfigParseErrorKind::DeserializeError(path_error) => {
590                match (path_error.inner(), expected_kind) {
591                    (ConfigError::Message(message), ConfigErrorKind::Message) => message,
592                    (ConfigError::NotFound(message), ConfigErrorKind::NotFound) => message,
593                    (other, expected) => {
594                        panic!(
595                            "for config error {config_err:?}, expected \
596                             ConfigErrorKind::{expected:?} for inner error {other:?}"
597                        );
598                    }
599                }
600            }
601            other => {
602                panic!(
603                    "for config error {other:?}, expected ConfigParseErrorKind::DeserializeError"
604                );
605            }
606        };
607
608        assert!(
609            message.contains(expected_message),
610            "expected message \"{message}\" to contain \"{expected_message}\""
611        );
612    }
613
614    #[test_case(
615        indoc! {r#"
616            [[profile.default.overrides]]
617            filter = "test(=my_test)"
618            retries = 2
619
620            [profile.ci]
621        "#},
622        BuildPlatform::Target,
623        RetryPolicy::new_without_delay(2)
624
625        ; "my_test matches exactly"
626    )]
627    #[test_case(
628        indoc! {r#"
629            [[profile.default.overrides]]
630            filter = "!test(=my_test)"
631            retries = 2
632
633            [profile.ci]
634        "#},
635        BuildPlatform::Target,
636        RetryPolicy::new_without_delay(0)
637
638        ; "not match"
639    )]
640    #[test_case(
641        indoc! {r#"
642            [[profile.default.overrides]]
643            filter = "test(=my_test)"
644
645            [profile.ci]
646        "#},
647        BuildPlatform::Target,
648        RetryPolicy::new_without_delay(0)
649
650        ; "no retries specified"
651    )]
652    #[test_case(
653        indoc! {r#"
654            [[profile.default.overrides]]
655            filter = "test(test)"
656            retries = 2
657
658            [[profile.default.overrides]]
659            filter = "test(=my_test)"
660            retries = 3
661
662            [profile.ci]
663        "#},
664        BuildPlatform::Target,
665        RetryPolicy::new_without_delay(2)
666
667        ; "earlier configs override later ones"
668    )]
669    #[test_case(
670        indoc! {r#"
671            [[profile.default.overrides]]
672            filter = "test(test)"
673            retries = 2
674
675            [profile.ci]
676
677            [[profile.ci.overrides]]
678            filter = "test(=my_test)"
679            retries = 3
680        "#},
681        BuildPlatform::Target,
682        RetryPolicy::new_without_delay(3)
683
684        ; "profile-specific configs override default ones"
685    )]
686    #[test_case(
687        indoc! {r#"
688            [[profile.default.overrides]]
689            filter = "(!package(test-package)) and test(test)"
690            retries = 2
691
692            [profile.ci]
693
694            [[profile.ci.overrides]]
695            filter = "!test(=my_test_2)"
696            retries = 3
697        "#},
698        BuildPlatform::Target,
699        RetryPolicy::new_without_delay(3)
700
701        ; "no overrides match my_test exactly"
702    )]
703    #[test_case(
704        indoc! {r#"
705            [[profile.default.overrides]]
706            platform = "x86_64-unknown-linux-gnu"
707            filter = "test(test)"
708            retries = 2
709
710            [[profile.default.overrides]]
711            filter = "test(=my_test)"
712            retries = 3
713
714            [profile.ci]
715        "#},
716        BuildPlatform::Host,
717        RetryPolicy::new_without_delay(2)
718
719        ; "earlier config applied because it matches host triple"
720    )]
721    #[test_case(
722        indoc! {r#"
723            [[profile.default.overrides]]
724            platform = "aarch64-apple-darwin"
725            filter = "test(test)"
726            retries = 2
727
728            [[profile.default.overrides]]
729            filter = "test(=my_test)"
730            retries = 3
731
732            [profile.ci]
733        "#},
734        BuildPlatform::Host,
735        RetryPolicy::new_without_delay(3)
736
737        ; "earlier config ignored because it doesn't match host triple"
738    )]
739    #[test_case(
740        indoc! {r#"
741            [[profile.default.overrides]]
742            platform = "aarch64-apple-darwin"
743            filter = "test(test)"
744            retries = 2
745
746            [[profile.default.overrides]]
747            filter = "test(=my_test)"
748            retries = 3
749
750            [profile.ci]
751        "#},
752        BuildPlatform::Target,
753        RetryPolicy::new_without_delay(2)
754
755        ; "earlier config applied because it matches target triple"
756    )]
757    #[test_case(
758        indoc! {r#"
759            [[profile.default.overrides]]
760            platform = "x86_64-unknown-linux-gnu"
761            filter = "test(test)"
762            retries = 2
763
764            [[profile.default.overrides]]
765            filter = "test(=my_test)"
766            retries = 3
767
768            [profile.ci]
769        "#},
770        BuildPlatform::Target,
771        RetryPolicy::new_without_delay(3)
772
773        ; "earlier config ignored because it doesn't match target triple"
774    )]
775    #[test_case(
776        indoc! {r#"
777            [[profile.default.overrides]]
778            platform = 'cfg(target_os = "macos")'
779            filter = "test(test)"
780            retries = 2
781
782            [[profile.default.overrides]]
783            filter = "test(=my_test)"
784            retries = 3
785
786            [profile.ci]
787        "#},
788        BuildPlatform::Target,
789        RetryPolicy::new_without_delay(2)
790
791        ; "earlier config applied because it matches target cfg expr"
792    )]
793    #[test_case(
794        indoc! {r#"
795            [[profile.default.overrides]]
796            platform = 'cfg(target_arch = "x86_64")'
797            filter = "test(test)"
798            retries = 2
799
800            [[profile.default.overrides]]
801            filter = "test(=my_test)"
802            retries = 3
803
804            [profile.ci]
805        "#},
806        BuildPlatform::Target,
807        RetryPolicy::new_without_delay(3)
808
809        ; "earlier config ignored because it doesn't match target cfg expr"
810    )]
811    fn overrides_retries(
812        config_contents: &str,
813        build_platform: BuildPlatform,
814        retries: RetryPolicy,
815    ) {
816        let workspace_dir = tempdir().unwrap();
817
818        let graph = temp_workspace(&workspace_dir, config_contents);
819        let package_id = graph.workspace().iter().next().unwrap().id();
820        let pcx = ParseContext::new(&graph);
821
822        let config = NextestConfig::from_sources(
823            graph.workspace().root(),
824            &pcx,
825            None,
826            &[][..],
827            &Default::default(),
828        )
829        .unwrap();
830        let binary_query = binary_query(&graph, package_id, "lib", "my-binary", build_platform);
831        let test_name = TestCaseName::new("my_test");
832        let query = TestQuery {
833            binary_query: binary_query.to_query(),
834            test_name: &test_name,
835        };
836        let profile = config
837            .profile("ci")
838            .expect("ci profile is defined")
839            .apply_build_platforms(&build_platforms());
840        let settings_for = profile.settings_for(NextestRunMode::Test, &query);
841        assert_eq!(
842            settings_for.retries(),
843            retries,
844            "actual retries don't match expected retries"
845        );
846    }
847
848    #[test]
849    fn overrides_flaky_result() {
850        let config_contents = indoc! {r#"
851            [[profile.default.overrides]]
852            filter = "test(=my_test)"
853            retries = { backoff = "fixed", count = 3 }
854            flaky-result = "fail"
855
856            [[profile.default.overrides]]
857            filter = "test(=other_test)"
858            retries = 2
859
860            [profile.ci]
861        "#};
862        let workspace_dir = tempdir().unwrap();
863
864        let graph = temp_workspace(&workspace_dir, config_contents);
865        let package_id = graph.workspace().iter().next().unwrap().id();
866        let pcx = ParseContext::new(&graph);
867
868        let config = NextestConfig::from_sources(
869            graph.workspace().root(),
870            &pcx,
871            None,
872            &[][..],
873            &Default::default(),
874        )
875        .unwrap();
876
877        let profile = config
878            .profile("ci")
879            .expect("ci profile is defined")
880            .apply_build_platforms(&build_platforms());
881
882        // my_test has flaky-result = "fail" set explicitly.
883        let binary_query = binary_query(
884            &graph,
885            package_id,
886            "lib",
887            "my-binary",
888            BuildPlatform::Target,
889        );
890        let test_name = TestCaseName::new("my_test");
891        let query = TestQuery {
892            binary_query: binary_query.to_query(),
893            test_name: &test_name,
894        };
895        let settings = profile.settings_for(NextestRunMode::Test, &query);
896        assert_eq!(
897            settings.flaky_result(),
898            FlakyResult::Fail,
899            "my_test flaky_result is fail"
900        );
901
902        // other_test uses shorthand retries = 2, which does not set
903        // flaky-result.
904        let test_name = TestCaseName::new("other_test");
905        let query = TestQuery {
906            binary_query: binary_query.to_query(),
907            test_name: &test_name,
908        };
909        let settings = profile.settings_for(NextestRunMode::Test, &query);
910        assert_eq!(
911            settings.flaky_result(),
912            FlakyResult::Pass,
913            "other_test flaky_result defaults to pass"
914        );
915    }
916
917    /// Test that retries and flaky_result resolve independently through the
918    /// override chain. An override that sets only retries should not override
919    /// a flaky_result set by a later (lower-priority) override.
920    #[test]
921    fn overrides_flaky_result_independent_resolution() {
922        let config_contents = indoc! {r#"
923            # Override 1: sets retries count only.
924            [[profile.default.overrides]]
925            filter = "test(=my_test)"
926            retries = 5
927
928            # Override 2: sets retries with flaky-result = "fail".
929            [[profile.default.overrides]]
930            filter = "all()"
931            retries = { backoff = "fixed", count = 2 }
932            flaky-result = "fail"
933
934            [profile.ci]
935        "#};
936        let workspace_dir = tempdir().unwrap();
937
938        let graph = temp_workspace(&workspace_dir, config_contents);
939        let package_id = graph.workspace().iter().next().unwrap().id();
940        let pcx = ParseContext::new(&graph);
941
942        let config = NextestConfig::from_sources(
943            graph.workspace().root(),
944            &pcx,
945            None,
946            &[][..],
947            &Default::default(),
948        )
949        .unwrap();
950
951        let profile = config
952            .profile("ci")
953            .expect("ci profile is defined")
954            .apply_build_platforms(&build_platforms());
955
956        let binary_query = binary_query(
957            &graph,
958            package_id,
959            "lib",
960            "my-binary",
961            BuildPlatform::Target,
962        );
963        let test_name = TestCaseName::new("my_test");
964        let query = TestQuery {
965            binary_query: binary_query.to_query(),
966            test_name: &test_name,
967        };
968        let settings = profile.settings_for(NextestRunMode::Test, &query);
969
970        // Retries count comes from override 1 (higher priority).
971        assert_eq!(
972            settings.retries(),
973            RetryPolicy::new_without_delay(5),
974            "retries count from first override"
975        );
976        // Flaky result comes from override 2 (first override didn't set it).
977        assert_eq!(
978            settings.flaky_result(),
979            FlakyResult::Fail,
980            "flaky_result from second override"
981        );
982    }
983
984    /// Test that `flaky-result = "fail"` (without retries) sets only the flaky
985    /// result, with the retry policy inherited from a lower-priority override.
986    #[test]
987    fn overrides_flaky_result_only() {
988        let config_contents = indoc! {r#"
989            # Override 1: sets only flaky-result, no retry policy.
990            [[profile.default.overrides]]
991            filter = "test(=my_test)"
992            flaky-result = "fail"
993
994            # Override 2: sets retries count for all tests.
995            [[profile.default.overrides]]
996            filter = "all()"
997            retries = 3
998
999            [profile.ci]
1000        "#};
1001        let workspace_dir = tempdir().unwrap();
1002
1003        let graph = temp_workspace(&workspace_dir, config_contents);
1004        let package_id = graph.workspace().iter().next().unwrap().id();
1005        let pcx = ParseContext::new(&graph);
1006
1007        let config = NextestConfig::from_sources(
1008            graph.workspace().root(),
1009            &pcx,
1010            None,
1011            &[][..],
1012            &Default::default(),
1013        )
1014        .unwrap();
1015
1016        let profile = config
1017            .profile("ci")
1018            .expect("ci profile is defined")
1019            .apply_build_platforms(&build_platforms());
1020
1021        let binary_query = binary_query(
1022            &graph,
1023            package_id,
1024            "lib",
1025            "my-binary",
1026            BuildPlatform::Target,
1027        );
1028        let test_name = TestCaseName::new("my_test");
1029        let query = TestQuery {
1030            binary_query: binary_query.to_query(),
1031            test_name: &test_name,
1032        };
1033        let settings = profile.settings_for(NextestRunMode::Test, &query);
1034
1035        // Retries come from override 2 (override 1 didn't set a policy).
1036        assert_eq!(
1037            settings.retries(),
1038            RetryPolicy::new_without_delay(3),
1039            "retries from second override"
1040        );
1041        // Flaky result comes from override 1.
1042        assert_eq!(
1043            settings.flaky_result(),
1044            FlakyResult::Fail,
1045            "flaky_result from first override"
1046        );
1047
1048        // For a test that doesn't match override 1, flaky_result defaults to
1049        // pass.
1050        let test_name = TestCaseName::new("other_test");
1051        let query = TestQuery {
1052            binary_query: binary_query.to_query(),
1053            test_name: &test_name,
1054        };
1055        let settings = profile.settings_for(NextestRunMode::Test, &query);
1056        assert_eq!(
1057            settings.retries(),
1058            RetryPolicy::new_without_delay(3),
1059            "other_test retries from second override"
1060        );
1061        assert_eq!(
1062            settings.flaky_result(),
1063            FlakyResult::Pass,
1064            "other_test flaky_result defaults to pass"
1065        );
1066    }
1067}