1
0
Fork 0
mirror of https://github.com/imjasonh/testscript-rs synced 2026-07-13 11:27:00 +00:00
testscript-rs/src/lib.rs

279 lines
8.7 KiB
Rust
Raw Normal View History

//! # testscript-rs
//!
//! A Rust crate for testing command-line tools using filesystem-based script files,
//! mirroring the functionality of Go's `rogpeppe/go-internal/testscript`.
//!
//! This crate provides a framework for writing integration tests for CLI tools
//! using `.txtar` format files that contain both test scripts and file contents.
pub mod error;
pub mod parser;
pub mod run;
pub use error::{Error, Result};
pub use parser::{Command, Script, TxtarFile};
pub use run::{CommandFn, RunParams, SetupFn, TestEnvironment};
// Re-export for advanced users who need direct access
pub use run::run_test;
// Internal function used by the Builder - not part of public API
fn run(params: &mut RunParams, test_data_glob: &str) -> Result<()> {
use walkdir::WalkDir;
// Simple glob pattern matching - for now just handle basic patterns like "testdata/*.txt"
let (base_dir, pattern) = if let Some(slash_pos) = test_data_glob.rfind('/') {
let base_dir = &test_data_glob[..slash_pos];
let pattern = &test_data_glob[slash_pos + 1..];
(base_dir, pattern)
} else {
(".", test_data_glob)
};
// Convert glob pattern to a simple matcher
let pattern_regex = pattern.replace("*", ".*");
let regex = regex::Regex::new(&format!("^{}$", pattern_regex))?;
let mut test_files = Vec::new();
// Walk the directory and collect matching files
for entry in WalkDir::new(base_dir).min_depth(1).max_depth(1) {
let entry = entry?;
if entry.file_type().is_file() {
if let Some(file_name) = entry.file_name().to_str() {
if regex.is_match(file_name) {
test_files.push(entry.path().to_path_buf());
}
}
}
}
// Sort test files for consistent execution order
test_files.sort();
if test_files.is_empty() {
return Err(Error::Generic(format!(
"No test files found matching pattern: {}",
test_data_glob
)));
}
// Run each test file
for test_file in test_files {
run::run_script(&test_file, params)
.map_err(|e| Error::Generic(format!("Test '{}' failed: {}", test_file.display(), e)))?;
}
Ok(())
}
/// Builder for configuring and running testscript tests
///
/// This provides a fluent interface for setting up and executing test scripts.
/// Network detection and 35+ common programs are automatically detected by default.
///
/// ## Automatic Condition Detection
///
/// The following conditions are automatically available without any setup:
///
/// - **Platform conditions**: `[unix]`, `[windows]`, `[linux]`, `[darwin]`, `[macos]`
/// - **Network condition**: `[net]` - Tests network connectivity by pinging reliable hosts
/// - **Build conditions**: `[debug]`, `[release]` - Based on compilation flags
/// - **Program conditions**: `[exec:program]` - Detects 35+ common programs including:
/// - Basic tools: cat, echo, ls, mkdir, rm, cp, mv, chmod, pwd, grep, find
/// - Development: git, make, cmake, docker, node, npm, python, go, cargo, rustc
/// - Archive: tar, gzip, zip, curl, wget, ssh, diff
/// - System: ps, kill, sleep, true, false, sh, bash, zsh
/// - **Environment conditions**: `[env:VAR]` - Dynamic checking of environment variables
///
/// ## Examples
///
/// ### Basic Usage
/// ```no_run
/// use testscript_rs::testscript;
///
/// // Simple usage - all conditions detected automatically
/// testscript::run("testdata").execute().unwrap();
/// ```
///
/// ### With Custom Setup
/// ```no_run
/// use testscript_rs::testscript;
///
/// testscript::run("testdata")
/// .setup(|env| {
/// // Compile your CLI tool
/// std::process::Command::new("cargo")
/// .args(["build", "--bin", "my-cli"])
/// .status()
/// .expect("Failed to build");
/// Ok(())
/// })
/// .command("my-cmd", |_env, _args| {
/// // Custom command implementation
/// Ok(())
/// })
/// .condition("custom", my_custom_check())
/// .execute()
/// .unwrap();
///
/// fn my_custom_check() -> bool {
/// // Your custom condition logic
/// true
/// }
/// ```
pub struct Builder {
dir: String,
params: RunParams,
}
impl Builder {
/// Create a new builder for the given test directory
fn new(dir: impl Into<String>) -> Self {
Self {
dir: dir.into(),
params: RunParams::new(),
}
}
/// Add a setup function that runs before each test script
///
/// The setup function receives a reference to the test environment and can
/// perform actions like compiling binaries or setting up test data.
pub fn setup<F>(mut self, func: F) -> Self
where
F: Fn(&TestEnvironment) -> Result<()> + 'static,
{
self.params = self.params.setup(func);
self
}
/// Add a custom command that can be used in test scripts
///
/// # Arguments
/// * `name` - The command name as it will appear in test scripts
/// * `func` - The function to execute when the command is called
pub fn command(mut self, name: &str, func: CommandFn) -> Self {
self.params = self.params.command(name, func);
self
}
/// Set a condition value for conditional command execution
///
/// Use this to add custom conditions beyond the built-in ones.
/// Many common conditions are automatically detected (see Builder docs for details).
///
/// # Arguments
/// * `name` - The condition name (use in scripts as `[name]`)
/// * `value` - Whether the condition is met
///
/// # Built-in Conditions (automatically available)
/// - `net` - Network connectivity
/// - `unix`, `windows`, `linux`, `darwin` - Platform detection
/// - `debug`, `release` - Build type
/// - `exec:program` - Program availability (35+ programs)
/// - `env:VAR` - Environment variables (dynamic)
///
/// # Examples
/// ```no_run
/// use testscript_rs::testscript;
///
/// testscript::run("testdata")
/// .condition("feature_enabled", cfg!(feature = "advanced"))
/// .condition("has_gpu", check_gpu_available())
/// .execute()
/// .unwrap();
///
/// fn check_gpu_available() -> bool {
/// // Your GPU detection logic
/// false
/// }
/// ```
pub fn condition(mut self, name: &str, value: bool) -> Self {
self.params = self.params.condition(name, value);
self
}
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>
2025-09-26 21:47:09 -04:00
/// 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.
/// Each test runs in isolation with its own temporary directory.
///
/// # Returns
/// `Ok(())` if all tests pass, or the first error encountered.
pub fn execute(mut self) -> Result<()> {
let pattern = format!("{}/*.txt", self.dir);
run(&mut self.params, &pattern)
}
}
/// Create a new testscript builder for the given directory
///
/// This is the main entry point for running testscript tests.
///
/// # Examples
///
/// ```no_run
/// use testscript_rs::testscript;
///
/// // Run all tests in testdata directory
/// testscript::run("testdata").execute().unwrap();
/// ```
pub mod testscript {
use super::*;
/// Create a new testscript builder for the given directory
pub fn run(dir: impl Into<String>) -> Builder {
Builder::new(dir)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_integration_test() {
// Test the parser directly with a simple script
let script_content = r#"exec echo hello
stdout hello
-- file.txt --
content"#;
let script = crate::parser::parse(script_content).unwrap();
assert_eq!(script.commands.len(), 2);
assert_eq!(script.files.len(), 1);
}
#[test]
fn test_example() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let testdata_dir = temp_dir.path().join("testdata");
fs::create_dir(&testdata_dir).unwrap();
let test_content = r#"exec echo "API works!"
stdout "API works!"
-- test.txt --
content"#;
fs::write(testdata_dir.join("api_test.txt"), test_content).unwrap();
let result = testscript::run(testdata_dir.to_string_lossy()).execute();
assert!(result.is_ok(), "API example failed: {:?}", result);
}
}