mirror of
https://github.com/imjasonh/testscript-rs
synced 2026-07-09 01:26:40 +00:00
add GHA workflow (#1)
* add GHA workflow Signed-off-by: Jason Hall <jason@chainguard.dev> * fix CI Signed-off-by: Jason Hall <jason@chainguard.dev> * update readme Signed-off-by: Jason Hall <jason@chainguard.dev> * add badge Signed-off-by: Jason Hall <jason@chainguard.dev> --------- Signed-off-by: Jason Hall <jason@chainguard.dev>
This commit is contained in:
parent
fa546ce09f
commit
448cf615db
16 changed files with 381 additions and 156 deletions
61
.github/workflows/ci.yml
vendored
Normal file
61
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
rust: [stable]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cargo/registry
|
||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Cache cargo index
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cargo/git
|
||||
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: target
|
||||
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- name: Test sample-cli example
|
||||
run: |
|
||||
cd examples/sample-cli
|
||||
cargo test --verbose
|
||||
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -193,18 +193,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
version = "2.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
version = "2.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@
|
|||
name = "testscript-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Steve Klabnik <steve@steveklabnik.com>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["Jason Hall"]
|
||||
license = "Apache-2.0"
|
||||
description = "A Rust crate for testing command-line tools using filesystem-based script files"
|
||||
repository = "https://github.com/steveklabnik/testscript-rs"
|
||||
repository = "https://github.com/imjasonh/testscript-rs"
|
||||
keywords = ["testing", "cli", "testscript", "integration-tests"]
|
||||
categories = ["development-tools::testing"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
tempfile = "3.0"
|
||||
walkdir = "2.0"
|
||||
regex = "1.0"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# testscript-rs
|
||||
|
||||
[](https://github.com/imjasonh/testscript-rs/actions)
|
||||
|
||||
A Rust crate for testing command-line tools using filesystem-based script files.
|
||||
|
||||
testscript-rs provides a framework for writing integration tests for CLI applications using the `.txtar` format, where test scripts and file contents are combined in a single file.
|
||||
|
|
@ -137,6 +139,12 @@ Test scripts use the `.txtar` format. For complete format documentation, see the
|
|||
|
||||
Commands can be prefixed with conditions (`[unix]`) or negated (`!`).
|
||||
|
||||
> Note: Some features of `testscript` in Go are not supported in this Rust port:
|
||||
>
|
||||
> - `[gc]` for whether Go was built with gc
|
||||
> - `[gccgo]` for whether Go was built with gccgo
|
||||
> - `[go1.x]` for whether the Go version is 1.x or later
|
||||
|
||||
## Examples
|
||||
|
||||
See [`examples/sample-cli/`](./examples/sample-cli/) and its `testdata` directory for more examples.
|
||||
|
|
|
|||
8
examples/sample-cli/Cargo.lock
generated
8
examples/sample-cli/Cargo.lock
generated
|
|
@ -321,18 +321,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
version = "2.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
version = "2.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ name = "sample-cli"
|
|||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
testscript-rs = { path = "../.." }
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::fs;
|
||||
use std::io::{self, BufRead, BufReader};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
@ -76,7 +76,10 @@ impl std::str::FromStr for CountMode {
|
|||
"lines" | "line" | "l" => Ok(CountMode::Lines),
|
||||
"words" | "word" | "w" => Ok(CountMode::Words),
|
||||
"chars" | "char" | "c" => Ok(CountMode::Chars),
|
||||
_ => Err(format!("Invalid count mode: {}. Use lines, words, or chars", s)),
|
||||
_ => Err(format!(
|
||||
"Invalid count mode: {}. Use lines, words, or chars",
|
||||
s
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -86,7 +89,11 @@ fn main() {
|
|||
|
||||
let result = match cli.command {
|
||||
Some(Commands::Count { mode, files }) => count_command(mode, files),
|
||||
Some(Commands::Grep { pattern, files, ignore_case }) => grep_command(pattern, files, ignore_case),
|
||||
Some(Commands::Grep {
|
||||
pattern,
|
||||
files,
|
||||
ignore_case,
|
||||
}) => grep_command(pattern, files, ignore_case),
|
||||
Some(Commands::Create { file, content }) => create_command(file, content),
|
||||
Some(Commands::Remove { files }) => remove_command(files),
|
||||
Some(Commands::List { dir }) => list_command(dir),
|
||||
|
|
@ -123,7 +130,11 @@ fn count_command(mode: CountMode, files: Vec<String>) -> Result<(), Box<dyn std:
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn grep_command(pattern: String, files: Vec<String>, ignore_case: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn grep_command(
|
||||
pattern: String,
|
||||
files: Vec<String>,
|
||||
ignore_case: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if files.is_empty() {
|
||||
return Err("No files specified".into());
|
||||
}
|
||||
|
|
@ -194,4 +205,4 @@ fn list_command(dir: String) -> Result<(), Box<dyn std::error::Error>> {
|
|||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
//!
|
||||
//! This demonstrates how to use testscript-rs to test a CLI application.
|
||||
|
||||
use testscript_rs::testscript;
|
||||
use std::process::Command;
|
||||
use testscript_rs::testscript;
|
||||
|
||||
#[test]
|
||||
fn test_sample_cli() {
|
||||
|
|
@ -20,9 +20,10 @@ fn test_sample_cli() {
|
|||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(testscript_rs::Error::Generic(
|
||||
format!("Failed to build sample-cli: {}", stderr)
|
||||
));
|
||||
return Err(testscript_rs::Error::Generic(format!(
|
||||
"Failed to build sample-cli: {}",
|
||||
stderr
|
||||
)));
|
||||
}
|
||||
|
||||
// Copy binary to test working directory
|
||||
|
|
@ -40,11 +41,14 @@ fn test_sample_cli() {
|
|||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&dest)
|
||||
.map_err(|e| testscript_rs::Error::Generic(format!("Failed to get permissions: {}", e)))?
|
||||
.map_err(|e| {
|
||||
testscript_rs::Error::Generic(format!("Failed to get permissions: {}", e))
|
||||
})?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&dest, perms)
|
||||
.map_err(|e| testscript_rs::Error::Generic(format!("Failed to set permissions: {}", e)))?;
|
||||
std::fs::set_permissions(&dest, perms).map_err(|e| {
|
||||
testscript_rs::Error::Generic(format!("Failed to set permissions: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
println!("Built and copied sample-cli to: {}", dest.display());
|
||||
|
|
@ -57,6 +61,4 @@ fn test_sample_cli() {
|
|||
Ok(_) => println!("All sample-cli tests passed!"),
|
||||
Err(e) => println!("Sample-cli tests had issues (expected): {}", e),
|
||||
}
|
||||
|
||||
// The test passes as long as testscript-rs works correctly
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,4 @@ fn main() {
|
|||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,4 +64,4 @@ impl Error {
|
|||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,9 +233,9 @@ fn parse_command_tokens(input: &str) -> Result<Vec<String>> {
|
|||
match next_ch {
|
||||
'\\' => current_token.push('\\'),
|
||||
'\'' => current_token.push('\''),
|
||||
'n' => current_token.push('\n'), // Still process \n in single quotes for Go compat
|
||||
't' => current_token.push('\t'), // Still process \t in single quotes for Go compat
|
||||
'r' => current_token.push('\r'), // Still process \r in single quotes for Go compat
|
||||
'n' => current_token.push('\n'), // Still process \n in single quotes for Go compat
|
||||
't' => current_token.push('\t'), // Still process \t in single quotes for Go compat
|
||||
'r' => current_token.push('\r'), // Still process \r in single quotes for Go compat
|
||||
_ => {
|
||||
current_token.push('\\');
|
||||
current_token.push(next_ch);
|
||||
|
|
@ -280,8 +280,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_parse_file_header() {
|
||||
assert_eq!(parse_file_header("-- hello.txt --"), Some("hello.txt".to_string()));
|
||||
assert_eq!(parse_file_header("-- sub/dir/file.go --"), Some("sub/dir/file.go".to_string()));
|
||||
assert_eq!(
|
||||
parse_file_header("-- hello.txt --"),
|
||||
Some("hello.txt".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_file_header("-- sub/dir/file.go --"),
|
||||
Some("sub/dir/file.go".to_string())
|
||||
);
|
||||
assert_eq!(parse_file_header("--hello--"), None);
|
||||
assert_eq!(parse_file_header("hello"), None);
|
||||
}
|
||||
|
|
@ -311,14 +317,18 @@ mod tests {
|
|||
assert!(!cmd.background);
|
||||
assert!(!cmd.negated);
|
||||
|
||||
let cmd = parse_command_line("[windows] exec echo hello", 2).unwrap().unwrap();
|
||||
let cmd = parse_command_line("[windows] exec echo hello", 2)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cmd.condition, Some("windows".to_string()));
|
||||
|
||||
let cmd = parse_command_line("exec echo hello &", 3).unwrap().unwrap();
|
||||
assert!(cmd.background);
|
||||
assert_eq!(cmd.args, vec!["echo", "hello"]);
|
||||
|
||||
let cmd = parse_command_line("! exists missing_file", 4).unwrap().unwrap();
|
||||
let cmd = parse_command_line("! exists missing_file", 4)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(cmd.negated);
|
||||
assert_eq!(cmd.name, "exists");
|
||||
assert_eq!(cmd.args, vec!["missing_file"]);
|
||||
|
|
@ -373,6 +383,9 @@ second file
|
|||
assert_eq!(script.files[1].name, "file2.txt");
|
||||
assert_eq!(script.files[1].contents, b"second file");
|
||||
assert_eq!(script.files[2].name, "expected.txt");
|
||||
assert_eq!(script.files[2].contents, b"first file\ncontent\nsecond file");
|
||||
assert_eq!(
|
||||
script.files[2].contents,
|
||||
b"first file\ncontent\nsecond file"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
249
src/run.rs
249
src/run.rs
|
|
@ -175,7 +175,12 @@ impl TestEnvironment {
|
|||
}
|
||||
|
||||
/// Execute a command in the background
|
||||
pub fn execute_background_command(&mut self, name: &str, cmd: &str, args: &[String]) -> Result<()> {
|
||||
pub fn execute_background_command(
|
||||
&mut self,
|
||||
name: &str,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
) -> Result<()> {
|
||||
let mut command = StdCommand::new(cmd);
|
||||
command
|
||||
.args(args)
|
||||
|
|
@ -196,7 +201,10 @@ impl TestEnvironment {
|
|||
self.last_output = Some(output.clone());
|
||||
Ok(output)
|
||||
} else {
|
||||
Err(Error::command_error("wait", format!("No background process named '{}'", name)))
|
||||
Err(Error::command_error(
|
||||
"wait",
|
||||
format!("No background process named '{}'", name),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,11 +218,17 @@ impl TestEnvironment {
|
|||
};
|
||||
|
||||
if !new_dir.exists() {
|
||||
return Err(Error::command_error("cd", format!("Directory '{}' does not exist", path)));
|
||||
return Err(Error::command_error(
|
||||
"cd",
|
||||
format!("Directory '{}' does not exist", path),
|
||||
));
|
||||
}
|
||||
|
||||
if !new_dir.is_dir() {
|
||||
return Err(Error::command_error("cd", format!("'{}' is not a directory", path)));
|
||||
return Err(Error::command_error(
|
||||
"cd",
|
||||
format!("'{}' is not a directory", path),
|
||||
));
|
||||
}
|
||||
|
||||
self.current_dir = new_dir;
|
||||
|
|
@ -248,21 +262,34 @@ impl TestEnvironment {
|
|||
/// Compare command output with expected content
|
||||
pub fn compare_output(&self, output_type: &str, expected: &str) -> Result<()> {
|
||||
let actual = match self.last_output {
|
||||
Some(ref output) => {
|
||||
match output_type {
|
||||
"stdout" => String::from_utf8_lossy(&output.stdout).trim_end().to_string(),
|
||||
"stderr" => String::from_utf8_lossy(&output.stderr).trim_end().to_string(),
|
||||
_ => return Err(Error::command_error(output_type, "Unknown output type")),
|
||||
}
|
||||
Some(ref output) => match output_type {
|
||||
"stdout" => String::from_utf8_lossy(&output.stdout)
|
||||
.trim_end()
|
||||
.to_string(),
|
||||
"stderr" => String::from_utf8_lossy(&output.stderr)
|
||||
.trim_end()
|
||||
.to_string(),
|
||||
_ => return Err(Error::command_error(output_type, "Unknown output type")),
|
||||
},
|
||||
None => {
|
||||
return Err(Error::command_error(
|
||||
output_type,
|
||||
"No command output available",
|
||||
))
|
||||
}
|
||||
None => return Err(Error::command_error(output_type, "No command output available")),
|
||||
};
|
||||
|
||||
// Substitute environment variables in the expected pattern
|
||||
let expected_substituted = self.substitute_env_vars(expected);
|
||||
|
||||
// Check if expected is a regex pattern (contains regex special characters)
|
||||
if expected_substituted.contains('^') || expected_substituted.contains('$') || expected_substituted.contains('[') || expected_substituted.contains('(') || expected_substituted.contains('*') || expected_substituted.contains('.') {
|
||||
if expected_substituted.contains('^')
|
||||
|| expected_substituted.contains('$')
|
||||
|| expected_substituted.contains('[')
|
||||
|| expected_substituted.contains('(')
|
||||
|| expected_substituted.contains('*')
|
||||
|| expected_substituted.contains('.')
|
||||
{
|
||||
let regex_pattern = format!("(?s){}", expected_substituted); // (?s) enables DOTALL mode
|
||||
let regex = Regex::new(®ex_pattern)
|
||||
.map_err(|e| Error::command_error(output_type, format!("Invalid regex: {}", e)))?;
|
||||
|
|
@ -331,8 +358,9 @@ impl TestEnvironment {
|
|||
}
|
||||
} else {
|
||||
let source_path = self.work_dir.join(source);
|
||||
fs::read(&source_path)
|
||||
.map_err(|e| Error::command_error("cp", format!("Cannot read '{}': {}", source, e)))?
|
||||
fs::read(&source_path).map_err(|e| {
|
||||
Error::command_error("cp", format!("Cannot read '{}': {}", source, e))
|
||||
})?
|
||||
};
|
||||
|
||||
// If dest exists and is a directory, copy into it
|
||||
|
|
@ -340,7 +368,8 @@ impl TestEnvironment {
|
|||
let filename = if source == "stdout" || source == "stderr" {
|
||||
source
|
||||
} else {
|
||||
Path::new(source).file_name()
|
||||
Path::new(source)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(source)
|
||||
};
|
||||
|
|
@ -373,11 +402,18 @@ impl TestEnvironment {
|
|||
let dest_path = self.work_dir.join(dest);
|
||||
|
||||
if !source_path.exists() {
|
||||
return Err(Error::command_error("mv", format!("Source '{}' does not exist", source)));
|
||||
return Err(Error::command_error(
|
||||
"mv",
|
||||
format!("Source '{}' does not exist", source),
|
||||
));
|
||||
}
|
||||
|
||||
fs::rename(&source_path, &dest_path)
|
||||
.map_err(|e| Error::command_error("mv", format!("Cannot move '{}' to '{}': {}", source, dest, e)))?;
|
||||
fs::rename(&source_path, &dest_path).map_err(|e| {
|
||||
Error::command_error(
|
||||
"mv",
|
||||
format!("Cannot move '{}' to '{}': {}", source, dest, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -408,8 +444,9 @@ impl TestEnvironment {
|
|||
}
|
||||
} else {
|
||||
let file_path = self.work_dir.join(filename);
|
||||
fs::read(&file_path)
|
||||
.map_err(|e| Error::command_error("stdin", format!("Cannot read '{}': {}", filename, e)))?
|
||||
fs::read(&file_path).map_err(|e| {
|
||||
Error::command_error("stdin", format!("Cannot read '{}': {}", filename, e))
|
||||
})?
|
||||
};
|
||||
|
||||
self.next_stdin = Some(content);
|
||||
|
|
@ -425,7 +462,10 @@ impl TestEnvironment {
|
|||
let _ = child.wait()?; // Reap the process
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::command_error("kill", format!("No background process named '{}'", name)))
|
||||
Err(Error::command_error(
|
||||
"kill",
|
||||
format!("No background process named '{}'", name),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -434,11 +474,13 @@ impl TestEnvironment {
|
|||
let path1 = self.work_dir.join(file1);
|
||||
let path2 = self.work_dir.join(file2);
|
||||
|
||||
let contents1 = fs::read_to_string(&path1)
|
||||
.map_err(|e| Error::command_error("cmpenv", format!("Cannot read '{}': {}", file1, e)))?;
|
||||
let contents1 = fs::read_to_string(&path1).map_err(|e| {
|
||||
Error::command_error("cmpenv", format!("Cannot read '{}': {}", file1, e))
|
||||
})?;
|
||||
|
||||
let contents2_raw = fs::read_to_string(&path2)
|
||||
.map_err(|e| Error::command_error("cmpenv", format!("Cannot read '{}': {}", file2, e)))?;
|
||||
let contents2_raw = fs::read_to_string(&path2).map_err(|e| {
|
||||
Error::command_error("cmpenv", format!("Cannot read '{}': {}", file2, e))
|
||||
})?;
|
||||
|
||||
// Substitute environment variables in file2
|
||||
let contents2 = self.substitute_env_vars(&contents2_raw);
|
||||
|
|
@ -462,8 +504,8 @@ impl TestEnvironment {
|
|||
// Handle $VAR and ${VAR} patterns
|
||||
for (key, value) in &self.env_vars {
|
||||
let patterns = [
|
||||
format!("${{{}}}", key), // ${VAR}
|
||||
format!("${}", key), // $VAR (but be careful with word boundaries)
|
||||
format!("${{{}}}", key), // ${VAR}
|
||||
format!("${}", key), // $VAR (but be careful with word boundaries)
|
||||
];
|
||||
|
||||
for pattern in &patterns {
|
||||
|
|
@ -489,26 +531,44 @@ impl TestEnvironment {
|
|||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&file_path)
|
||||
.map_err(|e| Error::command_error("chmod", format!("Cannot get metadata for '{}': {}", file, e)))?
|
||||
.map_err(|e| {
|
||||
Error::command_error(
|
||||
"chmod",
|
||||
format!("Cannot get metadata for '{}': {}", file, e),
|
||||
)
|
||||
})?
|
||||
.permissions();
|
||||
perms.set_mode(mode_int);
|
||||
fs::set_permissions(&file_path, perms)
|
||||
.map_err(|e| Error::command_error("chmod", format!("Cannot set permissions for '{}': {}", file, e)))?;
|
||||
fs::set_permissions(&file_path, perms).map_err(|e| {
|
||||
Error::command_error(
|
||||
"chmod",
|
||||
format!("Cannot set permissions for '{}': {}", file, e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// On Windows, we can only set read-only status
|
||||
let mut perms = fs::metadata(&file_path)
|
||||
.map_err(|e| Error::command_error("chmod", format!("Cannot get metadata for '{}': {}", file, e)))?
|
||||
.map_err(|e| {
|
||||
Error::command_error(
|
||||
"chmod",
|
||||
format!("Cannot get metadata for '{}': {}", file, e),
|
||||
)
|
||||
})?
|
||||
.permissions();
|
||||
|
||||
// If mode doesn't include write permissions for owner (e.g., 444), make it read-only
|
||||
let readonly = (mode_int & 0o200) == 0;
|
||||
perms.set_readonly(readonly);
|
||||
|
||||
fs::set_permissions(&file_path, perms)
|
||||
.map_err(|e| Error::command_error("chmod", format!("Cannot set permissions for '{}': {}", file, e)))?;
|
||||
fs::set_permissions(&file_path, perms).map_err(|e| {
|
||||
Error::command_error(
|
||||
"chmod",
|
||||
format!("Cannot set permissions for '{}': {}", file, e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -518,19 +578,19 @@ impl TestEnvironment {
|
|||
pub fn unquote_file(&self, file: &str) -> Result<()> {
|
||||
let file_path = self.work_dir.join(file);
|
||||
|
||||
let contents = fs::read_to_string(&file_path)
|
||||
.map_err(|e| Error::command_error("unquote", format!("Cannot read '{}': {}", file, e)))?;
|
||||
let contents = fs::read_to_string(&file_path).map_err(|e| {
|
||||
Error::command_error("unquote", format!("Cannot read '{}': {}", file, e))
|
||||
})?;
|
||||
|
||||
let unquoted_contents: String = contents
|
||||
.lines()
|
||||
.map(|line| {
|
||||
line.strip_prefix('>').unwrap_or(line)
|
||||
})
|
||||
.map(|line| line.strip_prefix('>').unwrap_or(line))
|
||||
.collect::<Vec<&str>>()
|
||||
.join("\n");
|
||||
|
||||
fs::write(&file_path, unquoted_contents)
|
||||
.map_err(|e| Error::command_error("unquote", format!("Cannot write '{}': {}", file, e)))?;
|
||||
fs::write(&file_path, unquoted_contents).map_err(|e| {
|
||||
Error::command_error("unquote", format!("Cannot write '{}': {}", file, e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -545,8 +605,9 @@ impl TestEnvironment {
|
|||
|
||||
for file in files {
|
||||
let file_path = self.work_dir.join(file);
|
||||
let contents = fs::read_to_string(&file_path)
|
||||
.map_err(|e| Error::command_error("grep", format!("Cannot read '{}': {}", file, e)))?;
|
||||
let contents = fs::read_to_string(&file_path).map_err(|e| {
|
||||
Error::command_error("grep", format!("Cannot read '{}': {}", file, e))
|
||||
})?;
|
||||
|
||||
for (line_num, line) in contents.lines().enumerate() {
|
||||
if regex.is_match(line) {
|
||||
|
|
@ -644,8 +705,11 @@ fn execute_command(env: &mut TestEnvironment, command: &Command, params: &RunPar
|
|||
|
||||
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
|
||||
Ok(_) => Err(Error::command_error(
|
||||
&command.name,
|
||||
"Command was expected to fail but succeeded",
|
||||
)),
|
||||
Err(_) => Ok(()), // Negated command failed as expected
|
||||
}
|
||||
} else {
|
||||
result
|
||||
|
|
@ -653,17 +717,29 @@ fn execute_command(env: &mut TestEnvironment, command: &Command, params: &RunPar
|
|||
}
|
||||
|
||||
/// Inner command execution logic
|
||||
fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: &RunParams) -> Result<()> {
|
||||
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 = params.conditions.get(condition).copied().unwrap_or_else(|| {
|
||||
// If condition starts with !, check for negation
|
||||
if let Some(base_condition) = condition.strip_prefix('!') {
|
||||
!params.conditions.get(base_condition).copied().unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
let condition_met = params
|
||||
.conditions
|
||||
.get(condition)
|
||||
.copied()
|
||||
.unwrap_or_else(|| {
|
||||
// If condition starts with !, check for negation
|
||||
if let Some(base_condition) = condition.strip_prefix('!') {
|
||||
!params
|
||||
.conditions
|
||||
.get(base_condition)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if !condition_met {
|
||||
return Ok(()); // Skip this command
|
||||
|
|
@ -697,10 +773,12 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: &
|
|||
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())
|
||||
format!(
|
||||
"Command '{}' failed with exit code {}: {}",
|
||||
cmd,
|
||||
output.status.code().unwrap_or(-1),
|
||||
stderr.trim()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -713,13 +791,19 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: &
|
|||
}
|
||||
"cmpenv" => {
|
||||
if command.args.len() != 2 {
|
||||
return Err(Error::command_error("cmpenv", "Expected exactly 2 arguments"));
|
||||
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"));
|
||||
return Err(Error::command_error(
|
||||
&command.name,
|
||||
"Expected exactly 1 argument",
|
||||
));
|
||||
}
|
||||
|
||||
let expected = &command.args[0];
|
||||
|
|
@ -752,13 +836,19 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: &
|
|||
}
|
||||
"exists" => {
|
||||
if command.args.is_empty() {
|
||||
return Err(Error::command_error("exists", "Expected at least 1 argument"));
|
||||
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"));
|
||||
return Err(Error::command_error(
|
||||
"exists",
|
||||
"Expected file argument after -readonly",
|
||||
));
|
||||
}
|
||||
(true, &command.args[1..])
|
||||
} else {
|
||||
|
|
@ -767,17 +857,26 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: &
|
|||
|
||||
for path in files {
|
||||
if !env.file_exists(path) {
|
||||
return Err(Error::command_error("exists", format!("File '{}' does not exist", 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)));
|
||||
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"));
|
||||
return Err(Error::command_error(
|
||||
"mkdir",
|
||||
"Expected at least 1 argument",
|
||||
));
|
||||
}
|
||||
env.create_directories(&command.args)?;
|
||||
}
|
||||
|
|
@ -813,7 +912,10 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: &
|
|||
let value = &arg[eq_pos + 1..];
|
||||
env.set_env_var(key, value);
|
||||
} else {
|
||||
return Err(Error::command_error("env", format!("Invalid env format: {}", arg)));
|
||||
return Err(Error::command_error(
|
||||
"env",
|
||||
format!("Invalid env format: {}", arg),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -855,19 +957,28 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: &
|
|||
}
|
||||
"chmod" => {
|
||||
if command.args.len() != 2 {
|
||||
return Err(Error::command_error("chmod", "Expected exactly 2 arguments"));
|
||||
return Err(Error::command_error(
|
||||
"chmod",
|
||||
"Expected exactly 2 arguments",
|
||||
));
|
||||
}
|
||||
env.change_permissions(&command.args[0], &command.args[1])?;
|
||||
}
|
||||
"unquote" => {
|
||||
if command.args.len() != 1 {
|
||||
return Err(Error::command_error("unquote", "Expected exactly 1 argument"));
|
||||
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"));
|
||||
return Err(Error::command_error(
|
||||
"grep",
|
||||
"Expected at least 2 arguments",
|
||||
));
|
||||
}
|
||||
let pattern = &command.args[0];
|
||||
let files = &command.args[1..];
|
||||
|
|
@ -935,4 +1046,4 @@ mod tests {
|
|||
// Should fail
|
||||
assert!(env.compare_files("file1.txt", "file3.txt").is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
use testscript_rs::{run_test};
|
||||
use testscript_rs::run_test;
|
||||
|
||||
#[test]
|
||||
fn test_exists_command() {
|
||||
|
|
@ -118,7 +118,10 @@ input content"#;
|
|||
let result = run_test(&script_path);
|
||||
// This might fail if cat isn't available - that's okay for now
|
||||
if result.is_err() {
|
||||
println!("Stdin test failed (cat might not be available): {:?}", result);
|
||||
println!(
|
||||
"Stdin test failed (cat might not be available): {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,4 +175,4 @@ hello from stdout"#;
|
|||
|
||||
let result = run_test(&script_path);
|
||||
assert!(result.is_ok(), "CP stdout test failed: {:?}", result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,11 @@ nested content"#;
|
|||
fs::write(&script_path, script_content).unwrap();
|
||||
|
||||
let result = run_test(&script_path);
|
||||
assert!(result.is_ok(), "Special file names test failed: {:?}", result);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Special file names test failed: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -161,4 +165,4 @@ cmp file1.txt file2.txt
|
|||
|
||||
let result = run_test(&script_path);
|
||||
assert!(result.is_err(), "Empty exec command should fail");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,14 +41,19 @@ count-args one two three"#;
|
|||
let result = testscript::run(testdata_dir.to_string_lossy())
|
||||
.command("greet", |_env, args| {
|
||||
if args.is_empty() {
|
||||
return Err(testscript_rs::Error::Generic("greet requires a name".to_string()));
|
||||
return Err(testscript_rs::Error::Generic(
|
||||
"greet requires a name".to_string(),
|
||||
));
|
||||
}
|
||||
// Custom command logic would go here
|
||||
Ok(())
|
||||
})
|
||||
.command("count-args", |_env, args| {
|
||||
if args.len() != 3 {
|
||||
return Err(testscript_rs::Error::Generic(format!("expected 3 args, got {}", args.len())));
|
||||
return Err(testscript_rs::Error::Generic(format!(
|
||||
"expected 3 args, got {}",
|
||||
args.len()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,13 +21,12 @@ existing content"#;
|
|||
fs::write(&script_path, script_content).unwrap();
|
||||
|
||||
// Create RunParams with setup hook
|
||||
let params = RunParams::new()
|
||||
.setup(|env| {
|
||||
// Setup hook creates a file to prove it ran
|
||||
let setup_file = env.work_dir.join("setup_was_here.txt");
|
||||
std::fs::write(&setup_file, "Setup ran successfully")?;
|
||||
Ok(())
|
||||
});
|
||||
let params = RunParams::new().setup(|env| {
|
||||
// Setup hook creates a file to prove it ran
|
||||
let setup_file = env.work_dir.join("setup_was_here.txt");
|
||||
std::fs::write(&setup_file, "Setup ran successfully")?;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let result = run_script(&script_path, ¶ms);
|
||||
assert!(result.is_ok(), "Setup hook test failed: {:?}", result);
|
||||
|
|
@ -46,36 +45,38 @@ stdout "Mock CLI Tool""#;
|
|||
fs::write(&script_path, script_content).unwrap();
|
||||
|
||||
// Create RunParams with setup hook that "compiles" a mock binary
|
||||
let params = RunParams::new()
|
||||
.setup(|env| {
|
||||
// Create a mock binary script for testing
|
||||
let binary_path = env.work_dir.join("my-tool");
|
||||
let mock_binary = r#"#!/bin/sh
|
||||
let params = RunParams::new().setup(|env| {
|
||||
// Create a mock binary script for testing
|
||||
let binary_path = env.work_dir.join("my-tool");
|
||||
let mock_binary = r#"#!/bin/sh
|
||||
if [ "$1" = "--help" ]; then
|
||||
echo "Mock CLI Tool"
|
||||
echo "Usage: my-tool [options]"
|
||||
else
|
||||
echo "Hello from my-tool"
|
||||
fi"#;
|
||||
std::fs::write(&binary_path, mock_binary)?;
|
||||
std::fs::write(&binary_path, mock_binary)?;
|
||||
|
||||
// Make it executable (on Unix systems)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&binary_path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&binary_path, perms)?;
|
||||
}
|
||||
// Make it executable (on Unix systems)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&binary_path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&binary_path, perms)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let result = run_script(&script_path, ¶ms);
|
||||
|
||||
// This might fail on Windows or if shell scripting isn't available
|
||||
if result.is_err() {
|
||||
println!("Binary compilation test failed (expected on some systems): {:?}", result);
|
||||
println!(
|
||||
"Binary compilation test failed (expected on some systems): {:?}",
|
||||
result
|
||||
);
|
||||
} else {
|
||||
println!("Setup hook binary test passed!");
|
||||
}
|
||||
|
|
@ -94,17 +95,16 @@ stdout "Value: hello_from_setup""#;
|
|||
fs::write(&script_path, script_content).unwrap();
|
||||
|
||||
// Create RunParams with setup hook that sets an environment variable
|
||||
let params = RunParams::new()
|
||||
.setup(|env| {
|
||||
// The setup hook has access to the TestEnvironment but can't modify it
|
||||
// This demonstrates the current API limitation - we can access but not modify
|
||||
println!("Setup running in: {}", env.work_dir.display());
|
||||
let params = RunParams::new().setup(|env| {
|
||||
// The setup hook has access to the TestEnvironment but can't modify it
|
||||
// This demonstrates the current API limitation - we can access but not modify
|
||||
println!("Setup running in: {}", env.work_dir.display());
|
||||
|
||||
// We could write files that contain environment setup
|
||||
let env_script = env.work_dir.join("setup_env.sh");
|
||||
std::fs::write(&env_script, "export TEST_FROM_SETUP=hello_from_setup")?;
|
||||
Ok(())
|
||||
});
|
||||
// We could write files that contain environment setup
|
||||
let env_script = env.work_dir.join("setup_env.sh");
|
||||
std::fs::write(&env_script, "export TEST_FROM_SETUP=hello_from_setup")?;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let mut params = params;
|
||||
// Manually set the environment variable for this test
|
||||
|
|
@ -113,7 +113,10 @@ stdout "Value: hello_from_setup""#;
|
|||
let result = run_script(&script_path, ¶ms);
|
||||
// This test demonstrates the current limitation - setup can't modify the running environment
|
||||
if result.is_err() {
|
||||
println!("Environment setup test failed (expected with current API): {:?}", result);
|
||||
println!(
|
||||
"Environment setup test failed (expected with current API): {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,12 +131,16 @@ exec echo "Should not execute""#;
|
|||
fs::write(&script_path, script_content).unwrap();
|
||||
|
||||
// Create RunParams with failing setup hook
|
||||
let params = RunParams::new()
|
||||
.setup(|_env| {
|
||||
Err(testscript_rs::Error::Generic("Setup deliberately failed".to_string()))
|
||||
});
|
||||
let params = RunParams::new().setup(|_env| {
|
||||
Err(testscript_rs::Error::Generic(
|
||||
"Setup deliberately failed".to_string(),
|
||||
))
|
||||
});
|
||||
|
||||
let result = run_script(&script_path, ¶ms);
|
||||
assert!(result.is_err(), "Setup hook should have failed the test");
|
||||
assert!(result.unwrap_err().to_string().contains("Setup deliberately failed"));
|
||||
}
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Setup deliberately failed"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue