331 lines
12 KiB
Rust
331 lines
12 KiB
Rust
//! Execution logic for running shell commands.
|
|
|
|
use crate::config::{ShellConfig, WhichShell};
|
|
use crate::testcase::{ShellInvocation, TestCase, TestCaseSet, TestFile};
|
|
use anyhow::{Context, Result};
|
|
use assert_fs::fixture::{FileWriteStr, PathChild};
|
|
#[cfg(unix)]
|
|
use std::os::unix::{fs::PermissionsExt, process::CommandExt, process::ExitStatusExt};
|
|
use std::{path::PathBuf, process::ExitStatus};
|
|
|
|
/// Default timeout for test commands in seconds.
|
|
pub const DEFAULT_TIMEOUT_IN_SECONDS: u64 = 15;
|
|
|
|
/// Result of running a shell command.
|
|
#[derive(Debug)]
|
|
pub struct RunResult {
|
|
/// Exit status of the command.
|
|
pub exit_status: ExitStatus,
|
|
/// Standard output.
|
|
pub stdout: String,
|
|
/// Standard error.
|
|
pub stderr: String,
|
|
/// Duration of the command.
|
|
pub duration: std::time::Duration,
|
|
}
|
|
|
|
impl TestCase {
|
|
/// Runs this test case with the given shell configuration.
|
|
pub async fn run_shell(
|
|
&self,
|
|
shell_config: &ShellConfig,
|
|
working_dir: &assert_fs::TempDir,
|
|
) -> Result<RunResult> {
|
|
let test_cmd = self.create_command_for_shell(shell_config, working_dir);
|
|
|
|
let result = if self.pty {
|
|
self.run_command_with_pty(test_cmd).await?
|
|
} else {
|
|
self.run_command_with_stdin(test_cmd).await?
|
|
};
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Creates the test files in the given temporary directory.
|
|
pub fn create_test_files_in(
|
|
&self,
|
|
temp_dir: &assert_fs::TempDir,
|
|
test_case_set: &TestCaseSet,
|
|
) -> Result<()> {
|
|
for test_file in test_case_set
|
|
.common_test_files
|
|
.iter()
|
|
.chain(self.test_files.iter())
|
|
{
|
|
Self::create_test_file(temp_dir, test_file, &test_case_set.source_dir)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn create_test_file(
|
|
temp_dir: &assert_fs::TempDir,
|
|
test_file: &TestFile,
|
|
source_dir: &std::path::Path,
|
|
) -> Result<()> {
|
|
let test_file_path = temp_dir.child(test_file.path.as_path());
|
|
|
|
if let Some(source_path) = &test_file.source_path {
|
|
if !test_file.contents.is_empty() {
|
|
return Err(anyhow::anyhow!(
|
|
"test file {} has both contents and source_path",
|
|
test_file_path.to_string_lossy()
|
|
));
|
|
}
|
|
|
|
if source_path.is_absolute() {
|
|
return Err(anyhow::anyhow!(
|
|
"source_path {} is not a relative path",
|
|
source_path.to_string_lossy()
|
|
));
|
|
}
|
|
|
|
let abs_source_path = source_dir.join(source_path);
|
|
|
|
let source_contents = std::fs::read_to_string(&abs_source_path)
|
|
.with_context(|| format!("reading {}", abs_source_path.to_string_lossy()))?;
|
|
|
|
test_file_path.write_str(source_contents.as_str())?;
|
|
} else {
|
|
test_file_path.write_str(test_file.contents.as_str())?;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
if test_file.executable {
|
|
// chmod u+x
|
|
let mut perms = test_file_path.metadata()?.permissions();
|
|
perms.set_mode(perms.mode() | 0o100);
|
|
std::fs::set_permissions(test_file_path, perms)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Constructs a `Command` to invoke the given shell binary, optionally
|
|
/// prepending a launcher (e.g., `["wasmtime", "run", "--"]`). When a
|
|
/// launcher is provided, the first element becomes the program to execute
|
|
/// and the rest are passed as leading arguments before the shell binary path.
|
|
fn new_shell_command(
|
|
shell_path: &std::path::Path,
|
|
launcher: Option<&[String]>,
|
|
) -> std::process::Command {
|
|
if let Some([program, leading_args @ ..]) = launcher {
|
|
let mut cmd = std::process::Command::new(program);
|
|
cmd.args(leading_args);
|
|
cmd.arg(shell_path);
|
|
cmd
|
|
} else {
|
|
std::process::Command::new(shell_path)
|
|
}
|
|
}
|
|
|
|
fn create_command_for_shell(
|
|
&self,
|
|
shell_config: &ShellConfig,
|
|
working_dir: &assert_fs::TempDir,
|
|
) -> std::process::Command {
|
|
let (mut test_cmd, coverage_target_dir) = match self.invocation {
|
|
ShellInvocation::ExecShellBinary => match &shell_config.which {
|
|
WhichShell::ShellUnderTest(name) => {
|
|
let cli_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
let default_target_dir = || cli_dir.parent().unwrap().join("target");
|
|
let target_dir = std::env::var("CARGO_TARGET_DIR")
|
|
.ok()
|
|
.map_or_else(default_target_dir, PathBuf::from);
|
|
(
|
|
Self::new_shell_command(name, shell_config.launcher.as_deref()),
|
|
Some(target_dir),
|
|
)
|
|
}
|
|
// Launcher only applies to the shell under test; the oracle is invoked directly.
|
|
WhichShell::NamedShell(name) => (Self::new_shell_command(name, None), None),
|
|
},
|
|
ShellInvocation::ExecScript(_) => unimplemented!("exec script test"),
|
|
};
|
|
|
|
if matches!(shell_config.which, WhichShell::ShellUnderTest(_)) {
|
|
for arg in &self.additional_test_args {
|
|
test_cmd.arg(arg);
|
|
}
|
|
}
|
|
|
|
for arg in &shell_config.default_args {
|
|
if !self.removed_default_args.contains(arg) {
|
|
test_cmd.arg(arg);
|
|
}
|
|
}
|
|
|
|
// Clear all environment vars for consistency.
|
|
test_cmd.args(&self.args).env_clear();
|
|
|
|
// Set locale to C for consistent behavior across systems.
|
|
test_cmd.env("LC_ALL", "C");
|
|
// Hard-code a well known prompt for PS1.
|
|
test_cmd.env("PS1", "test$ ");
|
|
// Try to get decent backtraces when problems get hit.
|
|
test_cmd.env("RUST_BACKTRACE", "1");
|
|
// Compute a PATH that contains what we need.
|
|
test_cmd.env("PATH", shell_config.compute_test_path_var());
|
|
|
|
// Set up any env vars needed for collecting coverage data.
|
|
if let Some(coverage_target_dir) = &coverage_target_dir {
|
|
test_cmd.env("CARGO_LLVM_COV_TARGET_DIR", coverage_target_dir);
|
|
test_cmd.env(
|
|
"LLVM_PROFILE_FILE",
|
|
coverage_target_dir.join("brush-%p-%40m.profraw"),
|
|
);
|
|
}
|
|
|
|
for (k, v) in &self.env {
|
|
test_cmd.env(k, v);
|
|
}
|
|
|
|
if let Some(home_dir) = &self.home_dir {
|
|
let abs_home_dir = if home_dir.is_relative() {
|
|
working_dir.join(home_dir)
|
|
} else {
|
|
home_dir.to_owned()
|
|
};
|
|
|
|
test_cmd.env("HOME", abs_home_dir.to_string_lossy().to_string());
|
|
}
|
|
|
|
test_cmd.current_dir(working_dir.to_string_lossy().to_string());
|
|
|
|
test_cmd
|
|
}
|
|
|
|
#[expect(clippy::unused_async)]
|
|
#[cfg(not(unix))]
|
|
async fn run_command_with_pty(&self, _cmd: std::process::Command) -> Result<RunResult> {
|
|
Err(anyhow::anyhow!("pty test not supported on this platform"))
|
|
}
|
|
|
|
#[expect(clippy::unused_async)]
|
|
#[cfg(unix)]
|
|
async fn run_command_with_pty(&self, cmd: std::process::Command) -> Result<RunResult> {
|
|
use crate::util::{make_expectrl_output_readable, read_expectrl_log};
|
|
use expectrl::{Expect, process::Termios as _};
|
|
|
|
let mut log = Vec::new();
|
|
let writer = std::io::Cursor::new(&mut log);
|
|
|
|
let start_time = std::time::Instant::now();
|
|
let mut p = expectrl::session::log(expectrl::Session::spawn(cmd)?, writer)?;
|
|
p.set_echo(true)?;
|
|
|
|
if let Some(stdin) = &self.stdin {
|
|
for line in stdin.lines() {
|
|
if let Some(expectation) = line.strip_prefix("#expect:") {
|
|
if let Err(inner) = p.expect(expectation) {
|
|
return Ok(RunResult {
|
|
exit_status: ExitStatus::from_raw(1),
|
|
stdout: read_expectrl_log(log).unwrap_or_default(),
|
|
stderr: std::format!("failed to expect '{expectation}': {inner}"),
|
|
duration: start_time.elapsed(),
|
|
});
|
|
}
|
|
} else if let Some(control_code) = line.strip_prefix("#send:") {
|
|
match control_code.to_lowercase().as_str() {
|
|
"ctrl+d" => p.send(expectrl::ControlCode::EndOfTransmission)?,
|
|
"tab" => p.send(expectrl::ControlCode::HorizontalTabulation)?,
|
|
"enter" => p.send(expectrl::ControlCode::LineFeed)?,
|
|
_ => (),
|
|
}
|
|
} else if line.trim() == "#expect-prompt" {
|
|
if let Err(inner) = p.expect("test$ ") {
|
|
return Ok(RunResult {
|
|
exit_status: ExitStatus::from_raw(1),
|
|
stdout: read_expectrl_log(log).unwrap_or_default(),
|
|
stderr: std::format!("failed to expect prompt: {inner}"),
|
|
duration: start_time.elapsed(),
|
|
});
|
|
}
|
|
} else {
|
|
p.send(line)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Err(inner) = p.expect(expectrl::Eof) {
|
|
return Ok(RunResult {
|
|
exit_status: ExitStatus::from_raw(1),
|
|
stdout: read_expectrl_log(log).unwrap_or_default(),
|
|
stderr: std::format!("failed to expect EOF: {inner}"),
|
|
duration: start_time.elapsed(),
|
|
});
|
|
}
|
|
|
|
let mut wait_status = p.get_process().status()?;
|
|
|
|
if matches!(wait_status, expectrl::process::unix::WaitStatus::StillAlive) {
|
|
// Try to terminate it safely.
|
|
p.get_process_mut()
|
|
.kill(expectrl::process::unix::Signal::SIGTERM)?;
|
|
wait_status = p.get_process().wait()?;
|
|
}
|
|
|
|
let duration = start_time.elapsed();
|
|
let output = read_expectrl_log(log)?;
|
|
let cleaned = make_expectrl_output_readable(output);
|
|
|
|
match wait_status {
|
|
expectrl::process::unix::WaitStatus::Exited(_, code) => Ok(RunResult {
|
|
exit_status: ExitStatus::from_raw(code),
|
|
stdout: cleaned,
|
|
stderr: String::new(),
|
|
duration,
|
|
}),
|
|
expectrl::process::unix::WaitStatus::Signaled(_, _, _) => {
|
|
Err(anyhow::anyhow!("process was signaled"))
|
|
}
|
|
_ => Err(anyhow::anyhow!(
|
|
"unexpected status for process: {wait_status:?}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[expect(clippy::unused_async)]
|
|
#[allow(unused_mut, reason = "only mutated on some platforms")]
|
|
async fn run_command_with_stdin(&self, mut cmd: std::process::Command) -> Result<RunResult> {
|
|
// SAFETY:
|
|
// To avoid bash trying to directly access /dev/tty and generate tty-related signals,
|
|
// we create a new session for the child process. The standard library has a setsid()
|
|
// API but it's unstable, so we use nix here. Calling pre_exec can be unsafe as
|
|
// it runs in the child process after fork() but before exec(), and there are constraints
|
|
// around what can be safely done in that context. However, calling setsid() is generally
|
|
// considered safe as it doesn't allocate memory or perform complex operations to forked
|
|
// state.
|
|
#[cfg(unix)]
|
|
unsafe {
|
|
cmd.pre_exec(|| {
|
|
let _ = nix::unistd::setsid();
|
|
Ok(())
|
|
})
|
|
};
|
|
|
|
let mut test_cmd = assert_cmd::Command::from_std(cmd);
|
|
|
|
test_cmd.timeout(std::time::Duration::from_secs(
|
|
self.timeout_in_seconds
|
|
.unwrap_or(DEFAULT_TIMEOUT_IN_SECONDS),
|
|
));
|
|
|
|
if let Some(stdin) = &self.stdin {
|
|
test_cmd.write_stdin(stdin.as_bytes());
|
|
}
|
|
|
|
let start_time = std::time::Instant::now();
|
|
let cmd_result = test_cmd.output()?;
|
|
let duration = start_time.elapsed();
|
|
|
|
Ok(RunResult {
|
|
exit_status: cmd_result.status,
|
|
stdout: String::from_utf8_lossy(cmd_result.stdout.as_slice()).to_string(),
|
|
stderr: String::from_utf8_lossy(cmd_result.stderr.as_slice()).to_string(),
|
|
duration,
|
|
})
|
|
}
|
|
}
|