Files
foxhunt/testing/e2e/src/corrode.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

459 lines
17 KiB
Rust

use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Stdio;
use tokio::process::Command;
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{info, warn, error, debug};
/// Corrode-MCP integration for E2E test execution
pub struct CorrodeTestRunner {
config: CorrodeConfig,
active_sessions: HashMap<String, CorrodeSession>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorrodeConfig {
pub executable_path: String,
pub workspace_path: String,
pub timeout_seconds: u64,
pub max_parallel_sessions: usize,
pub log_level: String,
}
impl Default for CorrodeConfig {
fn default() -> Self {
Self {
executable_path: "corrode".to_string(),
workspace_path: "/home/jgrusewski/Work/foxhunt".to_string(),
timeout_seconds: 300,
max_parallel_sessions: 4,
log_level: "info".to_string(),
}
}
}
#[derive(Debug)]
pub struct CorrodeSession {
pub id: String,
pub process: tokio::process::Child,
pub start_time: tokio::time::Instant,
pub test_name: String,
}
#[derive(Debug, 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, Serialize, Deserialize)]
pub struct TestExecutionResult {
pub test_name: String,
pub success: bool,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub execution_time: tokio::time::Duration,
pub error_message: Option<String>,
}
impl CorrodeTestRunner {
pub fn new(config: CorrodeConfig) -> Self {
Self {
config,
active_sessions: HashMap::new(),
}
}
pub fn with_default_config() -> Self {
Self::new(CorrodeConfig::default())
}
/// Execute a single test via corrode-mcp
pub async fn execute_test(&mut self, request: TestExecutionRequest) -> Result<TestExecutionResult> {
info!("Executing test: {}", request.test_name);
if self.active_sessions.len() >= self.config.max_parallel_sessions {
return Err(anyhow::anyhow!("Max parallel sessions reached"));
}
let session_id = uuid::Uuid::new_v4().to_string();
let start_time = tokio::time::Instant::now();
// Prepare environment variables
let mut env_vars = self.prepare_environment(&request)?;
// Add Foxhunt-specific environment variables
env_vars.insert("FOXHUNT_TEST_MODE".to_string(), "true".to_string());
env_vars.insert("DATABASE_URL".to_string(), "postgresql://localhost/foxhunt_test".to_string());
env_vars.insert("RUST_LOG".to_string(), self.config.log_level.clone());
// Build corrode command
let mut command = Command::new(&self.config.executable_path);
command
.current_dir(request.working_directory.as_ref().unwrap_or(&self.config.workspace_path))
.args(&["execute", "--test", &request.test_command])
.envs(&env_vars);
if request.capture_output {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
}
debug!("Starting corrode process: {:?}", command);
let mut process = command.spawn().map_err(|e| {
error!("Failed to spawn corrode process: {}", e);
anyhow::anyhow!("Failed to spawn corrode process: {}", e)
})?;
let session = CorrodeSession {
id: session_id.clone(),
process,
start_time,
test_name: request.test_name.clone(),
};
self.active_sessions.insert(session_id.clone(), session);
// Wait for completion with timeout
let timeout = tokio::time::Duration::from_secs(
request.timeout_seconds.unwrap_or(self.config.timeout_seconds)
);
let result = tokio::time::timeout(timeout, self.wait_for_completion(&session_id)).await;
// Clean up session
self.cleanup_session(&session_id).await?;
match result {
Ok(execution_result) => Ok(execution_result?),
Err(_) => {
warn!("Test execution timed out: {}", request.test_name);
Ok(TestExecutionResult {
test_name: request.test_name,
success: false,
exit_code: None,
stdout: String::new(),
stderr: "Test execution timed out".to_string(),
execution_time: timeout,
error_message: Some("Timeout exceeded".to_string()),
})
}
}
}
/// Execute multiple tests in parallel
pub async fn execute_test_suite(&mut self, requests: Vec<TestExecutionRequest>) -> Result<Vec<TestExecutionResult>> {
info!("Executing test suite with {} tests", requests.len());
let mut results = Vec::new();
let mut tasks = Vec::new();
// Split requests into batches to respect max parallel sessions
for batch in requests.chunks(self.config.max_parallel_sessions) {
let mut batch_tasks = Vec::new();
for request in batch {
let mut runner = CorrodeTestRunner::new(self.config.clone());
let request_clone = request.clone();
let task = tokio::spawn(async move {
runner.execute_test(request_clone).await
});
batch_tasks.push(task);
}
// Wait for current batch to complete
for task in batch_tasks {
match task.await {
Ok(result) => results.push(result?),
Err(e) => {
error!("Task execution error: {}", e);
return Err(anyhow::anyhow!("Task execution failed: {}", e));
}
}
}
}
Ok(results)
}
/// Execute E2E trading workflow test
pub async fn execute_trading_workflow_test(&mut self) -> Result<TestExecutionResult> {
let request = TestExecutionRequest {
test_name: "e2e_trading_workflow".to_string(),
test_command: "cargo test --package foxhunt-e2e --test trading_workflow -- --nocapture".to_string(),
environment: self.create_trading_test_environment()?,
working_directory: Some(self.config.workspace_path.clone()),
timeout_seconds: Some(600), // 10 minutes for full trading workflow
capture_output: true,
};
self.execute_test(request).await
}
/// Execute ML pipeline test
pub async fn execute_ml_pipeline_test(&mut self) -> Result<TestExecutionResult> {
let request = TestExecutionRequest {
test_name: "e2e_ml_pipeline".to_string(),
test_command: "cargo test --package foxhunt-e2e --test ml_pipeline -- --nocapture".to_string(),
environment: self.create_ml_test_environment()?,
working_directory: Some(self.config.workspace_path.clone()),
timeout_seconds: Some(900), // 15 minutes for ML training/inference
capture_output: true,
};
self.execute_test(request).await
}
/// Execute database integration test
pub async fn execute_database_test(&mut self) -> Result<TestExecutionResult> {
let request = TestExecutionRequest {
test_name: "e2e_database_integration".to_string(),
test_command: "cargo test --package foxhunt-e2e --test database -- --nocapture".to_string(),
environment: self.create_database_test_environment()?,
working_directory: Some(self.config.workspace_path.clone()),
timeout_seconds: Some(300), // 5 minutes for database tests
capture_output: true,
};
self.execute_test(request).await
}
/// Execute complete E2E test suite
pub async fn execute_full_e2e_suite(&mut self) -> Result<Vec<TestExecutionResult>> {
info!("Starting complete E2E test suite execution");
let requests = vec![
// Service startup tests
TestExecutionRequest {
test_name: "service_startup".to_string(),
test_command: "cargo test --package foxhunt-e2e test_service_startup".to_string(),
environment: self.create_service_test_environment()?,
working_directory: Some(self.config.workspace_path.clone()),
timeout_seconds: Some(120),
capture_output: true,
},
// Database integration
TestExecutionRequest {
test_name: "database_integration".to_string(),
test_command: "cargo test --package foxhunt-e2e test_database_integration".to_string(),
environment: self.create_database_test_environment()?,
working_directory: Some(self.config.workspace_path.clone()),
timeout_seconds: Some(300),
capture_output: true,
},
// gRPC client tests
TestExecutionRequest {
test_name: "grpc_clients".to_string(),
test_command: "cargo test --package foxhunt-e2e test_grpc_clients".to_string(),
environment: self.create_service_test_environment()?,
working_directory: Some(self.config.workspace_path.clone()),
timeout_seconds: Some(180),
capture_output: true,
},
// ML pipeline tests
TestExecutionRequest {
test_name: "ml_pipeline".to_string(),
test_command: "cargo test --package foxhunt-e2e test_ml_pipeline".to_string(),
environment: self.create_ml_test_environment()?,
working_directory: Some(self.config.workspace_path.clone()),
timeout_seconds: Some(900),
capture_output: true,
},
// Trading workflow tests
TestExecutionRequest {
test_name: "trading_workflows".to_string(),
test_command: "cargo test --package foxhunt-e2e test_trading_workflows".to_string(),
environment: self.create_trading_test_environment()?,
working_directory: Some(self.config.workspace_path.clone()),
timeout_seconds: Some(600),
capture_output: true,
},
];
self.execute_test_suite(requests).await
}
/// Generate comprehensive test report
pub async fn generate_test_report(&self, 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 total_execution_time: tokio::time::Duration = results.iter()
.map(|r| r.execution_time)
.sum();
let mut report = String::new();
report.push_str("# Foxhunt E2E Test Report\n\n");
report.push_str(&format!("Generated: {}\n", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")));
report.push_str(&format!("Total Tests: {}\n", total_tests));
report.push_str(&format!("Passed: {}\n", passed_tests));
report.push_str(&format!("Failed: {}\n", failed_tests));
report.push_str(&format!("Success Rate: {:.1}%\n", (passed_tests as f64 / total_tests as f64) * 100.0));
report.push_str(&format!("Total Execution Time: {:?}\n\n", total_execution_time));
report.push_str("## Test Results\n\n");
for result in results {
let status = if result.success { "✅ PASS" } else { "❌ FAIL" };
report.push_str(&format!("### {} - {}\n", status, result.test_name));
report.push_str(&format!("- Execution Time: {:?}\n", result.execution_time));
if let Some(exit_code) = result.exit_code {
report.push_str(&format!("- Exit Code: {}\n", exit_code));
}
if !result.success {
if let Some(error) = &result.error_message {
report.push_str(&format!("- Error: {}\n", error));
}
if !result.stderr.is_empty() {
report.push_str("- Stderr:\n```\n");
report.push_str(&result.stderr);
report.push_str("\n```\n");
}
}
report.push_str("\n");
}
Ok(report)
}
// Private helper methods
async fn wait_for_completion(&mut self, session_id: &str) -> Result<TestExecutionResult> {
let session = self.active_sessions.get_mut(session_id)
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", session_id))?;
let start_time = session.start_time;
let test_name = session.test_name.clone();
let output = session.process.wait_with_output().await?;
let execution_time = start_time.elapsed();
let result = TestExecutionResult {
test_name,
success: output.status.success(),
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
execution_time,
error_message: if !output.status.success() {
Some(format!("Process exited with code: {:?}", output.status.code()))
} else {
None
},
};
Ok(result)
}
async fn cleanup_session(&mut self, session_id: &str) -> Result<()> {
if let Some(mut session) = self.active_sessions.remove(session_id) {
// Ensure process is terminated
if let Err(e) = session.process.kill().await {
warn!("Failed to kill process for session {}: {}", session_id, e);
}
}
Ok(())
}
fn prepare_environment(&self, request: &TestExecutionRequest) -> Result<HashMap<String, String>> {
let mut env = request.environment.clone();
// Add standard test environment variables
env.insert("RUST_BACKTRACE".to_string(), "1".to_string());
env.insert("RUST_LOG".to_string(), self.config.log_level.clone());
Ok(env)
}
fn create_service_test_environment(&self) -> Result<HashMap<String, String>> {
let mut env = HashMap::new();
env.insert("TRADING_SERVICE_PORT".to_string(), "50051".to_string());
env.insert("BACKTESTING_SERVICE_PORT".to_string(), "50052".to_string());
env.insert("ML_TRAINING_SERVICE_PORT".to_string(), "50053".to_string());
env.insert("DATABASE_URL".to_string(), "postgresql://localhost/foxhunt_test".to_string());
Ok(env)
}
fn create_database_test_environment(&self) -> Result<HashMap<String, String>> {
let mut env = HashMap::new();
env.insert("DATABASE_URL".to_string(), "postgresql://localhost/foxhunt_test".to_string());
env.insert("PGUSER".to_string(), "postgres".to_string());
env.insert("PGPASSWORD".to_string(), "postgres".to_string());
env.insert("PGDATABASE".to_string(), "foxhunt_test".to_string());
Ok(env)
}
fn create_ml_test_environment(&self) -> Result<HashMap<String, String>> {
let mut env = HashMap::new();
env.insert("ML_MODEL_PATH".to_string(), "/tmp/foxhunt_test_models".to_string());
env.insert("CUDA_VISIBLE_DEVICES".to_string(), "0".to_string());
env.insert("TORCH_DEVICE".to_string(), "cpu".to_string()); // Use CPU for tests
Ok(env)
}
fn create_trading_test_environment(&self) -> Result<HashMap<String, String>> {
let mut env = HashMap::new();
env.insert("TRADING_MODE".to_string(), "simulation".to_string());
env.insert("BROKER_ENDPOINT".to_string(), "simulation://localhost".to_string());
env.insert("RISK_CHECK_ENABLED".to_string(), "true".to_string());
Ok(env)
}
}
impl Clone for TestExecutionRequest {
fn clone(&self) -> Self {
Self {
test_name: self.test_name.clone(),
test_command: self.test_command.clone(),
environment: self.environment.clone(),
working_directory: self.working_directory.clone(),
timeout_seconds: self.timeout_seconds,
capture_output: self.capture_output,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_corrode_config() {
let config = CorrodeConfig::default();
assert!(!config.executable_path.is_empty());
assert!(config.timeout_seconds > 0);
}
#[test]
fn test_environment_preparation() {
let runner = CorrodeTestRunner::with_default_config();
let request = TestExecutionRequest {
test_name: "test".to_string(),
test_command: "echo test".to_string(),
environment: HashMap::new(),
working_directory: None,
timeout_seconds: None,
capture_output: true,
};
let env = runner.prepare_environment(&request).unwrap();
assert!(env.contains_key("RUST_BACKTRACE"));
assert!(env.contains_key("RUST_LOG"));
}
}