2025-09-26 21:17:45 -04:00
|
|
|
//! Command execution logic
|
|
|
|
|
|
|
|
|
|
use crate::error::{Error, Result};
|
|
|
|
|
use crate::parser::Command;
|
|
|
|
|
use crate::run::{environment::TestEnvironment, params::RunParams};
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
2025-09-26 21:47:09 -04:00
|
|
|
/// 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,
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-26 21:17:45 -04:00
|
|
|
/// 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
|
|
|
|
|
let content = fs::read_to_string(script_path)?;
|
|
|
|
|
let script = crate::parser::parse(&content).map_err(|e| {
|
|
|
|
|
// Enhance parse errors with script context
|
|
|
|
|
match e {
|
|
|
|
|
Error::Parse { line, message } => Error::script_error(
|
|
|
|
|
script_path.to_string_lossy().to_string(),
|
|
|
|
|
line,
|
|
|
|
|
&content,
|
|
|
|
|
Error::Parse { line, message },
|
|
|
|
|
),
|
|
|
|
|
other => other,
|
|
|
|
|
}
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
// Create script context for better error reporting
|
|
|
|
|
let script_file = script_path.to_string_lossy().to_string();
|
|
|
|
|
|
|
|
|
|
// Create test environment
|
2025-09-30 06:57:10 +00:00
|
|
|
let mut env = TestEnvironment::new_with_root(params.workdir_root.as_deref())?;
|
2025-09-26 21:17:45 -04:00
|
|
|
|
|
|
|
|
// Set up files from the script
|
|
|
|
|
env.setup_files(&script.files)?;
|
|
|
|
|
|
|
|
|
|
// Set the $WORK environment variable to the working directory
|
|
|
|
|
let work_dir_str = env.work_dir.to_string_lossy().to_string();
|
|
|
|
|
env.set_env_var("WORK", &work_dir_str);
|
|
|
|
|
|
|
|
|
|
// Run setup hook if provided
|
|
|
|
|
if let Some(setup) = ¶ms.setup {
|
2025-12-30 01:46:37 +00:00
|
|
|
setup(&mut env)?;
|
2025-09-26 21:17:45 -04:00
|
|
|
}
|
|
|
|
|
|
2025-09-26 21:47:09 -04:00
|
|
|
// Track script updates if we're in update mode
|
|
|
|
|
let mut updates = Vec::new();
|
|
|
|
|
|
2025-09-26 21:17:45 -04:00
|
|
|
// Execute commands
|
|
|
|
|
for command in &script.commands {
|
2025-09-26 21:47:09 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-27 19:03:10 +00:00
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-26 21:47:09 -04:00
|
|
|
// Wrap error with script context for non-update cases or non-output errors
|
2025-09-26 21:17:45 -04:00
|
|
|
return Err(Error::script_error(
|
|
|
|
|
&script_file,
|
|
|
|
|
command.line_num,
|
|
|
|
|
&content,
|
|
|
|
|
e,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for early termination
|
|
|
|
|
if env.should_skip {
|
2025-09-27 19:03:10 +00:00
|
|
|
// 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");
|
|
|
|
|
}
|
2025-09-26 21:17:45 -04:00
|
|
|
return Err(Error::Generic("Test skipped".to_string()));
|
|
|
|
|
}
|
|
|
|
|
if env.should_stop {
|
|
|
|
|
break; // Stop early but don't fail
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-26 21:47:09 -04:00
|
|
|
// Apply updates if any were collected
|
|
|
|
|
if !updates.is_empty() && params.update_scripts {
|
|
|
|
|
apply_script_updates(script_path, &content, &updates)?;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-27 19:03:10 +00:00
|
|
|
// Wait for any remaining background processes - handle failures here too
|
2025-09-26 21:17:45 -04:00
|
|
|
let background_names: Vec<String> = env.background_processes.keys().cloned().collect();
|
|
|
|
|
for name in background_names {
|
2025-09-27 19:03:10 +00:00
|
|
|
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);
|
|
|
|
|
}
|
2025-09-26 21:17:45 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-26 21:47:09 -04:00
|
|
|
/// 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(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-26 21:17:45 -04:00
|
|
|
/// Execute a single command
|
|
|
|
|
fn execute_command(env: &mut TestEnvironment, command: &Command, params: &RunParams) -> Result<()> {
|
|
|
|
|
// For negated commands, we expect them to fail
|
|
|
|
|
let result = execute_command_inner(env, command, params);
|
|
|
|
|
|
|
|
|
|
if command.negated {
|
|
|
|
|
match result {
|
|
|
|
|
Ok(_) => Err(Error::command_error(
|
|
|
|
|
&command.name,
|
|
|
|
|
"Command was expected to fail but succeeded",
|
|
|
|
|
)),
|
|
|
|
|
Err(_) => Ok(()), // Negated command failed as expected
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Inner command execution logic
|
|
|
|
|
fn execute_command_inner(
|
|
|
|
|
env: &mut TestEnvironment,
|
|
|
|
|
command: &Command,
|
|
|
|
|
params: &RunParams,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
// Check condition if present
|
|
|
|
|
if let Some(ref condition) = command.condition {
|
|
|
|
|
let condition_met = if let Some(value) = params.conditions.get(condition) {
|
|
|
|
|
*value
|
Add network and advanced condition support with automatic detection and CI optimization (#21)
## Add Network and Advanced Condition Support with Automatic Detection -
COMPLETE ✅
This PR implements comprehensive network and advanced condition support
for testscript-rs, bringing feature parity with Go's testscript package
with automatic detection like the original.
### Core Features Implemented ✨
#### 1. **Network Conditions (`[net]`) - Always Available &
CI-Optimized**
- Auto-detects network availability with CI-optimized approach:
- **TCP-first detection**: Tries TCP connection to DNS servers (faster
than ping)
- **Ping fallback**: Uses ping with shorter timeouts if TCP fails
- Cross-platform implementation optimized for CI environments
- Available by default like Go testscript - no manual setup required
- Can be used in test scripts as `[net]` for network-required tests or
`[!net]` to skip when offline
#### 2. **Environment Variable Conditions (`[env:VAR]`)**
- Dynamic condition checking for environment variables at execution time
- No pre-registration required - conditions are evaluated when
encountered
- Supports negation: `[!env:MISSING_VAR]` to test when variables are not
set
- Perfect for CI-specific tests: `[env:CI] exec deploy --dry-run`
#### 3. **Optimized Program Detection - Built-in Like Go**
- **15+ essential programs detected automatically** including:
- **Basic commands**: cat, echo, ls, mkdir, rm, cp, mv, grep, find
- **Core development tools**: git, make, curl, python, node, docker
- **System tools**: sleep, true, false, sh
- Cross-platform program existence checking (uses `where` on Windows,
`which` on Unix)
- Optimized for CI performance with reduced startup time
- No manual configuration needed - works exactly like Go testscript
### API Simplification
**Simple, Go-like API** - everything detected automatically:
```rust
testscript::run("testdata")
.execute() // Network + 15+ programs detected automatically
.unwrap();
```
**Manual conditions still supported** for custom scenarios:
```rust
testscript::run("testdata")
.condition("custom", my_custom_check())
.execute()
.unwrap();
```
### CI Optimization
- **Network detection**: TCP-first approach with 500ms timeouts (vs
1000ms+ ping)
- **Program list**: Optimized from 35+ to 15+ essential programs for
faster startup
- **Better reliability**: Handles restricted CI network environments
- **Ubuntu CI compatibility**: Resolved timeout and network access
issues
### Real-World Use Cases
This enables powerful testing scenarios that work out of the box:
```
# Network-dependent API tests (automatic detection, CI-optimized)
[net] exec curl https://api.example.com/health
[net] stdout "OK"
# Tool-specific tests (automatic detection)
[exec:docker] exec docker run --rm hello-world
[exec:docker] stdout "Hello from Docker!"
[exec:git] exec git status
[exec:python] exec python --version
# Environment-based behavior
[env:CI] exec deploy --dry-run --verbose
[!env:PRODUCTION] exec run-dangerous-test
# Platform + environment combinations
[unix] [env:DISPLAY] exec gui-test --headless
[windows] [exec:powershell] exec Get-Process
```
### Implementation Details
- **Network detection**: Multi-approach detection (TCP + ping fallback)
optimized for CI reliability
- **Program detection**: Essential program list covering most common
development tools
- **Dynamic evaluation**: Environment conditions are checked at
execution time, not initialization
- **Error handling**: Clear error messages for unknown conditions
- **Cross-platform**: Works consistently across Windows, macOS, and
Linux with CI optimizations
### Go Testscript Compatibility ✅
This implementation now matches Go testscript's behavior:
- All common conditions available by default without setup
- Network condition automatically detected with CI optimization
- Essential program detection built-in with performance focus
- Clean, simple API that "just works"
- Environment variable conditions work dynamically
- CI-friendly performance characteristics
### Testing
Added comprehensive test coverage with 7 new test cases covering:
- Network condition detection (automatic, CI-optimized)
- Environment variable condition parsing and evaluation
- Optimized program detection (automatic)
- Condition combination scenarios
- Cross-platform compatibility
- Helper function validation
All existing tests continue to pass (47 total), ensuring no regressions.
### Backward Compatibility
This is a purely additive change. All existing functionality is
preserved:
- Platform conditions (`[unix]`, `[windows]`, `[linux]`, `[darwin]`)
- Build type conditions (`[debug]`, `[release]`)
- Manual `.condition()` API still works for custom conditions
- All existing test scripts continue to work unchanged
Fixes imjasonh/testscript-rs#10
<!-- START COPILOT CODING AGENT SUFFIX -->
<details>
<summary>Original prompt</summary>
>
> ----
>
> *This section details on the original issue you should resolve*
>
> <issue_title>Add network and advanced condition support</issue_title>
> <issue_description>## Feature Request: Advanced Condition Support
>
> Go's testscript supports advanced conditions beyond platform
detection:
>
> ## Missing Conditions
>
> 1. **Network conditions**: `[net]` - Test network availability
> 2. **Advanced platform conditions**: More granular OS/arch detection
> 3. **Environment-based conditions**: Custom environment variable
conditions
>
> ## Proposed API
>
> ```rust
> testscript::run("testdata")
> .condition("net", check_network_available())
> .condition("docker", command_exists("docker"))
> .condition("env:CI", std::env::var("CI").is_ok())
> .execute()
> .unwrap();
> ```
>
> ## Built-in Condition Helpers
>
> ```rust
> // Automatic network detection
> testscript::run("testdata")
> .auto_detect_network()
> .auto_detect_programs(&["docker", "git", "npm"])
> .execute()
> .unwrap();
> ```
>
> ## Implementation Notes
>
> - Network detection could ping a reliable host
> - Program detection should use cross-platform approach (not just
`which`)
> - Environment conditions could support pattern matching
> - Consider caching condition results for performance
>
> ## Use Cases
>
> - **Docker tests**: `[docker] exec docker run hello-world`
> - **Network tests**: `[net] exec curl example.com`
> - **CI-specific tests**: `[env:CI] exec deploy
--dry-run`</issue_description>
>
> ## Comments on the Issue (you are @copilot in this section)
>
> <comments>
> </comments>
>
</details>
Fixes imjasonh/testscript-rs#10
<!-- START COPILOT CODING AGENT TIPS -->
---
💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.
---------
Signed-off-by: Jason Hall <jason@chainguard.dev>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
Co-authored-by: Jason Hall <jason@chainguard.dev>
2025-09-27 17:00:36 +00:00
|
|
|
} else if condition.starts_with("env:") {
|
|
|
|
|
RunParams::check_env_condition(condition)
|
|
|
|
|
} else if let Some(program) = condition.strip_prefix("exec:") {
|
|
|
|
|
RunParams::program_exists(program)
|
2025-09-26 21:17:45 -04:00
|
|
|
} else if let Some(base_condition) = condition.strip_prefix('!') {
|
|
|
|
|
// Handle negated conditions
|
|
|
|
|
if let Some(value) = params.conditions.get(base_condition) {
|
|
|
|
|
!value
|
Add network and advanced condition support with automatic detection and CI optimization (#21)
## Add Network and Advanced Condition Support with Automatic Detection -
COMPLETE ✅
This PR implements comprehensive network and advanced condition support
for testscript-rs, bringing feature parity with Go's testscript package
with automatic detection like the original.
### Core Features Implemented ✨
#### 1. **Network Conditions (`[net]`) - Always Available &
CI-Optimized**
- Auto-detects network availability with CI-optimized approach:
- **TCP-first detection**: Tries TCP connection to DNS servers (faster
than ping)
- **Ping fallback**: Uses ping with shorter timeouts if TCP fails
- Cross-platform implementation optimized for CI environments
- Available by default like Go testscript - no manual setup required
- Can be used in test scripts as `[net]` for network-required tests or
`[!net]` to skip when offline
#### 2. **Environment Variable Conditions (`[env:VAR]`)**
- Dynamic condition checking for environment variables at execution time
- No pre-registration required - conditions are evaluated when
encountered
- Supports negation: `[!env:MISSING_VAR]` to test when variables are not
set
- Perfect for CI-specific tests: `[env:CI] exec deploy --dry-run`
#### 3. **Optimized Program Detection - Built-in Like Go**
- **15+ essential programs detected automatically** including:
- **Basic commands**: cat, echo, ls, mkdir, rm, cp, mv, grep, find
- **Core development tools**: git, make, curl, python, node, docker
- **System tools**: sleep, true, false, sh
- Cross-platform program existence checking (uses `where` on Windows,
`which` on Unix)
- Optimized for CI performance with reduced startup time
- No manual configuration needed - works exactly like Go testscript
### API Simplification
**Simple, Go-like API** - everything detected automatically:
```rust
testscript::run("testdata")
.execute() // Network + 15+ programs detected automatically
.unwrap();
```
**Manual conditions still supported** for custom scenarios:
```rust
testscript::run("testdata")
.condition("custom", my_custom_check())
.execute()
.unwrap();
```
### CI Optimization
- **Network detection**: TCP-first approach with 500ms timeouts (vs
1000ms+ ping)
- **Program list**: Optimized from 35+ to 15+ essential programs for
faster startup
- **Better reliability**: Handles restricted CI network environments
- **Ubuntu CI compatibility**: Resolved timeout and network access
issues
### Real-World Use Cases
This enables powerful testing scenarios that work out of the box:
```
# Network-dependent API tests (automatic detection, CI-optimized)
[net] exec curl https://api.example.com/health
[net] stdout "OK"
# Tool-specific tests (automatic detection)
[exec:docker] exec docker run --rm hello-world
[exec:docker] stdout "Hello from Docker!"
[exec:git] exec git status
[exec:python] exec python --version
# Environment-based behavior
[env:CI] exec deploy --dry-run --verbose
[!env:PRODUCTION] exec run-dangerous-test
# Platform + environment combinations
[unix] [env:DISPLAY] exec gui-test --headless
[windows] [exec:powershell] exec Get-Process
```
### Implementation Details
- **Network detection**: Multi-approach detection (TCP + ping fallback)
optimized for CI reliability
- **Program detection**: Essential program list covering most common
development tools
- **Dynamic evaluation**: Environment conditions are checked at
execution time, not initialization
- **Error handling**: Clear error messages for unknown conditions
- **Cross-platform**: Works consistently across Windows, macOS, and
Linux with CI optimizations
### Go Testscript Compatibility ✅
This implementation now matches Go testscript's behavior:
- All common conditions available by default without setup
- Network condition automatically detected with CI optimization
- Essential program detection built-in with performance focus
- Clean, simple API that "just works"
- Environment variable conditions work dynamically
- CI-friendly performance characteristics
### Testing
Added comprehensive test coverage with 7 new test cases covering:
- Network condition detection (automatic, CI-optimized)
- Environment variable condition parsing and evaluation
- Optimized program detection (automatic)
- Condition combination scenarios
- Cross-platform compatibility
- Helper function validation
All existing tests continue to pass (47 total), ensuring no regressions.
### Backward Compatibility
This is a purely additive change. All existing functionality is
preserved:
- Platform conditions (`[unix]`, `[windows]`, `[linux]`, `[darwin]`)
- Build type conditions (`[debug]`, `[release]`)
- Manual `.condition()` API still works for custom conditions
- All existing test scripts continue to work unchanged
Fixes imjasonh/testscript-rs#10
<!-- START COPILOT CODING AGENT SUFFIX -->
<details>
<summary>Original prompt</summary>
>
> ----
>
> *This section details on the original issue you should resolve*
>
> <issue_title>Add network and advanced condition support</issue_title>
> <issue_description>## Feature Request: Advanced Condition Support
>
> Go's testscript supports advanced conditions beyond platform
detection:
>
> ## Missing Conditions
>
> 1. **Network conditions**: `[net]` - Test network availability
> 2. **Advanced platform conditions**: More granular OS/arch detection
> 3. **Environment-based conditions**: Custom environment variable
conditions
>
> ## Proposed API
>
> ```rust
> testscript::run("testdata")
> .condition("net", check_network_available())
> .condition("docker", command_exists("docker"))
> .condition("env:CI", std::env::var("CI").is_ok())
> .execute()
> .unwrap();
> ```
>
> ## Built-in Condition Helpers
>
> ```rust
> // Automatic network detection
> testscript::run("testdata")
> .auto_detect_network()
> .auto_detect_programs(&["docker", "git", "npm"])
> .execute()
> .unwrap();
> ```
>
> ## Implementation Notes
>
> - Network detection could ping a reliable host
> - Program detection should use cross-platform approach (not just
`which`)
> - Environment conditions could support pattern matching
> - Consider caching condition results for performance
>
> ## Use Cases
>
> - **Docker tests**: `[docker] exec docker run hello-world`
> - **Network tests**: `[net] exec curl example.com`
> - **CI-specific tests**: `[env:CI] exec deploy
--dry-run`</issue_description>
>
> ## Comments on the Issue (you are @copilot in this section)
>
> <comments>
> </comments>
>
</details>
Fixes imjasonh/testscript-rs#10
<!-- START COPILOT CODING AGENT TIPS -->
---
💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.
---------
Signed-off-by: Jason Hall <jason@chainguard.dev>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
Co-authored-by: Jason Hall <jason@chainguard.dev>
2025-09-27 17:00:36 +00:00
|
|
|
} else if base_condition.starts_with("env:") {
|
|
|
|
|
// Dynamic negated environment variable condition
|
|
|
|
|
!RunParams::check_env_condition(base_condition)
|
|
|
|
|
} else if let Some(program) = base_condition.strip_prefix("exec:") {
|
|
|
|
|
!RunParams::program_exists(program)
|
2025-09-26 21:17:45 -04:00
|
|
|
} else {
|
|
|
|
|
return Err(Error::UnknownCondition {
|
|
|
|
|
condition: base_condition.to_string(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return Err(Error::UnknownCondition {
|
|
|
|
|
condition: condition.clone(),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if !condition_met {
|
|
|
|
|
return Ok(()); // Skip this command
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for custom commands first
|
|
|
|
|
if let Some(custom_fn) = params.commands.get(&command.name) {
|
|
|
|
|
return custom_fn(env, &command.args);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle built-in commands
|
|
|
|
|
match command.name.as_str() {
|
|
|
|
|
"exec" => {
|
|
|
|
|
if command.args.is_empty() {
|
|
|
|
|
return Err(Error::command_error("exec", "No command specified"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cmd = &command.args[0];
|
|
|
|
|
let args = &command.args[1..];
|
|
|
|
|
|
|
|
|
|
if command.background {
|
|
|
|
|
// Use the command name as the background process name
|
|
|
|
|
let process_name = cmd.to_string();
|
|
|
|
|
env.execute_background_command(&process_name, cmd, args)?;
|
|
|
|
|
} else {
|
|
|
|
|
let output = env.execute_command(cmd, args)?;
|
|
|
|
|
|
|
|
|
|
// Check exit status
|
|
|
|
|
if !output.status.success() {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"exec",
|
|
|
|
|
format!(
|
|
|
|
|
"Command '{}' failed with exit code {}: {}",
|
|
|
|
|
cmd,
|
|
|
|
|
output.status.code().unwrap_or(-1),
|
|
|
|
|
stderr.trim()
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"cmp" => {
|
|
|
|
|
if command.args.len() != 2 {
|
|
|
|
|
return Err(Error::command_error("cmp", "Expected exactly 2 arguments"));
|
|
|
|
|
}
|
|
|
|
|
env.compare_files(&command.args[0], &command.args[1])?;
|
|
|
|
|
}
|
|
|
|
|
"cmpenv" => {
|
|
|
|
|
if command.args.len() != 2 {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"cmpenv",
|
|
|
|
|
"Expected exactly 2 arguments",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
env.compare_files_with_env(&command.args[0], &command.args[1])?;
|
|
|
|
|
}
|
|
|
|
|
"stdout" | "stderr" => {
|
|
|
|
|
if command.args.len() != 1 {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
&command.name,
|
|
|
|
|
"Expected exactly 1 argument",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let expected = &command.args[0];
|
|
|
|
|
|
|
|
|
|
// Check if argument is a filename or literal text
|
|
|
|
|
let expected_content = if expected == "-" {
|
|
|
|
|
// Empty string
|
|
|
|
|
"".to_string()
|
|
|
|
|
} else if let Ok(file_content) = fs::read_to_string(env.work_dir.join(expected)) {
|
|
|
|
|
// It's a file - use its contents
|
|
|
|
|
file_content.trim_end().to_string()
|
|
|
|
|
} else {
|
|
|
|
|
// It's literal text
|
|
|
|
|
expected.clone()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
env.compare_output(&command.name, &expected_content)?;
|
|
|
|
|
}
|
|
|
|
|
"cd" => {
|
|
|
|
|
if command.args.len() != 1 {
|
|
|
|
|
return Err(Error::command_error("cd", "Expected exactly 1 argument"));
|
|
|
|
|
}
|
|
|
|
|
env.change_directory(&command.args[0])?;
|
|
|
|
|
}
|
|
|
|
|
"wait" => {
|
|
|
|
|
if command.args.len() != 1 {
|
|
|
|
|
return Err(Error::command_error("wait", "Expected exactly 1 argument"));
|
|
|
|
|
}
|
|
|
|
|
env.wait_for_background(&command.args[0])?;
|
|
|
|
|
}
|
|
|
|
|
"exists" => {
|
|
|
|
|
if command.args.is_empty() {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"exists",
|
|
|
|
|
"Expected at least 1 argument",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for -readonly flag
|
|
|
|
|
let (check_readonly, files) = if command.args[0] == "-readonly" {
|
|
|
|
|
if command.args.len() < 2 {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"exists",
|
|
|
|
|
"Expected file argument after -readonly",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
(true, &command.args[1..])
|
|
|
|
|
} else {
|
|
|
|
|
(false, &command.args[..])
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for path in files {
|
|
|
|
|
if !env.file_exists(path) {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"exists",
|
|
|
|
|
format!("File '{}' does not exist", path),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if check_readonly && !env.is_readonly(path) {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"exists",
|
|
|
|
|
format!("File '{}' is not read-only", path),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"mkdir" => {
|
|
|
|
|
if command.args.is_empty() {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"mkdir",
|
|
|
|
|
"Expected at least 1 argument",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
env.create_directories(&command.args)?;
|
|
|
|
|
}
|
|
|
|
|
"cp" => {
|
|
|
|
|
if command.args.len() < 2 {
|
|
|
|
|
return Err(Error::command_error("cp", "Expected at least 2 arguments"));
|
|
|
|
|
}
|
|
|
|
|
env.copy_files(&command.args)?;
|
|
|
|
|
}
|
|
|
|
|
"rm" => {
|
|
|
|
|
if command.args.is_empty() {
|
|
|
|
|
return Err(Error::command_error("rm", "Expected at least 1 argument"));
|
|
|
|
|
}
|
|
|
|
|
env.remove_files(&command.args)?;
|
|
|
|
|
}
|
|
|
|
|
"mv" => {
|
|
|
|
|
if command.args.len() != 2 {
|
|
|
|
|
return Err(Error::command_error("mv", "Expected exactly 2 arguments"));
|
|
|
|
|
}
|
|
|
|
|
env.move_file(&command.args[0], &command.args[1])?;
|
|
|
|
|
}
|
|
|
|
|
"env" => {
|
|
|
|
|
if command.args.is_empty() {
|
|
|
|
|
// Print current environment for debugging
|
|
|
|
|
for (key, value) in &env.env_vars {
|
|
|
|
|
println!("{}={}", key, value);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Set environment variables
|
|
|
|
|
for arg in &command.args {
|
|
|
|
|
if let Some(eq_pos) = arg.find('=') {
|
|
|
|
|
let key = &arg[..eq_pos];
|
|
|
|
|
let value = &arg[eq_pos + 1..];
|
|
|
|
|
env.set_env_var(key, value);
|
|
|
|
|
} else {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"env",
|
|
|
|
|
format!("Invalid env format: {}", arg),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"stdin" => {
|
|
|
|
|
if command.args.len() != 1 {
|
|
|
|
|
return Err(Error::command_error("stdin", "Expected exactly 1 argument"));
|
|
|
|
|
}
|
|
|
|
|
env.set_stdin_from_file(&command.args[0])?;
|
|
|
|
|
}
|
|
|
|
|
"skip" => {
|
|
|
|
|
env.should_skip = true;
|
|
|
|
|
let message = if command.args.is_empty() {
|
|
|
|
|
"Test skipped".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
command.args.join(" ")
|
|
|
|
|
};
|
|
|
|
|
return Err(Error::Generic(format!("SKIP: {}", message)));
|
|
|
|
|
}
|
|
|
|
|
"stop" => {
|
|
|
|
|
env.should_stop = true;
|
|
|
|
|
let _message = if command.args.is_empty() {
|
|
|
|
|
"Test stopped early".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
command.args.join(" ")
|
|
|
|
|
};
|
|
|
|
|
// For stop, we don't return an error - the test passes but stops
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
"kill" => {
|
|
|
|
|
let (signal, target) = if command.args.len() == 2 && command.args[0].starts_with('-') {
|
|
|
|
|
(Some(command.args[0].as_str()), &command.args[1])
|
|
|
|
|
} else if command.args.len() == 1 {
|
|
|
|
|
(None, &command.args[0])
|
|
|
|
|
} else {
|
|
|
|
|
return Err(Error::command_error("kill", "Expected 1 or 2 arguments"));
|
|
|
|
|
};
|
|
|
|
|
env.kill_background_process(target, signal)?;
|
|
|
|
|
}
|
|
|
|
|
"chmod" => {
|
|
|
|
|
if command.args.len() != 2 {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"chmod",
|
|
|
|
|
"Expected exactly 2 arguments",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
env.change_permissions(&command.args[0], &command.args[1])?;
|
|
|
|
|
}
|
|
|
|
|
"symlink" => {
|
|
|
|
|
if command.args.len() != 2 {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"symlink",
|
|
|
|
|
"Expected exactly 2 arguments: target link_name",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
env.create_symlink(&command.args[0], &command.args[1])?;
|
|
|
|
|
}
|
|
|
|
|
"unquote" => {
|
|
|
|
|
if command.args.len() != 1 {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"unquote",
|
|
|
|
|
"Expected exactly 1 argument",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
env.unquote_file(&command.args[0])?;
|
|
|
|
|
}
|
|
|
|
|
"grep" => {
|
|
|
|
|
if command.args.len() < 2 {
|
|
|
|
|
return Err(Error::command_error(
|
|
|
|
|
"grep",
|
|
|
|
|
"Expected at least 2 arguments",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let pattern = &command.args[0];
|
|
|
|
|
let files = &command.args[1..];
|
|
|
|
|
env.grep_files(pattern, files)?;
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
return Err(Error::UnknownCommand {
|
|
|
|
|
command: command.name.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|