use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use testty::spec::model::ScenarioSpec;
#[derive(Parser)]
#[command(name = "testty", about, version)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Run {
scenario: PathBuf,
#[arg(long)]
bin: Option<PathBuf>,
#[arg(long)]
proof: Option<PathBuf>,
},
Schema,
Proof {
#[command(subcommand)]
command: ProofCommand,
},
Update,
}
impl Command {
fn dispatch(self) -> ExitCode {
match self {
Command::Run {
scenario,
bin,
proof,
} => run_scenario(&scenario, bin.as_deref(), proof.as_deref()),
Command::Schema => not_implemented("schema"),
Command::Proof {
command: ProofCommand::Open { .. },
} => not_implemented("proof open"),
Command::Proof {
command: ProofCommand::Gallery { .. },
} => not_implemented("proof gallery"),
Command::Update => not_implemented("update"),
}
}
}
#[derive(Subcommand)]
enum ProofCommand {
Open {
html: PathBuf,
},
Gallery {
dir: PathBuf,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
cli.command.dispatch()
}
fn run_scenario(
scenario_path: &Path,
bin_override: Option<&Path>,
proof: Option<&Path>,
) -> ExitCode {
let mut stderr = io::stderr().lock();
run_scenario_reporting(scenario_path, bin_override, proof, &mut stderr)
}
fn run_scenario_reporting(
scenario_path: &Path,
bin_override: Option<&Path>,
proof: Option<&Path>,
out: &mut dyn Write,
) -> ExitCode {
let text = match fs::read_to_string(scenario_path) {
Ok(text) => text,
Err(err) => {
let _ = writeln!(
out,
"testty: cannot read {}: {err}",
scenario_path.display()
);
return ExitCode::FAILURE;
}
};
let mut spec = match ScenarioSpec::from_yaml(&text) {
Ok(spec) => spec,
Err(err) => {
let _ = writeln!(out, "testty: {err}");
return ExitCode::FAILURE;
}
};
if let Some(bin) = bin_override {
spec.session.bin = bin.to_path_buf();
}
if proof.is_some() {
let _ = writeln!(
out,
"testty: --proof is not yet supported; running without proof output"
);
}
let (_frame, failures) = match spec.lower().run() {
Ok(result) => result,
Err(err) => {
let _ = writeln!(out, "testty: scenario failed to run: {err}");
return ExitCode::FAILURE;
}
};
if failures.is_empty() {
let _ = writeln!(out, "testty: scenario passed");
return ExitCode::SUCCESS;
}
for failure in &failures {
let _ = writeln!(out, "FAIL: {}", failure.message);
}
let _ = writeln!(out, "testty: {} expectation(s) failed", failures.len());
ExitCode::FAILURE
}
fn not_implemented(verb: &str) -> ExitCode {
let mut stderr = io::stderr().lock();
let _ = writeln!(stderr, "testty: `{verb}` is not yet implemented");
ExitCode::FAILURE
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
#[test]
fn parses_run_with_scenario_path() {
let argv = ["testty", "run", "scenario.yaml"];
let cli = Cli::parse_from(argv);
assert!(matches!(
cli.command,
Command::Run { scenario, bin: None, proof: None }
if scenario.as_path() == Path::new("scenario.yaml")
));
}
#[test]
fn parses_run_with_bin_and_proof_options() {
let argv = [
"testty",
"run",
"scenario.yaml",
"--bin",
"./app",
"--proof",
"out",
];
let cli = Cli::parse_from(argv);
assert!(matches!(
cli.command,
Command::Run { bin: Some(bin), proof: Some(proof), .. }
if bin.as_path() == Path::new("./app") && proof.as_path() == Path::new("out")
));
}
#[test]
fn parses_schema_verb() {
let argv = ["testty", "schema"];
let cli = Cli::parse_from(argv);
assert!(matches!(cli.command, Command::Schema));
}
#[test]
fn parses_proof_open_with_html_path() {
let argv = ["testty", "proof", "open", "report.html"];
let cli = Cli::parse_from(argv);
assert!(matches!(
cli.command,
Command::Proof { command: ProofCommand::Open { html } }
if html.as_path() == Path::new("report.html")
));
}
#[test]
fn parses_proof_gallery_with_dir_path() {
let argv = ["testty", "proof", "gallery", "proofs"];
let cli = Cli::parse_from(argv);
assert!(matches!(
cli.command,
Command::Proof { command: ProofCommand::Gallery { dir } }
if dir.as_path() == Path::new("proofs")
));
}
#[test]
fn parses_update_verb() {
let argv = ["testty", "update"];
let cli = Cli::parse_from(argv);
assert!(matches!(cli.command, Command::Update));
}
#[test]
fn run_without_scenario_is_rejected() {
let argv = ["testty", "run"];
let result = Cli::try_parse_from(argv);
assert!(result.is_err());
}
#[test]
fn every_remaining_stub_reports_unimplemented_and_fails() {
let verbs: [Command; 4] = [
Command::Schema,
Command::Proof {
command: ProofCommand::Open {
html: PathBuf::from("r.html"),
},
},
Command::Proof {
command: ProofCommand::Gallery {
dir: PathBuf::from("d"),
},
},
Command::Update,
];
for verb in verbs {
assert_eq!(verb.dispatch(), ExitCode::FAILURE);
}
}
#[test]
fn run_scenario_fails_when_file_missing() {
let missing = Path::new("/nonexistent/testty-scenario.yaml");
let code = run_scenario(missing, None, None);
assert_eq!(code, ExitCode::FAILURE);
}
#[test]
fn run_scenario_fails_on_unsupported_version() {
let temp = tempfile::tempdir().expect("temp dir");
let path = temp.path().join("bad.yaml");
std::fs::write(&path, "version: 999\nsession:\n bin: /bin/echo\n").expect("write");
let code = run_scenario(&path, None, None);
assert_eq!(code, ExitCode::FAILURE);
}
#[cfg(unix)]
#[test]
fn dispatch_runs_scenario_for_run_verb() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("temp dir");
let script = temp.path().join("greet.sh");
std::fs::write(&script, "#!/bin/sh\nprintf 'Hello World'\nsleep 60\n").expect("write");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("perms");
let scenario = temp.path().join("scenario.yaml");
std::fs::write(
&scenario,
format!(
"session:\n bin: {bin}\n size: [80, 24]\nsteps:\n - wait_for_stable_frame: {{ \
stable_ms: 300, timeout_ms: 5000 }}\nexpect:\n - text_in_region: {{ text: \
\"Hello World\", region: [0, 0, 80, 1] }}\n",
bin = script.display()
),
)
.expect("write scenario");
let verb = Command::Run {
scenario,
bin: None,
proof: None,
};
let code = verb.dispatch();
assert_eq!(code, ExitCode::SUCCESS);
}
#[test]
fn run_scenario_fails_when_binary_cannot_spawn() {
let temp = tempfile::tempdir().expect("temp dir");
let scenario = temp.path().join("scenario.yaml");
std::fs::write(
&scenario,
"session:\n bin: /nonexistent/testty-missing-binary\n size: [80, 24]\nsteps:\n - \
wait_for_stable_frame: { stable_ms: 100, timeout_ms: 1000 }\n",
)
.expect("write scenario");
let code = run_scenario(&scenario, None, None);
assert_eq!(code, ExitCode::FAILURE);
}
#[cfg(unix)]
#[test]
fn run_scenario_passes_against_fixture_binary() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("temp dir");
let script = temp.path().join("greet.sh");
std::fs::write(&script, "#!/bin/sh\nprintf 'Hello World'\nsleep 60\n").expect("write");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("perms");
let scenario = temp.path().join("scenario.yaml");
std::fs::write(
&scenario,
format!(
"session:\n bin: {bin}\n size: [80, 24]\nsteps:\n - wait_for_stable_frame: {{ \
stable_ms: 300, timeout_ms: 5000 }}\nexpect:\n - text_in_region: {{ text: \
\"Hello World\", region: [0, 0, 80, 1] }}\n",
bin = script.display()
),
)
.expect("write scenario");
let code = run_scenario(&scenario, None, None);
assert_eq!(code, ExitCode::SUCCESS);
}
#[cfg(unix)]
#[test]
fn run_scenario_honors_bin_override() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("temp dir");
let script = temp.path().join("greet.sh");
std::fs::write(&script, "#!/bin/sh\nprintf 'Hello World'\nsleep 60\n").expect("write");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("perms");
let scenario = temp.path().join("scenario.yaml");
std::fs::write(
&scenario,
"session:\n bin: /nonexistent/placeholder\n size: [80, 24]\nsteps:\n - \
wait_for_stable_frame: { stable_ms: 300, timeout_ms: 5000 }\nexpect:\n - \
text_in_region: { text: \"Hello World\", region: [0, 0, 80, 1] }\n",
)
.expect("write scenario");
let code = run_scenario(&scenario, Some(&script), None);
assert_eq!(code, ExitCode::SUCCESS);
}
#[cfg(unix)]
#[test]
fn run_scenario_warns_when_proof_requested() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("temp dir");
let script = temp.path().join("greet.sh");
std::fs::write(&script, "#!/bin/sh\nprintf 'Hello World'\nsleep 60\n").expect("write");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("perms");
let scenario = temp.path().join("scenario.yaml");
std::fs::write(
&scenario,
format!(
"session:\n bin: {bin}\n size: [80, 24]\nsteps:\n - wait_for_stable_frame: {{ \
stable_ms: 300, timeout_ms: 5000 }}\nexpect:\n - text_in_region: {{ text: \
\"Hello World\", region: [0, 0, 80, 1] }}\n",
bin = script.display()
),
)
.expect("write scenario");
let proof = temp.path().join("proof-out");
let mut out = Vec::new();
let code = run_scenario_reporting(&scenario, None, Some(&proof), &mut out);
let log = String::from_utf8(out).expect("utf8 log");
assert_eq!(code, ExitCode::SUCCESS);
assert!(log.contains("--proof is not yet supported"));
}
#[cfg(unix)]
#[test]
fn run_scenario_reports_failed_expectations() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("temp dir");
let script = temp.path().join("greet.sh");
std::fs::write(&script, "#!/bin/sh\nprintf 'Hello World'\nsleep 60\n").expect("write");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("perms");
let scenario = temp.path().join("scenario.yaml");
std::fs::write(
&scenario,
format!(
"session:\n bin: {bin}\n size: [80, 24]\nsteps:\n - wait_for_stable_frame: {{ \
stable_ms: 300, timeout_ms: 5000 }}\nexpect:\n - text_in_region: {{ text: \
\"Goodbye Moon\", region: [0, 0, 80, 1] }}\n",
bin = script.display()
),
)
.expect("write scenario");
let mut out = Vec::new();
let code = run_scenario_reporting(&scenario, None, None, &mut out);
let log = String::from_utf8(out).expect("utf8 log");
assert_eq!(code, ExitCode::FAILURE);
assert!(log.contains("expectation(s) failed"));
}
}