mirror of
https://github.com/imjasonh/testscript-rs
synced 2026-07-08 09:05:34 +00:00
Add UpdateScripts functionality for test maintenance (#20)
- [x] Analyze existing codebase structure and architecture
- [x] Review test files to understand expected UpdateScripts
functionality
- [x] Understand current API and parameter structure
- [x] Add `update_scripts` parameter to RunParams struct
- [x] Add `update_scripts` method to Builder API
- [x] Support UPDATE_SCRIPTS environment variable detection
- [x] Implement update functionality in stdout/stderr command execution
- [x] Modify output comparison to capture and update actual output when
in update mode
- [x] Add script file modification logic to update test files
- [x] Create focused tests for update functionality
- [x] Validate changes work end-to-end
- [x] Update documentation in README.md
- [x] Clean up temporary files
- [x] Move UpdateScripts section to bottom of README per review feedback
- [x] Fix code formatting issues with cargo fmt
## Implementation Summary
Successfully implemented complete UpdateScripts functionality for test
maintenance in testscript-rs:
### Key Features
- **API support**: `.update_scripts(true)` method on Builder
- **Environment variable**: `UPDATE_SCRIPTS=1` environment variable
support
- **Smart updating**: Only updates `stdout`/`stderr` expectations,
preserves file structure
- **Proper quoting**: Handles complex output with appropriate shell
quoting
- **Error handling**: Continues execution instead of failing in update
mode
### API Usage
```rust
// Via API
testscript::run("testdata")
.update_scripts(true)
.execute()
.unwrap();
// Via environment variable
UPDATE_SCRIPTS=1 cargo test
```
### Implementation Details
- Added `update_scripts: bool` field to `RunParams`
- Modified `run_script_impl` to collect updates instead of failing on
mismatches
- Implemented `apply_script_updates` function for file modification
- Added comprehensive test coverage with 4 test cases
- Updated README.md with usage examples (moved to bottom per review
feedback)
- Fixed code formatting issues identified by CI
### Testing
- All existing tests continue to pass
- New functionality tested via API and environment variable
- Manual validation confirms proper file updating
- Normal mode behavior preserved (still fails on mismatches when
disabled)
- Code passes formatting checks and linting
The implementation matches Go's testscript behavior and provides
essential test maintenance functionality.
<!-- START COPILOT CODING AGENT SUFFIX -->
<details>
<summary>Original prompt</summary>
>
> ----
>
> *This section details on the original issue you should resolve*
>
> <issue_title>Add UpdateScripts functionality for test
maintenance</issue_title>
> <issue_description>## Feature Request: UpdateScripts (Test Maintenance
Mode)
>
> Go's testscript supports an `UpdateScripts` parameter that
automatically updates test files with actual command output. This is
invaluable for test maintenance.
>
> ## Proposed API
>
> ```rust
> testscript::run("testdata")
> .update_scripts(true)
> .execute()
> .unwrap();
> ```
>
> Or via environment variable:
> ```bash
> UPDATE_SCRIPTS=1 cargo test
> ```
>
> ## How It Works
>
> When enabled, instead of failing on output mismatches:
> 1. **Capture actual output**
> 2. **Update the test file** with the actual output
> 3. **Continue to next test**
>
> ## Example
>
> 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"
> ```
>
> ## Benefits
>
> - **Easy test maintenance**: Update expected outputs after changes
> - **Reduce manual work**: No hand-editing of test files
> - **Go compatibility**: Matches upstream behavior
> - **Development workflow**: Essential for evolving CLI tools
>
> ## Implementation Notes
>
> - Should only update `stdout`/`stderr` expectations
> - Preserve comments and file structure
> - Add safety checks to prevent accidental
overwrites</issue_description>
>
> ## Comments on the Issue (you are @copilot in this section)
>
> <comments>
> </comments>
>
</details>
Fixes imjasonh/testscript-rs#8
<!-- START COPILOT CODING AGENT TIPS -->
---
💬 Share your feedback on Copilot coding agent for the chance to win a
$200 gift card! Click
[here](https://survey3.medallia.com/?EAHeSx-AP01bZqG0Ld9QLQ) to start
the survey.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
This commit is contained in:
parent
0c4315ece8
commit
fdb53b03fa
5 changed files with 298 additions and 2 deletions
34
README.md
34
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<String> = 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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ pub struct RunParams {
|
|||
pub setup: Option<SetupFn>,
|
||||
/// Conditions that can be checked in scripts
|
||||
pub conditions: HashMap<String, bool>,
|
||||
/// 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")
|
||||
|
|
|
|||
142
tests/update_scripts.rs
Normal file
142
tests/update_scripts.rs
Normal file
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue