integration_tests/
nextest_cli.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use camino::Utf8PathBuf;
5use color_eyre::{
6    Result,
7    eyre::{Context, bail, eyre},
8};
9use nextest_metadata::TestListSummary;
10use std::{
11    borrow::Cow,
12    collections::HashMap,
13    ffi::OsString,
14    fmt,
15    io::{self, Read, Write},
16    iter,
17    process::{Command, ExitStatus, Stdio},
18};
19
20pub fn cargo_bin() -> String {
21    match std::env::var("CARGO") {
22        Ok(v) => v,
23        Err(std::env::VarError::NotPresent) => "cargo".to_owned(),
24        Err(err) => panic!("error obtaining CARGO env var: {err}"),
25    }
26}
27
28#[derive(Clone, Debug)]
29pub struct CargoNextestCli {
30    bin: Utf8PathBuf,
31    args: Vec<String>,
32    envs: HashMap<OsString, OsString>,
33    unchecked: bool,
34}
35
36impl CargoNextestCli {
37    pub fn for_test() -> Self {
38        let bin = std::env::var("NEXTEST_BIN_EXE_cargo-nextest-dup")
39            .expect("unable to find cargo-nextest-dup");
40        Self {
41            bin: bin.into(),
42            args: vec!["nextest".to_owned()],
43            envs: HashMap::new(),
44            unchecked: false,
45        }
46    }
47
48    /// Creates a new CargoNextestCli instance for use in a setup script.
49    ///
50    /// Scripts don't have access to the `NEXTEST_BIN_EXE_cargo-nextest-dup` environment variable,
51    /// so we run `cargo run --bin cargo-nextest-dup nextest debug current-exe` instead.
52    pub fn for_script() -> Result<Self> {
53        let cargo_bin = cargo_bin();
54        let mut command = std::process::Command::new(&cargo_bin);
55        command.args([
56            "run",
57            "--bin",
58            "cargo-nextest-dup",
59            "--",
60            "nextest",
61            "debug",
62            "current-exe",
63        ]);
64        let output = command.output().wrap_err("failed to get current exe")?;
65
66        let output = CargoNextestOutput {
67            command: Box::new(command),
68            exit_status: output.status,
69            stdout: output.stdout,
70            stderr: output.stderr,
71        };
72
73        if !output.exit_status.success() {
74            bail!("failed to get current exe:\n\n{output:?}");
75        }
76
77        // The output is the path to the current exe.
78        let exe =
79            String::from_utf8(output.stdout).wrap_err("current exe output isn't valid UTF-8")?;
80
81        Ok(Self {
82            bin: Utf8PathBuf::from(exe.trim_end()),
83            args: vec!["nextest".to_owned()],
84            envs: HashMap::new(),
85            unchecked: false,
86        })
87    }
88
89    pub fn arg(&mut self, arg: impl Into<String>) -> &mut Self {
90        self.args.push(arg.into());
91        self
92    }
93
94    pub fn args(&mut self, arg: impl IntoIterator<Item = impl Into<String>>) -> &mut Self {
95        self.args.extend(arg.into_iter().map(Into::into));
96        self
97    }
98
99    pub fn env(&mut self, k: impl Into<OsString>, v: impl Into<OsString>) -> &mut Self {
100        self.envs.insert(k.into(), v.into());
101        self
102    }
103
104    pub fn envs(
105        &mut self,
106        envs: impl IntoIterator<Item = (impl Into<OsString>, impl Into<OsString>)>,
107    ) -> &mut Self {
108        self.envs
109            .extend(envs.into_iter().map(|(k, v)| (k.into(), v.into())));
110        self
111    }
112
113    pub fn unchecked(&mut self, unchecked: bool) -> &mut Self {
114        self.unchecked = unchecked;
115        self
116    }
117
118    pub fn output(&self) -> CargoNextestOutput {
119        let mut command = Command::new(&self.bin);
120        command.args(&self.args);
121        command.envs(&self.envs);
122        command
123            .stdin(Stdio::null())
124            .stdout(Stdio::piped())
125            .stderr(Stdio::piped());
126
127        let command_str = shell_words::join(
128            iter::once(self.bin.as_str()).chain(self.args.iter().map(|s| s.as_str())),
129        );
130        eprintln!("*** executing: {command_str}");
131
132        let mut child = command.spawn().expect("process spawn succeeded");
133        let mut stdout = child.stdout.take().expect("stdout is a pipe");
134        let mut stderr = child.stderr.take().expect("stderr is a pipe");
135
136        let stdout_thread = std::thread::spawn(move || {
137            let mut stdout_buf = Vec::new();
138            loop {
139                let mut buffer = [0; 1024];
140                match stdout.read(&mut buffer) {
141                    Ok(n @ 1..) => {
142                        stdout_buf.extend_from_slice(&buffer[..n]);
143                        let mut io_stdout = std::io::stdout().lock();
144                        io_stdout
145                            .write_all(&buffer[..n])
146                            .wrap_err("error writing to our stdout")?;
147                        io_stdout.flush().wrap_err("error flushing our stdout")?;
148                    }
149                    Ok(0) => break Ok(stdout_buf),
150                    Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
151                    Err(error) => {
152                        break Err(eyre!(error).wrap_err("error reading from child stdout"));
153                    }
154                }
155            }
156        });
157
158        let stderr_thread = std::thread::spawn(move || {
159            let mut stderr_buf = Vec::new();
160            loop {
161                let mut buffer = [0; 1024];
162                match stderr.read(&mut buffer) {
163                    Ok(n @ 1..) => {
164                        stderr_buf.extend_from_slice(&buffer[..n]);
165                        let mut io_stderr = std::io::stderr().lock();
166                        io_stderr
167                            .write_all(&buffer[..n])
168                            .wrap_err("error writing to our stderr")?;
169                        io_stderr.flush().wrap_err("error flushing our stderr")?;
170                    }
171                    Ok(0) => break Ok(stderr_buf),
172                    Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
173                    Err(error) => {
174                        break Err(eyre!(error).wrap_err("error reading from child stderr"));
175                    }
176                }
177            }
178        });
179
180        // Wait for the child process to finish first. The stdout and stderr
181        // threads will exit once the process has exited and the pipes' write
182        // ends have been closed.
183        let exit_status = child.wait().expect("child process exited");
184
185        let stdout_buf = stdout_thread
186            .join()
187            .expect("stdout thread exited without panicking")
188            .expect("wrote to our stdout successfully");
189        let stderr_buf = stderr_thread
190            .join()
191            .expect("stderr thread exited without panicking")
192            .expect("wrote to our stderr successfully");
193
194        let ret = CargoNextestOutput {
195            command: Box::new(command),
196            exit_status,
197            stdout: stdout_buf,
198            stderr: stderr_buf,
199        };
200
201        eprintln!("*** command {command_str} exited with status {exit_status}");
202
203        if !self.unchecked && !exit_status.success() {
204            panic!("command failed");
205        }
206
207        ret
208    }
209}
210
211pub struct CargoNextestOutput {
212    pub command: Box<Command>,
213    pub exit_status: ExitStatus,
214    pub stdout: Vec<u8>,
215    pub stderr: Vec<u8>,
216}
217
218impl CargoNextestOutput {
219    pub fn stdout_as_str(&self) -> Cow<'_, str> {
220        String::from_utf8_lossy(&self.stdout)
221    }
222
223    pub fn stderr_as_str(&self) -> Cow<'_, str> {
224        String::from_utf8_lossy(&self.stderr)
225    }
226
227    pub fn decode_test_list_json(&self) -> Result<TestListSummary> {
228        Ok(serde_json::from_slice(&self.stdout)?)
229    }
230
231    /// Returns the output as a (hopefully) platform-independent snapshot that
232    /// can be checked in and compared.
233    pub fn to_snapshot(&self) -> String {
234        // Don't include the command as its representation is
235        // platform-dependent.
236        let output = format!(
237            "exit code: {:?}\n\
238            --- stdout ---\n{}\n\n--- stderr ---\n{}\n",
239            self.exit_status.code(),
240            String::from_utf8_lossy(&self.stdout),
241            String::from_utf8_lossy(&self.stderr),
242        );
243
244        // Turn "exit status" and "exit code" into "exit status|code"
245        let output = output.replace("exit status: ", "exit status|code: ");
246        output.replace("exit code: ", "exit status|code: ")
247    }
248}
249
250impl fmt::Display for CargoNextestOutput {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        write!(
253            f,
254            "command: {:?}\nexit code: {:?}\n\
255                   --- stdout ---\n{}\n\n--- stderr ---\n{}\n\n",
256            self.command,
257            self.exit_status.code(),
258            String::from_utf8_lossy(&self.stdout),
259            String::from_utf8_lossy(&self.stderr)
260        )
261    }
262}
263
264// Make Debug output the same as Display output, so `.unwrap()` and `.expect()` are nicer.
265impl fmt::Debug for CargoNextestOutput {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        fmt::Display::fmt(self, f)
268    }
269}