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>
459 lines
17 KiB
Rust
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"));
|
|
}
|
|
} |