1
0
Fork 0
mirror of https://github.com/imjasonh/testscript-rs synced 2026-07-09 09:36:48 +00:00
testscript-rs/src/run/environment.rs
copilot-swe-agent[bot] fe5da3cebe Implement TestWork functionality for preserving working directories on failure
Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
2025-09-27 17:27:10 +00:00

367 lines
12 KiB
Rust

//! Test execution environment management
use crate::error::{Error, Result};
use crate::parser::TxtarFile;
use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::{Child, Command as StdCommand, Output, Stdio};
use tempfile::TempDir;
/// Test execution environment for a single script run
pub struct TestEnvironment {
/// The root temporary directory for the test run
pub work_dir: PathBuf,
/// The underlying TempDir that cleans up on drop
_temp_dir: TempDir,
/// Environment variables for this specific run
pub env_vars: HashMap<String, String>,
/// Current working directory relative to work_dir
pub current_dir: PathBuf,
/// Output from the last executed command
pub last_output: Option<Output>,
/// Background processes indexed by name
pub background_processes: HashMap<String, Child>,
/// Standard input content for the next exec command
pub next_stdin: Option<Vec<u8>>,
/// Whether the test should be skipped
pub should_skip: bool,
/// Whether the test should stop early (but pass)
pub should_stop: bool,
}
impl TestEnvironment {
/// Create a new test environment with a temporary directory
pub fn new() -> Result<Self> {
let temp_dir = tempfile::tempdir()?;
let work_dir = temp_dir.path().to_path_buf();
Ok(TestEnvironment {
work_dir: work_dir.clone(),
_temp_dir: temp_dir,
env_vars: HashMap::new(),
current_dir: work_dir,
last_output: None,
background_processes: HashMap::new(),
next_stdin: None,
should_skip: false,
should_stop: false,
})
}
/// Set up files from the parsed script in the work directory
pub fn setup_files(&mut self, files: &[TxtarFile]) -> Result<()> {
for file in files {
let file_path = self.work_dir.join(&file.name);
// Create parent directories if needed
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
// Write the file contents
fs::write(&file_path, &file.contents)?;
}
Ok(())
}
/// Execute a command in the current test environment
pub fn execute_command(&mut self, cmd: &str, args: &[String]) -> Result<Output> {
let mut command = StdCommand::new(cmd);
command
.args(args)
.current_dir(&self.current_dir)
.envs(&self.env_vars);
let output = if let Some(stdin_content) = self.next_stdin.take() {
command.stdin(Stdio::piped());
let mut child = command.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
stdin.write_all(&stdin_content)?;
}
child.wait_with_output()?
} else {
command.output()?
};
self.last_output = Some(output.clone());
Ok(output)
}
/// Execute a command in the background
pub fn execute_background_command(
&mut self,
name: &str,
cmd: &str,
args: &[String],
) -> Result<()> {
let mut command = StdCommand::new(cmd);
command
.args(args)
.current_dir(&self.current_dir)
.envs(&self.env_vars)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = command.spawn()?;
self.background_processes.insert(name.to_string(), child);
Ok(())
}
/// Wait for a background process to complete
pub fn wait_for_background(&mut self, name: &str) -> Result<Output> {
if let Some(child) = self.background_processes.remove(name) {
let output = child.wait_with_output()?;
self.last_output = Some(output.clone());
Ok(output)
} else {
Err(Error::command_error(
"wait",
format!("No background process named '{}'", name),
))
}
}
/// Change the current directory (relative to work_dir)
pub fn change_directory(&mut self, path: &str) -> Result<()> {
let new_dir = if path.starts_with('/') || path.contains(':') {
// Absolute path - not allowed in tests for security
return Err(Error::command_error("cd", "Absolute paths not allowed"));
} else {
self.work_dir.join(path)
};
if !new_dir.exists() {
return Err(Error::command_error(
"cd",
format!("Directory '{}' does not exist", path),
));
}
if !new_dir.is_dir() {
return Err(Error::command_error(
"cd",
format!("'{}' is not a directory", path),
));
}
self.current_dir = new_dir;
Ok(())
}
/// Compare two files for equality
pub fn compare_files(&self, file1: &str, file2: &str) -> Result<()> {
let path1 = self.work_dir.join(file1);
let path2 = self.work_dir.join(file2);
let contents1 = fs::read(&path1)
.map_err(|e| Error::command_error("cmp", format!("Cannot read '{}': {}", file1, e)))?;
let contents2 = fs::read(&path2)
.map_err(|e| Error::command_error("cmp", format!("Cannot read '{}': {}", file2, e)))?;
if contents1 != contents2 {
let content1_str = String::from_utf8_lossy(&contents1);
let content2_str = String::from_utf8_lossy(&contents2);
return Err(Error::FileCompare {
message: format!(
"Files differ:\n'{}' contains:\n{}\n\n'{}' contains:\n{}",
file1, content1_str, file2, content2_str
),
});
}
Ok(())
}
/// Compare command output with expected content
pub fn compare_output(&self, output_type: &str, expected: &str) -> Result<()> {
let actual = match self.last_output {
Some(ref output) => match output_type {
"stdout" => String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_string(),
"stderr" => String::from_utf8_lossy(&output.stderr)
.trim_end()
.to_string(),
_ => return Err(Error::command_error(output_type, "Unknown output type")),
},
None => {
return Err(Error::command_error(
output_type,
"No command output available",
))
}
};
// Substitute environment variables in the expected pattern
let expected_substituted = self.substitute_env_vars(expected);
// Check if expected is a regex pattern (contains regex special characters)
if expected_substituted.contains('^')
|| expected_substituted.contains('$')
|| expected_substituted.contains('[')
|| expected_substituted.contains('(')
|| expected_substituted.contains('*')
|| expected_substituted.contains('.')
{
let regex_pattern = format!("(?s){}", expected_substituted); // (?s) enables DOTALL mode
let regex = Regex::new(&regex_pattern)
.map_err(|e| Error::command_error(output_type, format!("Invalid regex: {}", e)))?;
if !regex.is_match(&actual) {
return Err(Error::OutputCompare {
expected: expected_substituted,
actual,
});
}
} else {
// Exact string match
if actual != expected_substituted {
return Err(Error::OutputCompare {
expected: expected_substituted,
actual,
});
}
}
Ok(())
}
/// Set environment variable
pub fn set_env_var(&mut self, key: &str, value: &str) {
self.env_vars.insert(key.to_string(), value.to_string());
}
/// Set stdin content for next exec command
pub fn set_stdin_content(&mut self, content: Vec<u8>) {
self.next_stdin = Some(content);
}
/// Set stdin from file content
pub fn set_stdin_from_file(&mut self, filename: &str) -> Result<()> {
let content = if filename == "stdout" {
if let Some(ref output) = self.last_output {
output.stdout.clone()
} else {
return Err(Error::command_error("stdin", "No stdout available"));
}
} else if filename == "stderr" {
if let Some(ref output) = self.last_output {
output.stderr.clone()
} else {
return Err(Error::command_error("stdin", "No stderr available"));
}
} else {
let file_path = self.work_dir.join(filename);
fs::read(&file_path).map_err(|e| {
Error::command_error("stdin", format!("Cannot read '{}': {}", filename, e))
})?
};
self.next_stdin = Some(content);
Ok(())
}
/// Kill a background process
pub fn kill_background_process(&mut self, name: &str, _signal: Option<&str>) -> Result<()> {
if let Some(mut child) = self.background_processes.remove(name) {
// On Unix, we could send specific signals, but for cross-platform compatibility,
// we'll just kill the process
child.kill()?;
let _ = child.wait()?; // Reap the process
Ok(())
} else {
Err(Error::command_error(
"kill",
format!("No background process named '{}'", name),
))
}
}
/// Substitute environment variables in a string
pub fn substitute_env_vars(&self, input: &str) -> String {
let mut result = input.to_string();
// Handle $VAR and ${VAR} patterns
for (key, value) in &self.env_vars {
let patterns = [
format!("${{{}}}", key), // ${VAR}
format!("${}", key), // $VAR (but be careful with word boundaries)
];
for pattern in &patterns {
result = result.replace(pattern, value);
}
}
// Handle special case: $$ -> $ (escape)
result = result.replace("$$", "$");
result
}
/// Preserve the work directory by preventing TempDir cleanup
/// Returns the path to the preserved directory
pub fn preserve_work_dir(self) -> std::path::PathBuf {
let work_dir = self.work_dir.clone();
// Leak the TempDir to prevent cleanup
std::mem::forget(self._temp_dir);
work_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_test_environment_creation() {
let env = TestEnvironment::new().unwrap();
assert!(env.work_dir.exists());
assert!(env.work_dir.is_dir());
}
#[test]
fn test_setup_files() {
let mut env = TestEnvironment::new().unwrap();
let files = vec![
TxtarFile {
name: "hello.txt".to_string(),
contents: b"Hello, world!".to_vec(),
},
TxtarFile {
name: "sub/dir/nested.txt".to_string(),
contents: b"Nested content".to_vec(),
},
];
env.setup_files(&files).unwrap();
let hello_path = env.work_dir.join("hello.txt");
let nested_path = env.work_dir.join("sub/dir/nested.txt");
assert!(hello_path.exists());
assert!(nested_path.exists());
assert_eq!(fs::read(&hello_path).unwrap(), b"Hello, world!");
assert_eq!(fs::read(&nested_path).unwrap(), b"Nested content");
}
#[test]
fn test_compare_files() {
let env = TestEnvironment::new().unwrap();
fs::write(env.work_dir.join("file1.txt"), "same content").unwrap();
fs::write(env.work_dir.join("file2.txt"), "same content").unwrap();
fs::write(env.work_dir.join("file3.txt"), "different content").unwrap();
// Should succeed
env.compare_files("file1.txt", "file2.txt").unwrap();
// Should fail
assert!(env.compare_files("file1.txt", "file3.txt").is_err());
}
}