Skip to main content

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