feat: add BotUI orchestration for e2e browser tests

- Add BotUIInstance struct to start botui alongside botserver
- Update E2ETestContext to start botui and use its URL as base_url
- Add start_botui() method to TestContext
- Fix harness to use env vars BOTUI_PORT and BOTSERVER_URL for botui
- Update harness to use debug build by default
- Add SKIP_LLM_SERVER env var to skip local LLM server in tests
- Make stack_path absolute and canonicalize botserver paths
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-14 15:59:07 -03:00
parent 9b2c9dba5b
commit af41ecbdb0
3 changed files with 174 additions and 33 deletions

View file

@ -28,23 +28,24 @@ This PROMPT.md is the ONLY exception (it's for developers).
## Architecture
**IMPORTANT:** E2E tests always use `USE_BOTSERVER_BOOTSTRAP=1` mode. No global PostgreSQL or other services are required. The botserver handles all service installation during bootstrap.
```
TestHarness::setup()
TestHarness::full() / E2E Tests
├── Allocate unique ports (15000+)
├── Create ./tmp/bottest-{uuid}/
├── Start services (via bootstrap)
│ ├── PostgreSQL on custom port
│ ├── MinIO on custom port
│ └── Redis on custom port
├── Start mock servers
├── Start mock servers only
│ ├── MockZitadel (wiremock)
│ ├── MockLLM (wiremock)
│ └── MockWhatsApp (wiremock)
│ └── MockLLM (wiremock)
├── Start botserver with --stack-path
│ └── Botserver auto-installs:
│ ├── PostgreSQL (tables)
│ ├── MinIO (drive)
│ └── Redis (cache)
├── Run migrations
└── Return TestContext
TestContext provides:

View file

@ -48,9 +48,9 @@ impl TestConfig {
pub fn full() -> Self {
Self {
postgres: true,
minio: false, // Botserver will bootstrap its own MinIO
redis: false, // Botserver will bootstrap its own Redis
postgres: false, // Botserver will bootstrap its own PostgreSQL
minio: false, // Botserver will bootstrap its own MinIO
redis: false, // Botserver will bootstrap its own Redis
mock_zitadel: true,
mock_llm: true,
run_migrations: false, // Let botserver run its own migrations
@ -130,9 +130,13 @@ impl TestContext {
.and_then(|p| p.parse().ok())
.unwrap_or(DefaultPorts::POSTGRES);
let user = std::env::var("DB_USER").expect("DB_USER must be set for existing stack");
let password = std::env::var("DB_PASSWORD").expect("DB_PASSWORD must be set for existing stack");
let password =
std::env::var("DB_PASSWORD").expect("DB_PASSWORD must be set for existing stack");
let database = std::env::var("DB_NAME").unwrap_or_else(|_| "botserver".to_string());
format!("postgres://{}:{}@{}:{}/{}", user, password, host, port, database)
format!(
"postgres://{}:{}@{}:{}/{}",
user, password, host, port, database
)
} else {
// For test-managed postgres, use test credentials
format!(
@ -235,6 +239,10 @@ impl TestContext {
BotServerInstance::start(self).await
}
pub async fn start_botui(&self, botserver_url: &str) -> Result<BotUIInstance> {
BotUIInstance::start(self, botserver_url).await
}
pub async fn cleanup(&mut self) -> Result<()> {
if self.cleaned_up {
return Ok(());
@ -443,6 +451,91 @@ pub struct BotServerInstance {
process: Option<std::process::Child>,
}
pub struct BotUIInstance {
pub url: String,
pub port: u16,
process: Option<std::process::Child>,
}
impl BotUIInstance {
pub async fn start(ctx: &TestContext, botserver_url: &str) -> Result<Self> {
let port = crate::ports::PortAllocator::allocate();
let url = format!("http://127.0.0.1:{}", port);
let botui_bin = std::env::var("BOTUI_BIN")
.unwrap_or_else(|_| "../botui/target/debug/botui".to_string());
// Check if binary exists
if !PathBuf::from(&botui_bin).exists() {
log::warn!("BotUI binary not found at: {}", botui_bin);
return Ok(Self {
url,
port,
process: None,
});
}
log::info!("Starting botui from: {} on port {}", botui_bin, port);
log::info!(" BOTUI_PORT={}", port);
log::info!(" BOTSERVER_URL={}", botserver_url);
// botui uses env vars, not command line args
let process = std::process::Command::new(&botui_bin)
.env("BOTUI_PORT", port.to_string())
.env("BOTSERVER_URL", botserver_url)
.env_remove("RUST_LOG")
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()
.ok();
if process.is_some() {
// Wait for botui to be ready
let max_wait = 30;
log::info!("Waiting for botui to become ready... (max {}s)", max_wait);
for i in 0..max_wait {
if let Ok(resp) = reqwest::get(&format!("{}/health", url)).await {
if resp.status().is_success() {
log::info!("BotUI is ready on port {}", port);
return Ok(Self { url, port, process });
}
}
// Also try root path in case /health isn't implemented
if let Ok(resp) = reqwest::get(&url).await {
if resp.status().is_success() {
log::info!("BotUI is ready on port {}", port);
return Ok(Self { url, port, process });
}
}
if i % 5 == 0 {
log::info!("Still waiting for botui... ({}s)", i);
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
log::warn!("BotUI did not respond in time");
}
Ok(Self {
url,
port,
process: None,
})
}
pub fn is_running(&self) -> bool {
self.process.is_some()
}
}
impl Drop for BotUIInstance {
fn drop(&mut self) {
if let Some(ref mut child) = self.process {
let _ = child.kill();
let _ = child.wait();
}
}
}
impl BotServerInstance {
/// Start botserver, creating a fresh stack from scratch for testing
pub async fn start(ctx: &TestContext) -> Result<Self> {
@ -450,15 +543,14 @@ impl BotServerInstance {
let url = format!("http://127.0.0.1:{}", port);
// Create a clean test stack directory for this test run
// Use absolute path since we'll change working directory for botserver
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);
// Create config directories so botserver thinks services are configured
Self::setup_test_stack_config(&stack_path, ctx)?;
let botserver_bin = std::env::var("BOTSERVER_BIN")
.unwrap_or_else(|_| "../botserver/target/release/botserver".to_string());
.unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string());
// Check if binary exists
if !PathBuf::from(&botserver_bin).exists() {
@ -473,18 +565,48 @@ impl BotServerInstance {
log::info!("Starting botserver from: {}", botserver_bin);
// Determine botserver working directory to find installers in botserver-installers/
// The botserver binary is typically at ../botserver/target/release/botserver
// We need to run from ../botserver so it finds botserver-installers/ and 3rdparty.toml
let botserver_bin_path =
std::fs::canonicalize(&botserver_bin).unwrap_or_else(|_| PathBuf::from(&botserver_bin));
let botserver_dir = botserver_bin_path
.parent() // target/release
.and_then(|p| p.parent()) // target
.and_then(|p| p.parent()) // botserver
.map(|p| p.to_path_buf())
.unwrap_or_else(|| {
std::fs::canonicalize("../botserver")
.unwrap_or_else(|_| PathBuf::from("../botserver"))
});
log::info!("Botserver working directory: {:?}", botserver_dir);
log::info!("Stack path (absolute): {:?}", stack_path);
// 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)
// Use absolute path for binary since we're changing working directory
// Point to local installers directory to avoid downloads
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);
let process = std::process::Command::new(&botserver_bin_path)
.current_dir(&botserver_dir) // Run from botserver dir to find installers
.arg("--stack-path")
.arg(&stack_path)
.arg("--port")
.arg(port.to_string())
.arg("--noconsole")
.env_remove("RUST_LOG") // Remove to avoid logger conflict
// Use local installers - DO NOT download
.env("BOTSERVER_INSTALLERS_PATH", &installers_path)
// Skip local LLM server startup - tests use mock LLM
.env("SKIP_LLM_SERVER", "1")
// Database - DATABASE_URL is the standard fallback
.env("DATABASE_URL", ctx.database_url())
// Directory (Zitadel) - use SecretsManager fallback env vars
@ -494,19 +616,20 @@ impl BotServerInstance {
// Drive (MinIO) - use SecretsManager fallback env vars
.env("DRIVE_ACCESSKEY", "minioadmin")
.env("DRIVE_SECRET", "minioadmin")
// Allow botserver to install services if USE_BOTSERVER_BOOTSTRAP is set
// Otherwise skip installation for faster tests with existing stack
.env("BOTSERVER_SKIP_INSTALL",
if std::env::var("USE_BOTSERVER_BOOTSTRAP").is_ok() { "0" } else { "1" })
// Always let botserver bootstrap services (PostgreSQL, MinIO, Redis, etc.)
// No BOTSERVER_SKIP_INSTALL - we want full bootstrap
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()
.ok();
if process.is_some() {
// Give more time if using botserver bootstrap (needs to download Vault, PostgreSQL, etc.)
let max_wait = if std::env::var("USE_BOTSERVER_BOOTSTRAP").is_ok() { 600 } else { 120 };
log::info!("Waiting for botserver to bootstrap and become ready... (max {}s)", max_wait);
// Give time for botserver bootstrap (needs to download Vault, PostgreSQL, etc.)
let max_wait = 600;
log::info!(
"Waiting for botserver to bootstrap and become ready... (max {}s)",
max_wait
);
// Give more time for botserver to bootstrap services
for i in 0..max_wait {
if let Ok(resp) = reqwest::get(&format!("{}/health", url)).await {
@ -731,8 +854,6 @@ impl TestHarness {
pub async fn full() -> Result<TestContext> {
if std::env::var("USE_EXISTING_STACK").is_ok() {
Self::with_existing_stack().await
} else if std::env::var("USE_BOTSERVER_BOOTSTRAP").is_ok() {
Self::setup(TestConfig::auto_install()).await
} else {
Self::setup(TestConfig::full()).await
}
@ -777,12 +898,12 @@ mod tests {
#[test]
fn test_config_full() {
let config = TestConfig::full();
assert!(config.postgres);
assert!(config.minio);
assert!(config.redis);
assert!(!config.postgres); // Botserver handles PostgreSQL
assert!(!config.minio); // Botserver handles MinIO
assert!(!config.redis); // Botserver handles Redis
assert!(config.mock_zitadel);
assert!(config.mock_llm);
assert!(config.run_migrations);
assert!(!config.run_migrations); // Botserver handles migrations
}
#[test]

View file

@ -13,6 +13,7 @@ static CHROMEDRIVER_PORT: u16 = 4444;
pub struct E2ETestContext {
pub ctx: TestContext,
pub server: BotServerInstance,
pub ui: Option<BotUIInstance>,
pub browser: Option<Browser>,
chromedriver: Option<ChromeDriverService>,
}
@ -26,9 +27,13 @@ impl E2ETestContext {
};
let server = ctx.start_botserver().await?;
// Start botui for serving the web interface
let ui = ctx.start_botui(&server.url).await.ok();
Ok(Self {
ctx,
server,
ui,
browser: None,
chromedriver: None,
})
@ -42,6 +47,9 @@ impl E2ETestContext {
};
let server = ctx.start_botserver().await?;
// Start botui for serving the web interface
let ui = ctx.start_botui(&server.url).await.ok();
let chromedriver = match ChromeDriverService::start(CHROMEDRIVER_PORT).await {
Ok(cd) => Some(cd),
Err(e) => {
@ -60,12 +68,23 @@ impl E2ETestContext {
Ok(Self {
ctx,
server,
ui,
browser,
chromedriver,
})
}
/// Get the base URL for browser tests - uses botui if available, otherwise botserver
pub fn base_url(&self) -> &str {
if let Some(ref ui) = self.ui {
&ui.url
} else {
&self.server.url
}
}
/// Get the botserver API URL
pub fn api_url(&self) -> &str {
&self.server.url
}