Test framework improvements and fixture updates

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-28 11:51:00 -03:00
parent b38574c588
commit 1232b2fc65
24 changed files with 208 additions and 266 deletions

View file

@ -131,7 +131,7 @@ impl ConversationTest {
ConversationBuilder::new(bot_name).build()
}
pub async fn with_context(ctx: &TestContext, bot_name: &str) -> Result<Self> {
pub fn with_context(ctx: &TestContext, bot_name: &str) -> Result<Self> {
let mut conv = ConversationBuilder::new(bot_name).build();
conv.llm_url = Some(ctx.llm_url());
Ok(conv)
@ -306,7 +306,7 @@ impl ConversationTest {
metadata
}
pub async fn assert_response_contains(&mut self, text: &str) -> &mut Self {
pub fn assert_response_contains(&mut self, text: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response {
if response.content.contains(text) {
AssertionResult::pass(&format!("Response contains '{text}'"))
@ -325,7 +325,7 @@ impl ConversationTest {
self
}
pub async fn assert_response_equals(&mut self, text: &str) -> &mut Self {
pub fn assert_response_equals(&mut self, text: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response {
if response.content == text {
AssertionResult::pass(&format!("Response equals '{text}'"))
@ -344,7 +344,7 @@ impl ConversationTest {
self
}
pub async fn assert_response_matches(&mut self, pattern: &str) -> &mut Self {
pub fn assert_response_matches(&mut self, pattern: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response {
match regex::Regex::new(pattern) {
Ok(re) => {
@ -372,7 +372,7 @@ impl ConversationTest {
self
}
pub async fn assert_response_not_contains(&mut self, text: &str) -> &mut Self {
pub fn assert_response_not_contains(&mut self, text: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response {
if response.content.contains(text) {
AssertionResult::fail(
@ -391,16 +391,13 @@ impl ConversationTest {
self
}
pub async fn assert_transferred_to_human(&mut self) -> &mut Self {
pub fn assert_transferred_to_human(&mut self) -> &mut Self {
let is_transferred = self.state == ConversationState::Transferred
|| self
.last_response
.as_ref()
.is_some_and(|r| {
r.content.to_lowercase().contains("transfer")
|| r.content.to_lowercase().contains("human")
|| r.content.to_lowercase().contains("agent")
});
|| self.last_response.as_ref().is_some_and(|r| {
r.content.to_lowercase().contains("transfer")
|| r.content.to_lowercase().contains("human")
|| r.content.to_lowercase().contains("agent")
});
let result = if is_transferred {
self.state = ConversationState::Transferred;
@ -417,7 +414,7 @@ impl ConversationTest {
self
}
pub async fn assert_queue_position(&mut self, expected: usize) -> &mut Self {
pub fn assert_queue_position(&mut self, expected: usize) -> &mut Self {
let actual = self
.context
.get("queue_position")
@ -438,7 +435,7 @@ impl ConversationTest {
self
}
pub async fn assert_response_within(&mut self, max_duration: Duration) -> &mut Self {
pub fn assert_response_within(&mut self, max_duration: Duration) -> &mut Self {
let result = if let Some(latency) = self.last_latency {
if latency <= max_duration {
AssertionResult::pass(&format!("Response within {max_duration:?}"))
@ -461,7 +458,7 @@ impl ConversationTest {
self
}
pub async fn assert_response_count(&mut self, expected: usize) -> &mut Self {
pub fn assert_response_count(&mut self, expected: usize) -> &mut Self {
let actual = self.responses.len();
let result = if actual == expected {
@ -478,7 +475,7 @@ impl ConversationTest {
self
}
pub async fn assert_response_type(&mut self, expected: ResponseContentType) -> &mut Self {
pub fn assert_response_type(&mut self, expected: ResponseContentType) -> &mut Self {
let result = if let Some(ref response) = self.last_response {
if response.content_type == expected {
AssertionResult::pass(&format!("Response type is {expected:?}"))
@ -510,7 +507,7 @@ impl ConversationTest {
self.context.get(key)
}
pub async fn end(&mut self) -> &mut Self {
pub fn end(&mut self) -> &mut Self {
self.state = ConversationState::Ended;
self.record.ended_at = Some(Utc::now());
self
@ -583,7 +580,7 @@ mod tests {
async fn test_assert_response_contains() {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await;
conv.assert_response_contains("Response").await;
conv.assert_response_contains("Response");
assert!(conv.all_passed());
}
@ -592,7 +589,7 @@ mod tests {
async fn test_assert_response_not_contains() {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await;
conv.assert_response_not_contains("nonexistent").await;
conv.assert_response_not_contains("nonexistent");
assert!(conv.all_passed());
}
@ -632,7 +629,7 @@ mod tests {
async fn test_end_conversation() {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("bye").await;
conv.end().await;
conv.end();
assert_eq!(conv.state(), ConversationState::Ended);
assert!(conv.record().ended_at.is_some());
@ -642,7 +639,7 @@ mod tests {
async fn test_failed_assertions() {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await;
conv.assert_response_equals("this will not match").await;
conv.assert_response_equals("this will not match");
assert!(!conv.all_passed());
assert_eq!(conv.failed_assertions().len(), 1);
@ -672,13 +669,13 @@ mod tests {
let mut conv = ConversationTest::new("support-bot");
conv.user_says("Hi").await;
conv.assert_response_contains("Response").await;
conv.assert_response_contains("Response");
conv.user_says("I need help").await;
conv.assert_response_contains("Response").await;
conv.assert_response_contains("Response");
conv.user_says("Thanks, bye").await;
conv.end().await;
conv.end();
assert_eq!(conv.sent_messages().len(), 3);
assert_eq!(conv.responses().len(), 3);
@ -689,7 +686,7 @@ mod tests {
async fn test_response_time_assertion() {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("quick test").await;
conv.assert_response_within(Duration::from_secs(5)).await;
conv.assert_response_within(Duration::from_secs(5));
assert!(conv.all_passed());
}
@ -699,7 +696,7 @@ mod tests {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("one").await;
conv.user_says("two").await;
conv.assert_response_count(2).await;
conv.assert_response_count(2);
assert!(conv.all_passed());
}

View file

@ -254,6 +254,7 @@ pub struct Screenshot {
impl Screenshot {
pub fn save(&self, path: impl Into<PathBuf>) -> Result<()> {
let _ = (&self.data, self.width, self.height);
let path = path.into();
anyhow::bail!("Screenshot save not yet implemented: {}", path.display())
}

View file

@ -1,4 +1,3 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
@ -334,6 +333,7 @@ pub fn sample_faqs() -> Vec<FAQ> {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub struct FAQ {
pub id: u32,
pub question: String,
@ -425,10 +425,12 @@ mod tests {
fn test_whatsapp_text_message() {
let payload = whatsapp_text_message("15551234567", "Hello");
assert_eq!(payload["object"], "whatsapp_business_account");
assert!(payload["entry"][0]["changes"][0]["value"]["messages"][0]["text"]["body"]
.as_str()
.unwrap()
.contains("Hello"));
assert!(
payload["entry"][0]["changes"][0]["value"]["messages"][0]["text"]["body"]
.as_str()
.unwrap()
.contains("Hello")
);
}
#[test]
@ -453,7 +455,9 @@ mod tests {
fn test_sample_kb_entries() {
let entries = sample_kb_entries();
assert!(!entries.is_empty());
assert!(entries.iter().any(|e| e.category == Some("products".to_string())));
assert!(entries
.iter()
.any(|e| e.category == Some("products".to_string())));
}
#[test]

View file

@ -1,4 +1,3 @@
pub mod data;
pub mod scripts;
@ -7,7 +6,6 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
@ -79,6 +77,7 @@ impl Default for Customer {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[allow(clippy::upper_case_acronyms)]
pub enum Channel {
WhatsApp,
Teams,
@ -278,7 +277,6 @@ impl Default for QueueStatus {
}
}
#[must_use]
pub fn admin_user() -> User {
User {

View file

@ -493,14 +493,18 @@ impl BotServerInstance {
if !stack_path.exists() {
anyhow::bail!(
"Main botserver-stack not found at {stack_path:?}.\n\
Run botserver once to initialize: cd ../botserver && cargo run"
"Main botserver-stack not found at {}.\n\
Run botserver once to initialize: cd ../botserver && cargo run",
stack_path.display()
);
}
log::info!("Starting botserver with MAIN stack at {stack_path:?}");
log::info!(
"Starting botserver with MAIN stack at {}",
stack_path.display()
);
println!("🚀 Starting BotServer with main stack...");
println!(" Stack: {stack_path:?}");
println!(" Stack: {}", stack_path.display());
let process = std::process::Command::new(&botserver_bin_path)
.current_dir(&botserver_dir)
@ -606,7 +610,7 @@ impl BotUIInstance {
log::info!("Starting botui from: {botui_bin} on port {port}");
log::info!(" BOTUI_PORT={port}");
log::info!(" BOTSERVER_URL={botserver_url}");
log::info!(" Working directory: {botui_dir:?}");
log::info!(" Working directory: {}", botui_dir.display());
let process = std::process::Command::new(&botui_bin_path)
.current_dir(&botui_dir)
@ -672,7 +676,7 @@ impl BotServerInstance {
let stack_path = ctx.data_dir.join("botserver-stack");
std::fs::create_dir_all(&stack_path)?;
let stack_path = stack_path.canonicalize().unwrap_or(stack_path);
log::info!("Created clean test stack at: {stack_path:?}");
log::info!("Created clean test stack at: {}", stack_path.display());
let botserver_bin = std::env::var("BOTSERVER_BIN")
.unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string());
@ -701,12 +705,12 @@ impl BotServerInstance {
.unwrap_or_else(|_| PathBuf::from("../botserver"))
});
log::info!("Botserver working directory: {botserver_dir:?}");
log::info!("Stack path (absolute): {stack_path:?}");
log::info!("Botserver working directory: {}", botserver_dir.display());
log::info!("Stack path (absolute): {}", stack_path.display());
let installers_path = botserver_dir.join("botserver-installers");
let installers_path = installers_path.canonicalize().unwrap_or(installers_path);
log::info!("Using installers from: {installers_path:?}");
log::info!("Using installers from: {}", installers_path.display());
let process = std::process::Command::new(&botserver_bin_path)
.current_dir(&botserver_dir)
@ -764,7 +768,7 @@ impl BotServerInstance {
self.process.is_some()
}
fn setup_test_stack_config(stack_path: &PathBuf, ctx: &TestContext) -> Result<()> {
fn setup_test_stack_config(stack_path: &std::path::Path, ctx: &TestContext) -> Result<()> {
let directory_conf = stack_path.join("conf/directory");
std::fs::create_dir_all(&directory_conf)?;
@ -800,7 +804,7 @@ ExternalPort: {}
Ok(())
}
fn generate_test_certificates(certs_dir: &PathBuf) -> Result<()> {
fn generate_test_certificates(certs_dir: &std::path::Path) -> Result<()> {
use std::process::Command;
let api_dir = certs_dir.join("api");
@ -922,7 +926,8 @@ impl TestHarness {
};
log::info!(
"Test {test_id} allocated ports: {ports:?}, data_dir: {data_dir:?}, use_existing_stack: {use_existing_stack}"
"Test {test_id} allocated ports: {ports:?}, data_dir: {}, use_existing_stack: {use_existing_stack}",
data_dir.display()
);
let data_dir_str = data_dir.to_str().unwrap().to_string();

View file

@ -35,6 +35,7 @@ pub mod prelude {
mod tests {
#[test]
fn test_library_loads() {
assert!(true);
let version = env!("CARGO_PKG_VERSION");
assert!(!version.is_empty());
}
}

View file

@ -308,7 +308,7 @@ async fn download_file(url: &str, dest: &PathBuf) -> Result<()> {
Ok(())
}
async fn extract_zip(zip_path: &PathBuf, dest_dir: &PathBuf) -> Result<()> {
fn extract_zip(zip_path: &PathBuf, dest_dir: &PathBuf) -> Result<()> {
info!("Extracting: {:?} to {:?}", zip_path, dest_dir);
let file = std::fs::File::open(zip_path)?;
@ -390,7 +390,7 @@ async fn setup_chromedriver(browser_path: &str) -> Result<PathBuf> {
let zip_path = cache_dir.join("chromedriver.zip");
download_file(&chromedriver_url, &zip_path).await?;
extract_zip(&zip_path, &cache_dir).await?;
extract_zip(&zip_path, &cache_dir)?;
let extracted_driver = cache_dir.join("chromedriver-linux64").join("chromedriver");
let final_path = get_chromedriver_path(&major_version);
@ -439,7 +439,7 @@ async fn setup_chrome_for_testing() -> Result<PathBuf> {
let zip_path = cache_dir.join("chrome.zip");
download_file(&chrome_url, &zip_path).await?;
extract_zip(&zip_path, &cache_dir).await?;
extract_zip(&zip_path, &cache_dir)?;
std::fs::remove_file(&zip_path).ok();
@ -494,12 +494,11 @@ async fn start_chromedriver(chromedriver_path: &PathBuf, port: u16) -> Result<st
async fn check_webdriver_available(port: u16) -> bool {
let url = format!("http://localhost:{port}/status");
let client = match reqwest::Client::builder()
let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
{
Ok(c) => c,
Err(_) => return false,
else {
return false;
};
client.get(&url).send().await.is_ok()
@ -641,7 +640,7 @@ fn run_cargo_test(
Ok((passed, failed, skipped))
}
async fn run_unit_tests(config: &RunnerConfig) -> Result<TestResults> {
fn run_unit_tests(config: &RunnerConfig) -> Result<TestResults> {
info!("Running unit tests...");
let mut results = TestResults::new("unit");
@ -998,8 +997,8 @@ async fn main() -> ExitCode {
match setup_test_dependencies().await {
Ok((chromedriver, chrome)) => {
println!("\n✅ Dependencies installed successfully!");
println!(" ChromeDriver: {chromedriver:?}");
println!(" Browser: {chrome:?}");
println!(" ChromeDriver: {}", chromedriver.display());
println!(" Browser: {}", chrome.display());
return ExitCode::SUCCESS;
}
Err(e) => {
@ -1029,11 +1028,11 @@ async fn main() -> ExitCode {
let mut all_results = Vec::new();
let result = match config.suite {
TestSuite::Unit => run_unit_tests(&config).await,
TestSuite::Unit => run_unit_tests(&config),
TestSuite::Integration => run_integration_tests(&config).await,
TestSuite::E2E => run_e2e_tests(&config).await,
TestSuite::All => {
let unit = run_unit_tests(&config).await;
let unit = run_unit_tests(&config);
let integration = run_integration_tests(&config).await;
let e2e = run_e2e_tests(&config).await;

View file

@ -1,6 +1,7 @@
use super::{new_expectation_store, Expectation, ExpectationStore};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fmt::Write;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@ -89,6 +90,7 @@ struct ChatChoice {
}
#[derive(Serialize)]
#[allow(clippy::struct_field_names)]
struct Usage {
prompt_tokens: u32,
completion_tokens: u32,
@ -219,11 +221,13 @@ impl MockLLM {
.unwrap()
.push(expectation.clone());
let mut store = self.expectations.lock().unwrap();
store.insert(
format!("completion:{prompt_contains}"),
Expectation::new(&format!("completion containing '{prompt_contains}'")),
);
{
let mut store = self.expectations.lock().unwrap();
store.insert(
format!("completion:{prompt_contains}"),
Expectation::new(&format!("completion containing '{prompt_contains}'")),
);
}
let response_text = response.to_string();
let model = self.default_model.clone();
@ -253,7 +257,8 @@ impl MockLLM {
let mut template = ResponseTemplate::new(200).set_body_json(&response_body);
if let Some(delay) = *latency.lock().unwrap() {
let latency_value = *latency.lock().unwrap();
if let Some(delay) = latency_value {
template = template.set_delay(delay);
}
@ -303,10 +308,11 @@ impl MockLLM {
finish_reason: None,
}],
};
sse_body.push_str(&format!(
"data: {}\n\n",
let _ = writeln!(
sse_body,
"data: {}\n",
serde_json::to_string(&first_chunk).unwrap()
));
);
for chunk_text in &chunks {
let chunk = StreamChunk {
@ -323,10 +329,11 @@ impl MockLLM {
finish_reason: None,
}],
};
sse_body.push_str(&format!(
"data: {}\n\n",
let _ = writeln!(
sse_body,
"data: {}\n",
serde_json::to_string(&chunk).unwrap()
));
);
}
let final_chunk = StreamChunk {
@ -343,10 +350,11 @@ impl MockLLM {
finish_reason: Some("stop".to_string()),
}],
};
sse_body.push_str(&format!(
"data: {}\n\n",
let _ = writeln!(
sse_body,
"data: {}\n",
serde_json::to_string(&final_chunk).unwrap()
));
);
sse_body.push_str("data: [DONE]\n\n");
let template = ResponseTemplate::new(200)
@ -596,10 +604,7 @@ impl MockLLM {
}
pub async fn call_count(&self) -> usize {
self.server
.received_requests()
.await
.map_or(0, |r| r.len())
self.server.received_requests().await.map_or(0, |r| r.len())
}
pub async fn assert_called_times(&self, expected: usize) {
@ -649,7 +654,7 @@ mod tests {
let response = ChatCompletionResponse {
id: "test-id".to_string(),
object: "chat.completion".to_string(),
created: 1234567890,
created: 1_234_567_890,
model: "gpt-4".to_string(),
choices: vec![ChatChoice {
index: 0,

View file

@ -1,4 +1,3 @@
use super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
@ -22,6 +21,7 @@ pub struct MockTeams {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::struct_field_names)]
pub struct Activity {
#[serde(rename = "type")]
pub activity_type: String,
@ -124,6 +124,7 @@ pub struct Attachment {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::struct_field_names)]
pub struct Entity {
#[serde(rename = "type")]
pub entity_type: String,
@ -318,9 +319,7 @@ impl MockTeams {
sent_activities.lock().unwrap().push(activity.clone());
let response = ResourceResponse {
id: activity.id,
};
let response = ResourceResponse { id: activity.id };
ResponseTemplate::new(200).set_body_json(&response)
})

View file

@ -1,4 +1,3 @@
use super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
@ -213,7 +212,8 @@ pub struct ConversationOrigin {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pricing {
pub billable: bool,
pub pricing_model: String,
#[serde(alias = "pricing_model")]
pub model: String,
pub category: String,
}
@ -354,7 +354,6 @@ impl MockWhatsApp {
async fn setup_default_routes(&self) {
let sent_messages = self.sent_messages.clone();
let _phone_id = self.phone_number_id.clone();
Mock::given(method("POST"))
.and(path_regex(r"/v\d+\.\d+/\d+/messages"))
@ -413,7 +412,6 @@ impl MockWhatsApp {
id: message_id.clone(),
to: to.to_string(),
message_type: match msg_type {
"text" => MessageType::Text,
"template" => MessageType::Template,
"image" => MessageType::Image,
"document" => MessageType::Document,
@ -483,6 +481,7 @@ impl MockWhatsApp {
#[must_use]
pub fn expect_send_message(&self, to: &str) -> MessageExpectation {
let _ = self;
MessageExpectation {
to: to.to_string(),
message_type: None,
@ -492,6 +491,7 @@ impl MockWhatsApp {
#[must_use]
pub fn expect_send_template(&self, name: &str) -> TemplateExpectation {
let _ = self;
TemplateExpectation {
name: name.to_string(),
to: None,
@ -707,7 +707,7 @@ impl MockWhatsApp {
}),
pricing: Some(Pricing {
billable: true,
pricing_model: "CBP".to_string(),
model: "CBP".to_string(),
category: "business_initiated".to_string(),
}),
}]),

View file

@ -1,4 +1,3 @@
use super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
@ -405,10 +404,7 @@ impl MockZitadel {
Mock::given(method("GET"))
.and(path("/oidc/v1/userinfo"))
.and(header(
"authorization",
format!("Bearer {token}").as_str(),
))
.and(header("authorization", format!("Bearer {token}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(&response))
.mount(&self.server)
.await;
@ -663,8 +659,8 @@ mod tests {
client_id: Some("client".to_string()),
username: Some("user@test.com".to_string()),
token_type: Some("Bearer".to_string()),
exp: Some(1234567890),
iat: Some(1234567800),
exp: Some(1_234_567_890),
iat: Some(1_234_567_800),
sub: Some("user-id".to_string()),
aud: Some("audience".to_string()),
iss: Some("issuer".to_string()),

View file

@ -1,4 +1,3 @@
use anyhow::{Context, Result};
use log::{info, warn};
use std::process::{Child, Command, Stdio};
@ -169,6 +168,7 @@ impl BrowserService {
self.port
}
#[allow(clippy::unused_async)]
pub async fn stop(&mut self) -> Result<()> {
if let Some(mut process) = self.process.take() {
info!("Stopping browser");

View file

@ -1,4 +1,3 @@
use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
use anyhow::{Context, Result};
use nix::sys::signal::{kill, Signal};
@ -28,7 +27,10 @@ impl MinioService {
if let Ok(stack_path) = std::env::var("BOTSERVER_STACK_PATH") {
let minio_path = PathBuf::from(&stack_path).join("bin/drive/minio");
if minio_path.exists() {
log::info!("Using MinIO from BOTSERVER_STACK_PATH: {minio_path:?}");
log::info!(
"Using MinIO from BOTSERVER_STACK_PATH: {}",
minio_path.display()
);
return Ok(minio_path);
}
}
@ -43,7 +45,7 @@ impl MinioService {
for rel_path in &relative_paths {
let minio_path = cwd.join(rel_path);
if minio_path.exists() {
log::info!("Using MinIO from botserver-stack: {minio_path:?}");
log::info!("Using MinIO from botserver-stack: {}", minio_path.display());
return Ok(minio_path);
}
}
@ -58,13 +60,13 @@ impl MinioService {
for path in &system_paths {
let minio_path = PathBuf::from(path);
if minio_path.exists() {
log::info!("Using system MinIO from: {minio_path:?}");
log::info!("Using system MinIO from: {}", minio_path.display());
return Ok(minio_path);
}
}
if let Ok(minio_path) = which::which("minio") {
log::info!("Using MinIO from PATH: {minio_path:?}");
log::info!("Using MinIO from PATH: {}", minio_path.display());
return Ok(minio_path);
}
@ -73,7 +75,7 @@ impl MinioService {
pub async fn start(api_port: u16, data_dir: &str) -> Result<Self> {
let bin_path = Self::find_minio_binary()?;
log::info!("Using MinIO from: {bin_path:?}");
log::info!("Using MinIO from: {}", bin_path.display());
let data_path = PathBuf::from(data_dir).join("minio");
ensure_dir(&data_path)?;
@ -90,7 +92,7 @@ impl MinioService {
secret_key: Self::DEFAULT_SECRET_KEY.to_string(),
};
service.start_server().await?;
service.start_server()?;
service.wait_ready().await?;
Ok(service)
@ -103,7 +105,7 @@ impl MinioService {
secret_key: &str,
) -> Result<Self> {
let bin_path = Self::find_minio_binary()?;
log::info!("Using MinIO from: {bin_path:?}");
log::info!("Using MinIO from: {}", bin_path.display());
let data_path = PathBuf::from(data_dir).join("minio");
ensure_dir(&data_path)?;
@ -120,13 +122,13 @@ impl MinioService {
secret_key: secret_key.to_string(),
};
service.start_server().await?;
service.start_server()?;
service.wait_ready().await?;
Ok(service)
}
async fn start_server(&mut self) -> Result<()> {
fn start_server(&mut self) -> Result<()> {
log::info!(
"Starting MinIO on port {} (console: {})",
self.api_port,
@ -192,11 +194,7 @@ impl MinioService {
.output();
let output = Command::new(&mc)
.args([
"mb",
"--ignore-existing",
&format!("{alias_name}/{name}"),
])
.args(["mb", "--ignore-existing", &format!("{alias_name}/{name}")])
.output()?;
if !output.status.success() {

View file

@ -1,4 +1,3 @@
use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
use anyhow::{Context, Result};
use nix::sys::signal::{kill, Signal};
@ -50,6 +49,7 @@ impl RedisService {
Ok(service)
}
#[allow(clippy::unused_async)]
async fn start_server(&mut self) -> Result<()> {
log::info!("Starting Redis on port {}", self.port);
@ -125,6 +125,7 @@ impl RedisService {
Ok(())
}
#[allow(clippy::unused_async)]
pub async fn execute(&self, args: &[&str]) -> Result<String> {
let redis_cli = Self::find_cli_binary()?;
@ -182,7 +183,10 @@ impl RedisService {
if result.is_empty() || result == "(empty list or set)" {
Ok(Vec::new())
} else {
Ok(result.lines().map(std::string::ToString::to_string).collect())
Ok(result
.lines()
.map(std::string::ToString::to_string)
.collect())
}
}
@ -193,10 +197,7 @@ impl RedisService {
pub async fn publish(&self, channel: &str, message: &str) -> Result<i64> {
let result = self.execute(&["PUBLISH", channel, message]).await?;
let count = result
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
let count = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
Ok(count)
}
@ -230,10 +231,7 @@ impl RedisService {
pub async fn llen(&self, key: &str) -> Result<i64> {
let result = self.execute(&["LLEN", key]).await?;
let len = result
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
let len = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
Ok(len)
}
@ -271,19 +269,13 @@ impl RedisService {
pub async fn incr(&self, key: &str) -> Result<i64> {
let result = self.execute(&["INCR", key]).await?;
let val = result
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
let val = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
Ok(val)
}
pub async fn decr(&self, key: &str) -> Result<i64> {
let result = self.execute(&["DECR", key]).await?;
let val = result
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
let val = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
Ok(val)
}

View file

@ -1,4 +1,3 @@
pub mod browser;
pub mod pages;
@ -108,6 +107,7 @@ impl Locator {
}
#[must_use]
#[allow(clippy::match_same_arms)]
pub fn to_css_selector(&self) -> String {
match self {
Self::Css(s) => s.clone(),

View file

@ -156,11 +156,9 @@ async fn perform_logout(browser: &Browser, base_url: &str) -> Result<bool, Strin
for selector in &logout_selectors {
let locator = Locator::css(selector);
if browser.exists(locator.clone()).await {
if browser.click(locator).await.is_ok() {
tokio::time::sleep(Duration::from_secs(1)).await;
break;
}
if browser.exists(locator.clone()).await && browser.click(locator).await.is_ok() {
tokio::time::sleep(Duration::from_secs(1)).await;
break;
}
}
@ -171,20 +169,19 @@ async fn perform_logout(browser: &Browser, base_url: &str) -> Result<bool, Strin
for selector in &logout_selectors {
let locator = Locator::css(selector);
if browser.exists(locator.clone()).await {
if browser.click(locator).await.is_ok() {
tokio::time::sleep(Duration::from_secs(1)).await;
break;
}
if browser.exists(locator.clone()).await && browser.click(locator).await.is_ok() {
tokio::time::sleep(Duration::from_secs(1)).await;
break;
}
}
}
let current_url = browser.current_url().await.unwrap_or_default();
let base_url_with_slash = format!("{base_url}/");
let logged_out = current_url.contains("/login")
|| current_url.contains("/logout")
|| current_url == format!("{}/", base_url)
|| current_url == base_url.to_string();
|| current_url == base_url_with_slash
|| current_url == base_url;
if logged_out {
return Ok(true);
@ -442,10 +439,10 @@ async fn test_session_persistence() {
tokio::time::sleep(Duration::from_secs(1)).await;
let current_url = browser.current_url().await.unwrap_or_default();
if !current_url.contains("/login") {
println!("✓ Session persisted after page refresh");
} else {
if current_url.contains("/login") {
eprintln!("✗ Session lost after refresh");
} else {
println!("✓ Session persisted after page refresh");
}
}
@ -454,10 +451,10 @@ async fn test_session_persistence() {
tokio::time::sleep(Duration::from_secs(1)).await;
let current_url = browser.current_url().await.unwrap_or_default();
if !current_url.contains("/login") {
println!("✓ Session maintained across navigation");
} else {
if current_url.contains("/login") {
eprintln!("✗ Session lost during navigation");
} else {
println!("✓ Session maintained across navigation");
}
}
} else {

View file

@ -1,41 +1,36 @@
use super::{should_run_e2e_tests, E2ETestContext};
use anyhow::{bail, Result};
use bottest::prelude::*;
use bottest::web::Locator;
#[tokio::test]
async fn test_chat_hi() {
async fn test_chat_hi() -> Result<()> {
if !should_run_e2e_tests() {
eprintln!("Skipping: E2E tests disabled");
return;
return Ok(());
}
let ctx = match E2ETestContext::setup_with_browser().await {
Ok(ctx) => ctx,
Err(e) => {
eprintln!("Test failed: {}", e);
panic!("Failed to setup E2E context: {}", e);
}
};
let ctx = E2ETestContext::setup_with_browser().await?;
if !ctx.has_browser() {
ctx.close().await;
panic!("Browser not available - cannot run E2E test");
bail!("Browser not available - cannot run E2E test");
}
if ctx.ui.is_none() {
ctx.close().await;
panic!("BotUI not available - chat tests require botui running on port 3000");
bail!("BotUI not available - chat tests require botui running on port 3000");
}
let browser = ctx.browser.as_ref().unwrap();
let ui_url = ctx.ui.as_ref().unwrap().url.clone();
let chat_url = format!("{}/#chat", ui_url);
println!("🌐 Navigating to: {}", chat_url);
println!("🌐 Navigating to: {chat_url}");
if let Err(e) = browser.goto(&chat_url).await {
ctx.close().await;
panic!("Failed to navigate to chat: {}", e);
bail!("Failed to navigate to chat: {e}");
}
println!("⏳ Waiting for page to load...");
@ -47,10 +42,10 @@ async fn test_chat_hi() {
for attempt in 1..=10 {
if browser.exists(input.clone()).await {
found_input = true;
println!("✓ Chat input found (attempt {})", attempt);
println!("✓ Chat input found (attempt {attempt})");
break;
}
println!(" ... waiting for chat input (attempt {}/10)", attempt);
println!(" ... waiting for chat input (attempt {attempt}/10)");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
@ -61,27 +56,25 @@ async fn test_chat_hi() {
}
if let Ok(source) = browser.page_source().await {
let preview: String = source.chars().take(2000).collect();
println!("Page source preview:\n{}", preview);
println!("Page source preview:\n{preview}");
}
ctx.close().await;
panic!("Chat input not found after 10 attempts");
bail!("Chat input not found after 10 attempts");
}
println!("⌨️ Typing 'hi'...");
if let Err(e) = browser.type_text(input.clone(), "hi").await {
ctx.close().await;
panic!("Failed to type: {}", e);
bail!("Failed to type: {e}");
}
let send_btn = Locator::css("#sendBtn, #ai-send, .ai-send, button[type='submit']");
match browser.click(send_btn).await {
Ok(_) => println!("✓ Message sent (click)"),
Err(_) => {
match browser.press_key(input, "Enter").await {
Ok(_) => println!("✓ Message sent (Enter key)"),
Err(e) => println!("⚠ Send may have failed: {}", e),
}
}
Ok(()) => println!("✓ Message sent (click)"),
Err(_) => match browser.press_key(input, "Enter").await {
Ok(()) => println!("✓ Message sent (Enter key)"),
Err(e) => println!("⚠ Send may have failed: {e}"),
},
}
println!("⏳ Waiting for bot response...");
@ -105,29 +98,25 @@ async fn test_chat_hi() {
ctx.close().await;
println!("✅ Chat test complete!");
Ok(())
}
#[tokio::test]
async fn test_chat_page_loads() {
async fn test_chat_page_loads() -> Result<()> {
if !should_run_e2e_tests() {
return;
return Ok(());
}
let ctx = match E2ETestContext::setup_with_browser().await {
Ok(ctx) => ctx,
Err(e) => {
panic!("Setup failed: {}", e);
}
};
let ctx = E2ETestContext::setup_with_browser().await?;
if !ctx.has_browser() {
ctx.close().await;
panic!("Browser not available");
bail!("Browser not available");
}
if ctx.ui.is_none() {
ctx.close().await;
panic!("BotUI not available - chat tests require botui. Start it with: cd ../botui && cargo run");
bail!("BotUI not available - chat tests require botui. Start it with: cd ../botui && cargo run");
}
let browser = ctx.browser.as_ref().unwrap();
@ -136,7 +125,7 @@ async fn test_chat_page_loads() {
if let Err(e) = browser.goto(&chat_url).await {
ctx.close().await;
panic!("Navigation failed: {}", e);
bail!("Navigation failed: {e}");
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
@ -149,9 +138,10 @@ async fn test_chat_page_loads() {
let _ = std::fs::write("/tmp/bottest-fail.png", &s);
}
ctx.close().await;
panic!("Chat not loaded: {}", e);
bail!("Chat not loaded: {e}");
}
}
ctx.close().await;
Ok(())
}

View file

@ -773,7 +773,10 @@ async fn test_with_fixtures() {
match ctx.ctx.insert_user(&user).await {
Ok(_) => println!("Inserted test user: {}", user.email),
Err(e) => eprintln!("Could not insert user (DB may not be directly accessible): {}", e),
Err(e) => eprintln!(
"Could not insert user (DB may not be directly accessible): {}",
e
),
}
match ctx.ctx.insert_bot(&bot).await {
@ -822,12 +825,10 @@ async fn test_mock_services_available() {
Ok(_pool) => println!("✓ Connected to existing PostgreSQL"),
Err(e) => eprintln!("Could not connect to existing PostgreSQL: {}", e),
}
} else if ctx.ctx.postgres().is_some() {
println!("✓ PostgreSQL is managed by harness");
} else {
if ctx.ctx.postgres().is_some() {
println!("✓ PostgreSQL is managed by harness");
} else {
eprintln!("PostgreSQL should be started in fresh stack mode");
}
eprintln!("PostgreSQL should be started in fresh stack mode");
}
ctx.close().await;

View file

@ -23,7 +23,7 @@ async fn is_service_running(url: &str) -> bool {
.build()
.unwrap_or_default();
if let Ok(resp) = client.get(&format!("{}/health", url)).send().await {
if let Ok(resp) = client.get(format!("{url}/health")).send().await {
if resp.status().is_success() {
return true;
}

View file

@ -36,25 +36,6 @@ async fn get_test_server() -> Option<(Option<TestContext>, String)> {
}
}
fn is_server_available_sync() -> bool {
if std::env::var("SKIP_INTEGRATION_TESTS").is_ok() {
return false;
}
if let Some(url) = external_server_url() {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.ok();
if let Some(client) = client {
return client.get(&url).send().is_ok();
}
}
false
}
macro_rules! skip_if_no_server {
($base_url:expr) => {
if $base_url.is_none() {
@ -658,7 +639,7 @@ async fn test_mock_llm_assertions() {
let client = reqwest::Client::new();
let _ = client
.post(&format!("{}/v1/chat/completions", mock_llm.url()))
.post(format!("{}/v1/chat/completions", mock_llm.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "test"}]
@ -685,7 +666,7 @@ async fn test_mock_llm_error_simulation() {
let client = reqwest::Client::new();
let response = client
.post(&format!("{}/v1/chat/completions", mock_llm.url()))
.post(format!("{}/v1/chat/completions", mock_llm.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "test"}]

View file

@ -1,7 +1,6 @@
use rhai::Engine;
use std::sync::{Arc, Mutex};
fn create_basic_engine() -> Engine {
let mut engine = Engine::new();
@ -135,9 +134,8 @@ impl InputProvider {
fn create_conversation_engine(output: OutputCollector, input: InputProvider) -> Engine {
let mut engine = create_basic_engine();
let output_clone = output.clone();
engine.register_fn("TALK", move |msg: &str| {
output_clone.add_message(msg.to_string());
output.add_message(msg.to_string());
});
engine.register_fn("HEAR", move || -> String { input.next_input() });
@ -145,7 +143,6 @@ fn create_conversation_engine(output: OutputCollector, input: InputProvider) ->
engine
}
#[test]
fn test_string_concatenation_in_engine() {
let engine = create_basic_engine();
@ -208,7 +205,6 @@ fn test_replace_function() {
assert_eq!(result, "bbb");
}
#[test]
fn test_math_operations_chain() {
let engine = create_basic_engine();
@ -264,14 +260,13 @@ fn test_val_function() {
let result: f64 = engine.eval(r#"VAL("42")"#).unwrap();
assert!((result - 42.0).abs() < f64::EPSILON);
let result: f64 = engine.eval(r#"VAL("3.14")"#).unwrap();
assert!((result - 3.14).abs() < f64::EPSILON);
let result: f64 = engine.eval(r#"VAL("3.5")"#).unwrap();
assert!((result - 3.5).abs() < f64::EPSILON);
let result: f64 = engine.eval(r#"VAL("invalid")"#).unwrap();
assert!((result - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_talk_output() {
let output = OutputCollector::new();
@ -393,19 +388,18 @@ fn test_keyword_detection() {
assert_eq!(messages[0], "I can help you! What do you need?");
}
#[test]
fn test_variable_assignment() {
let engine = create_basic_engine();
let result: i64 = engine
.eval(
r#"
r"
let x = 10;
let y = 20;
let z = x + y;
z
"#,
",
)
.unwrap();
assert_eq!(result, 30);
@ -442,7 +436,6 @@ fn test_numeric_expressions() {
assert_eq!(result, 12);
}
#[test]
fn test_for_loop() {
let output = OutputCollector::new();
@ -472,7 +465,7 @@ fn test_while_loop() {
let result: i64 = engine
.eval(
r#"
r"
let count = 0;
let sum = 0;
while count < 5 {
@ -480,21 +473,19 @@ fn test_while_loop() {
count = count + 1;
}
sum
"#,
",
)
.unwrap();
assert_eq!(result, 10);
}
#[test]
fn test_division_by_zero() {
let engine = create_basic_engine();
let result = engine.eval::<f64>("10.0 / 0.0");
match result {
Ok(val) => assert!(val.is_infinite() || val.is_nan()),
Err(_) => (),
if let Ok(val) = result {
assert!(val.is_infinite() || val.is_nan());
}
}
@ -514,7 +505,6 @@ fn test_type_mismatch() {
assert!(result.is_err());
}
#[test]
fn test_greeting_script_logic() {
let output = OutputCollector::new();
@ -608,7 +598,6 @@ fn test_echo_bot_logic() {
assert_eq!(messages[2], "You said: How are you?");
}
#[test]
fn test_order_lookup_simulation() {
let output = OutputCollector::new();

View file

@ -154,37 +154,37 @@ async fn test_query_result_types() {
use diesel::sql_query;
#[derive(QueryableByName)]
struct TypeTestResult {
struct TypeTestRow {
#[diesel(sql_type = diesel::sql_types::Integer)]
int_val: i32,
integer: i32,
#[diesel(sql_type = diesel::sql_types::BigInt)]
bigint_val: i64,
bigint: i64,
#[diesel(sql_type = diesel::sql_types::Text)]
text_val: String,
text: String,
#[diesel(sql_type = diesel::sql_types::Bool)]
bool_val: bool,
flag: bool,
#[diesel(sql_type = diesel::sql_types::Double)]
float_val: f64,
decimal: f64,
}
let mut conn = pool.get().expect("Failed to get connection");
let result: Vec<TypeTestResult> = sql_query(
let result: Vec<TypeTestRow> = sql_query(
"SELECT
42 as int_val,
9223372036854775807::bigint as bigint_val,
'hello' as text_val,
true as bool_val,
3.125 as float_val",
42 as integer,
9223372036854775807::bigint as bigint,
'hello' as text,
true as flag,
3.125 as decimal",
)
.load(&mut conn)
.expect("Query failed");
assert_eq!(result.len(), 1);
assert_eq!(result[0].int_val, 42);
assert_eq!(result[0].bigint_val, 9223372036854775807_i64);
assert_eq!(result[0].text_val, "hello");
assert!(result[0].bool_val);
assert!((result[0].float_val - 3.125).abs() < 0.0001);
assert_eq!(result[0].integer, 42);
assert_eq!(result[0].bigint, 9_223_372_036_854_775_807_i64);
assert_eq!(result[0].text, "hello");
assert!(result[0].flag);
assert!((result[0].decimal - 3.125).abs() < 0.0001);
}
#[tokio::test]

View file

@ -29,16 +29,6 @@ pub fn should_run_integration_tests() -> bool {
true
}
#[macro_export]
macro_rules! skip_if_no_services {
() => {
if !crate::integration::should_run_integration_tests() {
eprintln!("Skipping integration test: SKIP_INTEGRATION_TESTS is set");
return;
}
};
}
#[tokio::test]
async fn test_harness_database_only() {
if !should_run_integration_tests() {

View file

@ -1,6 +1,5 @@
#[test]
fn test_unit_module_loads() {
// Unit tests are now inline in botserver source files
// This module is kept for integration test infrastructure
assert!(true);
let module_name = module_path!();
assert!(module_name.contains("unit"));
}