flowey_lib_common/
run_cargo_nextest_run.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Run cargo-nextest tests.
5
6use crate::run_cargo_build::CargoBuildProfile;
7use flowey::node::prelude::*;
8use std::collections::BTreeMap;
9use std::ffi::OsString;
10
11#[derive(Serialize, Deserialize)]
12pub struct TestResults {
13    pub all_tests_passed: bool,
14    /// Path to JUnit XML output (if enabled by the nextest profile)
15    pub junit_xml: Option<PathBuf>,
16}
17
18/// Parameters related to building nextest tests
19pub mod build_params {
20    use crate::run_cargo_build::CargoBuildProfile;
21    use flowey::node::prelude::*;
22    use std::collections::BTreeMap;
23
24    #[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Debug)]
25    pub enum PanicAbortTests {
26        /// Assume the current rust toolchain is nightly
27        // FUTURE: current flowey infrastructure doesn't actually have a path for
28        // multi-toolchain drifting
29        UsingNightly,
30        /// Build with `RUSTC_BOOTSTRAP=1` set
31        UsingRustcBootstrap,
32    }
33
34    #[derive(Serialize, Deserialize)]
35    pub enum FeatureSet {
36        All,
37        Specific(Vec<String>),
38    }
39
40    /// Types of things that can be documented
41    #[derive(Serialize, Deserialize)]
42    pub enum TestPackages {
43        /// Document an entire workspace workspace (with exclusions)
44        Workspace {
45            /// Exclude certain crates
46            exclude: Vec<String>,
47        },
48        /// Document a specific set of crates.
49        Crates {
50            /// Crates to document
51            crates: Vec<String>,
52        },
53    }
54
55    #[derive(Serialize, Deserialize)]
56    pub struct NextestBuildParams<C = VarNotClaimed> {
57        /// Packages to test for
58        pub packages: ReadVar<TestPackages, C>,
59        /// Cargo features to enable when building
60        pub features: FeatureSet,
61        /// Whether to disable default features
62        pub no_default_features: bool,
63        /// Whether to build tests with unstable `-Zpanic-abort-tests` flag
64        pub unstable_panic_abort_tests: Option<PanicAbortTests>,
65        /// Build unit tests for the specified target
66        pub target: target_lexicon::Triple,
67        /// Build unit tests with the specified cargo profile
68        pub profile: CargoBuildProfile,
69        /// Additional env vars set when building the tests
70        pub extra_env: ReadVar<BTreeMap<String, String>, C>,
71    }
72}
73
74/// Nextest run mode to use
75#[derive(Serialize, Deserialize)]
76pub enum NextestRunKind {
77    /// Build and run tests in a single step.
78    BuildAndRun(build_params::NextestBuildParams),
79    /// Run tests from pre-built nextest archive file.
80    RunFromArchive(ReadVar<PathBuf>),
81}
82
83#[derive(Serialize, Deserialize)]
84pub struct Run {
85    /// Friendly name for this test group that will be displayed in logs.
86    pub friendly_name: String,
87    /// What kind of test run this is (inline build vs. from nextest archive).
88    pub run_kind: NextestRunKind,
89    /// Working directory the test archive was created from.
90    pub working_dir: ReadVar<PathBuf>,
91    /// Path to `.config/nextest.toml`
92    pub config_file: ReadVar<PathBuf>,
93    /// Path to any tool-specific config files
94    pub tool_config_files: Vec<(String, ReadVar<PathBuf>)>,
95    /// Nextest profile to use when running the source code (as defined in the
96    /// `.config.nextest.toml`).
97    pub nextest_profile: String,
98    /// Nextest test filter expression
99    pub nextest_filter_expr: Option<String>,
100    /// Whether to run ignored tests
101    pub run_ignored: bool,
102    /// Set rlimits to allow unlimited sized coredump file (if supported)
103    pub with_rlimit_unlimited_core_size: bool,
104    /// Additional env vars set when executing the tests.
105    pub extra_env: Option<ReadVar<BTreeMap<String, String>>>,
106    /// Wait for specified side-effects to resolve before building / running any
107    /// tests. (e.g: to allow for some ambient packages / dependencies to
108    /// get installed).
109    pub pre_run_deps: Vec<ReadVar<SideEffect>>,
110    /// Results of running the tests
111    pub results: WriteVar<TestResults>,
112}
113
114flowey_request! {
115    pub enum Request {
116        /// Set the default nextest fast fail behavior. Defaults to not
117        /// fast-failing when a single test fails.
118        DefaultNextestFailFast(bool),
119        /// Set the default behavior when a test failure is encountered.
120        /// Defaults to not terminating the job when a single test fails.
121        DefaultTerminateJobOnFail(bool),
122        Run(Run),
123    }
124}
125
126enum RunKindDeps<C = VarNotClaimed> {
127    BuildAndRun {
128        params: build_params::NextestBuildParams<C>,
129        nextest_installed: ReadVar<SideEffect, C>,
130        rust_toolchain: ReadVar<Option<String>, C>,
131        cargo_flags: ReadVar<crate::cfg_cargo_common_flags::Flags, C>,
132    },
133    RunFromArchive {
134        archive_file: ReadVar<PathBuf, C>,
135        nextest_bin: ReadVar<PathBuf, C>,
136    },
137}
138
139new_flow_node!(struct Node);
140
141impl FlowNode for Node {
142    type Request = Request;
143
144    fn imports(ctx: &mut ImportCtx<'_>) {
145        ctx.import::<crate::cfg_cargo_common_flags::Node>();
146        ctx.import::<crate::download_cargo_nextest::Node>();
147        ctx.import::<crate::install_rust::Node>();
148    }
149
150    fn emit(requests: Vec<Self::Request>, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
151        let mut run = Vec::new();
152        let mut fail_fast = None;
153        let mut terminate_job_on_fail = None;
154
155        for req in requests {
156            match req {
157                Request::DefaultNextestFailFast(v) => {
158                    same_across_all_reqs("OverrideFailFast", &mut fail_fast, v)?
159                }
160                Request::DefaultTerminateJobOnFail(v) => {
161                    same_across_all_reqs("TerminateJobOnFail", &mut terminate_job_on_fail, v)?
162                }
163                Request::Run(v) => run.push(v),
164            }
165        }
166
167        let terminate_job_on_fail = terminate_job_on_fail.unwrap_or(false);
168
169        for Run {
170            friendly_name,
171            run_kind,
172            working_dir,
173            config_file,
174            tool_config_files,
175            nextest_profile,
176            extra_env,
177            with_rlimit_unlimited_core_size,
178            nextest_filter_expr,
179            run_ignored,
180            pre_run_deps,
181            results,
182        } in run
183        {
184            let run_kind_deps = match run_kind {
185                NextestRunKind::BuildAndRun(params) => {
186                    let cargo_flags = ctx.reqv(crate::cfg_cargo_common_flags::Request::GetFlags);
187
188                    let nextest_installed =
189                        ctx.reqv(crate::download_cargo_nextest::Request::InstallWithCargo);
190
191                    let rust_toolchain = ctx.reqv(crate::install_rust::Request::GetRustupToolchain);
192
193                    ctx.req(crate::install_rust::Request::InstallTargetTriple(
194                        params.target.clone(),
195                    ));
196
197                    RunKindDeps::BuildAndRun {
198                        params,
199                        nextest_installed,
200                        rust_toolchain,
201                        cargo_flags,
202                    }
203                }
204                NextestRunKind::RunFromArchive(archive_file) => {
205                    let nextest_bin =
206                        ctx.reqv(crate::download_cargo_nextest::Request::InstallStandalone);
207
208                    RunKindDeps::RunFromArchive {
209                        archive_file,
210                        nextest_bin,
211                    }
212                }
213            };
214
215            let (all_tests_passed_read, all_tests_passed_write) = ctx.new_var();
216            let (junit_xml_read, junit_xml_write) = ctx.new_var();
217
218            ctx.emit_rust_step(format!("run '{friendly_name}' nextest tests"), |ctx| {
219                pre_run_deps.claim(ctx);
220
221                let run_kind_deps = run_kind_deps.claim(ctx);
222                let working_dir = working_dir.claim(ctx);
223                let config_file = config_file.claim(ctx);
224                let tool_config_files = tool_config_files
225                    .into_iter()
226                    .map(|(a, b)| (a, b.claim(ctx)))
227                    .collect::<Vec<_>>();
228                let extra_env = extra_env.claim(ctx);
229                let all_tests_passed_var = all_tests_passed_write.claim(ctx);
230                let junit_xml_write = junit_xml_write.claim(ctx);
231                move |rt| {
232                    let working_dir = rt.read(working_dir);
233                    let config_file = rt.read(config_file);
234                    let mut with_env = rt.read(extra_env).unwrap_or_default();
235
236                    // first things first - determine if junit is supported by
237                    // the profile, and if so, where the output if going to be.
238                    let junit_path = {
239                        let nextest_toml = fs_err::read_to_string(&config_file)?
240                            .parse::<toml_edit::DocumentMut>()
241                            .context("failed to parse nextest.toml")?;
242
243                        let path = Some(&nextest_toml)
244                            .and_then(|i| i.get("profile"))
245                            .and_then(|i| i.get(&nextest_profile))
246                            .and_then(|i| i.get("junit"))
247                            .and_then(|i| i.get("path"));
248
249                        if let Some(path) = path {
250                            let path: PathBuf =
251                                path.as_str().context("malformed nextest.toml")?.into();
252                            Some(path)
253                        } else {
254                            None
255                        }
256                    };
257
258                    enum NextestInvocation {
259                        // when tests are already built and provided via archive
260                        Standalone { nextest_bin: PathBuf },
261                        // when tests need to be compiled first
262                        WithCargo { rust_toolchain: Option<String> },
263                    }
264
265                    // the invocation of `nextest run` is quite different
266                    // depending on whether this is an archived run or not, as
267                    // archives don't require passing build args (after all -
268                    // those were passed when the archive was built), nor do
269                    // they require having cargo installed.
270                    let (nextest_invocation, build_args, build_env) = match run_kind_deps {
271                        RunKindDeps::BuildAndRun {
272                            params:
273                                build_params::NextestBuildParams {
274                                    packages,
275                                    features,
276                                    no_default_features,
277                                    unstable_panic_abort_tests,
278                                    target,
279                                    profile,
280                                    extra_env,
281                                },
282                            nextest_installed: _, // side-effect
283                            rust_toolchain,
284                            cargo_flags,
285                        } => {
286                            let (mut build_args, build_env) = cargo_nextest_build_args_and_env(
287                                rt.read(cargo_flags),
288                                profile,
289                                target,
290                                rt.read(packages),
291                                features,
292                                unstable_panic_abort_tests,
293                                no_default_features,
294                                rt.read(extra_env),
295                            );
296
297                            let nextest_invocation = NextestInvocation::WithCargo {
298                                rust_toolchain: rt.read(rust_toolchain),
299                            };
300
301                            // nextest also requires explicitly specifying the
302                            // path to a cargo-metadata.json file when running
303                            // using --workspace-remap (which do we below).
304                            let cargo_metadata_path = std::env::current_dir()?
305                                .absolute()?
306                                .join("cargo_metadata.json");
307
308                            let sh = xshell::Shell::new()?;
309                            sh.change_dir(&working_dir);
310                            let output =
311                                xshell::cmd!(sh, "cargo metadata --format-version 1").output()?;
312                            let cargo_metadata = String::from_utf8(output.stdout)?;
313                            fs_err::write(&cargo_metadata_path, cargo_metadata)?;
314
315                            build_args.push("--cargo-metadata".into());
316                            build_args.push(cargo_metadata_path.display().to_string());
317
318                            (nextest_invocation, build_args, build_env)
319                        }
320                        RunKindDeps::RunFromArchive {
321                            archive_file,
322                            nextest_bin,
323                        } => {
324                            let build_args = vec![
325                                "--archive-file".into(),
326                                rt.read(archive_file).display().to_string(),
327                            ];
328
329                            let nextest_invocation = NextestInvocation::Standalone {
330                                nextest_bin: rt.read(nextest_bin),
331                            };
332
333                            (nextest_invocation, build_args, BTreeMap::default())
334                        }
335                    };
336
337                    let mut args: Vec<OsString> = Vec::new();
338
339                    let argv0: OsString = match nextest_invocation {
340                        NextestInvocation::Standalone { nextest_bin } => nextest_bin.into(),
341                        NextestInvocation::WithCargo { rust_toolchain } => {
342                            if let Some(rust_toolchain) = rust_toolchain {
343                                args.extend(["run".into(), rust_toolchain.into(), "cargo".into()]);
344                                "rustup".into()
345                            } else {
346                                "cargo".into()
347                            }
348                        }
349                    };
350
351                    args.extend([
352                        "nextest".into(),
353                        "run".into(),
354                        "--profile".into(),
355                        (&nextest_profile).into(),
356                        "--config-file".into(),
357                        config_file.into(),
358                        "--workspace-remap".into(),
359                        (&working_dir).into(),
360                    ]);
361
362                    for (tool, config_file) in tool_config_files {
363                        args.extend([
364                            "--tool-config-file".into(),
365                            format!("{}:{}", tool, rt.read(config_file).display()).into(),
366                        ]);
367                    }
368
369                    args.extend(build_args.into_iter().map(Into::into));
370
371                    if let Some(nextest_filter_expr) = nextest_filter_expr {
372                        args.push("--filter-expr".into());
373                        args.push(nextest_filter_expr.into());
374                    }
375
376                    if run_ignored {
377                        args.push("--run-ignored".into());
378                        args.push("all".into());
379                    }
380
381                    if let Some(fail_fast) = fail_fast {
382                        if fail_fast {
383                            args.push("--fail-fast".into());
384                        } else {
385                            args.push("--no-fail-fast".into());
386                        }
387                    }
388
389                    // useful default to have
390                    if !with_env.contains_key("RUST_BACKTRACE") {
391                        with_env.insert("RUST_BACKTRACE".into(), "1".into());
392                    }
393
394                    // if running in CI, no need to waste time with incremental
395                    // build artifacts
396                    if !matches!(rt.backend(), FlowBackend::Local) {
397                        with_env.insert("CARGO_INCREMENTAL".into(), "0".into());
398                    }
399
400                    // also update WSLENV in cases where we're running windows tests via WSL2
401                    if crate::_util::running_in_wsl(rt) {
402                        let old_wslenv = std::env::var("WSLENV");
403                        let new_wslenv = with_env.keys().cloned().collect::<Vec<_>>().join(":");
404                        with_env.insert(
405                            "WSLENV".into(),
406                            format!(
407                                "{}{}",
408                                old_wslenv.map(|s| s + ":").unwrap_or_default(),
409                                new_wslenv
410                            ),
411                        );
412                    }
413
414                    // the build_env vars don't need to be mirrored to WSLENV,
415                    // and so they are only injected after the WSLENV code has
416                    // run.
417                    with_env.extend(build_env);
418
419                    // allow unlimited coredump sizes
420                    //
421                    // FUTURE: would be cool if `flowey` had the ability to pass
422                    // around "callbacks" as part of a, which would subsume the
423                    // need to support things like `with_env` and
424                    // `with_rlimit_unlimited_core_size`.
425                    //
426                    // This _should_ be doable using the same sort of mechanism
427                    // that regular flowey Rust-based steps get registered +
428                    // invoked. i.e: the serializable "callback" object is just
429                    // a unique identifier for a set of
430                    // (NodeHandle,callback_idx,requests), which flowey can use
431                    // to "play-through" the specified node in order to get the
432                    // caller a handle to a concrete `Box<dyn Fn...>`.
433                    //
434                    // I suspect there'll need to be some `Any` involved to get
435                    // things to line up... but honestly, this seems doable?
436                    // Will need to find time to experiment with this...
437                    #[cfg(unix)]
438                    let old_core_rlimits = if with_rlimit_unlimited_core_size
439                        && matches!(rt.platform(), FlowPlatform::Linux(_))
440                    {
441                        let limits = rlimit::getrlimit(rlimit::Resource::CORE)?;
442                        rlimit::setrlimit(
443                            rlimit::Resource::CORE,
444                            rlimit::INFINITY,
445                            rlimit::INFINITY,
446                        )?;
447                        Some(limits)
448                    } else {
449                        None
450                    };
451
452                    #[cfg(not(unix))]
453                    let _ = with_rlimit_unlimited_core_size;
454
455                    let arg_string = || {
456                        args.iter()
457                            .map(|v| v.to_string_lossy().to_string())
458                            .collect::<Vec<_>>()
459                            .join(" ")
460                    };
461
462                    let env_string = with_env
463                        .iter()
464                        .map(|(k, v)| format!("{k}='{v}'"))
465                        .collect::<Vec<_>>()
466                        .join(" ");
467
468                    // nextest has meaningful exit codes that we want to parse.
469                    // <https://github.com/nextest-rs/nextest/blob/main/nextest-metadata/src/exit_codes.rs#L12>
470                    //
471                    // unfortunately, xshell doesn't have a mode where it can
472                    // both emit to stdout/stderr, _and_ report the specific
473                    // exit code of the process.
474                    //
475                    // So we have to use the raw process API instead.
476                    log::info!(
477                        "$ {} {} {}",
478                        env_string,
479                        argv0.to_string_lossy(),
480                        arg_string()
481                    );
482                    let mut command = std::process::Command::new(&argv0);
483                    command.args(&args).envs(with_env).current_dir(&working_dir);
484
485                    let mut child = command.spawn().with_context(|| {
486                        format!(
487                            "failed to spawn '{} {}'",
488                            argv0.to_string_lossy(),
489                            arg_string()
490                        )
491                    })?;
492
493                    let status = child.wait()?;
494
495                    #[cfg(unix)]
496                    if let Some((soft, hard)) = old_core_rlimits {
497                        rlimit::setrlimit(rlimit::Resource::CORE, soft, hard)?;
498                    }
499
500                    let all_tests_passed = match (status.success(), status.code()) {
501                        (true, _) => true,
502                        // documented nextest exit code for when a test has failed
503                        (false, Some(100)) => false,
504                        // any other exit code means something has gone disastrously wrong
505                        (false, _) => anyhow::bail!("failed to run nextest"),
506                    };
507
508                    rt.write(all_tests_passed_var, &all_tests_passed);
509
510                    if !all_tests_passed {
511                        log::warn!("encountered at least one test failure!");
512
513                        if terminate_job_on_fail {
514                            anyhow::bail!("terminating job (TerminateJobOnFail = true)")
515                        } else {
516                            // special string on ADO that causes step to show orange (!)
517                            // FUTURE: flowey should prob have a built-in API for this
518                            if matches!(rt.backend(), FlowBackend::Ado) {
519                                eprintln!("##vso[task.complete result=SucceededWithIssues;]")
520                            } else {
521                                log::warn!("encountered at least one test failure");
522                            }
523                        }
524                    }
525
526                    let junit_xml = if let Some(junit_path) = junit_path {
527                        let emitted_xml = working_dir
528                            .join("target")
529                            .join("nextest")
530                            .join(&nextest_profile)
531                            .join(junit_path);
532                        let final_xml = std::env::current_dir()?.join("junit.xml");
533                        // copy locally to avoid trashing the output between test runs
534                        fs_err::rename(emitted_xml, &final_xml)?;
535                        Some(final_xml.absolute()?)
536                    } else {
537                        None
538                    };
539
540                    rt.write(junit_xml_write, &junit_xml);
541
542                    Ok(())
543                }
544            });
545
546            ctx.emit_minor_rust_step("write results", |ctx| {
547                let all_tests_passed = all_tests_passed_read.claim(ctx);
548                let junit_xml = junit_xml_read.claim(ctx);
549                let results = results.claim(ctx);
550
551                move |rt| {
552                    let all_tests_passed = rt.read(all_tests_passed);
553                    let junit_xml = rt.read(junit_xml);
554
555                    rt.write(
556                        results,
557                        &TestResults {
558                            all_tests_passed,
559                            junit_xml,
560                        },
561                    );
562                }
563            });
564        }
565
566        Ok(())
567    }
568}
569
570// shared with `cargo_nextest_archive`
571pub(crate) fn cargo_nextest_build_args_and_env(
572    cargo_flags: crate::cfg_cargo_common_flags::Flags,
573    cargo_profile: CargoBuildProfile,
574    target: target_lexicon::Triple,
575    packages: build_params::TestPackages,
576    features: build_params::FeatureSet,
577    unstable_panic_abort_tests: Option<build_params::PanicAbortTests>,
578    no_default_features: bool,
579    mut extra_env: BTreeMap<String, String>,
580) -> (Vec<String>, BTreeMap<String, String>) {
581    let locked = cargo_flags.locked.then_some("--locked");
582    let verbose = cargo_flags.verbose.then_some("--verbose");
583    let cargo_profile = match &cargo_profile {
584        CargoBuildProfile::Debug => "dev",
585        CargoBuildProfile::Release => "release",
586        CargoBuildProfile::Custom(s) => s,
587    };
588    let target = target.to_string();
589
590    let packages: Vec<String> = {
591        // exclude benches
592        let mut v = vec!["--tests".into(), "--bins".into()];
593
594        match packages {
595            build_params::TestPackages::Workspace { exclude } => {
596                v.push("--workspace".into());
597                for crate_name in exclude {
598                    v.push("--exclude".into());
599                    v.push(crate_name);
600                }
601            }
602            build_params::TestPackages::Crates { crates } => {
603                for crate_name in crates {
604                    v.push("-p".into());
605                    v.push(crate_name);
606                }
607            }
608        }
609
610        v
611    };
612
613    let features: Vec<String> = {
614        let mut v = Vec::new();
615
616        if no_default_features {
617            v.push("--no-default-features".into())
618        }
619
620        match features {
621            build_params::FeatureSet::All => v.push("--all-features".into()),
622            build_params::FeatureSet::Specific(features) => {
623                if !features.is_empty() {
624                    v.push("--features".into());
625                    v.push(features.join(","));
626                }
627            }
628        }
629
630        v
631    };
632
633    let (z_panic_abort_tests, use_rustc_bootstrap) = match unstable_panic_abort_tests {
634        Some(kind) => (
635            Some("-Zpanic-abort-tests"),
636            match kind {
637                build_params::PanicAbortTests::UsingNightly => false,
638                build_params::PanicAbortTests::UsingRustcBootstrap => true,
639            },
640        ),
641        None => (None, false),
642    };
643
644    let mut args = Vec::new();
645    args.extend(locked.map(Into::into));
646    args.extend(verbose.map(Into::into));
647    args.push("--cargo-profile".into());
648    args.push(cargo_profile.into());
649    args.extend(z_panic_abort_tests.map(Into::into));
650    args.push("--target".into());
651    args.push(target);
652    args.extend(packages);
653    args.extend(features);
654
655    let mut env = BTreeMap::new();
656    if use_rustc_bootstrap {
657        env.insert("RUSTC_BOOTSTRAP".into(), "1".into());
658    }
659    env.append(&mut extra_env);
660
661    (args, env)
662}
663
664// FUTURE: this seems like something a proc-macro can help with...
665impl build_params::NextestBuildParams {
666    pub fn claim(self, ctx: &mut StepCtx<'_>) -> build_params::NextestBuildParams<VarClaimed> {
667        let build_params::NextestBuildParams {
668            packages,
669            features,
670            no_default_features,
671            unstable_panic_abort_tests,
672            target,
673            profile,
674            extra_env,
675        } = self;
676
677        build_params::NextestBuildParams {
678            packages: packages.claim(ctx),
679            features,
680            no_default_features,
681            unstable_panic_abort_tests,
682            target,
683            profile,
684            extra_env: extra_env.claim(ctx),
685        }
686    }
687}
688
689// FUTURE: this seems like something a proc-macro can help with...
690impl RunKindDeps {
691    pub fn claim(self, ctx: &mut StepCtx<'_>) -> RunKindDeps<VarClaimed> {
692        match self {
693            RunKindDeps::BuildAndRun {
694                params,
695                nextest_installed,
696                rust_toolchain,
697                cargo_flags,
698            } => RunKindDeps::BuildAndRun {
699                params: params.claim(ctx),
700                nextest_installed: nextest_installed.claim(ctx),
701                rust_toolchain: rust_toolchain.claim(ctx),
702                cargo_flags: cargo_flags.claim(ctx),
703            },
704            RunKindDeps::RunFromArchive {
705                archive_file,
706                nextest_bin,
707            } => RunKindDeps::RunFromArchive {
708                archive_file: archive_file.claim(ctx),
709                nextest_bin: nextest_bin.claim(ctx),
710            },
711        }
712    }
713}