651 lines
20 KiB
Rust
651 lines
20 KiB
Rust
mod duration;
|
|
mod format;
|
|
mod matching;
|
|
mod parse;
|
|
mod run;
|
|
mod update;
|
|
|
|
use std::fs;
|
|
use std::io::{Write, stderr, stdout};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process;
|
|
use std::time::Instant;
|
|
|
|
use format::{TestResult, evaluate_file, format_failure, format_summary, print_dot};
|
|
use parse::{Command, Directive, ExitCode, ShoutFile};
|
|
use run::{CommandResult, RunOptions, cleanup_tmp_dir, command_passes, run_file};
|
|
use update::rewrite_file;
|
|
|
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
// ANSI
|
|
const RED: &str = "\x1b[31m";
|
|
const YELLOW: &str = "\x1b[33m";
|
|
const DIM: &str = "\x1b[2m";
|
|
const RESET: &str = "\x1b[0m";
|
|
|
|
struct TestOpts {
|
|
files: Vec<String>,
|
|
update: bool,
|
|
keep: bool,
|
|
clean_env: bool,
|
|
path_dirs: Vec<String>,
|
|
timeout: String,
|
|
verbose: bool,
|
|
port_from: u16,
|
|
filter: Option<String>,
|
|
parallel: bool,
|
|
}
|
|
|
|
fn print_usage() {
|
|
eprintln!("Usage: shout [options] [command]");
|
|
eprintln!();
|
|
eprintln!("$ shell output tester");
|
|
eprintln!();
|
|
eprintln!("Options:");
|
|
eprintln!(" -V, --version output the version number");
|
|
eprintln!(" -h, --help display help for command");
|
|
eprintln!();
|
|
eprintln!("Commands:");
|
|
eprintln!(" test [options] [files...] Run .shout test files");
|
|
eprintln!(" version Print the version");
|
|
eprintln!(" example Print an example .shout file");
|
|
eprintln!(" help [command] display help for command");
|
|
}
|
|
|
|
fn print_test_help() {
|
|
eprintln!("Usage: shout test [options] [files...]");
|
|
eprintln!();
|
|
eprintln!("Run .shout test files");
|
|
eprintln!();
|
|
eprintln!("Arguments:");
|
|
eprintln!(" files Files or directories to test");
|
|
eprintln!();
|
|
eprintln!("Options:");
|
|
eprintln!(" -u, --update Rewrite expected output in-place with actual output");
|
|
eprintln!(" -k, --keep Keep temp directories after run");
|
|
eprintln!(" --clean-env Start with empty environment");
|
|
eprintln!(" --path <path> Prepend <path> to PATH (repeatable)");
|
|
eprintln!(" --timeout <dur> Per-command timeout (default: 10s)");
|
|
eprintln!(" -v, --verbose Print each command as it runs");
|
|
eprintln!(" --port-from <n> Auto-assign $PORT starting from <n> (default: 5400)");
|
|
eprintln!(" -t, --filter <pattern> Only run files matching <pattern> (substring match)");
|
|
eprintln!(" --parallel Run files in parallel");
|
|
eprintln!(" -h, --help display help for command");
|
|
}
|
|
|
|
fn parse_args() -> Option<(&'static str, TestOpts)> {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
|
|
if args.is_empty() || args[0] == "-h" || args[0] == "--help" {
|
|
print_usage();
|
|
process::exit(if args.is_empty() { 1 } else { 0 });
|
|
}
|
|
|
|
if args[0] == "help" {
|
|
if args.len() < 2 {
|
|
print_usage();
|
|
} else {
|
|
match args[1].as_str() {
|
|
"test" => print_test_help(),
|
|
"example" => print_example_help(),
|
|
"version" => print_version_help(),
|
|
"help" => print_help_help(),
|
|
other => {
|
|
eprintln!("Unknown command: {other}");
|
|
eprintln!("Run 'shout --help' for usage");
|
|
process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
process::exit(0);
|
|
}
|
|
|
|
let subcommand = args[0].as_str();
|
|
|
|
match subcommand {
|
|
"-V" | "--version" | "version" => {
|
|
println!("{VERSION}");
|
|
process::exit(0);
|
|
}
|
|
"example" => {
|
|
print_example();
|
|
process::exit(0);
|
|
}
|
|
"test" => {}
|
|
other => {
|
|
eprintln!("Unknown command: {other}");
|
|
eprintln!("Run 'shout --help' for usage");
|
|
process::exit(1);
|
|
}
|
|
}
|
|
|
|
let mut opts = TestOpts {
|
|
files: vec![],
|
|
update: false,
|
|
keep: false,
|
|
clean_env: false,
|
|
path_dirs: vec![],
|
|
timeout: "10s".to_string(),
|
|
verbose: false,
|
|
port_from: 5400,
|
|
filter: None,
|
|
parallel: false,
|
|
};
|
|
|
|
let mut i = 1;
|
|
while i < args.len() {
|
|
match args[i].as_str() {
|
|
"-h" | "--help" => {
|
|
print_test_help();
|
|
process::exit(0);
|
|
}
|
|
"-u" | "--update" => opts.update = true,
|
|
"-k" | "--keep" => opts.keep = true,
|
|
"--clean-env" => opts.clean_env = true,
|
|
"-v" | "--verbose" => opts.verbose = true,
|
|
"--parallel" => opts.parallel = true,
|
|
"--path" => {
|
|
i += 1;
|
|
if i >= args.len() {
|
|
eprintln!("--path requires a value");
|
|
process::exit(1);
|
|
}
|
|
opts.path_dirs.push(args[i].clone());
|
|
}
|
|
"--timeout" => {
|
|
i += 1;
|
|
if i >= args.len() {
|
|
eprintln!("--timeout requires a value");
|
|
process::exit(1);
|
|
}
|
|
opts.timeout = args[i].clone();
|
|
}
|
|
"--port-from" => {
|
|
i += 1;
|
|
if i >= args.len() {
|
|
eprintln!("--port-from requires a value");
|
|
process::exit(1);
|
|
}
|
|
opts.port_from = match args[i].parse() {
|
|
Ok(n) => n,
|
|
Err(_) => {
|
|
eprintln!("--port-from must be an integer");
|
|
process::exit(1);
|
|
}
|
|
};
|
|
}
|
|
"-t" | "--filter" => {
|
|
i += 1;
|
|
if i >= args.len() {
|
|
eprintln!("--filter requires a value");
|
|
process::exit(1);
|
|
}
|
|
opts.filter = Some(args[i].clone());
|
|
}
|
|
arg if arg.starts_with('-') => {
|
|
eprintln!("Unknown option: {arg}");
|
|
process::exit(1);
|
|
}
|
|
_ => opts.files.push(args[i].clone()),
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
Some(("test", opts))
|
|
}
|
|
|
|
fn find_shout_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) {
|
|
if let Ok(entries) = fs::read_dir(dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
find_shout_files_recursive(&path, out);
|
|
} else if path.extension().is_some_and(|ext| ext == "shout") {
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn filter_gitignored(files: Vec<PathBuf>) -> Vec<PathBuf> {
|
|
if files.is_empty() {
|
|
return files;
|
|
}
|
|
|
|
let input: String = files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>().join("\n");
|
|
|
|
let result = process::Command::new("git")
|
|
.args(["check-ignore", "--stdin"])
|
|
.stdin(process::Stdio::piped())
|
|
.stdout(process::Stdio::piped())
|
|
.stderr(process::Stdio::null())
|
|
.spawn();
|
|
|
|
let Ok(mut child) = result else {
|
|
return files;
|
|
};
|
|
|
|
if let Some(mut stdin) = child.stdin.take() {
|
|
let _ = stdin.write_all(input.as_bytes());
|
|
}
|
|
|
|
let output = child.wait_with_output();
|
|
let Ok(output) = output else {
|
|
return files;
|
|
};
|
|
|
|
let ignored: std::collections::HashSet<String> = String::from_utf8_lossy(&output.stdout)
|
|
.lines()
|
|
.filter(|l| !l.is_empty())
|
|
.map(|l| l.to_string())
|
|
.collect();
|
|
|
|
files
|
|
.into_iter()
|
|
.filter(|f| !ignored.contains(&f.to_string_lossy().to_string()))
|
|
.collect()
|
|
}
|
|
|
|
fn find_shout_files(paths: &[String]) -> Vec<PathBuf> {
|
|
let mut explicit = Vec::new();
|
|
let mut discovered = Vec::new();
|
|
|
|
for p in paths {
|
|
let abs = fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p));
|
|
if abs.is_file() && abs.extension().is_some_and(|e| e == "shout") {
|
|
explicit.push(abs);
|
|
} else if abs.is_dir() {
|
|
find_shout_files_recursive(&abs, &mut discovered);
|
|
} else if abs.extension().is_some_and(|e| e == "shout") {
|
|
explicit.push(abs);
|
|
}
|
|
}
|
|
|
|
let filtered = filter_gitignored(discovered);
|
|
let mut all: Vec<PathBuf> = explicit.into_iter().chain(filtered).collect();
|
|
all.sort();
|
|
all
|
|
}
|
|
|
|
fn run_one(
|
|
file_path: &Path,
|
|
port: u16,
|
|
opts: &TestOpts,
|
|
timeout_ms: u64,
|
|
cwd: &Path,
|
|
) -> TestResult {
|
|
let content = match fs::read_to_string(file_path) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return evaluate_file(
|
|
&rel_path(cwd, file_path),
|
|
&[],
|
|
Some(&format!("Failed to read file: {e}")),
|
|
);
|
|
}
|
|
};
|
|
|
|
let rel = rel_path(cwd, file_path);
|
|
let parsed = match parse::parse(&rel, &content) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
return evaluate_file(&rel, &[], Some(&e.0));
|
|
}
|
|
};
|
|
|
|
// Resolve directives
|
|
let mut setup_env: Vec<(String, String)> = vec![];
|
|
let mut user_env: Vec<(String, String)> = vec![];
|
|
let mut setup_commands: Vec<Command> = vec![];
|
|
let mut teardown_commands: Vec<Command> = parsed.teardown_commands.clone();
|
|
let mut setup_defs: Vec<(String, String)> = vec![];
|
|
let mut user_defs: Vec<(String, String)> = vec![];
|
|
|
|
for d in &parsed.directives {
|
|
match d {
|
|
Directive::Setup { path: setup_path, .. } => {
|
|
let full_path = file_path.parent().unwrap().join(setup_path);
|
|
let setup_content = match fs::read_to_string(&full_path) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return evaluate_file(
|
|
&rel,
|
|
&[],
|
|
Some(&format!("Failed to read setup file {setup_path}: {e}")),
|
|
);
|
|
}
|
|
};
|
|
let setup_rel = rel_path(cwd, &full_path);
|
|
let setup_parsed = match parse::parse_setup(&setup_rel, &setup_content) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
return evaluate_file(&rel, &[], Some(&e.0));
|
|
}
|
|
};
|
|
for sd in &setup_parsed.directives {
|
|
match sd {
|
|
Directive::Env { key, value, .. } => {
|
|
setup_env.push((key.clone(), value.clone()));
|
|
}
|
|
Directive::Def { name, body, .. } => {
|
|
setup_defs.push((name.clone(), body.clone()));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
setup_commands.extend(setup_parsed.commands);
|
|
teardown_commands.extend(setup_parsed.teardown_commands);
|
|
}
|
|
Directive::Env { key, value, .. } => {
|
|
user_env.push((key.clone(), value.clone()));
|
|
}
|
|
Directive::Def { name, body, .. } => {
|
|
user_defs.push((name.clone(), body.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge defs: setup first, then user overrides
|
|
let mut defs: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
|
for (name, body) in &setup_defs {
|
|
defs.insert(name.clone(), body.clone());
|
|
}
|
|
for (name, body) in &user_defs {
|
|
defs.insert(name.clone(), body.clone());
|
|
}
|
|
|
|
// Merge env: setup first, then user overrides
|
|
let mut env_vars: Vec<(String, String)> = vec![];
|
|
env_vars.extend(setup_env.clone());
|
|
// Remove setup keys that user overrides
|
|
let user_keys: std::collections::HashSet<&str> = user_env.iter().map(|(k, _)| k.as_str()).collect();
|
|
env_vars.retain(|(k, _)| !user_keys.contains(k.as_str()));
|
|
env_vars.extend(user_env.clone());
|
|
|
|
// Assign PORT if not set
|
|
let has_port = env_vars.iter().any(|(k, _)| k == "PORT");
|
|
if !has_port {
|
|
env_vars.push(("PORT".to_string(), port.to_string()));
|
|
}
|
|
|
|
// Apply macro substitutions
|
|
let apply_defs = |commands: &mut Vec<Command>| {
|
|
for cmd in commands.iter_mut() {
|
|
if let Some(body) = defs.get(&cmd.command) {
|
|
cmd.command = body.clone();
|
|
}
|
|
}
|
|
};
|
|
let mut user_commands = parsed.commands.clone();
|
|
apply_defs(&mut setup_commands);
|
|
apply_defs(&mut user_commands);
|
|
|
|
// Merge commands: setup + user + teardown
|
|
let mut merged_commands = Vec::new();
|
|
merged_commands.extend(setup_commands.clone());
|
|
merged_commands.extend(user_commands.clone());
|
|
merged_commands.extend(teardown_commands.clone());
|
|
|
|
let merged = ShoutFile {
|
|
path: parsed.path.clone(),
|
|
commands: merged_commands,
|
|
directives: vec![],
|
|
teardown_commands: vec![],
|
|
};
|
|
|
|
let setup_len = setup_commands.len();
|
|
let user_len = user_commands.len();
|
|
|
|
let run_opts = RunOptions {
|
|
clean_env: opts.clean_env,
|
|
path_dirs: opts.path_dirs.clone(),
|
|
env_vars,
|
|
source_dir: file_path.parent().map(|p| p.to_string_lossy().to_string()),
|
|
project_dir: Some(cwd.to_string_lossy().to_string()),
|
|
timeout_ms,
|
|
verbose: opts.verbose,
|
|
};
|
|
|
|
let on_cmd: Option<Box<dyn Fn(&parse::Command)>> = if opts.verbose {
|
|
Some(Box::new(|cmd: &parse::Command| {
|
|
let _ = write!(stderr(), "{DIM} $ {}{RESET}\n", cmd.command);
|
|
}))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let on_result: Box<dyn Fn(usize, &CommandResult)> = Box::new(move |index: usize, result: &CommandResult| {
|
|
if index >= setup_len && index < setup_len + user_len {
|
|
print_dot(command_passes(result));
|
|
}
|
|
});
|
|
|
|
let file_result = run_file(
|
|
&merged,
|
|
&run_opts,
|
|
on_cmd.as_deref(),
|
|
Some(&*on_result),
|
|
);
|
|
|
|
// Check setup commands for failures
|
|
for i in 0..setup_commands.len() {
|
|
if let Some(r) = file_result.results.get(i) {
|
|
let expected = &setup_commands[i].exit_code;
|
|
let ok = match expected {
|
|
ExitCode::Default => r.exit_code == 0,
|
|
ExitCode::Any => r.exit_code != 0,
|
|
ExitCode::Code(c) => r.exit_code == *c,
|
|
};
|
|
if !ok {
|
|
if opts.keep {
|
|
let _ = writeln!(stderr(), "{}", file_result.tmp_dir);
|
|
} else {
|
|
cleanup_tmp_dir(&file_result.tmp_dir);
|
|
}
|
|
return evaluate_file(
|
|
&parsed.path,
|
|
&[],
|
|
Some(&format!(
|
|
"setup command failed (exit {}): $ {}",
|
|
r.exit_code, setup_commands[i].command
|
|
)),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract user command results
|
|
let file_own_results: Vec<CommandResult> = file_result
|
|
.results
|
|
.iter()
|
|
.skip(setup_len)
|
|
.take(user_len)
|
|
.cloned()
|
|
.collect();
|
|
|
|
// Warn on teardown failures
|
|
let teardown_results: Vec<&CommandResult> = file_result
|
|
.results
|
|
.iter()
|
|
.skip(setup_len + user_len)
|
|
.collect();
|
|
for (i, r) in teardown_results.iter().enumerate() {
|
|
if r.exit_code != 0 {
|
|
if let Some(td) = teardown_commands.get(i) {
|
|
let _ = write!(
|
|
stderr(),
|
|
"{YELLOW}warning: teardown command failed (exit {}): $ {}{RESET}\n",
|
|
r.exit_code, td.command
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let test_result = evaluate_file(&parsed.path, &file_own_results, file_result.error.as_deref());
|
|
|
|
// Update mode
|
|
if opts.update && !file_own_results.is_empty() {
|
|
let updated = rewrite_file(&parsed, &file_own_results, &content);
|
|
if updated != content {
|
|
let _ = fs::write(file_path, &updated);
|
|
}
|
|
}
|
|
|
|
if opts.keep {
|
|
let _ = writeln!(stderr(), "{}", file_result.tmp_dir);
|
|
} else {
|
|
cleanup_tmp_dir(&file_result.tmp_dir);
|
|
}
|
|
|
|
test_result
|
|
}
|
|
|
|
fn rel_path(base: &Path, path: &Path) -> String {
|
|
pathdiff(path, base)
|
|
}
|
|
|
|
/// Simple relative path calculation.
|
|
fn pathdiff(path: &Path, base: &Path) -> String {
|
|
if let Ok(stripped) = path.strip_prefix(base) {
|
|
stripped.to_string_lossy().to_string()
|
|
} else {
|
|
path.to_string_lossy().to_string()
|
|
}
|
|
}
|
|
|
|
fn print_example_help() {
|
|
eprintln!("Usage: shout example");
|
|
eprintln!();
|
|
eprintln!("Print an example .shout file");
|
|
}
|
|
|
|
fn print_version_help() {
|
|
eprintln!("Usage: shout version");
|
|
eprintln!();
|
|
eprintln!("Print the version");
|
|
}
|
|
|
|
fn print_help_help() {
|
|
eprintln!("Usage: shout help [command]");
|
|
eprintln!();
|
|
eprintln!("Display help for command");
|
|
}
|
|
|
|
fn print_example() {
|
|
println!(
|
|
r#"# Example .shout file
|
|
$ echo hello
|
|
hello
|
|
|
|
$ echo "one"; echo "two"; echo "three"
|
|
one
|
|
...
|
|
three
|
|
|
|
$ cat nonexistent
|
|
cat: nonexistent: ...
|
|
[1]
|
|
|
|
$ true
|
|
[0]"#
|
|
);
|
|
}
|
|
|
|
fn main() {
|
|
let (_, opts) = parse_args().unwrap();
|
|
|
|
let timeout_ms = match duration::parse_duration(&opts.timeout) {
|
|
Ok(ms) => ms,
|
|
Err(e) => {
|
|
eprintln!("{e}");
|
|
process::exit(1);
|
|
}
|
|
};
|
|
|
|
let paths = if opts.files.is_empty() {
|
|
vec![".".to_string()]
|
|
} else {
|
|
opts.files.clone()
|
|
};
|
|
|
|
let cwd = std::env::current_dir().unwrap();
|
|
let mut files = find_shout_files(&paths);
|
|
|
|
if let Some(ref pattern) = opts.filter {
|
|
files.retain(|f| rel_path(&cwd, f).contains(pattern));
|
|
}
|
|
|
|
if files.is_empty() {
|
|
if opts.filter.is_some() {
|
|
eprintln!("No .shout files matching \"{}\"", opts.filter.as_ref().unwrap());
|
|
} else {
|
|
eprintln!("No .shout files found");
|
|
}
|
|
process::exit(1);
|
|
}
|
|
|
|
let start = Instant::now();
|
|
let mut results: Vec<TestResult> = Vec::new();
|
|
let mut next_port = opts.port_from;
|
|
|
|
let print_error_dot = |r: &TestResult| {
|
|
if r.error.is_some() {
|
|
let _ = stdout().write_all(format!("{RED}F{RESET}").as_bytes());
|
|
let _ = stdout().flush();
|
|
}
|
|
};
|
|
|
|
if opts.parallel {
|
|
// Pre-assign ports
|
|
let file_ports: Vec<(PathBuf, u16)> = files
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, f)| (f.clone(), opts.port_from + i as u16))
|
|
.collect();
|
|
|
|
let mut par_results: Vec<Option<TestResult>> = file_ports.iter().map(|_| None).collect();
|
|
std::thread::scope(|s| {
|
|
let handles: Vec<_> = file_ports
|
|
.iter()
|
|
.map(|(f, port)| s.spawn(|| run_one(f, *port, &opts, timeout_ms, &cwd)))
|
|
.collect();
|
|
for (slot, handle) in par_results.iter_mut().zip(handles) {
|
|
let r = handle.join().expect("thread panicked");
|
|
print_error_dot(&r);
|
|
*slot = Some(r);
|
|
}
|
|
});
|
|
results.extend(par_results.into_iter().map(|r| r.unwrap()));
|
|
let _ = writeln!(stdout());
|
|
} else {
|
|
for file_path in &files {
|
|
let r = run_one(file_path, next_port, &opts, timeout_ms, &cwd);
|
|
print_error_dot(&r);
|
|
results.push(r);
|
|
next_port += 1;
|
|
}
|
|
let _ = writeln!(stdout());
|
|
}
|
|
|
|
// Print failures
|
|
let failures: Vec<&TestResult> = results.iter().filter(|r| !r.passed).collect();
|
|
if !failures.is_empty() {
|
|
println!();
|
|
for f in &failures {
|
|
println!("{}", format_failure(f));
|
|
println!();
|
|
}
|
|
}
|
|
|
|
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
|
|
let single_file = if files.len() == 1 {
|
|
Some(rel_path(&cwd, &files[0]))
|
|
} else {
|
|
None
|
|
};
|
|
println!("{}", format_summary(&results, elapsed, single_file.as_deref()));
|
|
|
|
process::exit(if failures.is_empty() { 0 } else { 1 });
|
|
}
|