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