integration_tests/
nextest_cli.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::env::TestEnvInfo;
5use camino::Utf8PathBuf;
6use color_eyre::{
7    Result,
8    eyre::{Context, bail, eyre},
9};
10use nextest_metadata::TestListSummary;
11#[cfg(windows)]
12use std::os::windows::io::AsRawHandle as _;
13use std::{
14    borrow::Cow,
15    collections::HashMap,
16    ffi::OsString,
17    fmt,
18    io::{self, Read, Write},
19    iter,
20    process::{Command, ExitStatus, Stdio},
21};
22#[cfg(windows)]
23use windows_sys::Win32::System::JobObjects::TerminateJobObject;
24
25pub fn cargo_bin() -> String {
26    match std::env::var("CARGO") {
27        Ok(v) => v,
28        Err(std::env::VarError::NotPresent) => "cargo".to_owned(),
29        Err(err) => panic!("error obtaining CARGO env var: {err}"),
30    }
31}
32
33#[derive(Clone, Debug)]
34pub struct CargoNextestCli {
35    bin: Utf8PathBuf,
36    args: Vec<String>,
37    envs: HashMap<OsString, OsString>,
38    envs_remove: Vec<OsString>,
39    current_dir: Option<Utf8PathBuf>,
40    unchecked: bool,
41}
42
43impl CargoNextestCli {
44    pub fn for_test(env_info: &TestEnvInfo) -> Self {
45        Self {
46            bin: env_info.cargo_nextest_dup_bin.clone(),
47            args: vec!["nextest".to_owned(), "--no-pager".to_owned()],
48            envs: HashMap::new(),
49            envs_remove: Vec::new(),
50            current_dir: None,
51            unchecked: false,
52        }
53    }
54
55    /// Creates a new CargoNextestCli instance for use in a setup script.
56    ///
57    /// Scripts don't have access to `CARGO_BIN_EXE_*` or `NEXTEST_BIN_EXE_*` environment
58    /// variables, so we run `cargo run --bin cargo-nextest-dup nextest debug current-exe` instead.
59    pub fn for_script() -> Result<Self> {
60        let cargo_bin = cargo_bin();
61        let mut command = std::process::Command::new(&cargo_bin);
62        command.args([
63            "run",
64            "--bin",
65            "cargo-nextest-dup",
66            "--",
67            "nextest",
68            "debug",
69            "current-exe",
70        ]);
71        let output = command.output().wrap_err("failed to get current exe")?;
72
73        let output = CargoNextestOutput {
74            command: Box::new(command),
75            exit_status: output.status,
76            stdout: output.stdout,
77            stderr: output.stderr,
78        };
79
80        if !output.exit_status.success() {
81            bail!("failed to get current exe:\n\n{output:?}");
82        }
83
84        // The output is the path to the current exe.
85        let exe =
86            String::from_utf8(output.stdout).wrap_err("current exe output isn't valid UTF-8")?;
87
88        Ok(Self {
89            bin: Utf8PathBuf::from(exe.trim_end()),
90            args: vec!["nextest".to_owned()],
91            envs: HashMap::new(),
92            envs_remove: Vec::new(),
93            current_dir: None,
94            unchecked: false,
95        })
96    }
97
98    pub fn arg(&mut self, arg: impl Into<String>) -> &mut Self {
99        self.args.push(arg.into());
100        self
101    }
102
103    pub fn args(&mut self, arg: impl IntoIterator<Item = impl Into<String>>) -> &mut Self {
104        self.args.extend(arg.into_iter().map(Into::into));
105        self
106    }
107
108    pub fn env(&mut self, k: impl Into<OsString>, v: impl Into<OsString>) -> &mut Self {
109        self.envs.insert(k.into(), v.into());
110        self
111    }
112
113    pub fn envs(
114        &mut self,
115        envs: impl IntoIterator<Item = (impl Into<OsString>, impl Into<OsString>)>,
116    ) -> &mut Self {
117        self.envs
118            .extend(envs.into_iter().map(|(k, v)| (k.into(), v.into())));
119        self
120    }
121
122    pub fn env_remove(&mut self, k: impl Into<OsString>) -> &mut Self {
123        self.envs_remove.push(k.into());
124        self
125    }
126
127    pub fn unchecked(&mut self, unchecked: bool) -> &mut Self {
128        self.unchecked = unchecked;
129        self
130    }
131
132    pub fn current_dir(&mut self, dir: impl Into<Utf8PathBuf>) -> &mut Self {
133        self.current_dir = Some(dir.into());
134        self
135    }
136
137    pub fn output(&self) -> CargoNextestOutput {
138        let mut command = Command::new(&self.bin);
139        command.args(&self.args);
140        // Apply env_remove first, then envs, so explicit env() calls can
141        // override env_remove().
142        for k in &self.envs_remove {
143            command.env_remove(k);
144        }
145        command.envs(&self.envs);
146        if let Some(dir) = &self.current_dir {
147            command.current_dir(dir);
148        }
149        command
150            .stdin(Stdio::null())
151            .stdout(Stdio::piped())
152            .stderr(Stdio::piped());
153
154        let command_str = shell_words::join(
155            iter::once(self.bin.as_str()).chain(self.args.iter().map(|s| s.as_str())),
156        );
157        eprintln!("*** executing: {command_str}");
158
159        let mut child = command.spawn().expect("process spawn succeeded");
160
161        // On Windows, wrap the child in a job object so that any leaked
162        // grandchildren are killed when we terminate the job. This prevents
163        // leaked processes from holding onto pipe handles and causing the
164        // *outer* test to be detected as leaky.
165        //
166        // This is best-effort: if job creation or assignment fails, we proceed
167        // without it.
168        #[cfg(windows)]
169        let job = {
170            win32job::Job::create_with_limit_info(
171                win32job::ExtendedLimitInfo::new().limit_breakaway_ok(),
172            )
173            .ok()
174            .inspect(|job| {
175                let handle = child.as_raw_handle();
176                _ = job.assign_process(handle as _);
177            })
178        };
179
180        let mut stdout = child.stdout.take().expect("stdout is a pipe");
181        let mut stderr = child.stderr.take().expect("stderr is a pipe");
182
183        let stdout_thread = std::thread::spawn(move || {
184            let mut stdout_buf = Vec::new();
185            loop {
186                let mut buffer = [0; 1024];
187                match stdout.read(&mut buffer) {
188                    Ok(n @ 1..) => {
189                        stdout_buf.extend_from_slice(&buffer[..n]);
190                        let mut io_stdout = std::io::stdout().lock();
191                        io_stdout
192                            .write_all(&buffer[..n])
193                            .wrap_err("error writing to our stdout")?;
194                        io_stdout.flush().wrap_err("error flushing our stdout")?;
195                    }
196                    Ok(0) => break Ok(stdout_buf),
197                    Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
198                    Err(error) => {
199                        break Err(eyre!(error).wrap_err("error reading from child stdout"));
200                    }
201                }
202            }
203        });
204
205        let stderr_thread = std::thread::spawn(move || {
206            let mut stderr_buf = Vec::new();
207            loop {
208                let mut buffer = [0; 1024];
209                match stderr.read(&mut buffer) {
210                    Ok(n @ 1..) => {
211                        stderr_buf.extend_from_slice(&buffer[..n]);
212                        let mut io_stderr = std::io::stderr().lock();
213                        io_stderr
214                            .write_all(&buffer[..n])
215                            .wrap_err("error writing to our stderr")?;
216                        io_stderr.flush().wrap_err("error flushing our stderr")?;
217                    }
218                    Ok(0) => break Ok(stderr_buf),
219                    Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
220                    Err(error) => {
221                        break Err(eyre!(error).wrap_err("error reading from child stderr"));
222                    }
223                }
224            }
225        });
226
227        // Wait for the child process to finish first. The stdout and stderr
228        // threads will exit once the process has exited and the pipes' write
229        // ends have been closed.
230        let exit_status = child.wait().expect("child process exited");
231
232        // On Windows, terminate the job object to kill any leaked grandchildren.
233        // This releases any pipe handles they were holding, allowing
234        // stdout/stderr to see EOF and the parent test to not be marked leaky.
235        #[cfg(windows)]
236        if let Some(job) = job {
237            let handle = job.handle();
238            // SAFETY: The handle is valid because we created the job object.
239            unsafe {
240                // Ignore the error here -- it's likely due to the process exiting.
241                // Note: 1 is the exit code returned by Windows.
242                _ = TerminateJobObject(handle as _, 1);
243            }
244        }
245
246        let stdout_buf = stdout_thread
247            .join()
248            .expect("stdout thread exited without panicking")
249            .expect("wrote to our stdout successfully");
250        let stderr_buf = stderr_thread
251            .join()
252            .expect("stderr thread exited without panicking")
253            .expect("wrote to our stderr successfully");
254
255        let ret = CargoNextestOutput {
256            command: Box::new(command),
257            exit_status,
258            stdout: stdout_buf,
259            stderr: stderr_buf,
260        };
261
262        eprintln!("*** command {command_str} exited with status {exit_status}");
263
264        if !self.unchecked && !exit_status.success() {
265            panic!("command failed");
266        }
267
268        ret
269    }
270}
271
272pub struct CargoNextestOutput {
273    pub command: Box<Command>,
274    pub exit_status: ExitStatus,
275    pub stdout: Vec<u8>,
276    pub stderr: Vec<u8>,
277}
278
279impl CargoNextestOutput {
280    pub fn stdout_as_str(&self) -> Cow<'_, str> {
281        String::from_utf8_lossy(&self.stdout)
282    }
283
284    pub fn stderr_as_str(&self) -> Cow<'_, str> {
285        String::from_utf8_lossy(&self.stderr)
286    }
287
288    pub fn decode_test_list_json(&self) -> Result<TestListSummary> {
289        Ok(serde_json::from_slice(&self.stdout)?)
290    }
291
292    /// Returns the output as a (hopefully) platform-independent snapshot that
293    /// can be checked in and compared.
294    pub fn to_snapshot(&self) -> String {
295        // Don't include the command as its representation is
296        // platform-dependent.
297        let output = format!(
298            "exit code: {:?}\n\
299            --- stdout ---\n{}\n\n--- stderr ---\n{}\n",
300            self.exit_status.code(),
301            String::from_utf8_lossy(&self.stdout),
302            String::from_utf8_lossy(&self.stderr),
303        );
304
305        // Turn "exit status" and "exit code" into "exit status|code"
306        let output = output.replace("exit status: ", "exit status|code: ");
307        output.replace("exit code: ", "exit status|code: ")
308    }
309}
310
311impl fmt::Display for CargoNextestOutput {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        write!(
314            f,
315            "command: {:?}\nexit code: {:?}\n\
316                   --- stdout ---\n{}\n\n--- stderr ---\n{}\n\n",
317            self.command,
318            self.exit_status.code(),
319            String::from_utf8_lossy(&self.stdout),
320            String::from_utf8_lossy(&self.stderr)
321        )
322    }
323}
324
325// Make Debug output the same as Display output, so `.unwrap()` and `.expect()` are nicer.
326impl fmt::Debug for CargoNextestOutput {
327    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328        fmt::Display::fmt(self, f)
329    }
330}