Merge nucleic/sleek-thistle-egret-fyej into dev
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
//! 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user