diff --git a/README.md b/README.md index bbd6c32..8cd359d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ testscript::run("testdata") Ok(()) }) .condition("feature-enabled", true) + .preserve_work_on_failure(true) // Debug failed tests .execute() .unwrap(); ``` @@ -200,3 +201,32 @@ stdout "my-tool 2.1.0" ``` This feature only updates `stdout` and `stderr` expectations while preserving file structure and comments. + +## TestWork (Debug Failed Tests) + +When tests fail, you can preserve the working directory to inspect the test environment and debug issues: + +```rust +testscript::run("testdata") + .preserve_work_on_failure(true) + .execute() + .unwrap(); +``` + +When a test fails with this option enabled, you'll see output like: + +``` +Test failed. Work directory preserved at: /tmp/testscript-work-abc123 +You can inspect the test environment: + cd /tmp/testscript-work-abc123 + ls -la +``` + +This feature matches Go's testscript `TestWork` functionality and makes debugging much easier by allowing you to: + +- Inspect files created during test execution +- Manually run commands that failed +- Understand the exact state when the test failed +- Debug complex test scenarios step-by-step + +The working directory contains all files from the test script's file blocks, plus any files created during test execution. diff --git a/examples/test_runner.rs b/examples/test_runner.rs index a2549bb..f2ec147 100644 --- a/examples/test_runner.rs +++ b/examples/test_runner.rs @@ -8,8 +8,22 @@ fn main() { std::env::set_current_dir(manifest_dir).expect("Failed to change directory"); } - // Run all tests in testdata directory - match testscript::run("testdata").execute() { + // Check for test directory from environment for test purposes + let test_dir = std::env::var("TESTSCRIPT_TEST_DIR").unwrap_or_else(|_| "testdata".to_string()); + + // Check if preserve work should be enabled + let preserve_work = std::env::var("TESTSCRIPT_PRESERVE_WORK") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + // Run all tests in the specified directory + let mut builder = testscript::run(test_dir); + + if preserve_work { + builder = builder.preserve_work_on_failure(true); + } + + match builder.execute() { Ok(()) => println!("All tests passed!"), Err(e) => { eprintln!("Test failed: {}", e); diff --git a/src/lib.rs b/src/lib.rs index 106d3d9..34887ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -93,7 +93,7 @@ fn run(params: &mut RunParams, test_data_glob: &str) -> Result<()> { /// testscript::run("testdata").execute().unwrap(); /// ``` /// -/// ### With Custom Setup +/// ### With Custom Setup and Work Directory Preservation /// ```no_run /// use testscript_rs::testscript; /// @@ -111,6 +111,7 @@ fn run(params: &mut RunParams, test_data_glob: &str) -> Result<()> { /// Ok(()) /// }) /// .condition("custom", my_custom_check()) +/// .preserve_work_on_failure(true) // Preserve work directory on test failure /// .execute() /// .unwrap(); /// @@ -200,6 +201,34 @@ impl Builder { self } + /// Enable or disable preserving working directories when tests fail + /// + /// When enabled, if a test fails, the working directory will be preserved + /// and its path will be printed to stderr for debugging purposes. + /// This matches the behavior of Go's testscript TestWork functionality. + /// + /// # Examples + /// ```no_run + /// use testscript_rs::testscript; + /// + /// testscript::run("testdata") + /// .preserve_work_on_failure(true) + /// .execute() + /// .unwrap(); + /// ``` + /// + /// When a test fails, you'll see output like: + /// ```text + /// Test failed. Work directory preserved at: /tmp/testscript-work-abc123 + /// You can inspect the test environment: + /// cd /tmp/testscript-work-abc123 + /// ls -la + /// ``` + pub fn preserve_work_on_failure(mut self, preserve: bool) -> Self { + self.params = self.params.preserve_work_on_failure(preserve); + 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/environment.rs b/src/run/environment.rs index f8ac0b7..8343923 100644 --- a/src/run/environment.rs +++ b/src/run/environment.rs @@ -301,6 +301,15 @@ impl TestEnvironment { 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)] diff --git a/src/run/execution.rs b/src/run/execution.rs index 551d7ce..88ac2dc 100644 --- a/src/run/execution.rs +++ b/src/run/execution.rs @@ -79,6 +79,15 @@ pub fn run_script_impl(script_path: &Path, params: &RunParams) -> Result<()> { } } + // Handle work directory preservation on failure + if params.preserve_work_on_failure { + let preserved_path = env.preserve_work_dir(); + eprintln!("Test failed. Work directory preserved at: {}", preserved_path.display()); + eprintln!("You can inspect the test environment:"); + eprintln!(" cd {}", preserved_path.display()); + eprintln!(" ls -la"); + } + // Wrap error with script context for non-update cases or non-output errors return Err(Error::script_error( &script_file, @@ -90,6 +99,14 @@ pub fn run_script_impl(script_path: &Path, params: &RunParams) -> Result<()> { // Check for early termination if env.should_skip { + // Handle work directory preservation for skipped tests if configured + if params.preserve_work_on_failure { + let preserved_path = env.preserve_work_dir(); + eprintln!("Test skipped. Work directory preserved at: {}", preserved_path.display()); + eprintln!("You can inspect the test environment:"); + eprintln!(" cd {}", preserved_path.display()); + eprintln!(" ls -la"); + } return Err(Error::Generic("Test skipped".to_string())); } if env.should_stop { @@ -102,10 +119,19 @@ pub fn run_script_impl(script_path: &Path, params: &RunParams) -> Result<()> { apply_script_updates(script_path, &content, &updates)?; } - // Wait for any remaining background processes + // Wait for any remaining background processes - handle failures here too let background_names: Vec = env.background_processes.keys().cloned().collect(); for name in background_names { - env.wait_for_background(&name)?; + if let Err(e) = env.wait_for_background(&name) { + if params.preserve_work_on_failure { + let preserved_path = env.preserve_work_dir(); + eprintln!("Test failed during background process cleanup. Work directory preserved at: {}", preserved_path.display()); + eprintln!("You can inspect the test environment:"); + eprintln!(" cd {}", preserved_path.display()); + eprintln!(" ls -la"); + } + return Err(e); + } } Ok(()) diff --git a/src/run/params.rs b/src/run/params.rs index 0b98c6c..0fa867c 100644 --- a/src/run/params.rs +++ b/src/run/params.rs @@ -20,6 +20,8 @@ pub struct RunParams { pub conditions: HashMap, /// Whether to update test scripts with actual output pub update_scripts: bool, + /// Whether to preserve working directories when tests fail + pub preserve_work_on_failure: bool, } impl RunParams { @@ -52,6 +54,7 @@ impl RunParams { setup: None, conditions, update_scripts, + preserve_work_on_failure: false, } } @@ -82,6 +85,12 @@ impl RunParams { self } + /// Set whether to preserve working directories when tests fail + pub fn preserve_work_on_failure(mut self, preserve: bool) -> Self { + self.preserve_work_on_failure = preserve; + self + } + /// Check if a program exists in PATH (cross-platform) pub fn program_exists(program: &str) -> bool { // TODO: Consider caching results for performance if needed diff --git a/tests/preserve_work.rs b/tests/preserve_work.rs new file mode 100644 index 0000000..3ad7b45 --- /dev/null +++ b/tests/preserve_work.rs @@ -0,0 +1,160 @@ +//! Tests for work directory preservation functionality + +use std::fs; +use std::process::Command; +use tempfile::TempDir; + +#[test] +fn test_preserve_work_on_failure_disabled_by_default() { + // Create a failing test + 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 "hello" +stdout "goodbye" +"#; + + fs::write(testdata_dir.join("failing_test.txt"), test_content).unwrap(); + + let result = testscript_rs::testscript::run(testdata_dir.to_string_lossy()).execute(); + + // Should fail + assert!(result.is_err()); + + // Work directory should be cleaned up (we can't easily test this since TempDir cleanup is automatic) +} + +#[test] +fn test_preserve_work_on_failure_enabled() { + // Create a failing test + 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 "hello" +stdout "goodbye" + +-- test_file.txt -- +This is test content +"#; + + fs::write(testdata_dir.join("failing_test.txt"), test_content).unwrap(); + + // We need to capture stderr to see the preservation message + // Since we can't easily capture stderr from the current process, + // we'll create a subprocess that runs our test + let output = Command::new("cargo") + .args(&["run", "--example", "test_runner"]) + .current_dir("/home/runner/work/testscript-rs/testscript-rs") + .env("TESTSCRIPT_TEST_DIR", testdata_dir.to_string_lossy().as_ref()) + .env("TESTSCRIPT_PRESERVE_WORK", "true") + .output(); + + if let Ok(output) = output { + let stderr = String::from_utf8_lossy(&output.stderr); + // Should contain preservation message + assert!(stderr.contains("Work directory preserved at:")); + } else { + // Fallback test - just ensure the API works + let result = testscript_rs::testscript::run(testdata_dir.to_string_lossy()) + .preserve_work_on_failure(true) + .execute(); + + assert!(result.is_err()); + } +} + +#[test] +fn test_preserve_work_on_success_no_preservation() { + // Create a passing test + 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 "hello" +stdout "hello" + +-- test_file.txt -- +This is test content +"#; + + fs::write(testdata_dir.join("passing_test.txt"), test_content).unwrap(); + + let result = testscript_rs::testscript::run(testdata_dir.to_string_lossy()) + .preserve_work_on_failure(true) + .execute(); + + // Should succeed and not preserve directory + assert!(result.is_ok()); +} + +#[test] +fn test_preserve_work_with_skip() { + // Create a test that skips + let temp_dir = TempDir::new().unwrap(); + let testdata_dir = temp_dir.path().join("testdata"); + fs::create_dir(&testdata_dir).unwrap(); + + let test_content = r#"skip this test is skipped + +-- test_file.txt -- +This is test content +"#; + + fs::write(testdata_dir.join("skip_test.txt"), test_content).unwrap(); + + let result = testscript_rs::testscript::run(testdata_dir.to_string_lossy()) + .preserve_work_on_failure(true) + .execute(); + + // Should fail due to skip, but this tests that the preserve logic handles skip correctly + assert!(result.is_err()); + if let Err(e) = result { + // The error might be wrapped in script context, so just check for skip-related content + let error_str = e.to_string(); + assert!(error_str.contains("SKIP") || error_str.contains("Test skipped") || error_str.contains("skip")); + } +} + +#[test] +fn test_preserve_work_with_background_process_failure() { + // Create a test that fails during background process cleanup + 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 sleep 10 & +wait sleep +"#; + + fs::write(testdata_dir.join("bg_test.txt"), test_content).unwrap(); + + // We don't assert success/failure here since sleep behavior varies, but the test exercises the code + let _result = testscript_rs::testscript::run(testdata_dir.to_string_lossy()) + .preserve_work_on_failure(true) + .execute(); + + // We don't assert success/failure here since sleep behavior varies, but the test exercises the code +} + +#[test] +fn test_builder_preserve_work_method_exists() { + // Test that the API exists and can be chained + 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 "hello" +stdout "hello" +"#; + + fs::write(testdata_dir.join("simple_test.txt"), test_content).unwrap(); + + let result = testscript_rs::testscript::run(testdata_dir.to_string_lossy()) + .preserve_work_on_failure(true) + .preserve_work_on_failure(false) // Can be chained and overridden + .execute(); + + assert!(result.is_ok()); +} \ No newline at end of file