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