bottest/src/bot/runner.rs

685 lines
19 KiB
Rust
Raw Normal View History

2025-12-06 11:05:57 -03:00
//! 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<String, String>,
/// 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<Bot>,
sessions: Arc<Mutex<HashMap<Uuid, SessionState>>>,
script_cache: Arc<Mutex<HashMap<String, String>>>,
metrics: Arc<Mutex<RunnerMetrics>>,
}
/// Internal session state
struct SessionState {
session: Session,
customer: Customer,
channel: Channel,
context: HashMap<String, serde_json::Value>,
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<BotResponse>,
pub state: ConversationState,
pub execution_time: Duration,
pub logs: Vec<LogEntry>,
pub error: Option<String>,
}
/// A log entry captured during execution
#[derive(Debug, Clone)]
pub struct LogEntry {
pub timestamp: chrono::DateTime<chrono::Utc>,
pub level: LogLevel,
pub message: String,
pub context: HashMap<String, String>,
}
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<Uuid> {
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<ExecutionResult> {
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<BotResponse> {
// 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<ExecutionResult> {
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<String> {
// 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<SessionInfo> {
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);
}
}