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::gen_cargo_nextest_run_cmd::RunKindDeps;
7use flowey::node::prelude::*;
8use std::collections::BTreeMap;
9
10#[derive(Serialize, Deserialize)]
11pub struct TestResults {
12    pub all_tests_passed: bool,
13    /// Path to JUnit XML output (if enabled by the nextest profile)
14    pub junit_xml: Option<PathBuf>,
15}
16
17/// Parameters related to building nextest tests
18pub mod build_params {
19    use crate::run_cargo_build::CargoBuildProfile;
20    use crate::run_cargo_build::CargoFeatureSet;
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    /// Types of things that can be documented
35    #[derive(Serialize, Deserialize)]
36    pub enum TestPackages {
37        /// Document an entire workspace workspace (with exclusions)
38        Workspace {
39            /// Exclude certain crates
40            exclude: Vec<String>,
41        },
42        /// Document a specific set of crates.
43        Crates {
44            /// Crates to document
45            crates: Vec<String>,
46        },
47    }
48
49    #[derive(Serialize, Deserialize)]
50    pub struct NextestBuildParams<C = VarNotClaimed> {
51        /// Packages to test for
52        pub packages: ReadVar<TestPackages, C>,
53        /// Cargo features to enable when building
54        pub features: CargoFeatureSet,
55        /// Whether to disable default features
56        pub no_default_features: bool,
57        /// Whether to build tests with unstable `-Zpanic-abort-tests` flag
58        pub unstable_panic_abort_tests: Option<PanicAbortTests>,
59        /// Build tests for the specified target
60        pub target: target_lexicon::Triple,
61        /// Build tests with the specified cargo profile
62        pub profile: CargoBuildProfile,
63        /// Additional env vars set when building the tests
64        pub extra_env: ReadVar<BTreeMap<String, String>, C>,
65    }
66}
67
68/// Nextest run mode to use
69#[derive(Serialize, Deserialize)]
70pub enum NextestRunKind {
71    /// Build and run tests in a single step.
72    BuildAndRun(build_params::NextestBuildParams),
73    /// Run tests from pre-built nextest archive file.
74    RunFromArchive {
75        archive_file: ReadVar<PathBuf>,
76        target: Option<ReadVar<target_lexicon::Triple>>,
77        nextest_bin: Option<ReadVar<PathBuf>>,
78    },
79}
80
81#[derive(Serialize, Deserialize)]
82pub struct Run {
83    /// Friendly name for this test group that will be displayed in logs.
84    pub friendly_name: String,
85    /// What kind of test run this is (inline build vs. from nextest archive).
86    pub run_kind: NextestRunKind,
87    /// Working directory the test archive was created from.
88    pub working_dir: ReadVar<PathBuf>,
89    /// Path to `.config/nextest.toml`
90    pub config_file: ReadVar<PathBuf>,
91    /// Path to any tool-specific config files
92    pub tool_config_files: Vec<(String, ReadVar<PathBuf>)>,
93    /// Nextest profile to use when running the source code (as defined in the
94    /// `.config.nextest.toml`).
95    pub nextest_profile: String,
96    /// Nextest test filter expression
97    pub nextest_filter_expr: Option<String>,
98    /// Whether to run ignored tests
99    pub run_ignored: bool,
100    /// Set rlimits to allow unlimited sized coredump file (if supported)
101    pub with_rlimit_unlimited_core_size: bool,
102    /// Additional env vars set when executing the tests.
103    pub extra_env: Option<ReadVar<BTreeMap<String, String>>>,
104    /// Wait for specified side-effects to resolve before building / running any
105    /// tests. (e.g: to allow for some ambient packages / dependencies to
106    /// get installed).
107    pub pre_run_deps: Vec<ReadVar<SideEffect>>,
108    /// Results of running the tests
109    pub results: WriteVar<TestResults>,
110}
111
112flowey_request! {
113    pub enum Request {
114        /// Set the default nextest fast fail behavior. Defaults to not
115        /// fast-failing when a single test fails.
116        DefaultNextestFailFast(bool),
117        /// Set the default behavior when a test failure is encountered.
118        /// Defaults to not terminating the job when a single test fails.
119        DefaultTerminateJobOnFail(bool),
120        Run(Run),
121    }
122}
123
124new_flow_node!(struct Node);
125
126impl FlowNode for Node {
127    type Request = Request;
128
129    fn imports(ctx: &mut ImportCtx<'_>) {
130        ctx.import::<crate::cfg_cargo_common_flags::Node>();
131        ctx.import::<crate::download_cargo_nextest::Node>();
132        ctx.import::<crate::install_cargo_nextest::Node>();
133        ctx.import::<crate::install_rust::Node>();
134        ctx.import::<crate::gen_cargo_nextest_run_cmd::Node>();
135    }
136
137    fn emit(requests: Vec<Self::Request>, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
138        let mut run = Vec::new();
139        let mut fail_fast = None;
140        let mut terminate_job_on_fail = None;
141
142        for req in requests {
143            match req {
144                Request::DefaultNextestFailFast(v) => {
145                    same_across_all_reqs("OverrideFailFast", &mut fail_fast, v)?
146                }
147                Request::DefaultTerminateJobOnFail(v) => {
148                    same_across_all_reqs("TerminateJobOnFail", &mut terminate_job_on_fail, v)?
149                }
150                Request::Run(v) => run.push(v),
151            }
152        }
153
154        let terminate_job_on_fail = terminate_job_on_fail.unwrap_or(false);
155
156        for Run {
157            friendly_name,
158            run_kind,
159            working_dir,
160            config_file,
161            tool_config_files,
162            nextest_profile,
163            extra_env,
164            with_rlimit_unlimited_core_size,
165            nextest_filter_expr,
166            run_ignored,
167            pre_run_deps,
168            results,
169        } in run
170        {
171            let run_kind_deps = match run_kind {
172                NextestRunKind::BuildAndRun(params) => {
173                    let cargo_flags = ctx.reqv(crate::cfg_cargo_common_flags::Request::GetFlags);
174
175                    let nextest_installed = ctx.reqv(crate::install_cargo_nextest::Request);
176
177                    let rust_toolchain = ctx.reqv(crate::install_rust::Request::GetRustupToolchain);
178
179                    ctx.req(crate::install_rust::Request::InstallTargetTriple(
180                        params.target.clone(),
181                    ));
182
183                    RunKindDeps::BuildAndRun {
184                        params,
185                        nextest_installed,
186                        rust_toolchain,
187                        cargo_flags,
188                    }
189                }
190                NextestRunKind::RunFromArchive {
191                    archive_file,
192                    target,
193                    nextest_bin,
194                } => {
195                    let target =
196                        target.unwrap_or(ReadVar::from_static(target_lexicon::Triple::host()));
197
198                    let nextest_bin = nextest_bin.unwrap_or_else(|| {
199                        ctx.reqv(|v| crate::download_cargo_nextest::Request::Get(target.clone(), v))
200                    });
201
202                    RunKindDeps::RunFromArchive {
203                        archive_file,
204                        nextest_bin,
205                        target,
206                    }
207                }
208            };
209
210            let cmd = ctx.reqv(|v| crate::gen_cargo_nextest_run_cmd::Request {
211                run_kind_deps,
212                working_dir: working_dir.clone(),
213                config_file: config_file.clone(),
214                tool_config_files,
215                nextest_profile: nextest_profile.clone(),
216                nextest_filter_expr,
217                run_ignored,
218                fail_fast,
219                extra_env,
220                extra_commands: None,
221                portable: false,
222                command: v,
223            });
224
225            let (all_tests_passed_read, all_tests_passed_write) = ctx.new_var();
226            let (junit_xml_read, junit_xml_write) = ctx.new_var();
227
228            ctx.emit_rust_step(format!("run '{friendly_name}' nextest tests"), |ctx| {
229                pre_run_deps.claim(ctx);
230
231                let working_dir = working_dir.claim(ctx);
232                let config_file = config_file.claim(ctx);
233                let all_tests_passed_var = all_tests_passed_write.claim(ctx);
234                let junit_xml_write = junit_xml_write.claim(ctx);
235                let cmd = cmd.claim(ctx);
236
237                move |rt| {
238                    let working_dir = rt.read(working_dir);
239                    let config_file = rt.read(config_file);
240                    let cmd = rt.read(cmd);
241
242                    // first things first - determine if junit is supported by
243                    // the profile, and if so, where the output if going to be.
244                    let junit_path = {
245                        let nextest_toml = fs_err::read_to_string(&config_file)?
246                            .parse::<toml_edit::DocumentMut>()
247                            .context("failed to parse nextest.toml")?;
248
249                        let path = Some(&nextest_toml)
250                            .and_then(|i| i.get("profile"))
251                            .and_then(|i| i.get(&nextest_profile))
252                            .and_then(|i| i.get("junit"))
253                            .and_then(|i| i.get("path"));
254
255                        if let Some(path) = path {
256                            let path: PathBuf =
257                                path.as_str().context("malformed nextest.toml")?.into();
258                            Some(path)
259                        } else {
260                            None
261                        }
262                    };
263
264                    // allow unlimited coredump sizes
265                    //
266                    // FUTURE: would be cool if `flowey` had the ability to pass
267                    // around "callbacks" as part of a, which would subsume the
268                    // need to support things like `with_env` and
269                    // `with_rlimit_unlimited_core_size`.
270                    //
271                    // This _should_ be doable using the same sort of mechanism
272                    // that regular flowey Rust-based steps get registered +
273                    // invoked. i.e: the serializable "callback" object is just
274                    // a unique identifier for a set of
275                    // (NodeHandle,callback_idx,requests), which flowey can use
276                    // to "play-through" the specified node in order to get the
277                    // caller a handle to a concrete `Box<dyn Fn...>`.
278                    //
279                    // I suspect there'll need to be some `Any` involved to get
280                    // things to line up... but honestly, this seems doable?
281                    // Will need to find time to experiment with this...
282                    #[cfg(unix)]
283                    let old_core_rlimits = if with_rlimit_unlimited_core_size
284                        && matches!(rt.platform(), FlowPlatform::Linux(_))
285                    {
286                        let limits = rlimit::getrlimit(rlimit::Resource::CORE)?;
287                        rlimit::setrlimit(
288                            rlimit::Resource::CORE,
289                            rlimit::INFINITY,
290                            rlimit::INFINITY,
291                        )?;
292                        Some(limits)
293                    } else {
294                        None
295                    };
296
297                    #[cfg(not(unix))]
298                    let _ = with_rlimit_unlimited_core_size;
299
300                    log::info!("{cmd}");
301
302                    // nextest has meaningful exit codes that we want to parse.
303                    // <https://github.com/nextest-rs/nextest/blob/main/nextest-metadata/src/exit_codes.rs#L12>
304                    //
305                    // unfortunately, xshell doesn't have a mode where it can
306                    // both emit to stdout/stderr, _and_ report the specific
307                    // exit code of the process.
308                    //
309                    // So we have to use the raw process API instead.
310                    assert_eq!(cmd.commands.len(), 1);
311                    let mut command = std::process::Command::new(&cmd.commands[0].0);
312                    command
313                        .args(&cmd.commands[0].1)
314                        .envs(&cmd.env)
315                        .current_dir(&working_dir);
316
317                    let mut child = command.spawn().with_context(|| {
318                        format!("failed to spawn '{}'", &cmd.commands[0].0.to_string_lossy())
319                    })?;
320
321                    let status = child.wait()?;
322
323                    #[cfg(unix)]
324                    if let Some((soft, hard)) = old_core_rlimits {
325                        rlimit::setrlimit(rlimit::Resource::CORE, soft, hard)?;
326                    }
327
328                    let all_tests_passed = match (status.success(), status.code()) {
329                        (true, _) => true,
330                        // documented nextest exit code for when a test has failed
331                        (false, Some(100)) => false,
332                        // any other exit code means something has gone disastrously wrong
333                        (false, _) => anyhow::bail!("failed to run nextest"),
334                    };
335
336                    rt.write(all_tests_passed_var, &all_tests_passed);
337
338                    if !all_tests_passed {
339                        log::warn!("encountered at least one test failure!");
340
341                        if terminate_job_on_fail {
342                            anyhow::bail!("terminating job (TerminateJobOnFail = true)")
343                        } else {
344                            // special string on ADO that causes step to show orange (!)
345                            // FUTURE: flowey should prob have a built-in API for this
346                            if matches!(rt.backend(), FlowBackend::Ado) {
347                                eprintln!("##vso[task.complete result=SucceededWithIssues;]")
348                            } else {
349                                log::warn!("encountered at least one test failure");
350                            }
351                        }
352                    }
353
354                    let junit_xml = if let Some(junit_path) = junit_path {
355                        let emitted_xml = working_dir
356                            .join("target")
357                            .join("nextest")
358                            .join(&nextest_profile)
359                            .join(junit_path);
360                        let final_xml = std::env::current_dir()?.join("junit.xml");
361                        // copy locally to avoid trashing the output between test runs
362                        fs_err::rename(emitted_xml, &final_xml)?;
363                        Some(final_xml.absolute()?)
364                    } else {
365                        None
366                    };
367
368                    rt.write(junit_xml_write, &junit_xml);
369
370                    Ok(())
371                }
372            });
373
374            ctx.emit_minor_rust_step("write results", |ctx| {
375                let all_tests_passed = all_tests_passed_read.claim(ctx);
376                let junit_xml = junit_xml_read.claim(ctx);
377                let results = results.claim(ctx);
378
379                move |rt| {
380                    let all_tests_passed = rt.read(all_tests_passed);
381                    let junit_xml = rt.read(junit_xml);
382
383                    rt.write(
384                        results,
385                        &TestResults {
386                            all_tests_passed,
387                            junit_xml,
388                        },
389                    );
390                }
391            });
392        }
393
394        Ok(())
395    }
396}
397
398// FUTURE: this seems like something a proc-macro can help with...
399impl build_params::NextestBuildParams {
400    pub fn claim(self, ctx: &mut StepCtx<'_>) -> build_params::NextestBuildParams<VarClaimed> {
401        let build_params::NextestBuildParams {
402            packages,
403            features,
404            no_default_features,
405            unstable_panic_abort_tests,
406            target,
407            profile,
408            extra_env,
409        } = self;
410
411        build_params::NextestBuildParams {
412            packages: packages.claim(ctx),
413            features,
414            no_default_features,
415            unstable_panic_abort_tests,
416            target,
417            profile,
418            extra_env: extra_env.claim(ctx),
419        }
420    }
421}