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