Skip to main content

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