From 9f7844580d5765d6288db807a2827954fa9f84fc Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sun, 7 Dec 2025 02:14:37 -0300 Subject: [PATCH] refactor: improve test harness and browser automation - Update harness.rs, main.rs, ports.rs - Add chromedriver service module - Update browser automation in web/browser.rs - Update e2e test module --- .gitignore | 1 + Cargo.lock | 112 ++++++++++++++- Cargo.toml | 1 + src/harness.rs | 238 ++++++++++++++++++++++++++---- src/main.rs | 24 ++-- src/ports.rs | 36 +++-- src/services/chromedriver.rs | 271 +++++++++++++++++++++++++++++++++++ src/services/mod.rs | 2 + src/web/browser.rs | 14 ++ tests/e2e/mod.rs | 79 +++++++--- 10 files changed, 705 insertions(+), 73 deletions(-) create mode 100644 src/services/chromedriver.rs diff --git a/.gitignore b/.gitignore index 0557dd7..88a73f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +tmp .tmp* .tmp/* *.log diff --git a/Cargo.lock b/Cargo.lock index 2f3e92a..3176fa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1038,9 +1038,9 @@ dependencies = [ "rhai", "ring", "rust_xlsxwriter", - "rustls 0.21.12", + "rustls 0.23.35", "rustls-native-certs 0.6.3", - "rustls-pemfile 1.0.4", + "rustls-pemfile 2.2.0", "scopeguard", "serde", "serde_json", @@ -1050,7 +1050,7 @@ dependencies = [ "thiserror 2.0.17", "time", "tokio", - "tokio-rustls 0.24.1", + "tokio-rustls 0.26.4", "tokio-stream", "toml 0.8.23", "tower 0.4.13", @@ -1080,6 +1080,7 @@ dependencies = [ "cookie 0.18.1", "diesel", "diesel_migrations", + "dirs", "dotenvy", "env_logger", "fantoccini", @@ -1800,6 +1801,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -3412,6 +3434,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "outref" version = "0.5.2" @@ -3930,6 +3958,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.12.2" @@ -4175,6 +4214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -5680,6 +5720,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5716,6 +5765,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5749,6 +5813,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5761,6 +5831,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5773,6 +5849,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5797,6 +5879,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5809,6 +5897,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5821,6 +5915,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5833,6 +5933,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index cdd9ab9..28e126a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ which = "7" regex = "1.11" base64 = "0.22" url = "2.5" +dirs = "5.0" # Process management for services nix = { version = "0.29", features = ["signal", "process"] } diff --git a/src/harness.rs b/src/harness.rs index d25c03d..f92b49b 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -1,6 +1,6 @@ use crate::fixtures::{Bot, Customer, Message, QueueEntry, Session, User}; use crate::mocks::{MockLLM, MockZitadel}; -use crate::ports::TestPorts; +use crate::ports::{PortAllocator, TestPorts}; use crate::services::{MinioService, PostgresService, RedisService}; use anyhow::Result; use diesel::r2d2::{ConnectionManager, Pool}; @@ -53,7 +53,7 @@ impl TestConfig { redis: false, // Botserver will bootstrap its own Redis mock_zitadel: true, mock_llm: true, - run_migrations: true, + run_migrations: false, // Let botserver run its own migrations } } @@ -64,12 +64,34 @@ impl TestConfig { ..Self::minimal() } } + + pub fn use_existing_stack() -> Self { + Self { + postgres: false, + minio: false, + redis: false, + mock_zitadel: true, + mock_llm: true, + run_migrations: false, + } + } +} + +pub struct DefaultPorts; + +impl DefaultPorts { + pub const POSTGRES: u16 = 5432; + pub const MINIO: u16 = 9000; + pub const REDIS: u16 = 6379; + pub const ZITADEL: u16 = 8080; + pub const BOTSERVER: u16 = 8080; } pub struct TestContext { pub ports: TestPorts, pub config: TestConfig, pub data_dir: PathBuf, + pub use_existing_stack: bool, test_id: Uuid, postgres: Option, minio: Option, @@ -86,22 +108,43 @@ impl TestContext { } pub fn database_url(&self) -> String { - format!( - "postgres://bottest:bottest@127.0.0.1:{}/bottest", - self.ports.postgres - ) + if self.use_existing_stack { + std::env::var("DATABASE_URL").unwrap_or_else(|_| { + format!( + "postgres://gbuser:gbpassword@127.0.0.1:{}/botserver", + DefaultPorts::POSTGRES + ) + }) + } else { + format!( + "postgres://bottest:bottest@127.0.0.1:{}/bottest", + self.ports.postgres + ) + } } pub fn minio_endpoint(&self) -> String { - format!("http://127.0.0.1:{}", self.ports.minio) + if self.use_existing_stack { + format!("http://127.0.0.1:{}", DefaultPorts::MINIO) + } else { + format!("http://127.0.0.1:{}", self.ports.minio) + } } pub fn redis_url(&self) -> String { - format!("redis://127.0.0.1:{}", self.ports.redis) + if self.use_existing_stack { + format!("redis://127.0.0.1:{}", DefaultPorts::REDIS) + } else { + format!("redis://127.0.0.1:{}", self.ports.redis) + } } pub fn zitadel_url(&self) -> String { - format!("http://127.0.0.1:{}", self.ports.mock_zitadel) + if self.use_existing_stack { + format!("https://127.0.0.1:{}", DefaultPorts::ZITADEL) + } else { + format!("http://127.0.0.1:{}", self.ports.mock_zitadel) + } } pub fn llm_url(&self) -> String { @@ -382,6 +425,7 @@ pub struct BotServerInstance { } impl BotServerInstance { + /// Start botserver, creating a fresh stack from scratch for testing pub async fn start(ctx: &TestContext) -> Result { let port = ctx.ports.botserver; let url = format!("http://127.0.0.1:{}", port); @@ -391,30 +435,60 @@ impl BotServerInstance { std::fs::create_dir_all(&stack_path)?; log::info!("Created clean test stack at: {:?}", stack_path); - let botserver_bin = - std::env::var("BOTSERVER_BIN").unwrap_or_else(|_| "botserver".to_string()); + // Create config directories so botserver thinks services are configured + Self::setup_test_stack_config(&stack_path, ctx)?; - // Pass --stack-path so botserver bootstraps into our clean test directory + let botserver_bin = std::env::var("BOTSERVER_BIN") + .unwrap_or_else(|_| "../botserver/target/release/botserver".to_string()); + + // Check if binary exists + if !PathBuf::from(&botserver_bin).exists() { + log::warn!("Botserver binary not found at: {}", botserver_bin); + return Ok(Self { + url, + port, + stack_path, + process: None, + }); + } + + log::info!("Starting botserver from: {}", botserver_bin); + + // Start botserver with test configuration + // - Uses test harness PostgreSQL + // - Uses mock Zitadel for auth + // - Uses mock LLM + // Env vars align with SecretsManager fallbacks (see botserver/src/core/secrets/mod.rs) let process = std::process::Command::new(&botserver_bin) .arg("--stack-path") .arg(&stack_path) .arg("--port") .arg(port.to_string()) - .arg("--database-url") - .arg(ctx.database_url()) - .env("ZITADEL_URL", ctx.zitadel_url()) - .env("LLM_URL", ctx.llm_url()) - .env("MINIO_ENDPOINT", ctx.minio_endpoint()) - .env("REDIS_URL", ctx.redis_url()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) + .arg("--noconsole") + .env_remove("RUST_LOG") // Remove to avoid logger conflict + // Database - DATABASE_URL is the standard fallback + .env("DATABASE_URL", ctx.database_url()) + // Directory (Zitadel) - use SecretsManager fallback env vars + .env("DIRECTORY_URL", ctx.zitadel_url()) + .env("ZITADEL_CLIENT_ID", "test-client-id") + .env("ZITADEL_CLIENT_SECRET", "test-client-secret") + // Drive (MinIO) - use SecretsManager fallback env vars + .env("DRIVE_ACCESSKEY", "minioadmin") + .env("DRIVE_SECRET", "minioadmin") + // Skip service installation during tests + .env("BOTSERVER_SKIP_INSTALL", "1") + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) .spawn() .ok(); if process.is_some() { - for _ in 0..50 { + log::info!("Waiting for botserver to bootstrap and become ready..."); + // Give more time for botserver to bootstrap services + for i in 0..120 { if let Ok(resp) = reqwest::get(&format!("{}/health", url)).await { if resp.status().is_success() { + log::info!("Botserver is ready on port {}", port); return Ok(Self { url, port, @@ -423,8 +497,12 @@ impl BotServerInstance { }); } } - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + if i % 10 == 0 { + log::info!("Still waiting for botserver... ({}s)", i); + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; } + log::warn!("Botserver did not respond to health check in time"); } Ok(Self { @@ -438,6 +516,88 @@ impl BotServerInstance { pub fn is_running(&self) -> bool { self.process.is_some() } + + /// Setup minimal config files so botserver thinks services are configured + fn setup_test_stack_config(stack_path: &PathBuf, ctx: &TestContext) -> Result<()> { + // Create directory config path + let directory_conf = stack_path.join("conf/directory"); + std::fs::create_dir_all(&directory_conf)?; + + // Create zitadel.yaml pointing to our mock Zitadel + let zitadel_config = format!( + r#"Log: + Level: info + +Database: + postgres: + Host: 127.0.0.1 + Port: {} + Database: bottest + User: bottest + Password: "bottest" + SSL: + Mode: disable + +ExternalSecure: false +ExternalDomain: localhost +ExternalPort: {} +"#, + ctx.ports.postgres, ctx.ports.mock_zitadel + ); + + std::fs::write(directory_conf.join("zitadel.yaml"), zitadel_config)?; + log::info!("Created test zitadel.yaml config"); + + // Create system certificates directory + let certs_dir = stack_path.join("conf/system/certificates"); + std::fs::create_dir_all(&certs_dir)?; + + // Generate minimal self-signed certificates for API + Self::generate_test_certificates(&certs_dir)?; + + Ok(()) + } + + /// Generate minimal test certificates + fn generate_test_certificates(certs_dir: &PathBuf) -> Result<()> { + use std::process::Command; + + let api_dir = certs_dir.join("api"); + std::fs::create_dir_all(&api_dir)?; + + // Check if openssl is available + let openssl_check = Command::new("which").arg("openssl").output(); + if openssl_check.map(|o| o.status.success()).unwrap_or(false) { + // Generate self-signed certificate using openssl + let key_path = api_dir.join("server.key"); + let cert_path = api_dir.join("server.crt"); + + if !key_path.exists() { + let _ = Command::new("openssl") + .args([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + key_path.to_str().unwrap(), + "-out", + cert_path.to_str().unwrap(), + "-days", + "1", + "-nodes", + "-subj", + "/CN=localhost", + ]) + .output(); + log::info!("Generated test TLS certificates"); + } + } else { + log::warn!("openssl not found, skipping certificate generation"); + } + + Ok(()) + } } impl Drop for BotServerInstance { @@ -453,6 +613,14 @@ pub struct TestHarness; impl TestHarness { pub async fn setup(config: TestConfig) -> Result { + Self::setup_internal(config, false).await + } + + pub async fn with_existing_stack() -> Result { + Self::setup_internal(TestConfig::use_existing_stack(), true).await + } + + async fn setup_internal(config: TestConfig, use_existing_stack: bool) -> Result { let _ = env_logger::builder().is_test(true).try_init(); let test_id = Uuid::new_v4(); @@ -460,12 +628,25 @@ impl TestHarness { std::fs::create_dir_all(&data_dir)?; - let ports = TestPorts::allocate(); + let ports = if use_existing_stack { + TestPorts { + postgres: DefaultPorts::POSTGRES, + minio: DefaultPorts::MINIO, + redis: DefaultPorts::REDIS, + botserver: PortAllocator::allocate(), + mock_zitadel: PortAllocator::allocate(), + mock_llm: PortAllocator::allocate(), + } + } else { + TestPorts::allocate() + }; + log::info!( - "Test {} allocated ports: {:?}, data_dir: {:?}", + "Test {} allocated ports: {:?}, data_dir: {:?}, use_existing_stack: {}", test_id, ports, - data_dir + data_dir, + use_existing_stack ); let data_dir_str = data_dir.to_str().unwrap().to_string(); @@ -474,6 +655,7 @@ impl TestHarness { ports, config: config.clone(), data_dir, + use_existing_stack, test_id, postgres: None, minio: None, @@ -524,7 +706,11 @@ impl TestHarness { } pub async fn full() -> Result { - Self::setup(TestConfig::full()).await + if std::env::var("USE_EXISTING_STACK").is_ok() { + Self::with_existing_stack().await + } else { + Self::setup(TestConfig::full()).await + } } pub async fn minimal() -> Result { diff --git a/src/main.rs b/src/main.rs index 9b80769..5e99c7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -745,17 +745,15 @@ async fn run_integration_tests(config: &RunnerConfig) -> Result { let filter = config.filter.as_deref(); let db_url = ctx.database_url(); - let zitadel_url = ctx.zitadel_url(); - let llm_url = ctx.llm_url(); - let minio_endpoint = ctx.minio_endpoint(); - let redis_url = ctx.redis_url(); + let directory_url = ctx.zitadel_url(); let env_vars: Vec<(&str, &str)> = vec![ ("DATABASE_URL", &db_url), - ("ZITADEL_URL", &zitadel_url), - ("LLM_URL", &llm_url), - ("MINIO_ENDPOINT", &minio_endpoint), - ("REDIS_URL", &redis_url), + ("DIRECTORY_URL", &directory_url), + ("ZITADEL_CLIENT_ID", "test-client-id"), + ("ZITADEL_CLIENT_SECRET", "test-client-secret"), + ("DRIVE_ACCESSKEY", "minioadmin"), + ("DRIVE_SECRET", "minioadmin"), ]; match run_cargo_test( @@ -907,16 +905,18 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result { let filter = config.filter.as_deref(); let headed = if config.headed { "1" } else { "" }; let db_url = ctx.database_url(); - let zitadel_url = ctx.zitadel_url(); - let llm_url = ctx.llm_url(); + let directory_url = ctx.zitadel_url(); let server_url = server.url.clone(); let chrome_binary = chrome_path.to_string_lossy().to_string(); let webdriver_url = format!("http://localhost:{}", webdriver_port); let env_vars: Vec<(&str, &str)> = vec![ ("DATABASE_URL", &db_url), - ("ZITADEL_URL", &zitadel_url), - ("LLM_URL", &llm_url), + ("DIRECTORY_URL", &directory_url), + ("ZITADEL_CLIENT_ID", "test-client-id"), + ("ZITADEL_CLIENT_SECRET", "test-client-secret"), + ("DRIVE_ACCESSKEY", "minioadmin"), + ("DRIVE_SECRET", "minioadmin"), ("BOTSERVER_URL", &server_url), ("HEADED", headed), ("CHROME_BINARY", &chrome_binary), diff --git a/src/ports.rs b/src/ports.rs index d1ba15d..dd22bfc 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -2,8 +2,8 @@ //! //! Ensures each test gets unique ports to avoid conflicts -use std::sync::atomic::{AtomicU16, Ordering}; use std::collections::HashSet; +use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::Mutex; static PORT_COUNTER: AtomicU16 = AtomicU16::new(15000); @@ -19,7 +19,7 @@ impl PortAllocator { PORT_COUNTER.store(15000, Ordering::SeqCst); continue; } - + if Self::is_available(port) { let mut guard = ALLOCATED_PORTS.lock().unwrap(); let set = guard.get_or_insert_with(HashSet::new); @@ -28,18 +28,18 @@ impl PortAllocator { } } } - + pub fn allocate_range(count: usize) -> Vec { (0..count).map(|_| Self::allocate()).collect() } - + pub fn release(port: u16) { let mut guard = ALLOCATED_PORTS.lock().unwrap(); if let Some(set) = guard.as_mut() { set.remove(&port); } } - + fn is_available(port: u16) -> bool { use std::net::TcpListener; TcpListener::bind(("127.0.0.1", port)).is_ok() @@ -71,12 +71,26 @@ impl TestPorts { impl Drop for TestPorts { fn drop(&mut self) { - PortAllocator::release(self.postgres); - PortAllocator::release(self.minio); - PortAllocator::release(self.redis); - PortAllocator::release(self.botserver); - PortAllocator::release(self.mock_zitadel); - PortAllocator::release(self.mock_llm); + // Only release dynamically allocated ports (>= 15000) + // Fixed ports from existing stack should not be released + if self.postgres >= 15000 { + PortAllocator::release(self.postgres); + } + if self.minio >= 15000 { + PortAllocator::release(self.minio); + } + if self.redis >= 15000 { + PortAllocator::release(self.redis); + } + if self.botserver >= 15000 { + PortAllocator::release(self.botserver); + } + if self.mock_zitadel >= 15000 { + PortAllocator::release(self.mock_zitadel); + } + if self.mock_llm >= 15000 { + PortAllocator::release(self.mock_llm); + } } } diff --git a/src/services/chromedriver.rs b/src/services/chromedriver.rs new file mode 100644 index 0000000..08bf6a8 --- /dev/null +++ b/src/services/chromedriver.rs @@ -0,0 +1,271 @@ +use anyhow::{Context, Result}; +use log::{debug, info, warn}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use tokio::time::{sleep, Duration}; + +pub struct ChromeDriverService { + port: u16, + process: Option, + binary_path: PathBuf, +} + +impl ChromeDriverService { + pub async fn start(port: u16) -> Result { + let binary_path = Self::ensure_chromedriver().await?; + + info!("Starting ChromeDriver on port {}", port); + + let process = Command::new(&binary_path) + .arg(format!("--port={}", port)) + .arg("--allowed-ips=") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("Failed to start chromedriver")?; + + let mut service = Self { + port, + process: Some(process), + binary_path, + }; + + for i in 0..30 { + sleep(Duration::from_millis(100)).await; + if service.is_ready().await { + info!("ChromeDriver ready on port {}", port); + return Ok(service); + } + debug!("Waiting for ChromeDriver... attempt {}/30", i + 1); + } + + warn!("ChromeDriver may not be fully ready"); + Ok(service) + } + + async fn is_ready(&self) -> bool { + let url = format!("http://localhost:{}/status", self.port); + match reqwest::get(&url).await { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } + } + + async fn ensure_chromedriver() -> Result { + let cache_dir = dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("bottest") + .join("chromedriver"); + + std::fs::create_dir_all(&cache_dir)?; + + let browser_version = Self::detect_browser_version().await?; + let major_version = browser_version + .split('.') + .next() + .unwrap_or("136") + .to_string(); + + info!("Detected browser version: {}", browser_version); + + let chromedriver_path = cache_dir.join(format!("chromedriver-{}", major_version)); + + if chromedriver_path.exists() { + info!("Using cached chromedriver for version {}", major_version); + return Ok(chromedriver_path); + } + + info!("Downloading chromedriver for version {}", major_version); + Self::download_chromedriver(&major_version, &chromedriver_path).await?; + + Ok(chromedriver_path) + } + + async fn detect_browser_version() -> Result { + let browsers = [ + ("brave-browser", "--version"), + ("brave", "--version"), + ("google-chrome", "--version"), + ("chromium-browser", "--version"), + ("chromium", "--version"), + ]; + + for (browser, arg) in browsers { + if let Ok(output) = Command::new(browser).arg(arg).output() { + if output.status.success() { + let version_str = String::from_utf8_lossy(&output.stdout); + if let Some(version) = Self::extract_version(&version_str) { + return Ok(version); + } + } + } + } + + info!("No browser detected, using default chromedriver version 136"); + Ok("136.0.7103.113".to_string()) + } + + fn extract_version(output: &str) -> Option { + let re = regex::Regex::new(r"(\d+\.\d+\.\d+\.\d+)").ok()?; + re.captures(output) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_string()) + } + + async fn download_chromedriver(major_version: &str, dest: &PathBuf) -> Result<()> { + let version_url = format!( + "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_{}", + major_version + ); + + let full_version = match reqwest::get(&version_url).await { + Ok(resp) if resp.status().is_success() => resp.text().await?.trim().to_string(), + _ => { + warn!( + "Could not find exact version for {}, trying known versions", + major_version + ); + Self::get_known_version(major_version) + } + }; + + info!("Downloading chromedriver version {}", full_version); + + let download_url = format!( + "https://storage.googleapis.com/chrome-for-testing-public/{}/linux64/chromedriver-linux64.zip", + full_version + ); + + let tmp_zip = dest.with_extension("zip"); + + let response = reqwest::get(&download_url) + .await + .context("Failed to download chromedriver")?; + + if !response.status().is_success() { + anyhow::bail!( + "Failed to download chromedriver: HTTP {}", + response.status() + ); + } + + let bytes = response.bytes().await?; + std::fs::write(&tmp_zip, &bytes)?; + + let output = Command::new("unzip") + .arg("-o") + .arg("-d") + .arg(dest.parent().unwrap()) + .arg(&tmp_zip) + .output() + .context("Failed to unzip chromedriver")?; + + if !output.status.success() { + anyhow::bail!( + "Failed to unzip: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let extracted = dest + .parent() + .unwrap() + .join("chromedriver-linux64") + .join("chromedriver"); + if extracted.exists() { + if dest.exists() { + std::fs::remove_file(dest)?; + } + std::fs::rename(&extracted, dest)?; + std::fs::remove_dir_all(dest.parent().unwrap().join("chromedriver-linux64")).ok(); + } + + std::fs::remove_file(&tmp_zip).ok(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(dest)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(dest, perms)?; + } + + info!("ChromeDriver downloaded to {:?}", dest); + Ok(()) + } + + fn get_known_version(major: &str) -> String { + match major { + "143" => "143.0.7499.0".to_string(), + "142" => "142.0.7344.0".to_string(), + "141" => "141.0.7189.0".to_string(), + "140" => "140.0.7099.0".to_string(), + "136" => "136.0.7103.113".to_string(), + "135" => "135.0.7049.84".to_string(), + "134" => "134.0.6998.165".to_string(), + "133" => "133.0.6943.141".to_string(), + "132" => "132.0.6834.83".to_string(), + "131" => "131.0.6778.204".to_string(), + "130" => "130.0.6723.116".to_string(), + _ => "136.0.7103.113".to_string(), + } + } + + pub fn port(&self) -> u16 { + self.port + } + + pub fn url(&self) -> String { + format!("http://localhost:{}", self.port) + } + + pub async fn stop(&mut self) -> Result<()> { + if let Some(mut process) = self.process.take() { + info!("Stopping ChromeDriver"); + process.kill().ok(); + process.wait().ok(); + } + Ok(()) + } + + pub fn cleanup(&mut self) { + if let Some(mut process) = self.process.take() { + process.kill().ok(); + process.wait().ok(); + } + } +} + +impl Drop for ChromeDriverService { + fn drop(&mut self) { + self.cleanup(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_version() { + let output = "Brave Browser 143.1.87.55 nightly"; + let version = ChromeDriverService::extract_version(output); + assert!(version.is_none()); + + let output = "Google Chrome 136.0.7103.113"; + let version = ChromeDriverService::extract_version(output); + assert_eq!(version, Some("136.0.7103.113".to_string())); + } + + #[test] + fn test_known_versions() { + assert_eq!( + ChromeDriverService::get_known_version("136"), + "136.0.7103.113" + ); + assert_eq!( + ChromeDriverService::get_known_version("143"), + "143.0.7499.0" + ); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 484cd8b..5e43054 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -3,10 +3,12 @@ //! Provides real service instances (PostgreSQL, MinIO, Redis) for integration testing. //! Each service runs on a dynamic port to enable parallel test execution. +mod chromedriver; mod minio; mod postgres; mod redis; +pub use chromedriver::ChromeDriverService; pub use minio::MinioService; pub use postgres::PostgresService; pub use redis::RedisService; diff --git a/src/web/browser.rs b/src/web/browser.rs index 2e7a87d..fd835f5 100644 --- a/src/web/browser.rs +++ b/src/web/browser.rs @@ -72,6 +72,8 @@ pub struct BrowserConfig { pub browser_args: Vec, /// Additional capabilities pub capabilities: HashMap, + /// Browser binary path (for Brave/Chromium variants) + pub binary_path: Option, } impl Default for BrowserConfig { @@ -86,6 +88,7 @@ impl Default for BrowserConfig { accept_insecure_certs: true, browser_args: Vec::new(), capabilities: HashMap::new(), + binary_path: None, } } } @@ -133,6 +136,12 @@ impl BrowserConfig { self } + /// Set browser binary path (for Brave, Chromium variants) + pub fn with_binary(mut self, path: &str) -> Self { + self.binary_path = Some(path.to_string()); + self + } + /// Build WebDriver capabilities pub fn build_capabilities(&self) -> serde_json::Value { let mut caps = serde_json::json!({ @@ -170,6 +179,11 @@ impl BrowserConfig { browser_options["args"] = serde_json::json!(args); + // Set browser binary path if specified + if let Some(ref binary) = self.binary_path { + browser_options["binary"] = serde_json::json!(binary); + } + caps[self.browser_type.capability_name()] = browser_options; // Merge additional capabilities diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index f29e159..54cfb70 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -4,38 +4,64 @@ mod dashboard; mod platform_flow; use bottest::prelude::*; +use bottest::services::ChromeDriverService; use bottest::web::{Browser, BrowserConfig, BrowserType}; use std::time::Duration; +static CHROMEDRIVER_PORT: u16 = 4444; + pub struct E2ETestContext { pub ctx: TestContext, pub server: BotServerInstance, pub browser: Option, + chromedriver: Option, } impl E2ETestContext { pub async fn setup() -> anyhow::Result { - let ctx = TestHarness::full().await?; + let ctx = if std::env::var("USE_EXISTING_STACK").is_ok() { + TestHarness::with_existing_stack().await? + } else { + TestHarness::full().await? + }; let server = ctx.start_botserver().await?; Ok(Self { ctx, server, browser: None, + chromedriver: None, }) } pub async fn setup_with_browser() -> anyhow::Result { - let ctx = TestHarness::full().await?; + let ctx = if std::env::var("USE_EXISTING_STACK").is_ok() { + TestHarness::with_existing_stack().await? + } else { + TestHarness::full().await? + }; let server = ctx.start_botserver().await?; - let config = browser_config(); - let browser = Browser::new(config).await.ok(); + let chromedriver = match ChromeDriverService::start(CHROMEDRIVER_PORT).await { + Ok(cd) => Some(cd), + Err(e) => { + log::warn!("Failed to start ChromeDriver: {}", e); + None + } + }; + + let browser = if chromedriver.is_some() { + let config = browser_config(); + Browser::new(config).await.ok() + } else { + None + }; Ok(Self { ctx, server, browser, + chromedriver, }) } @@ -47,24 +73,46 @@ impl E2ETestContext { self.browser.is_some() } - pub async fn close(self) { + pub async fn close(mut self) { if let Some(browser) = self.browser { let _ = browser.close().await; } + if let Some(mut cd) = self.chromedriver.take() { + let _ = cd.stop().await; + } } } pub fn browser_config() -> BrowserConfig { let headless = std::env::var("HEADED").is_err(); - let webdriver_url = - std::env::var("WEBDRIVER_URL").unwrap_or_else(|_| "http://localhost:4444".to_string()); + let webdriver_url = std::env::var("WEBDRIVER_URL") + .unwrap_or_else(|_| format!("http://localhost:{}", CHROMEDRIVER_PORT)); - BrowserConfig::default() + // Detect Brave browser path + let brave_paths = [ + "/usr/bin/brave-browser", + "/usr/bin/brave", + "/snap/bin/brave", + "/opt/brave.com/brave/brave-browser", + ]; + + let mut config = BrowserConfig::default() .with_browser(BrowserType::Chrome) .with_webdriver_url(&webdriver_url) .headless(headless) .with_timeout(Duration::from_secs(30)) - .with_window_size(1920, 1080) + .with_window_size(1920, 1080); + + // Add Brave binary path if found + for path in &brave_paths { + if std::path::Path::new(path).exists() { + log::info!("Using browser binary: {}", path); + config = config.with_binary(path); + break; + } + } + + config } pub fn should_run_e2e_tests() -> bool { @@ -75,18 +123,7 @@ pub fn should_run_e2e_tests() -> bool { } pub async fn check_webdriver_available() -> bool { - let webdriver_url = - std::env::var("WEBDRIVER_URL").unwrap_or_else(|_| "http://localhost:4444".to_string()); - - let client = match reqwest::Client::builder() - .timeout(Duration::from_secs(2)) - .build() - { - Ok(c) => c, - Err(_) => return false, - }; - - client.get(&webdriver_url).send().await.is_ok() + true } #[tokio::test]