use std::path::PathBuf;
use std::time::Instant;
use clap::Subcommand;
use crate::config::Config;
use crate::history::{self, RunRecord};
use crate::hooks;
use crate::project::CheckmateProject;
use crate::runner::{
AssertionEvaluator, TestExecutor, TestSpec,
TestResult, TestSuiteResult, TestStatus, AssertionSummary, TestFailure,
};
#[derive(Subcommand)]
pub enum TestCommands {
Run {
specs: Vec<String>,
#[arg(short, long)]
test: Option<String>,
#[arg(short, long)]
verbose: bool,
},
Validate {
specs: Vec<String>,
},
List {
specs: Vec<String>,
},
}
pub fn run(command: TestCommands) -> Result<(), Box<dyn std::error::Error>> {
match command {
TestCommands::Run { specs, test, verbose } => run_tests(&specs, test.as_deref(), verbose),
TestCommands::Validate { specs } => validate_specs(&specs),
TestCommands::List { specs } => list_tests(&specs),
}
}
fn run_tests(spec_files: &[String], filter: Option<&str>, verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
let project = CheckmateProject::discover();
let config = Config::load(project.as_ref());
let resolved_specs = resolve_spec_files(spec_files, project.as_ref())?;
if resolved_specs.is_empty() {
if project.is_some() {
eprintln!("No test specs found in .checkmate/tests/");
eprintln!("Create spec files with .yaml extension, or provide spec paths explicitly.");
} else {
eprintln!("No test spec files provided and no .checkmate/ found.");
eprintln!("Run 'cm init' or provide spec files explicitly.");
}
return Ok(());
}
if verbose {
if let Some(ref base_url) = config.env.base_url {
println!("Using base_url: {}", base_url);
}
}
let mut all_passed = true;
for spec_path in &resolved_specs {
let spec_name = spec_path.file_name()
.map(|s| s.to_string_lossy().to_string());
if verbose {
println!("Running: {}", spec_path.display());
}
if let Some(ref proj) = project {
hooks::fire_pre_run(proj, spec_name.as_deref());
}
let mut spec = TestSpec::from_file(spec_path.to_string_lossy().as_ref())?;
if spec.env.base_url.is_none() {
spec.env.base_url = config.env.base_url.clone();
}
if spec.env.timeout_ms.is_none() {
spec.env.timeout_ms = Some(config.env.timeout_ms);
}
let result = run_spec(&spec, filter, verbose, &config)?;
let run_id = if let Some(ref proj) = project {
let record = RunRecord::from_suite_result(&result, spec_name.as_deref());
let id = record.id.clone();
if let Err(e) = history::save_run(proj, &record) {
if verbose {
eprintln!("Warning: Failed to save run history: {}", e);
}
} else if verbose {
println!("Recorded: {}", record.id);
}
hooks::fire_post_run(
proj,
&id,
spec_name.as_deref(),
result.summary.total,
result.summary.passed,
result.summary.failed,
result.summary.errors,
result.duration_ms,
);
Some(id)
} else {
None
};
let _ = run_id;
let json = serde_json::to_string_pretty(&result)?;
println!("{}", json);
if !result.all_passed() {
all_passed = false;
}
}
if !all_passed {
std::process::exit(1);
}
Ok(())
}
pub fn resolve_spec_files(specs: &[String], project: Option<&CheckmateProject>) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
if specs.is_empty() {
if let Some(project) = project {
return discover_test_specs(&project.tests_dir);
}
return Ok(Vec::new());
}
let mut resolved = Vec::new();
for spec in specs {
let path = PathBuf::from(spec);
if path.exists() {
resolved.push(path);
continue;
}
if let Some(project) = project {
let with_yaml = project.tests_dir.join(format!("{}.yaml", spec));
if with_yaml.exists() {
resolved.push(with_yaml);
continue;
}
let with_yml = project.tests_dir.join(format!("{}.yml", spec));
if with_yml.exists() {
resolved.push(with_yml);
continue;
}
}
resolved.push(path);
}
Ok(resolved)
}
fn discover_test_specs(tests_dir: &PathBuf) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
if !tests_dir.exists() {
return Ok(Vec::new());
}
let mut specs: Vec<PathBuf> = std::fs::read_dir(tests_dir)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.map_or(false, |ext| ext == "yaml" || ext == "yml")
})
.collect();
specs.sort();
Ok(specs)
}
fn run_spec(spec: &TestSpec, filter: Option<&str>, verbose: bool, _config: &Config) -> Result<TestSuiteResult, Box<dyn std::error::Error>> {
let suite_start = Instant::now();
let mut suite_result = TestSuiteResult::new(spec.name.clone());
let executor = TestExecutor::new(&spec.env)?;
for (test_name, test_case) in &spec.tests {
if let Some(f) = filter {
if test_name != f {
continue;
}
}
if verbose {
println!(" Running test: {}", test_name);
}
let test_start = Instant::now();
let mut evaluator = AssertionEvaluator::new();
let mut failures: Vec<TestFailure> = Vec::new();
let mut assertion_summary = AssertionSummary::default();
let mut test_status = TestStatus::Passed;
for (req_idx, request_name) in test_case.requests.iter().enumerate() {
if verbose {
println!(" Request {}: {}", req_idx + 1, request_name);
}
let result = executor.execute(
spec,
request_name,
&test_case.endpoint,
&test_case.method,
test_case.timeout_ms,
);
let response = match result {
Ok(r) => r,
Err(e) => {
test_status = TestStatus::Error;
failures.push(TestFailure {
request_index: req_idx,
assertion: "HTTP request".to_string(),
expected: None,
actual: None,
message: None,
error: Some(e.to_string()),
});
if test_case.fail_fast {
break;
}
continue;
}
};
if response.status != test_case.expect_status {
test_status = TestStatus::Failed;
failures.push(TestFailure {
request_index: req_idx,
assertion: "HTTP status".to_string(),
expected: Some(test_case.expect_status.to_string()),
actual: Some(response.status.to_string()),
message: Some("Unexpected HTTP status".to_string()),
error: None,
});
if test_case.fail_fast {
break;
}
}
let request_def = spec.resolve_request(request_name)?;
if !request_def.extract.is_empty() {
if let Err(e) = executor.extract_variables(&request_def.extract, &response.body) {
if verbose {
eprintln!(" Warning: extraction failed: {}", e);
}
}
}
evaluator.set_current(response.body);
if test_case.skip_first && req_idx == 0 {
if verbose {
println!(" Skipping assertions (skip_first)");
}
continue;
}
for assertion in &test_case.assertions {
assertion_summary.total += 1;
let result = evaluator.evaluate(assertion);
if result.passed {
assertion_summary.passed += 1;
if verbose {
println!(" ✓ {}", assertion.query.as_deref().unwrap_or("(no query)"));
}
} else {
assertion_summary.failed += 1;
test_status = TestStatus::Failed;
let failure = TestFailure {
request_index: req_idx,
assertion: assertion.query.clone().unwrap_or_default(),
expected: result.expected,
actual: result.actual,
message: result.message,
error: result.error,
};
if verbose {
println!(" ✗ {}", assertion.query.as_deref().unwrap_or("(no query)"));
if let Some(ref msg) = failure.message {
println!(" {}", msg);
}
}
failures.push(failure);
if test_case.fail_fast {
break;
}
}
}
if test_case.fail_fast && test_status != TestStatus::Passed {
break;
}
}
let test_result = TestResult {
name: test_name.clone(),
description: test_case.description.clone(),
status: test_status,
duration_ms: test_start.elapsed().as_millis() as u64,
request_count: test_case.requests.len(),
assertions: assertion_summary,
failures,
};
suite_result.add_result(test_result);
}
suite_result.set_duration(suite_start.elapsed().as_millis() as u64);
Ok(suite_result)
}
fn validate_specs(spec_files: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let project = CheckmateProject::discover();
let resolved_specs = resolve_spec_files(spec_files, project.as_ref())?;
if resolved_specs.is_empty() {
eprintln!("No test specs to validate.");
return Ok(());
}
let mut all_valid = true;
for spec_path in &resolved_specs {
match TestSpec::from_file(spec_path.to_string_lossy().as_ref()) {
Ok(spec) => {
println!("✓ {} - valid", spec_path.display());
println!(" {} requests, {} tests",
spec.requests.len(),
spec.tests.len()
);
}
Err(e) => {
println!("✗ {} - invalid: {}", spec_path.display(), e);
all_valid = false;
}
}
}
if !all_valid {
std::process::exit(1);
}
Ok(())
}
fn list_tests(spec_files: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let project = CheckmateProject::discover();
let resolved_specs = resolve_spec_files(spec_files, project.as_ref())?;
if resolved_specs.is_empty() {
eprintln!("No test specs found.");
return Ok(());
}
for spec_path in &resolved_specs {
let spec = TestSpec::from_file(spec_path.to_string_lossy().as_ref())?;
println!("{}:", spec_path.display());
if let Some(ref name) = spec.name {
println!(" Suite: {}", name);
}
println!(" Tests:");
for (name, test) in &spec.tests {
let desc = test.description.as_deref().unwrap_or("");
if desc.is_empty() {
println!(" - {}", name);
} else {
println!(" - {}: {}", name, desc);
}
}
println!();
}
Ok(())
}