//! Bot runner for executing tests //! //! Provides a test runner that can execute BASIC scripts and simulate //! bot behavior for integration testing. use super::{BotResponse, ConversationState, ResponseContentType}; use crate::fixtures::{Bot, Channel, Customer, Session}; use crate::harness::TestContext; use anyhow::{Context, Result}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use uuid::Uuid; /// Configuration for the bot runner #[derive(Debug, Clone)] pub struct BotRunnerConfig { /// Working directory for the bot pub working_dir: PathBuf, /// Maximum execution time for a single request pub timeout: Duration, /// Whether to use mock services pub use_mocks: bool, /// Environment variables to set pub env_vars: HashMap, /// Whether to capture logs pub capture_logs: bool, /// Log level pub log_level: LogLevel, } impl Default for BotRunnerConfig { fn default() -> Self { Self { working_dir: std::env::temp_dir().join("bottest"), timeout: Duration::from_secs(30), use_mocks: true, env_vars: HashMap::new(), capture_logs: true, log_level: LogLevel::Info, } } } /// Log level for bot runner #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LogLevel { Trace, Debug, Info, Warn, Error, } impl Default for LogLevel { fn default() -> Self { Self::Info } } /// Bot runner for executing bot scripts and simulating conversations pub struct BotRunner { config: BotRunnerConfig, bot: Option, sessions: Arc>>, script_cache: Arc>>, metrics: Arc>, } /// Internal session state struct SessionState { session: Session, customer: Customer, channel: Channel, context: HashMap, conversation_state: ConversationState, message_count: usize, started_at: Instant, } /// Metrics collected by the runner #[derive(Debug, Default, Clone)] pub struct RunnerMetrics { pub total_requests: u64, pub successful_requests: u64, pub failed_requests: u64, pub total_latency_ms: u64, pub min_latency_ms: u64, pub max_latency_ms: u64, pub script_executions: u64, pub transfer_to_human_count: u64, } impl RunnerMetrics { /// Get average latency in milliseconds pub fn avg_latency_ms(&self) -> u64 { if self.total_requests > 0 { self.total_latency_ms / self.total_requests } else { 0 } } /// Get success rate as percentage pub fn success_rate(&self) -> f64 { if self.total_requests > 0 { (self.successful_requests as f64 / self.total_requests as f64) * 100.0 } else { 0.0 } } } /// Result of a bot execution #[derive(Debug, Clone)] pub struct ExecutionResult { pub session_id: Uuid, pub response: Option, pub state: ConversationState, pub execution_time: Duration, pub logs: Vec, pub error: Option, } /// A log entry captured during execution #[derive(Debug, Clone)] pub struct LogEntry { pub timestamp: chrono::DateTime, pub level: LogLevel, pub message: String, pub context: HashMap, } impl BotRunner { /// Create a new bot runner with default configuration pub fn new() -> Self { Self::with_config(BotRunnerConfig::default()) } /// Create a new bot runner with custom configuration pub fn with_config(config: BotRunnerConfig) -> Self { Self { config, bot: None, sessions: Arc::new(Mutex::new(HashMap::new())), script_cache: Arc::new(Mutex::new(HashMap::new())), metrics: Arc::new(Mutex::new(RunnerMetrics::default())), } } /// Create a bot runner with a test context pub fn with_context(_ctx: &TestContext, config: BotRunnerConfig) -> Self { Self::with_config(config) } /// Set the bot to run pub fn set_bot(&mut self, bot: Bot) -> &mut Self { self.bot = Some(bot); self } /// Load a BASIC script pub fn load_script(&mut self, name: &str, content: &str) -> &mut Self { self.script_cache .lock() .unwrap() .insert(name.to_string(), content.to_string()); self } /// Load a script from a file pub fn load_script_file(&mut self, name: &str, path: &PathBuf) -> Result<&mut Self> { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read script file: {:?}", path))?; self.script_cache .lock() .unwrap() .insert(name.to_string(), content); Ok(self) } /// Start a new session pub fn start_session(&mut self, customer: Customer) -> Result { let session_id = Uuid::new_v4(); let bot_id = self.bot.as_ref().map(|b| b.id).unwrap_or_else(Uuid::new_v4); let session = Session { id: session_id, bot_id, customer_id: customer.id, channel: customer.channel, ..Default::default() }; let state = SessionState { session, channel: customer.channel, customer, context: HashMap::new(), conversation_state: ConversationState::Initial, message_count: 0, started_at: Instant::now(), }; self.sessions.lock().unwrap().insert(session_id, state); Ok(session_id) } /// End a session pub fn end_session(&mut self, session_id: Uuid) -> Result<()> { self.sessions.lock().unwrap().remove(&session_id); Ok(()) } /// Process a message in a session pub async fn process_message( &mut self, session_id: Uuid, message: &str, ) -> Result { let start = Instant::now(); let mut logs = Vec::new(); // Update metrics { let mut metrics = self.metrics.lock().unwrap(); metrics.total_requests += 1; } // Get session state let state = { let sessions = self.sessions.lock().unwrap(); sessions.get(&session_id).cloned() }; let state = match state { Some(s) => s, None => { return Ok(ExecutionResult { session_id, response: None, state: ConversationState::Error, execution_time: start.elapsed(), logs, error: Some("Session not found".to_string()), }); } }; if self.config.capture_logs { logs.push(LogEntry { timestamp: chrono::Utc::now(), level: LogLevel::Debug, message: format!("Processing message: {}", message), context: HashMap::new(), }); } // Execute bot logic (placeholder - would call actual bot runtime) let response = self.execute_bot_logic(session_id, message, &state).await; let execution_time = start.elapsed(); // Update metrics { let mut metrics = self.metrics.lock().unwrap(); let latency_ms = execution_time.as_millis() as u64; metrics.total_latency_ms += latency_ms; if metrics.min_latency_ms == 0 || latency_ms < metrics.min_latency_ms { metrics.min_latency_ms = latency_ms; } if latency_ms > metrics.max_latency_ms { metrics.max_latency_ms = latency_ms; } if response.is_ok() { metrics.successful_requests += 1; } else { metrics.failed_requests += 1; } } // Update session state { let mut sessions = self.sessions.lock().unwrap(); if let Some(session_state) = sessions.get_mut(&session_id) { session_state.message_count += 1; session_state.conversation_state = ConversationState::WaitingForUser; } } match response { Ok(bot_response) => Ok(ExecutionResult { session_id, response: Some(bot_response), state: ConversationState::WaitingForUser, execution_time, logs, error: None, }), Err(e) => Ok(ExecutionResult { session_id, response: None, state: ConversationState::Error, execution_time, logs, error: Some(e.to_string()), }), } } /// Execute bot logic (placeholder for actual implementation) async fn execute_bot_logic( &self, _session_id: Uuid, message: &str, _state: &SessionState, ) -> Result { // In a real implementation, this would: // 1. Load the bot's BASIC script // 2. Execute it with the message as input // 3. Return the bot's response // For now, return a mock response Ok(BotResponse { id: Uuid::new_v4(), content: format!("Echo: {}", message), content_type: ResponseContentType::Text, metadata: HashMap::new(), latency_ms: 50, }) } /// Execute a BASIC script directly pub async fn execute_script( &mut self, script_name: &str, input: &str, ) -> Result { let session_id = Uuid::new_v4(); let start = Instant::now(); let mut logs = Vec::new(); // Get script from cache let script = { let cache = self.script_cache.lock().unwrap(); cache.get(script_name).cloned() }; let script = match script { Some(s) => s, None => { return Ok(ExecutionResult { session_id, response: None, state: ConversationState::Error, execution_time: start.elapsed(), logs, error: Some(format!("Script '{}' not found", script_name)), }); } }; if self.config.capture_logs { logs.push(LogEntry { timestamp: chrono::Utc::now(), level: LogLevel::Debug, message: format!("Executing script: {}", script_name), context: HashMap::new(), }); } // Update metrics { let mut metrics = self.metrics.lock().unwrap(); metrics.script_executions += 1; } // Execute script (placeholder) let result = self.execute_script_internal(&script, input).await; let execution_time = start.elapsed(); match result { Ok(output) => Ok(ExecutionResult { session_id, response: Some(BotResponse { id: Uuid::new_v4(), content: output, content_type: ResponseContentType::Text, metadata: HashMap::new(), latency_ms: execution_time.as_millis() as u64, }), state: ConversationState::WaitingForUser, execution_time, logs, error: None, }), Err(e) => Ok(ExecutionResult { session_id, response: None, state: ConversationState::Error, execution_time, logs, error: Some(e.to_string()), }), } } /// Internal script execution (placeholder) async fn execute_script_internal(&self, _script: &str, input: &str) -> Result { // In a real implementation, this would parse and execute the BASIC script // For now, just echo the input Ok(format!("Script output for: {}", input)) } /// Get current metrics pub fn metrics(&self) -> RunnerMetrics { self.metrics.lock().unwrap().clone() } /// Reset metrics pub fn reset_metrics(&mut self) { *self.metrics.lock().unwrap() = RunnerMetrics::default(); } /// Get active session count pub fn active_session_count(&self) -> usize { self.sessions.lock().unwrap().len() } /// Get session info pub fn get_session_info(&self, session_id: Uuid) -> Option { let sessions = self.sessions.lock().unwrap(); sessions.get(&session_id).map(|s| SessionInfo { session_id: s.session.id, customer_id: s.customer.id, channel: s.channel, message_count: s.message_count, state: s.conversation_state, duration: s.started_at.elapsed(), }) } /// Set environment variable for bot execution pub fn set_env(&mut self, key: &str, value: &str) -> &mut Self { self.config .env_vars .insert(key.to_string(), value.to_string()); self } /// Set timeout pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self { self.config.timeout = timeout; self } } impl Default for BotRunner { fn default() -> Self { Self::new() } } /// Information about a session #[derive(Debug, Clone)] pub struct SessionInfo { pub session_id: Uuid, pub customer_id: Uuid, pub channel: Channel, pub message_count: usize, pub state: ConversationState, pub duration: Duration, } // Implement Clone for SessionState impl Clone for SessionState { fn clone(&self) -> Self { Self { session: self.session.clone(), customer: self.customer.clone(), channel: self.channel, context: self.context.clone(), conversation_state: self.conversation_state, message_count: self.message_count, started_at: self.started_at, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_bot_runner_config_default() { let config = BotRunnerConfig::default(); assert_eq!(config.timeout, Duration::from_secs(30)); assert!(config.use_mocks); assert!(config.capture_logs); } #[test] fn test_runner_metrics_avg_latency() { let mut metrics = RunnerMetrics::default(); metrics.total_requests = 10; metrics.total_latency_ms = 1000; assert_eq!(metrics.avg_latency_ms(), 100); } #[test] fn test_runner_metrics_success_rate() { let mut metrics = RunnerMetrics::default(); metrics.total_requests = 100; metrics.successful_requests = 95; assert_eq!(metrics.success_rate(), 95.0); } #[test] fn test_runner_metrics_zero_requests() { let metrics = RunnerMetrics::default(); assert_eq!(metrics.avg_latency_ms(), 0); assert_eq!(metrics.success_rate(), 0.0); } #[test] fn test_bot_runner_new() { let runner = BotRunner::new(); assert_eq!(runner.active_session_count(), 0); } #[test] fn test_load_script() { let mut runner = BotRunner::new(); runner.load_script("test", "TALK \"Hello\""); let cache = runner.script_cache.lock().unwrap(); assert!(cache.contains_key("test")); } #[test] fn test_start_session() { let mut runner = BotRunner::new(); let customer = Customer::default(); let session_id = runner.start_session(customer).unwrap(); assert_eq!(runner.active_session_count(), 1); assert!(runner.get_session_info(session_id).is_some()); } #[test] fn test_end_session() { let mut runner = BotRunner::new(); let customer = Customer::default(); let session_id = runner.start_session(customer).unwrap(); assert_eq!(runner.active_session_count(), 1); runner.end_session(session_id).unwrap(); assert_eq!(runner.active_session_count(), 0); } #[tokio::test] async fn test_process_message() { let mut runner = BotRunner::new(); let customer = Customer::default(); let session_id = runner.start_session(customer).unwrap(); let result = runner.process_message(session_id, "Hello").await.unwrap(); assert!(result.response.is_some()); assert!(result.error.is_none()); assert_eq!(result.state, ConversationState::WaitingForUser); } #[tokio::test] async fn test_process_message_invalid_session() { let mut runner = BotRunner::new(); let invalid_session_id = Uuid::new_v4(); let result = runner .process_message(invalid_session_id, "Hello") .await .unwrap(); assert!(result.response.is_none()); assert!(result.error.is_some()); assert_eq!(result.state, ConversationState::Error); } #[tokio::test] async fn test_execute_script() { let mut runner = BotRunner::new(); runner.load_script("greeting", "TALK \"Hello\""); let result = runner.execute_script("greeting", "Hi").await.unwrap(); assert!(result.response.is_some()); assert!(result.error.is_none()); } #[tokio::test] async fn test_execute_script_not_found() { let mut runner = BotRunner::new(); let result = runner.execute_script("nonexistent", "Hi").await.unwrap(); assert!(result.response.is_none()); assert!(result.error.is_some()); assert!(result.error.unwrap().contains("not found")); } #[test] fn test_metrics_tracking() { let runner = BotRunner::new(); let metrics = runner.metrics(); assert_eq!(metrics.total_requests, 0); assert_eq!(metrics.successful_requests, 0); } #[test] fn test_reset_metrics() { let mut runner = BotRunner::new(); // Manually update metrics { let mut metrics = runner.metrics.lock().unwrap(); metrics.total_requests = 100; } runner.reset_metrics(); let metrics = runner.metrics(); assert_eq!(metrics.total_requests, 0); } #[test] fn test_set_env() { let mut runner = BotRunner::new(); runner.set_env("API_KEY", "test123"); assert_eq!( runner.config.env_vars.get("API_KEY"), Some(&"test123".to_string()) ); } #[test] fn test_set_timeout() { let mut runner = BotRunner::new(); runner.set_timeout(Duration::from_secs(60)); assert_eq!(runner.config.timeout, Duration::from_secs(60)); } #[test] fn test_session_info() { let mut runner = BotRunner::new(); let customer = Customer::default(); let customer_id = customer.id; let session_id = runner.start_session(customer).unwrap(); let info = runner.get_session_info(session_id).unwrap(); assert_eq!(info.session_id, session_id); assert_eq!(info.customer_id, customer_id); assert_eq!(info.message_count, 0); assert_eq!(info.state, ConversationState::Initial); } #[test] fn test_log_level_default() { let level = LogLevel::default(); assert_eq!(level, LogLevel::Info); } }