diff --git a/README.md b/README.md index 703179a..0727fae 100644 --- a/README.md +++ b/README.md @@ -165,3 +165,37 @@ Error in testdata/hello.txt at line 6: See [`examples/sample-cli/`](./examples/sample-cli/) and its `testdata` directory for more examples. There are also more tests in [`testdata`](./testdata/) that demonstrate and check this implementations behavior. + +## UpdateScripts (Test Maintenance) + +UpdateScripts automatically updates test files with actual command output, making test maintenance easier: + +```rust +// Enable via API +testscript::run("testdata") + .update_scripts(true) + .execute() + .unwrap(); +``` + +Or via environment variable: + +```bash +UPDATE_SCRIPTS=1 cargo test +``` + +When enabled, instead of failing on output mismatches, the test files will be updated with actual command output: + +**Before (failing test):** +``` +exec my-tool --version +stdout "my-tool 1.0" +``` + +**After running with update mode:** +``` +exec my-tool --version +stdout "my-tool 2.1.0" +``` + +This feature only updates `stdout` and `stderr` expectations while preserving file structure and comments. diff --git a/src/lib.rs b/src/lib.rs index 9c8078c..9173d75 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -140,6 +140,15 @@ impl Builder { self } + /// Enable or disable updating test scripts with actual output + /// + /// When enabled, instead of failing on output mismatches, the test files + /// will be updated with the actual command output. + pub fn update_scripts(mut self, update: bool) -> Self { + self.params = self.params.update_scripts(update); + self + } + /// Execute all test scripts in the configured directory /// /// This will discover all `.txt` files in the directory and run them as test scripts. diff --git a/src/run/execution.rs b/src/run/execution.rs index 9669524..ad35fee 100644 --- a/src/run/execution.rs +++ b/src/run/execution.rs @@ -6,6 +6,17 @@ use crate::run::{environment::TestEnvironment, params::RunParams}; use std::fs; use std::path::Path; +/// Information about a script update needed when in update mode +#[derive(Debug, Clone)] +pub struct ScriptUpdate { + /// The line number where the stdout/stderr command appears + pub line_num: usize, + /// The command name (stdout or stderr) + pub command_name: String, + /// The new expected output + pub new_output: String, +} + /// Run a single script with the given parameters - main implementation pub fn run_script_impl(script_path: &Path, params: &RunParams) -> Result<()> { // Read and parse the script @@ -41,10 +52,34 @@ pub fn run_script_impl(script_path: &Path, params: &RunParams) -> Result<()> { setup(&env)?; } + // Track script updates if we're in update mode + let mut updates = Vec::new(); + // Execute commands for command in &script.commands { - if let Err(e) = execute_command(&mut env, command, params) { - // Wrap error with script context + let result = execute_command(&mut env, command, params); + + if let Err(e) = result { + // If we're in update mode and this is an output comparison error, capture the update + if params.update_scripts { + if let Error::OutputCompare { + expected: _, + actual, + } = &e + { + if command.name == "stdout" || command.name == "stderr" { + updates.push(ScriptUpdate { + line_num: command.line_num, + command_name: command.name.clone(), + new_output: actual.clone(), + }); + // Continue instead of failing + continue; + } + } + } + + // Wrap error with script context for non-update cases or non-output errors return Err(Error::script_error( &script_file, command.line_num, @@ -62,6 +97,11 @@ pub fn run_script_impl(script_path: &Path, params: &RunParams) -> Result<()> { } } + // Apply updates if any were collected + if !updates.is_empty() && params.update_scripts { + apply_script_updates(script_path, &content, &updates)?; + } + // Wait for any remaining background processes let background_names: Vec = env.background_processes.keys().cloned().collect(); for name in background_names { @@ -71,6 +111,63 @@ pub fn run_script_impl(script_path: &Path, params: &RunParams) -> Result<()> { Ok(()) } +/// Apply script updates to the actual file +fn apply_script_updates(script_path: &Path, content: &str, updates: &[ScriptUpdate]) -> Result<()> { + let lines: Vec<&str> = content.lines().collect(); + let mut updated_lines = Vec::new(); + + let mut i = 0; + while i < lines.len() { + let line_num = i + 1; // Line numbers are 1-based + + // Check if this line needs to be updated + if let Some(update) = updates.iter().find(|u| u.line_num == line_num) { + // This is a stdout/stderr command that needs updating + let line = lines[i]; + let cmd_part = format!("{} ", update.command_name); + + if line.trim_start().starts_with(&cmd_part) { + // Extract the indentation from the original line + let indent = line.len() - line.trim_start().len(); + let indent_str = " ".repeat(indent); + + // Create the updated line with proper quoting + let quoted_output = if update.new_output.contains(' ') + || update.new_output.contains('\n') + || update.new_output.contains('"') + { + // Use proper shell quoting for complex strings + format!("\"{}\"", update.new_output.replace('"', "\\\"")) + } else if update.new_output.is_empty() { + "\"-\"".to_string() + } else { + update.new_output.clone() + }; + + updated_lines.push(format!( + "{}{} {}", + indent_str, update.command_name, quoted_output + )); + } else { + // Shouldn't happen, but preserve the original line if it doesn't match + updated_lines.push(line.to_string()); + } + } else { + // Keep the original line + updated_lines.push(lines[i].to_string()); + } + i += 1; + } + + // Write the updated content back to the file + let updated_content = updated_lines.join("\n"); + if updated_content != content { + fs::write(script_path, updated_content)?; + } + + Ok(()) +} + /// Execute a single command fn execute_command(env: &mut TestEnvironment, command: &Command, params: &RunParams) -> Result<()> { // For negated commands, we expect them to fail diff --git a/src/run/params.rs b/src/run/params.rs index 63e6892..9ca28f2 100644 --- a/src/run/params.rs +++ b/src/run/params.rs @@ -18,6 +18,8 @@ pub struct RunParams { pub setup: Option, /// Conditions that can be checked in scripts pub conditions: HashMap, + /// Whether to update test scripts with actual output + pub update_scripts: bool, } impl RunParams { @@ -44,10 +46,16 @@ impl RunParams { conditions.insert("exec:mkdir".to_string(), Self::program_exists("mkdir")); conditions.insert("exec:rm".to_string(), Self::program_exists("rm")); + // Check UPDATE_SCRIPTS environment variable + let update_scripts = std::env::var("UPDATE_SCRIPTS") + .map(|v| v == "1" || v.to_lowercase() == "true") + .unwrap_or(false); + RunParams { commands: HashMap::new(), setup: None, conditions, + update_scripts, } } @@ -72,6 +80,12 @@ impl RunParams { self } + /// Set whether to update scripts with actual output + pub fn update_scripts(mut self, update: bool) -> Self { + self.update_scripts = update; + self + } + /// Check if a program exists in PATH fn program_exists(program: &str) -> bool { std::process::Command::new("which") diff --git a/tests/update_scripts.rs b/tests/update_scripts.rs new file mode 100644 index 0000000..206c4e0 --- /dev/null +++ b/tests/update_scripts.rs @@ -0,0 +1,142 @@ +//! Tests for update scripts functionality + +use std::fs; +use tempfile::TempDir; +use testscript_rs::testscript; + +#[test] +fn test_update_scripts_stdout() { + let temp_dir = TempDir::new().unwrap(); + let testdata_dir = temp_dir.path().join("testdata"); + fs::create_dir(&testdata_dir).unwrap(); + + // Create a test script with incorrect expected output + let test_content = r#"exec echo "actual output" +stdout "expected output" +"#; + + let test_file = testdata_dir.join("update_test.txt"); + fs::write(&test_file, test_content).unwrap(); + + // Run with update_scripts enabled - should update the file instead of failing + let result = testscript::run(testdata_dir.to_string_lossy()) + .update_scripts(true) + .execute(); + + assert!(result.is_ok(), "Update should succeed: {:?}", result); + + // Check that the file was updated with the actual output + let updated_content = fs::read_to_string(&test_file).unwrap(); + assert!( + updated_content.contains("stdout \"actual output\""), + "File should be updated with actual output, got: {}", + updated_content + ); +} + +#[test] +fn test_update_scripts_stderr() { + let temp_dir = TempDir::new().unwrap(); + let testdata_dir = temp_dir.path().join("testdata"); + fs::create_dir(&testdata_dir).unwrap(); + + // Create a test script with incorrect expected stderr output + let test_content = r#"exec sh -c "echo 'error message' >&2" +stderr "wrong error message" +"#; + + let test_file = testdata_dir.join("stderr_update_test.txt"); + fs::write(&test_file, test_content).unwrap(); + + // Run with update_scripts enabled + let result = testscript::run(testdata_dir.to_string_lossy()) + .update_scripts(true) + .execute(); + + assert!(result.is_ok(), "Update should succeed: {:?}", result); + + // Check that the file was updated + let updated_content = fs::read_to_string(&test_file).unwrap(); + assert!( + updated_content.contains("stderr \"error message\""), + "File should be updated with actual stderr output, got: {}", + updated_content + ); +} + +#[test] +fn test_update_scripts_via_env_var() { + // Ensure clean state + std::env::remove_var("UPDATE_SCRIPTS"); + + let temp_dir = TempDir::new().unwrap(); + let testdata_dir = temp_dir.path().join("testdata"); + fs::create_dir(&testdata_dir).unwrap(); + + // Create a test script with incorrect expected output + let test_content = r#"exec echo "env test output" +stdout "wrong output" +"#; + + let test_file = testdata_dir.join("env_update_test.txt"); + fs::write(&test_file, test_content).unwrap(); + + // Set the environment variable + std::env::set_var("UPDATE_SCRIPTS", "1"); + + // Run without explicitly setting update_scripts - should read from env var + let result = testscript::run(testdata_dir.to_string_lossy()).execute(); + + // Clean up the env var immediately after the test + std::env::remove_var("UPDATE_SCRIPTS"); + + assert!( + result.is_ok(), + "Update via env var should succeed: {:?}", + result + ); + + // Check that the file was updated + let updated_content = fs::read_to_string(&test_file).unwrap(); + assert!( + updated_content.contains("stdout \"env test output\""), + "File should be updated via env var, got: {}", + updated_content + ); +} + +#[test] +fn test_normal_mode_still_fails() { + // Ensure UPDATE_SCRIPTS env var is not set + std::env::remove_var("UPDATE_SCRIPTS"); + + let temp_dir = TempDir::new().unwrap(); + let testdata_dir = temp_dir.path().join("testdata"); + fs::create_dir(&testdata_dir).unwrap(); + + // Create a test script with incorrect expected output + let test_content = r#"exec echo "actual output" +stdout "expected output" +"#; + + let test_file = testdata_dir.join("normal_test.txt"); + fs::write(&test_file, test_content).unwrap(); + + // Explicitly set update_scripts to false to override any env var + let result = testscript::run(testdata_dir.to_string_lossy()) + .update_scripts(false) + .execute(); + + assert!( + result.is_err(), + "Normal mode should still fail on mismatch: {:?}", + result + ); + + // Check that the file was NOT updated + let content = fs::read_to_string(&test_file).unwrap(); + assert_eq!( + content, test_content, + "File should not be modified in normal mode" + ); +}