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:
parent
9b2c9dba5b
commit
af41ecbdb0
3 changed files with 174 additions and 33 deletions
21
PROMPT.md
21
PROMPT.md
|
|
@ -28,23 +28,24 @@ This PROMPT.md is the ONLY exception (it's for developers).
|
||||||
|
|
||||||
## Architecture
|
## 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+)
|
├── Allocate unique ports (15000+)
|
||||||
├── Create ./tmp/bottest-{uuid}/
|
├── Create ./tmp/bottest-{uuid}/
|
||||||
│
|
│
|
||||||
├── Start services (via bootstrap)
|
├── Start mock servers only
|
||||||
│ ├── PostgreSQL on custom port
|
|
||||||
│ ├── MinIO on custom port
|
|
||||||
│ └── Redis on custom port
|
|
||||||
│
|
|
||||||
├── Start mock servers
|
|
||||||
│ ├── MockZitadel (wiremock)
|
│ ├── MockZitadel (wiremock)
|
||||||
│ ├── MockLLM (wiremock)
|
│ └── MockLLM (wiremock)
|
||||||
│ └── MockWhatsApp (wiremock)
|
│
|
||||||
|
├── Start botserver with --stack-path
|
||||||
|
│ └── Botserver auto-installs:
|
||||||
|
│ ├── PostgreSQL (tables)
|
||||||
|
│ ├── MinIO (drive)
|
||||||
|
│ └── Redis (cache)
|
||||||
│
|
│
|
||||||
├── Run migrations
|
|
||||||
└── Return TestContext
|
└── Return TestContext
|
||||||
|
|
||||||
TestContext provides:
|
TestContext provides:
|
||||||
|
|
|
||||||
163
src/harness.rs
163
src/harness.rs
|
|
@ -48,7 +48,7 @@ impl TestConfig {
|
||||||
|
|
||||||
pub fn full() -> Self {
|
pub fn full() -> Self {
|
||||||
Self {
|
Self {
|
||||||
postgres: true,
|
postgres: false, // Botserver will bootstrap its own PostgreSQL
|
||||||
minio: false, // Botserver will bootstrap its own MinIO
|
minio: false, // Botserver will bootstrap its own MinIO
|
||||||
redis: false, // Botserver will bootstrap its own Redis
|
redis: false, // Botserver will bootstrap its own Redis
|
||||||
mock_zitadel: true,
|
mock_zitadel: true,
|
||||||
|
|
@ -130,9 +130,13 @@ impl TestContext {
|
||||||
.and_then(|p| p.parse().ok())
|
.and_then(|p| p.parse().ok())
|
||||||
.unwrap_or(DefaultPorts::POSTGRES);
|
.unwrap_or(DefaultPorts::POSTGRES);
|
||||||
let user = std::env::var("DB_USER").expect("DB_USER must be set for existing stack");
|
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());
|
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 {
|
} else {
|
||||||
// For test-managed postgres, use test credentials
|
// For test-managed postgres, use test credentials
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -235,6 +239,10 @@ impl TestContext {
|
||||||
BotServerInstance::start(self).await
|
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<()> {
|
pub async fn cleanup(&mut self) -> Result<()> {
|
||||||
if self.cleaned_up {
|
if self.cleaned_up {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -443,6 +451,91 @@ pub struct BotServerInstance {
|
||||||
process: Option<std::process::Child>,
|
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 {
|
impl BotServerInstance {
|
||||||
/// Start botserver, creating a fresh stack from scratch for testing
|
/// Start botserver, creating a fresh stack from scratch for testing
|
||||||
pub async fn start(ctx: &TestContext) -> Result<Self> {
|
pub async fn start(ctx: &TestContext) -> Result<Self> {
|
||||||
|
|
@ -450,15 +543,14 @@ impl BotServerInstance {
|
||||||
let url = format!("http://127.0.0.1:{}", port);
|
let url = format!("http://127.0.0.1:{}", port);
|
||||||
|
|
||||||
// Create a clean test stack directory for this test run
|
// 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");
|
let stack_path = ctx.data_dir.join("botserver-stack");
|
||||||
std::fs::create_dir_all(&stack_path)?;
|
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);
|
||||||
|
|
||||||
// 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")
|
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
|
// Check if binary exists
|
||||||
if !PathBuf::from(&botserver_bin).exists() {
|
if !PathBuf::from(&botserver_bin).exists() {
|
||||||
|
|
@ -473,18 +565,48 @@ impl BotServerInstance {
|
||||||
|
|
||||||
log::info!("Starting botserver from: {}", botserver_bin);
|
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
|
// Start botserver with test configuration
|
||||||
// - Uses test harness PostgreSQL
|
// - Uses test harness PostgreSQL
|
||||||
// - Uses mock Zitadel for auth
|
// - Uses mock Zitadel for auth
|
||||||
// - Uses mock LLM
|
// - Uses mock LLM
|
||||||
// Env vars align with SecretsManager fallbacks (see botserver/src/core/secrets/mod.rs)
|
// 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(&stack_path)
|
.arg(&stack_path)
|
||||||
.arg("--port")
|
.arg("--port")
|
||||||
.arg(port.to_string())
|
.arg(port.to_string())
|
||||||
.arg("--noconsole")
|
.arg("--noconsole")
|
||||||
.env_remove("RUST_LOG") // Remove to avoid logger conflict
|
.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
|
// Database - DATABASE_URL is the standard fallback
|
||||||
.env("DATABASE_URL", ctx.database_url())
|
.env("DATABASE_URL", ctx.database_url())
|
||||||
// Directory (Zitadel) - use SecretsManager fallback env vars
|
// Directory (Zitadel) - use SecretsManager fallback env vars
|
||||||
|
|
@ -494,19 +616,20 @@ impl BotServerInstance {
|
||||||
// Drive (MinIO) - use SecretsManager fallback env vars
|
// Drive (MinIO) - use SecretsManager fallback env vars
|
||||||
.env("DRIVE_ACCESSKEY", "minioadmin")
|
.env("DRIVE_ACCESSKEY", "minioadmin")
|
||||||
.env("DRIVE_SECRET", "minioadmin")
|
.env("DRIVE_SECRET", "minioadmin")
|
||||||
// Allow botserver to install services if USE_BOTSERVER_BOOTSTRAP is set
|
// Always let botserver bootstrap services (PostgreSQL, MinIO, Redis, etc.)
|
||||||
// Otherwise skip installation for faster tests with existing stack
|
// No BOTSERVER_SKIP_INSTALL - we want full bootstrap
|
||||||
.env("BOTSERVER_SKIP_INSTALL",
|
|
||||||
if std::env::var("USE_BOTSERVER_BOOTSTRAP").is_ok() { "0" } else { "1" })
|
|
||||||
.stdout(std::process::Stdio::inherit())
|
.stdout(std::process::Stdio::inherit())
|
||||||
.stderr(std::process::Stdio::inherit())
|
.stderr(std::process::Stdio::inherit())
|
||||||
.spawn()
|
.spawn()
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
if process.is_some() {
|
if process.is_some() {
|
||||||
// Give more time if using botserver bootstrap (needs to download Vault, PostgreSQL, etc.)
|
// Give time for botserver bootstrap (needs to download Vault, PostgreSQL, etc.)
|
||||||
let max_wait = if std::env::var("USE_BOTSERVER_BOOTSTRAP").is_ok() { 600 } else { 120 };
|
let max_wait = 600;
|
||||||
log::info!("Waiting for botserver to bootstrap and become ready... (max {}s)", max_wait);
|
log::info!(
|
||||||
|
"Waiting for botserver to bootstrap and become ready... (max {}s)",
|
||||||
|
max_wait
|
||||||
|
);
|
||||||
// Give more time for botserver to bootstrap services
|
// Give more time for botserver to bootstrap services
|
||||||
for i in 0..max_wait {
|
for i in 0..max_wait {
|
||||||
if let Ok(resp) = reqwest::get(&format!("{}/health", url)).await {
|
if let Ok(resp) = reqwest::get(&format!("{}/health", url)).await {
|
||||||
|
|
@ -731,8 +854,6 @@ impl TestHarness {
|
||||||
pub async fn full() -> Result<TestContext> {
|
pub async fn full() -> Result<TestContext> {
|
||||||
if std::env::var("USE_EXISTING_STACK").is_ok() {
|
if std::env::var("USE_EXISTING_STACK").is_ok() {
|
||||||
Self::with_existing_stack().await
|
Self::with_existing_stack().await
|
||||||
} else if std::env::var("USE_BOTSERVER_BOOTSTRAP").is_ok() {
|
|
||||||
Self::setup(TestConfig::auto_install()).await
|
|
||||||
} else {
|
} else {
|
||||||
Self::setup(TestConfig::full()).await
|
Self::setup(TestConfig::full()).await
|
||||||
}
|
}
|
||||||
|
|
@ -777,12 +898,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_config_full() {
|
fn test_config_full() {
|
||||||
let config = TestConfig::full();
|
let config = TestConfig::full();
|
||||||
assert!(config.postgres);
|
assert!(!config.postgres); // Botserver handles PostgreSQL
|
||||||
assert!(config.minio);
|
assert!(!config.minio); // Botserver handles MinIO
|
||||||
assert!(config.redis);
|
assert!(!config.redis); // Botserver handles Redis
|
||||||
assert!(config.mock_zitadel);
|
assert!(config.mock_zitadel);
|
||||||
assert!(config.mock_llm);
|
assert!(config.mock_llm);
|
||||||
assert!(config.run_migrations);
|
assert!(!config.run_migrations); // Botserver handles migrations
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ static CHROMEDRIVER_PORT: u16 = 4444;
|
||||||
pub struct E2ETestContext {
|
pub struct E2ETestContext {
|
||||||
pub ctx: TestContext,
|
pub ctx: TestContext,
|
||||||
pub server: BotServerInstance,
|
pub server: BotServerInstance,
|
||||||
|
pub ui: Option<BotUIInstance>,
|
||||||
pub browser: Option<Browser>,
|
pub browser: Option<Browser>,
|
||||||
chromedriver: Option<ChromeDriverService>,
|
chromedriver: Option<ChromeDriverService>,
|
||||||
}
|
}
|
||||||
|
|
@ -26,9 +27,13 @@ impl E2ETestContext {
|
||||||
};
|
};
|
||||||
let server = ctx.start_botserver().await?;
|
let server = ctx.start_botserver().await?;
|
||||||
|
|
||||||
|
// Start botui for serving the web interface
|
||||||
|
let ui = ctx.start_botui(&server.url).await.ok();
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
ctx,
|
ctx,
|
||||||
server,
|
server,
|
||||||
|
ui,
|
||||||
browser: None,
|
browser: None,
|
||||||
chromedriver: None,
|
chromedriver: None,
|
||||||
})
|
})
|
||||||
|
|
@ -42,6 +47,9 @@ impl E2ETestContext {
|
||||||
};
|
};
|
||||||
let server = ctx.start_botserver().await?;
|
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 {
|
let chromedriver = match ChromeDriverService::start(CHROMEDRIVER_PORT).await {
|
||||||
Ok(cd) => Some(cd),
|
Ok(cd) => Some(cd),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -60,12 +68,23 @@ impl E2ETestContext {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
ctx,
|
ctx,
|
||||||
server,
|
server,
|
||||||
|
ui,
|
||||||
browser,
|
browser,
|
||||||
chromedriver,
|
chromedriver,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the base URL for browser tests - uses botui if available, otherwise botserver
|
||||||
pub fn base_url(&self) -> &str {
|
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
|
&self.server.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue