Files
foxhunt/testing/e2e/src/bin/e2e_test_runner.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

713 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(clippy::unnecessary_to_owned)] // String literals in HTML generation
use anyhow::Result;
use clap::{Arg, ArgMatches, Command};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
use tokio::fs;
use tracing::{error, info};
// Corrode integration structures (to be implemented)
#[derive(Debug, Clone)]
pub struct CorrodeConfig {
pub executable_path: String,
pub workspace_path: String,
pub timeout_seconds: u64,
pub max_parallel_sessions: usize,
pub log_level: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestExecutionRequest {
pub test_name: String,
pub test_command: String,
pub environment: HashMap<String, String>,
pub working_directory: Option<String>,
pub timeout_seconds: Option<u64>,
pub capture_output: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestExecutionResult {
pub test_name: String,
pub success: bool,
pub execution_time: Duration,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub error_message: Option<String>,
}
pub struct CorrodeTestRunner {
}
impl CorrodeTestRunner {
pub fn new(_config: CorrodeConfig) -> Self {
Self {}
}
pub fn with_default_config() -> Self {
Self::new(CorrodeConfig {
executable_path: "corrode".to_string(),
workspace_path: std::env::current_dir()
.unwrap()
.to_string_lossy()
.to_string(),
timeout_seconds: 600,
max_parallel_sessions: 4,
log_level: "info".to_string(),
})
}
pub async fn execute_test(
&mut self,
request: TestExecutionRequest,
) -> Result<TestExecutionResult> {
// Stub implementation - would integrate with corrode-mcp
info!("Executing test: {}", request.test_name);
Ok(TestExecutionResult {
test_name: request.test_name,
success: false,
execution_time: Duration::from_secs(0),
exit_code: Some(1),
stdout: String::new(),
stderr: "Test runner not fully implemented".to_string(),
error_message: Some("Corrode integration pending".to_string()),
})
}
pub async fn execute_test_suite(
&mut self,
requests: Vec<TestExecutionRequest>,
) -> Result<Vec<TestExecutionResult>> {
let mut results = Vec::new();
for request in requests {
results.push(self.execute_test(request).await?);
}
Ok(results)
}
pub async fn generate_test_report(&self, results: &[TestExecutionResult]) -> Result<String> {
let total = results.len();
let passed = results.iter().filter(|r| r.success).count();
let failed = total - passed;
let mut report = "# E2E Test Report\n\n".to_string();
report.push_str(&format!("**Total Tests:** {}\n", total));
report.push_str(&format!("**Passed:** {}\n", passed));
report.push_str(&format!("**Failed:** {}\n\n", failed));
for result in results {
let status = if result.success { "PASS" } else { "FAIL" };
report.push_str(&format!(
"- {} - {} ({:?})\n",
result.test_name, status, result.execution_time
));
}
Ok(report)
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Setup logging
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let matches = build_cli().get_matches();
match matches.subcommand() {
Some(("run", sub_matches)) => run_tests(sub_matches).await,
Some(("list", _)) => list_available_tests().await,
Some(("report", sub_matches)) => generate_report(sub_matches).await,
Some(("validate", _)) => validate_environment().await,
_ => {
eprintln!("Use --help for available commands");
Ok(())
},
}
}
fn build_cli() -> Command {
Command::new("foxhunt-e2e-runner")
.version("1.0.0")
.about("Foxhunt E2E Test Runner with Corrode-MCP Integration")
.subcommand(
Command::new("run")
.about("Run E2E tests")
.arg(
Arg::new("test-pattern")
.long("test")
.short('t')
.value_name("PATTERN")
.help("Test pattern to run (e.g., 'trading', 'ml', 'all')")
.default_value("all"),
)
.arg(
Arg::new("parallel")
.long("parallel")
.short('j')
.value_name("COUNT")
.help("Number of parallel test sessions")
.default_value("4"),
)
.arg(
Arg::new("timeout")
.long("timeout")
.value_name("SECONDS")
.help("Test timeout in seconds")
.default_value("600"),
)
.arg(
Arg::new("output-dir")
.long("output-dir")
.short('o')
.value_name("DIR")
.help("Output directory for test results")
.default_value("./test-results"),
)
.arg(
Arg::new("fail-fast")
.long("fail-fast")
.action(clap::ArgAction::SetTrue)
.help("Stop on first test failure"),
)
.arg(
Arg::new("verbose")
.long("verbose")
.short('v')
.action(clap::ArgAction::SetTrue)
.help("Enable verbose output"),
),
)
.subcommand(Command::new("list").about("List available E2E tests"))
.subcommand(
Command::new("report")
.about("Generate test report from previous run")
.arg(
Arg::new("results-dir")
.long("results-dir")
.short('r')
.value_name("DIR")
.help("Directory containing test results")
.default_value("./test-results"),
)
.arg(
Arg::new("format")
.long("format")
.short('f')
.value_name("FORMAT")
.help("Report format (markdown, json, html)")
.default_value("markdown"),
),
)
.subcommand(Command::new("validate").about("Validate test environment and dependencies"))
}
async fn run_tests(matches: &ArgMatches) -> Result<()> {
let test_pattern = matches.get_one::<String>("test-pattern").unwrap();
let parallel_count: usize = matches.get_one::<String>("parallel").unwrap().parse()?;
let timeout: u64 = matches.get_one::<String>("timeout").unwrap().parse()?;
let output_dir = matches.get_one::<String>("output-dir").unwrap();
let fail_fast = matches.get_flag("fail-fast");
let verbose = matches.get_flag("verbose");
info!("Starting E2E test execution");
info!("Test pattern: {}", test_pattern);
info!("Parallel sessions: {}", parallel_count);
info!("Timeout: {}s", timeout);
info!("Output directory: {}", output_dir);
// Create output directory
fs::create_dir_all(output_dir).await?;
// Initialize corrode test runner
let config = CorrodeConfig {
executable_path: "corrode".to_string(),
workspace_path: std::env::current_dir()?.to_string_lossy().to_string(),
timeout_seconds: timeout,
max_parallel_sessions: parallel_count,
log_level: if verbose {
"debug".to_string()
} else {
"info".to_string()
},
};
let mut runner = CorrodeTestRunner::new(config);
// Generate test execution plan
let test_requests = generate_test_plan(test_pattern, timeout)?;
info!("Generated {} test requests", test_requests.len());
// Execute tests
let start_time = tokio::time::Instant::now();
let mut results = Vec::new();
if fail_fast {
// Execute tests sequentially, stopping on first failure
for request in test_requests {
info!("Executing test: {}", request.test_name);
let result = runner.execute_test(request).await?;
if verbose {
println!(
"Test: {} - {}",
result.test_name,
if result.success { "PASSED" } else { "FAILED" }
);
if !result.stdout.is_empty() {
println!("STDOUT:\n{}", result.stdout);
}
if !result.stderr.is_empty() {
println!("STDERR:\n{}", result.stderr);
}
}
let success = result.success;
results.push(result);
if !success {
error!("Test failed, stopping execution due to --fail-fast");
break;
}
}
} else {
// Execute tests in parallel
results = runner.execute_test_suite(test_requests).await?;
}
let total_duration = start_time.elapsed();
// Generate and save report
let report = runner.generate_test_report(&results).await?;
let report_path = format!("{}/test_report.md", output_dir);
fs::write(&report_path, &report).await?;
// Save detailed results as JSON
let json_results = serde_json::to_string_pretty(&results)?;
let json_path = format!("{}/test_results.json", output_dir);
fs::write(&json_path, &json_results).await?;
// Print summary
let total_tests = results.len();
let passed_tests = results.iter().filter(|r| r.success).count();
let failed_tests = total_tests - passed_tests;
println!("\n=== E2E Test Summary ===");
println!("Total Tests: {}", total_tests);
println!(
"Passed: {} ({}%)",
passed_tests,
(passed_tests as f64 / total_tests as f64 * 100.0) as i32
);
println!(
"Failed: {} ({}%)",
failed_tests,
(failed_tests as f64 / total_tests as f64 * 100.0) as i32
);
println!("Total Duration: {:?}", total_duration);
println!("Report saved to: {}", report_path);
println!("Detailed results: {}", json_path);
if failed_tests > 0 {
println!("\nFailed Tests:");
for result in &results {
if !result.success {
println!(" - {} ({:?})", result.test_name, result.execution_time);
if let Some(error) = &result.error_message {
println!(" Error: {}", error);
}
}
}
std::process::exit(1);
}
Ok(())
}
async fn list_available_tests() -> Result<()> {
println!("Available E2E Test Categories:\n");
println!("🔧 Service Tests:");
println!(" - service_startup: Test all services start and respond to health checks");
println!(" - service_shutdown: Test graceful service shutdown");
println!(" - service_recovery: Test service recovery after failures\n");
println!("🗄️ Database Tests:");
println!(" - database_integration: Test PostgreSQL integration and queries");
println!(" - database_migrations: Test database schema migrations");
println!(" - database_performance: Test database query performance\n");
println!("📡 gRPC Tests:");
println!(" - grpc_clients: Test all gRPC client connections and authentication");
println!(" - grpc_streaming: Test streaming gRPC calls");
println!(" - grpc_error_handling: Test gRPC error scenarios\n");
println!("🤖 ML Pipeline Tests:");
println!(" - ml_inference: Test ML model inference pipelines");
println!(" - ml_training: Test ML model training workflows");
println!(" - ml_ensemble: Test ensemble prediction workflows\n");
println!("💼 Trading Tests:");
println!(" - trading_workflows: Complete trading workflow tests");
println!(" - order_lifecycle: Order submission to execution lifecycle");
println!(" - risk_management: Risk management and safety mechanisms");
println!(" - emergency_stop: Emergency stop and kill switch tests\n");
println!("🎯 Full Suite:");
println!(" - all: Run complete E2E test suite");
println!(" - smoke: Run smoke tests for quick validation");
println!(" - performance: Run performance and load tests\n");
println!("Usage Examples:");
println!(" ./test_runner run --test all # Run all tests");
println!(" ./test_runner run --test trading # Run trading tests only");
println!(" ./test_runner run --test ml --parallel 2 # Run ML tests with 2 parallel sessions");
println!(
" ./test_runner run --test smoke --fail-fast # Run smoke tests, stop on first failure"
);
Ok(())
}
async fn generate_report(matches: &ArgMatches) -> Result<()> {
let results_dir = matches.get_one::<String>("results-dir").unwrap();
let format = matches.get_one::<String>("format").unwrap();
info!("Generating test report from: {}", results_dir);
let json_path = format!("{}/test_results.json", results_dir);
let json_content = fs::read_to_string(&json_path)
.await
.map_err(|_| anyhow::anyhow!("Could not read test results from {}", json_path))?;
let results: Vec<TestExecutionResult> = serde_json::from_str(&json_content)?;
let runner = CorrodeTestRunner::with_default_config();
let report = runner.generate_test_report(&results).await?;
match format.as_str() {
"markdown" | "md" => {
let output_path = format!("{}/report.md", results_dir);
fs::write(&output_path, &report).await?;
println!("Markdown report saved to: {}", output_path);
},
"json" => {
let json_report = serde_json::to_string_pretty(&results)?;
let output_path = format!("{}/report.json", results_dir);
fs::write(&output_path, &json_report).await?;
println!("JSON report saved to: {}", output_path);
},
"html" => {
let html_report = generate_html_report(&results).await?;
let output_path = format!("{}/report.html", results_dir);
fs::write(&output_path, &html_report).await?;
println!("HTML report saved to: {}", output_path);
},
_ => {
return Err(anyhow::anyhow!("Unsupported format: {}", format));
},
}
Ok(())
}
async fn validate_environment() -> Result<()> {
println!("🔍 Validating E2E Test Environment\n");
// Check corrode executable
print!("Checking corrode executable... ");
match tokio::process::Command::new("corrode")
.arg("--version")
.output()
.await
{
Ok(output) => {
if output.status.success() {
println!("✅ Found");
let version = String::from_utf8_lossy(&output.stdout);
println!(" Version: {}", version.trim());
} else {
println!("❌ Error running corrode");
}
},
Err(_) => {
println!("❌ Not found");
println!(" Please install corrode-mcp for test execution");
},
}
// Check Rust toolchain
print!("Checking Rust toolchain... ");
match tokio::process::Command::new("cargo")
.arg("--version")
.output()
.await
{
Ok(output) => {
if output.status.success() {
println!("✅ Found");
let version = String::from_utf8_lossy(&output.stdout);
println!(" {}", version.trim());
} else {
println!("❌ Error running cargo");
}
},
Err(_) => {
println!("❌ Not found");
},
}
// Check PostgreSQL
print!("Checking PostgreSQL... ");
match tokio::process::Command::new("psql")
.arg("--version")
.output()
.await
{
Ok(output) => {
if output.status.success() {
println!("✅ Found");
let version = String::from_utf8_lossy(&output.stdout);
println!(" {}", version.trim());
} else {
println!("❌ Error running psql");
}
},
Err(_) => {
println!("❌ Not found");
println!(" PostgreSQL is required for database tests");
},
}
// Check environment variables
println!("\nEnvironment Variables:");
let required_vars = ["DATABASE_URL", "RUST_LOG"];
let optional_vars = ["CUDA_VISIBLE_DEVICES", "TORCH_DEVICE"];
for var in required_vars {
match std::env::var(var) {
Ok(value) => println!("{}: {}", var, value),
Err(_) => println!("{} (required)", var),
}
}
for var in optional_vars {
match std::env::var(var) {
Ok(value) => println!("{}: {}", var, value),
Err(_) => println!(" {} (optional)", var),
}
}
// Check test compilation
print!("\nChecking E2E test compilation... ");
match tokio::process::Command::new("cargo")
.args(["check", "--package", "e2e_tests"])
.output()
.await
{
Ok(output) => {
if output.status.success() {
println!("✅ Compiles successfully");
} else {
println!("❌ Compilation errors");
let stderr = String::from_utf8_lossy(&output.stderr);
println!("Errors:\n{}", stderr);
}
},
Err(e) => {
println!("❌ Error checking compilation: {}", e);
},
}
println!("\n🎯 Environment validation complete!");
println!("Run './test_runner run --test smoke' to perform a quick smoke test.");
Ok(())
}
fn generate_test_plan(pattern: &str, timeout: u64) -> Result<Vec<TestExecutionRequest>> {
let mut requests = Vec::new();
let base_env = HashMap::from([
("RUST_BACKTRACE".to_string(), "1".to_string()),
("FOXHUNT_TEST_MODE".to_string(), "true".to_string()),
(
"DATABASE_URL".to_string(),
"postgresql://localhost/foxhunt_test".to_string(),
),
]);
match pattern {
"all" => {
// Complete test suite
requests.extend(generate_service_tests(timeout, &base_env));
requests.extend(generate_database_tests(timeout, &base_env));
requests.extend(generate_grpc_tests(timeout, &base_env));
requests.extend(generate_ml_tests(timeout, &base_env));
requests.extend(generate_trading_tests(timeout, &base_env));
},
"smoke" => {
// Quick validation tests
requests.push(TestExecutionRequest {
test_name: "smoke_service_startup".to_string(),
test_command: "cargo test --package e2e_tests test_service_startup --timeout 30"
.to_string(),
environment: base_env.clone(),
working_directory: None,
timeout_seconds: Some(60),
capture_output: true,
});
},
"services" | "service" => {
requests.extend(generate_service_tests(timeout, &base_env));
},
"database" | "db" => {
requests.extend(generate_database_tests(timeout, &base_env));
},
"grpc" => {
requests.extend(generate_grpc_tests(timeout, &base_env));
},
"ml" => {
requests.extend(generate_ml_tests(timeout, &base_env));
},
"trading" => {
requests.extend(generate_trading_tests(timeout, &base_env));
},
_ => {
return Err(anyhow::anyhow!("Unknown test pattern: {}", pattern));
},
}
Ok(requests)
}
fn generate_service_tests(
timeout: u64,
base_env: &HashMap<String, String>,
) -> Vec<TestExecutionRequest> {
vec![TestExecutionRequest {
test_name: "service_startup".to_string(),
test_command: "cargo test --package e2e_tests test_service_startup".to_string(),
environment: base_env.clone(),
working_directory: None,
timeout_seconds: Some(timeout),
capture_output: true,
}]
}
fn generate_database_tests(
timeout: u64,
base_env: &HashMap<String, String>,
) -> Vec<TestExecutionRequest> {
vec![TestExecutionRequest {
test_name: "database_integration".to_string(),
test_command: "cargo test --package e2e_tests test_database_integration".to_string(),
environment: base_env.clone(),
working_directory: None,
timeout_seconds: Some(timeout),
capture_output: true,
}]
}
fn generate_grpc_tests(
timeout: u64,
base_env: &HashMap<String, String>,
) -> Vec<TestExecutionRequest> {
vec![TestExecutionRequest {
test_name: "grpc_clients".to_string(),
test_command: "cargo test --package e2e_tests test_grpc_clients".to_string(),
environment: base_env.clone(),
working_directory: None,
timeout_seconds: Some(timeout),
capture_output: true,
}]
}
fn generate_ml_tests(
timeout: u64,
base_env: &HashMap<String, String>,
) -> Vec<TestExecutionRequest> {
vec![TestExecutionRequest {
test_name: "ml_pipeline".to_string(),
test_command: "cargo test --package e2e_tests test_ml_pipeline".to_string(),
environment: base_env.clone(),
working_directory: None,
timeout_seconds: Some(timeout),
capture_output: true,
}]
}
fn generate_trading_tests(
timeout: u64,
base_env: &HashMap<String, String>,
) -> Vec<TestExecutionRequest> {
vec![TestExecutionRequest {
test_name: "trading_workflows".to_string(),
test_command: "cargo test --package e2e_tests test_trading_workflows".to_string(),
environment: base_env.clone(),
working_directory: None,
timeout_seconds: Some(timeout),
capture_output: true,
}]
}
async fn generate_html_report(results: &[TestExecutionResult]) -> Result<String> {
let total_tests = results.len();
let passed_tests = results.iter().filter(|r| r.success).count();
let failed_tests = total_tests - passed_tests;
let mut html = String::new();
html.push_str("<!DOCTYPE html>\n<html><head><title>Foxhunt E2E Test Report</title>");
html.push_str("<style>body{font-family:Arial,sans-serif;margin:20px;}");
html.push_str(".pass{color:green;} .fail{color:red;} .summary{background:#f0f0f0;padding:10px;border-radius:5px;}");
html.push_str("table{border-collapse:collapse;width:100%;} th,td{border:1px solid #ddd;padding:8px;text-align:left;}");
html.push_str("</style></head><body>");
html.push_str(&"<h1>Foxhunt E2E Test Report</h1>".to_string());
html.push_str(&"<div class='summary'>".to_string());
html.push_str(&format!(
"<p>Generated: {}</p>",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
html.push_str(&format!("<p>Total Tests: {}</p>", total_tests));
html.push_str(&format!(
"<p>Passed: <span class='pass'>{}</span></p>",
passed_tests
));
html.push_str(&format!(
"<p>Failed: <span class='fail'>{}</span></p>",
failed_tests
));
html.push_str(&"</div>".to_string());
html.push_str("<h2>Test Results</h2><table>");
html.push_str("<tr><th>Test Name</th><th>Status</th><th>Duration</th><th>Exit Code</th></tr>");
for result in results {
let status_class = if result.success { "pass" } else { "fail" };
let status_text = if result.success { "PASS" } else { "FAIL" };
let exit_code = result
.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "N/A".to_string());
html.push_str(&format!(
"<tr><td>{}</td><td class='{}'>{}</td><td>{:?}</td><td>{}</td></tr>",
result.test_name, status_class, status_text, result.execution_time, exit_code
));
}
html.push_str("</table></body></html>");
Ok(html)
}