Add comprehensive E2E testing framework with platform flow
- Create platform_flow.rs with complete user journey test (load → botserver → login → chat → logout) - Add TEMP_STACK_SETUP.md guide for using temporary isolated stacks - Add E2E_TESTING_PLAN.md with architecture and implementation strategy - Update e2e mod.rs to include new platform_flow module - Include helpers for: platform loading, botserver verification, login, chat, logout - Support for headless and headed browser testing - Environment variable configuration for WebDriver, timeouts, logging
This commit is contained in:
parent
157a727334
commit
de58bc16a0
4 changed files with 1246 additions and 0 deletions
244
E2E_TESTING_PLAN.md
Normal file
244
E2E_TESTING_PLAN.md
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# E2E Testing Plan: Temporary Stack Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the architecture for comprehensive E2E testing in the General Bots platform using a temporary, isolated stack that can be spawned for testing and automatically cleaned up.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Current challenges:
|
||||
- E2E tests require a pre-configured environment
|
||||
- Testing can interfere with the main development stack
|
||||
- No easy way to test the complete flow: platform loading → botserver startup → login → chat → logout
|
||||
- Integration tests are difficult to automate and reproduce
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### 1. Temporary Stack Option in BotServer
|
||||
|
||||
Add a new CLI flag `--temp-stack` to BotServer that:
|
||||
|
||||
```bash
|
||||
cargo run -- --temp-stack
|
||||
# or with custom timeout
|
||||
cargo run -- --temp-stack --temp-stack-timeout 300
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Creates a temporary directory: `/tmp/botserver-test-{timestamp}-{random}/`
|
||||
- Sets up all required services (PostgreSQL, MinIO, Redis, etc.) in this directory
|
||||
- Configures BotServer to use this isolated environment
|
||||
- Provides environment variables for test harness to connect
|
||||
- Automatically cleans up on shutdown (SIGTERM/SIGINT)
|
||||
- Optional timeout that auto-shuts down after N seconds (useful for CI/CD)
|
||||
|
||||
### 2. E2E Test Flow
|
||||
|
||||
The complete user journey test will validate:
|
||||
|
||||
```
|
||||
1. Platform Loading
|
||||
└─ Health check endpoint responds
|
||||
└─ UI assets served correctly
|
||||
└─ Database migrations completed
|
||||
|
||||
2. BotServer Initialization
|
||||
└─ Service discovery working
|
||||
└─ Configuration loaded
|
||||
└─ Dependencies connected
|
||||
|
||||
3. Authentication (Login)
|
||||
└─ Navigate to login page
|
||||
└─ Enter valid credentials
|
||||
└─ Session created
|
||||
└─ Redirected to dashboard
|
||||
|
||||
4. Chat Interaction
|
||||
└─ Open chat window
|
||||
└─ Send message
|
||||
└─ Receive AI response
|
||||
└─ Message history persisted
|
||||
|
||||
5. Logout
|
||||
└─ Click logout button
|
||||
└─ Session invalidated
|
||||
└─ Redirected to login
|
||||
└─ Cannot access protected routes
|
||||
```
|
||||
|
||||
### 3. Test Architecture
|
||||
|
||||
#### Test Harness Enhancement
|
||||
|
||||
```rust
|
||||
pub struct TemporaryStack {
|
||||
pub temp_dir: PathBuf,
|
||||
pub botserver_process: Child,
|
||||
pub botserver_url: String,
|
||||
pub services: ServiceManager,
|
||||
}
|
||||
|
||||
impl TemporaryStack {
|
||||
pub async fn spawn() -> anyhow::Result<Self>;
|
||||
pub async fn wait_ready(&self) -> anyhow::Result<()>;
|
||||
pub async fn shutdown(mut self) -> anyhow::Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
#### E2E Test Structure
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_complete_user_journey() {
|
||||
// 1. Spawn temporary isolated stack
|
||||
let stack = TemporaryStack::spawn().await.expect("Failed to spawn stack");
|
||||
stack.wait_ready().await.expect("Stack failed to become ready");
|
||||
|
||||
// 2. Setup browser
|
||||
let browser = Browser::new(browser_config()).await.expect("Browser failed");
|
||||
|
||||
// 3. Test complete flow
|
||||
test_platform_loading(&browser, &stack).await.expect("Platform load failed");
|
||||
test_botserver_running(&stack).await.expect("BotServer not running");
|
||||
test_login_flow(&browser, &stack).await.expect("Login failed");
|
||||
test_chat_interaction(&browser, &stack).await.expect("Chat failed");
|
||||
test_logout_flow(&browser, &stack).await.expect("Logout failed");
|
||||
|
||||
// 4. Cleanup (automatic on drop)
|
||||
drop(stack);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Implementation Phases
|
||||
|
||||
#### Phase 1: BotServer Temp Stack Support
|
||||
- [ ] Add `--temp-stack` CLI argument
|
||||
- [ ] Create `TempStackConfig` struct
|
||||
- [ ] Implement temporary directory setup
|
||||
- [ ] Update service initialization to support temp paths
|
||||
- [ ] Add cleanup on shutdown
|
||||
|
||||
#### Phase 2: Test Harness Integration
|
||||
- [ ] Create `TemporaryStack` struct in test framework
|
||||
- [ ] Implement stack spawning logic
|
||||
- [ ] Add readiness checks
|
||||
- [ ] Implement graceful shutdown
|
||||
|
||||
#### Phase 3: Complete E2E Test Suite
|
||||
- [ ] Platform loading test
|
||||
- [ ] BotServer initialization test
|
||||
- [ ] Complete login → chat → logout flow
|
||||
- [ ] Error handling and edge cases
|
||||
|
||||
#### Phase 4: CI/CD Integration
|
||||
- [ ] Docker compose for CI environment
|
||||
- [ ] GitHub Actions workflow
|
||||
- [ ] Artifact collection on failure
|
||||
- [ ] Performance benchmarks
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Environment Variables
|
||||
|
||||
When `--temp-stack` is enabled, BotServer outputs:
|
||||
|
||||
```bash
|
||||
export BOTSERVER_TEMP_STACK_DIR="/tmp/botserver-test-2024-01-15-abc123/"
|
||||
export BOTSERVER_URL="http://localhost:8000"
|
||||
export DB_HOST="127.0.0.1"
|
||||
export DB_PORT="5432"
|
||||
export DB_NAME="botserver_test_abc123"
|
||||
export REDIS_URL="redis://127.0.0.1:6379"
|
||||
export MINIO_URL="http://127.0.0.1:9000"
|
||||
```
|
||||
|
||||
### Cleanup Strategy
|
||||
|
||||
- **Graceful**: On SIGTERM/SIGINT, wait for in-flight requests then cleanup
|
||||
- **Timeout**: Auto-shutdown after `--temp-stack-timeout` seconds
|
||||
- **Forceful**: If timeout reached, force kill processes and cleanup
|
||||
- **Persistent on Error**: Keep temp dir if error occurs (for debugging)
|
||||
|
||||
### Service Isolation
|
||||
|
||||
Each temporary stack includes:
|
||||
|
||||
```
|
||||
/tmp/botserver-test-{id}/
|
||||
├── postgres/
|
||||
│ └── data/
|
||||
├── redis/
|
||||
│ └── data/
|
||||
├── minio/
|
||||
│ └── data/
|
||||
├── botserver/
|
||||
│ ├── logs/
|
||||
│ ├── config/
|
||||
│ └── cache/
|
||||
└── state.json
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Isolation**: Each test gets a completely clean environment
|
||||
2. **Reproducibility**: Same setup every time
|
||||
3. **Automation**: Can run in CI/CD without manual setup
|
||||
4. **Debugging**: Failed tests leave artifacts for investigation
|
||||
5. **Performance**: Multiple tests can run in parallel with different ports
|
||||
6. **Safety**: No risk of interfering with development environment
|
||||
|
||||
## Limitations
|
||||
|
||||
- **LXC Containers**: Cannot test containerization (as mentioned)
|
||||
- **Network**: Tests run on localhost only
|
||||
- **Performance**: Startup time ~10-30 seconds per test
|
||||
- **Parallelization**: Need port management for parallel execution
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Run Single E2E Test
|
||||
```bash
|
||||
cargo test --test e2e_complete_flow -- --nocapture
|
||||
```
|
||||
|
||||
### Run with Headed Browser (for debugging)
|
||||
```bash
|
||||
HEADED=1 cargo test --test e2e_complete_flow
|
||||
```
|
||||
|
||||
### Keep Temp Stack on Failure
|
||||
```bash
|
||||
KEEP_TEMP_STACK_ON_ERROR=1 cargo test --test e2e_complete_flow
|
||||
```
|
||||
|
||||
### Run All E2E Tests
|
||||
```bash
|
||||
cargo test --lib e2e:: -- --nocapture
|
||||
```
|
||||
|
||||
## Monitoring & Logging
|
||||
|
||||
- BotServer logs: `/tmp/botserver-test-{id}/botserver.log`
|
||||
- Database logs: `/tmp/botserver-test-{id}/postgres.log`
|
||||
- Test output: stdout/stderr from test harness
|
||||
- Performance metrics: Collected during each phase
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✓ Platform fully loads without errors
|
||||
✓ BotServer starts and services become ready within 30 seconds
|
||||
✓ User can login with test credentials
|
||||
✓ Chat messages are sent and responses received
|
||||
✓ User can logout and session is invalidated
|
||||
✓ All cleanup happens automatically
|
||||
✓ Test runs consistently multiple times
|
||||
✓ CI/CD integration works smoothly
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Implement `--temp-stack` flag in BotServer
|
||||
2. Update config loading to support temp paths
|
||||
3. Create `TemporaryStack` test utility
|
||||
4. Write comprehensive E2E test suite
|
||||
5. Integrate into CI/CD pipeline
|
||||
6. Document for team
|
||||
583
TEMP_STACK_SETUP.md
Normal file
583
TEMP_STACK_SETUP.md
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
# Temporary Stack Setup Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The temporary stack feature allows you to spawn an isolated BotServer environment for testing purposes. This guide explains how to implement and use this feature.
|
||||
|
||||
## Architecture
|
||||
|
||||
### What is a Temporary Stack?
|
||||
|
||||
A temporary stack is a self-contained, isolated instance of the General Bots platform that:
|
||||
- Runs in a dedicated temporary directory
|
||||
- Uses isolated database, cache, and storage
|
||||
- Can be spawned and torn down automatically
|
||||
- Doesn't interfere with your main development environment
|
||||
- Perfect for E2E testing and integration tests
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
/tmp/botserver-test-{timestamp}-{random}/
|
||||
├── postgres/
|
||||
│ ├── data/ # PostgreSQL data directory
|
||||
│ ├── postgres.log # Database logs
|
||||
│ └── postgresql.conf # Database config
|
||||
├── redis/
|
||||
│ ├── data/ # Redis persistence
|
||||
│ └── redis.log # Redis logs
|
||||
├── minio/
|
||||
│ ├── data/ # S3-compatible storage
|
||||
│ └── minio.log # MinIO logs
|
||||
├── botserver/
|
||||
│ ├── config/ # BotServer configuration
|
||||
│ ├── logs/
|
||||
│ │ ├── botserver.log # Main application logs
|
||||
│ │ ├── api.log # API logs
|
||||
│ │ └── debug.log # Debug logs
|
||||
│ ├── cache/ # Local cache directory
|
||||
│ └── state.json # Stack state and metadata
|
||||
└── env.stack # Environment variables for this stack
|
||||
```
|
||||
|
||||
## Implementation: BotServer Changes
|
||||
|
||||
### 1. Add CLI Arguments
|
||||
|
||||
Update `botserver/src/main.rs`:
|
||||
|
||||
```rust
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about)]
|
||||
struct Args {
|
||||
/// Enable temporary stack mode for testing
|
||||
#[arg(long)]
|
||||
temp_stack: bool,
|
||||
|
||||
/// Custom temporary stack root directory
|
||||
/// If not provided, uses /tmp/botserver-test-{timestamp}-{random}
|
||||
#[arg(long)]
|
||||
stack_root: Option<PathBuf>,
|
||||
|
||||
/// Timeout in seconds for temporary stack auto-shutdown
|
||||
/// Useful for CI/CD pipelines
|
||||
#[arg(long)]
|
||||
temp_stack_timeout: Option<u64>,
|
||||
|
||||
/// Keep temporary stack directory after shutdown (for debugging)
|
||||
#[arg(long)]
|
||||
keep_temp_stack: bool,
|
||||
|
||||
// ... existing arguments ...
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
if args.temp_stack {
|
||||
return run_temp_stack(args).await;
|
||||
}
|
||||
|
||||
// ... normal startup code ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Implement Temporary Stack Manager
|
||||
|
||||
Create `botserver/src/temp_stack.rs`:
|
||||
|
||||
```rust
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Child};
|
||||
use chrono::Local;
|
||||
use uuid::Uuid;
|
||||
use anyhow::{anyhow, Context};
|
||||
use log::{info, debug};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
pub struct TemporaryStack {
|
||||
pub root_dir: PathBuf,
|
||||
pub postgres_dir: PathBuf,
|
||||
pub redis_dir: PathBuf,
|
||||
pub minio_dir: PathBuf,
|
||||
pub botserver_dir: PathBuf,
|
||||
pub postgres_process: Option<Child>,
|
||||
pub redis_process: Option<Child>,
|
||||
pub minio_process: Option<Child>,
|
||||
pub botserver_process: Option<Child>,
|
||||
pub keep_on_shutdown: bool,
|
||||
pub auto_shutdown_duration: Option<Duration>,
|
||||
}
|
||||
|
||||
impl TemporaryStack {
|
||||
/// Create and initialize a new temporary stack
|
||||
pub async fn new(
|
||||
custom_root: Option<PathBuf>,
|
||||
keep_on_shutdown: bool,
|
||||
auto_shutdown: Option<u64>,
|
||||
) -> anyhow::Result<Self> {
|
||||
// Generate unique directory name
|
||||
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
|
||||
let unique_id = Uuid::new_v4().to_string()[..8].to_string();
|
||||
let dir_name = format!("botserver-test-{}-{}", timestamp, unique_id);
|
||||
|
||||
let root_dir = match custom_root {
|
||||
Some(p) => p.join(&dir_name),
|
||||
None => std::env::temp_dir().join(dir_name),
|
||||
};
|
||||
|
||||
info!("Creating temporary stack at: {}", root_dir.display());
|
||||
|
||||
// Create directory structure
|
||||
fs::create_dir_all(&root_dir)
|
||||
.context("Failed to create temp stack root directory")?;
|
||||
|
||||
let postgres_dir = root_dir.join("postgres");
|
||||
let redis_dir = root_dir.join("redis");
|
||||
let minio_dir = root_dir.join("minio");
|
||||
let botserver_dir = root_dir.join("botserver");
|
||||
|
||||
fs::create_dir_all(&postgres_dir)?;
|
||||
fs::create_dir_all(&redis_dir)?;
|
||||
fs::create_dir_all(&minio_dir)?;
|
||||
fs::create_dir_all(&botserver_dir)?;
|
||||
|
||||
let auto_shutdown_duration = auto_shutdown.map(Duration::from_secs);
|
||||
|
||||
Ok(Self {
|
||||
root_dir,
|
||||
postgres_dir,
|
||||
redis_dir,
|
||||
minio_dir,
|
||||
botserver_dir,
|
||||
postgres_process: None,
|
||||
redis_process: None,
|
||||
minio_process: None,
|
||||
botserver_process: None,
|
||||
keep_on_shutdown,
|
||||
auto_shutdown_duration,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start all services in the temporary stack
|
||||
pub async fn start_services(&mut self) -> anyhow::Result<()> {
|
||||
info!("Starting temporary stack services");
|
||||
|
||||
// Start PostgreSQL
|
||||
self.start_postgres().await?;
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Start Redis
|
||||
self.start_redis().await?;
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
|
||||
// Start MinIO
|
||||
self.start_minio().await?;
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
|
||||
info!("All temporary stack services started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start PostgreSQL
|
||||
async fn start_postgres(&mut self) -> anyhow::Result<()> {
|
||||
info!("Starting PostgreSQL");
|
||||
|
||||
let data_dir = self.postgres_dir.join("data");
|
||||
fs::create_dir_all(&data_dir)?;
|
||||
|
||||
// Initialize PostgreSQL cluster if needed
|
||||
let initdb_output = Command::new("initdb")
|
||||
.arg("-D")
|
||||
.arg(&data_dir)
|
||||
.output();
|
||||
|
||||
if initdb_output.is_ok() {
|
||||
debug!("Initialized PostgreSQL cluster");
|
||||
}
|
||||
|
||||
let process = Command::new("postgres")
|
||||
.arg("-D")
|
||||
.arg(&data_dir)
|
||||
.arg("-p")
|
||||
.arg("5433") // Use different port than default
|
||||
.spawn()
|
||||
.context("Failed to start PostgreSQL")?;
|
||||
|
||||
self.postgres_process = Some(process);
|
||||
info!("PostgreSQL started on port 5433");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start Redis
|
||||
async fn start_redis(&mut self) -> anyhow::Result<()> {
|
||||
info!("Starting Redis");
|
||||
|
||||
let data_dir = self.redis_dir.join("data");
|
||||
fs::create_dir_all(&data_dir)?;
|
||||
|
||||
let process = Command::new("redis-server")
|
||||
.arg("--port")
|
||||
.arg("6380") // Use different port than default
|
||||
.arg("--dir")
|
||||
.arg(&data_dir)
|
||||
.spawn()
|
||||
.context("Failed to start Redis")?;
|
||||
|
||||
self.redis_process = Some(process);
|
||||
info!("Redis started on port 6380");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start MinIO
|
||||
async fn start_minio(&mut self) -> anyhow::Result<()> {
|
||||
info!("Starting MinIO");
|
||||
|
||||
let data_dir = self.minio_dir.join("data");
|
||||
fs::create_dir_all(&data_dir)?;
|
||||
|
||||
let process = Command::new("minio")
|
||||
.arg("server")
|
||||
.arg(&data_dir)
|
||||
.arg("--address")
|
||||
.arg("127.0.0.1:9001") // Use different port than default
|
||||
.spawn()
|
||||
.context("Failed to start MinIO")?;
|
||||
|
||||
self.minio_process = Some(process);
|
||||
info!("MinIO started on port 9001");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write environment configuration for this stack
|
||||
pub fn write_env_config(&self) -> anyhow::Result<()> {
|
||||
let env_content = format!(
|
||||
r#"# Temporary Stack Configuration
|
||||
# Generated at: {}
|
||||
|
||||
# Stack Identity
|
||||
BOTSERVER_STACK_ID={}
|
||||
BOTSERVER_TEMP_STACK_DIR={}
|
||||
BOTSERVER_KEEP_ON_SHUTDOWN={}
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgres://botuser:botpass@127.0.0.1:5433/botserver
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5433
|
||||
DB_NAME=botserver
|
||||
DB_USER=botuser
|
||||
DB_PASSWORD=botpass
|
||||
|
||||
# Cache
|
||||
REDIS_URL=redis://127.0.0.1:6380
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6380
|
||||
|
||||
# Storage
|
||||
MINIO_URL=http://127.0.0.1:9001
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=botserver
|
||||
|
||||
# API
|
||||
API_HOST=127.0.0.1
|
||||
API_PORT=8000
|
||||
API_URL=http://127.0.0.1:8000
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
LOG_FILE={}/botserver.log
|
||||
"#,
|
||||
chrono::Local::now(),
|
||||
Uuid::new_v4(),
|
||||
self.root_dir.display(),
|
||||
self.keep_on_shutdown,
|
||||
self.botserver_dir.display(),
|
||||
);
|
||||
|
||||
let env_file = self.root_dir.join("env.stack");
|
||||
fs::write(&env_file, env_content)
|
||||
.context("Failed to write environment configuration")?;
|
||||
|
||||
info!("Environment configuration written to: {}", env_file.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for all services to be ready
|
||||
pub async fn wait_ready(&self, timeout: Duration) -> anyhow::Result<()> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Check PostgreSQL
|
||||
loop {
|
||||
if start.elapsed() > timeout {
|
||||
return Err(anyhow!("Timeout waiting for PostgreSQL"));
|
||||
}
|
||||
match Command::new("pg_isready")
|
||||
.arg("-h")
|
||||
.arg("127.0.0.1")
|
||||
.arg("-p")
|
||||
.arg("5433")
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => break,
|
||||
_ => sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
info!("PostgreSQL is ready");
|
||||
|
||||
// Check Redis
|
||||
loop {
|
||||
if start.elapsed() > timeout {
|
||||
return Err(anyhow!("Timeout waiting for Redis"));
|
||||
}
|
||||
match Command::new("redis-cli")
|
||||
.arg("-p")
|
||||
.arg("6380")
|
||||
.arg("ping")
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => break,
|
||||
_ => sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
info!("Redis is ready");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gracefully shutdown all services
|
||||
pub async fn shutdown(&mut self) -> anyhow::Result<()> {
|
||||
info!("Shutting down temporary stack");
|
||||
|
||||
// Stop BotServer
|
||||
if let Some(mut proc) = self.botserver_process.take() {
|
||||
let _ = proc.kill();
|
||||
}
|
||||
|
||||
// Stop services
|
||||
if let Some(mut proc) = self.minio_process.take() {
|
||||
let _ = proc.kill();
|
||||
}
|
||||
if let Some(mut proc) = self.redis_process.take() {
|
||||
let _ = proc.kill();
|
||||
}
|
||||
if let Some(mut proc) = self.postgres_process.take() {
|
||||
let _ = proc.kill();
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Cleanup directory if not keeping
|
||||
if !self.keep_on_shutdown {
|
||||
if let Err(e) = fs::remove_dir_all(&self.root_dir) {
|
||||
log::warn!("Failed to cleanup temp stack directory: {}", e);
|
||||
} else {
|
||||
info!("Temporary stack cleaned up: {}", self.root_dir.display());
|
||||
}
|
||||
} else {
|
||||
info!("Keeping temporary stack at: {}", self.root_dir.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TemporaryStack {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = tokio::runtime::Handle::current().block_on(self.shutdown()) {
|
||||
log::error!("Error during temporary stack cleanup: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Integration in Main
|
||||
|
||||
Add to `botserver/src/main.rs`:
|
||||
|
||||
```rust
|
||||
mod temp_stack;
|
||||
use temp_stack::TemporaryStack;
|
||||
|
||||
async fn run_temp_stack(args: Args) -> anyhow::Result<()> {
|
||||
// Setup logging
|
||||
env_logger::Builder::from_default_env()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.try_init()?;
|
||||
|
||||
info!("Starting BotServer in temporary stack mode");
|
||||
|
||||
// Create temporary stack
|
||||
let mut temp_stack = TemporaryStack::new(
|
||||
args.stack_root,
|
||||
args.keep_temp_stack,
|
||||
args.temp_stack_timeout,
|
||||
).await?;
|
||||
|
||||
// Start services
|
||||
temp_stack.start_services().await?;
|
||||
temp_stack.write_env_config()?;
|
||||
|
||||
// Wait for services to be ready
|
||||
temp_stack.wait_ready(Duration::from_secs(30)).await?;
|
||||
|
||||
info!("Temporary stack ready!");
|
||||
info!("Stack directory: {}", temp_stack.root_dir.display());
|
||||
info!("Environment config: {}/env.stack", temp_stack.root_dir.display());
|
||||
|
||||
// Setup auto-shutdown timer if specified
|
||||
if let Some(timeout) = temp_stack.auto_shutdown_duration {
|
||||
tokio::spawn(async move {
|
||||
sleep(timeout).await;
|
||||
info!("Auto-shutdown timeout reached, shutting down");
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Continue with normal BotServer startup using the temp stack config
|
||||
run_botserver_with_stack(temp_stack).await
|
||||
}
|
||||
```
|
||||
|
||||
## Using Temporary Stack in Tests
|
||||
|
||||
### In Test Harness
|
||||
|
||||
```rust
|
||||
// bottest/src/harness.rs
|
||||
|
||||
pub struct TemporaryStackHandle {
|
||||
stack: TemporaryStack,
|
||||
}
|
||||
|
||||
impl TestHarness {
|
||||
pub async fn with_temp_stack() -> anyhow::Result<Self> {
|
||||
let mut stack = TemporaryStack::new(None, false, None).await?;
|
||||
stack.start_services().await?;
|
||||
stack.wait_ready(Duration::from_secs(30)).await?;
|
||||
|
||||
// Load environment from stack config
|
||||
let env_file = stack.root_dir.join("env.stack");
|
||||
load_env_file(&env_file)?;
|
||||
|
||||
// Create harness with temp stack
|
||||
let mut harness = Self::new();
|
||||
harness.temp_stack = Some(stack);
|
||||
Ok(harness)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### In E2E Tests
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_with_temp_stack() {
|
||||
// Spawn temporary stack
|
||||
let mut harness = TestHarness::with_temp_stack()
|
||||
.await
|
||||
.expect("Failed to create temp stack");
|
||||
|
||||
// Tests run in isolation
|
||||
// Stack automatically cleaned up on drop
|
||||
}
|
||||
```
|
||||
|
||||
## Running Tests with Temporary Stack
|
||||
|
||||
```bash
|
||||
# Run E2E test with automatic temporary stack
|
||||
cargo test --test e2e test_complete_platform_flow -- --nocapture
|
||||
|
||||
# Keep temporary stack for debugging on failure
|
||||
KEEP_TEMP_STACK_ON_ERROR=1 cargo test --test e2e -- --nocapture
|
||||
|
||||
# Use custom temporary directory
|
||||
cargo test --test e2e -- --nocapture \
|
||||
--temp-stack-root /var/tmp/bottest
|
||||
|
||||
# Run with browser UI visible
|
||||
HEADED=1 cargo test --test e2e -- --nocapture
|
||||
|
||||
# Run with auto-shutdown after 5 minutes (300 seconds)
|
||||
cargo test --test e2e -- --nocapture \
|
||||
--temp-stack-timeout 300
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Control temporary stack behavior with environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SKIP_E2E_TESTS` | unset | Skip E2E tests if set |
|
||||
| `HEADED` | unset | Show browser UI instead of headless |
|
||||
| `KEEP_TEMP_STACK_ON_ERROR` | unset | Keep temp directory if test fails |
|
||||
| `WEBDRIVER_URL` | `http://localhost:4444` | WebDriver endpoint for browser automation |
|
||||
| `LOG_LEVEL` | `info` | Logging level: debug, info, warn, error |
|
||||
| `TEMP_STACK_TIMEOUT` | unset | Auto-shutdown timeout in seconds |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### PostgreSQL fails to start
|
||||
|
||||
```bash
|
||||
# Make sure PostgreSQL binaries are installed
|
||||
which postgres initdb pg_isready
|
||||
|
||||
# Check if port 5433 is available
|
||||
lsof -i :5433
|
||||
|
||||
# Initialize manually
|
||||
initdb -D /tmp/botserver-test-*/postgres/data
|
||||
```
|
||||
|
||||
### Redis fails to start
|
||||
|
||||
```bash
|
||||
# Verify Redis is installed
|
||||
which redis-server redis-cli
|
||||
|
||||
# Check if port 6380 is available
|
||||
lsof -i :6380
|
||||
```
|
||||
|
||||
### Cleanup issues
|
||||
|
||||
```bash
|
||||
# Manually cleanup stale directories
|
||||
rm -rf /tmp/botserver-test-*
|
||||
|
||||
# Keep temporary stack for debugging
|
||||
KEEP_TEMP_STACK_ON_ERROR=1 cargo test --test e2e
|
||||
```
|
||||
|
||||
### Check stack logs
|
||||
|
||||
```bash
|
||||
# View BotServer logs
|
||||
tail -f /tmp/botserver-test-{id}/botserver/logs/botserver.log
|
||||
|
||||
# View database logs
|
||||
tail -f /tmp/botserver-test-{id}/postgres.log
|
||||
|
||||
# View all logs
|
||||
ls -la /tmp/botserver-test-{id}/*/
|
||||
```
|
||||
|
||||
## Benefits Summary
|
||||
|
||||
✓ **Isolation** - Each test has its own environment
|
||||
✓ **Automation** - No manual setup required
|
||||
✓ **Reproducibility** - Same setup every time
|
||||
✓ **Safety** - Won't interfere with main development
|
||||
✓ **Cleanup** - Automatic resource management
|
||||
✓ **Debugging** - Can preserve stacks for investigation
|
||||
✓ **CI/CD Ready** - Perfect for automated testing pipelines
|
||||
✓ **Scalability** - Run multiple tests in parallel with port management
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod auth_flow;
|
||||
mod chat;
|
||||
mod dashboard;
|
||||
mod platform_flow;
|
||||
|
||||
use bottest::prelude::*;
|
||||
use bottest::web::{Browser, BrowserConfig, BrowserType};
|
||||
|
|
|
|||
418
tests/e2e/platform_flow.rs
Normal file
418
tests/e2e/platform_flow.rs
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
//! Complete E2E test for General Bots platform flow
|
||||
//!
|
||||
//! Tests the full user journey:
|
||||
//! 1. Platform loading (UI assets)
|
||||
//! 2. BotServer initialization
|
||||
//! 3. User login
|
||||
//! 4. Chat interaction
|
||||
//! 5. User logout
|
||||
|
||||
use bottest::prelude::*;
|
||||
use bottest::web::{Browser, BrowserConfig};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{browser_config, check_webdriver_available, should_run_e2e_tests, E2ETestContext};
|
||||
|
||||
/// Step 1: Verify platform loads
|
||||
/// - Check UI is served
|
||||
/// - Verify health endpoint responds
|
||||
/// - Confirm database migrations completed
|
||||
pub async fn verify_platform_loading(ctx: &E2ETestContext) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Check health endpoint
|
||||
let health_url = format!("{}/health", ctx.base_url());
|
||||
let health_resp = client.get(&health_url).send().await?;
|
||||
assert!(
|
||||
health_resp.status().is_success(),
|
||||
"Health check failed with status: {}",
|
||||
health_resp.status()
|
||||
);
|
||||
|
||||
println!("✓ Platform health check passed");
|
||||
|
||||
// Verify API is responsive
|
||||
let api_url = format!("{}/api/v1", ctx.base_url());
|
||||
let api_resp = client.get(&api_url).send().await?;
|
||||
assert!(
|
||||
api_resp.status().is_success() || api_resp.status().as_u16() == 401,
|
||||
"API endpoint failed with status: {}",
|
||||
api_resp.status()
|
||||
);
|
||||
|
||||
println!("✓ Platform API responsive");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 2: Verify BotServer is running and initialized
|
||||
/// - Check service discovery
|
||||
/// - Verify configuration loaded
|
||||
/// - Confirm database connection
|
||||
pub async fn verify_botserver_running(ctx: &E2ETestContext) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Check if server is actually running
|
||||
assert!(ctx.server.is_running(), "BotServer process is not running");
|
||||
|
||||
println!("✓ BotServer process running");
|
||||
|
||||
// Verify server info endpoint
|
||||
let info_url = format!("{}/api/v1/server/info", ctx.base_url());
|
||||
match client.get(&info_url).send().await {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
let body = resp.text().await?;
|
||||
assert!(!body.is_empty(), "Server info response is empty");
|
||||
println!(
|
||||
"✓ BotServer initialized with info: {}",
|
||||
body.chars().take(100).collect::<String>()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"⚠ Server info endpoint returned {}, continuing anyway",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"⚠ Could not reach server info endpoint: {}, continuing anyway",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("✓ BotServer is running and initialized");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 3: User login flow
|
||||
/// - Navigate to login page
|
||||
/// - Enter test credentials
|
||||
/// - Verify session created
|
||||
/// - Confirm redirect to dashboard
|
||||
pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
|
||||
let login_url = format!("{}/login", ctx.base_url());
|
||||
|
||||
// Navigate to login page
|
||||
browser.navigate(&login_url).await?;
|
||||
println!("✓ Navigated to login page: {}", login_url);
|
||||
|
||||
// Wait for login form to be visible
|
||||
let timeout = Duration::from_secs(10);
|
||||
browser
|
||||
.wait_for_element("input[type='email']", timeout)
|
||||
.await?;
|
||||
println!("✓ Login form loaded");
|
||||
|
||||
// Fill in test credentials
|
||||
let test_email = "test@example.com";
|
||||
let test_password = "TestPassword123!";
|
||||
|
||||
browser
|
||||
.fill_input("input[type='email']", test_email)
|
||||
.await?;
|
||||
println!("✓ Entered email: {}", test_email);
|
||||
|
||||
browser
|
||||
.fill_input("input[type='password']", test_password)
|
||||
.await?;
|
||||
println!("✓ Entered password");
|
||||
|
||||
// Submit login form
|
||||
let submit_timeout = Duration::from_secs(5);
|
||||
browser
|
||||
.click("button[type='submit']", submit_timeout)
|
||||
.await?;
|
||||
println!("✓ Clicked login button");
|
||||
|
||||
// Wait for redirect or dashboard
|
||||
let redirect_timeout = Duration::from_secs(15);
|
||||
let current_url = browser.get_current_url(redirect_timeout).await?;
|
||||
|
||||
// Check we're not on login page anymore
|
||||
assert!(
|
||||
!current_url.contains("/login"),
|
||||
"Still on login page after login attempt. URL: {}",
|
||||
current_url
|
||||
);
|
||||
|
||||
println!("✓ Redirected from login page to: {}", current_url);
|
||||
|
||||
// Verify we can see dashboard or chat area
|
||||
browser
|
||||
.wait_for_element(
|
||||
"[data-testid='chat-area'], [data-testid='dashboard'], main",
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
println!("✓ Dashboard or chat area visible");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 4: Chat interaction
|
||||
/// - Open chat window
|
||||
/// - Send test message
|
||||
/// - Receive bot response
|
||||
/// - Verify message persisted
|
||||
pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
|
||||
// Ensure we're on chat page
|
||||
let chat_url = format!("{}/chat", ctx.base_url());
|
||||
browser.navigate(&chat_url).await?;
|
||||
println!("✓ Navigated to chat page");
|
||||
|
||||
// Wait for chat interface to load
|
||||
browser
|
||||
.wait_for_element(
|
||||
"[data-testid='message-input'], textarea.chat-input, input.message",
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
println!("✓ Chat interface loaded");
|
||||
|
||||
// Send test message
|
||||
let test_message = "Hello, I need help";
|
||||
browser
|
||||
.fill_input("textarea.chat-input, input.message", test_message)
|
||||
.await?;
|
||||
println!("✓ Typed message: {}", test_message);
|
||||
|
||||
// Click send button or press Enter
|
||||
let send_result = browser
|
||||
.click(
|
||||
"button[data-testid='send-button'], button.send-btn",
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await;
|
||||
|
||||
if send_result.is_err() {
|
||||
// Try pressing Enter as alternative
|
||||
browser.press_key("Enter").await?;
|
||||
println!("✓ Sent message with Enter key");
|
||||
} else {
|
||||
println!("✓ Clicked send button");
|
||||
}
|
||||
|
||||
// Wait for message to appear in chat history
|
||||
browser
|
||||
.wait_for_element(
|
||||
"[data-testid='message-item'], .message-bubble, [class*='message']",
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
println!("✓ Message appeared in chat");
|
||||
|
||||
// Wait for bot response
|
||||
let response_timeout = Duration::from_secs(30);
|
||||
browser
|
||||
.wait_for_element(
|
||||
"[data-testid='bot-response'], .bot-message, [class*='bot']",
|
||||
response_timeout,
|
||||
)
|
||||
.await?;
|
||||
println!("✓ Received bot response");
|
||||
|
||||
// Get response text
|
||||
let response_text = browser
|
||||
.get_text("[data-testid='bot-response'], .bot-message, [class*='bot']")
|
||||
.await
|
||||
.ok();
|
||||
|
||||
if let Some(text) = response_text {
|
||||
println!(
|
||||
"✓ Bot response: {}",
|
||||
text.chars().take(100).collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 5: User logout flow
|
||||
/// - Click logout button
|
||||
/// - Verify session invalidated
|
||||
/// - Confirm redirect to login
|
||||
/// - Verify cannot access protected routes
|
||||
pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
|
||||
// Find and click logout button
|
||||
let logout_selectors = vec![
|
||||
"button[data-testid='logout-btn']",
|
||||
"button.logout",
|
||||
"[data-testid='user-menu'] button[data-testid='logout']",
|
||||
"a[href*='logout']",
|
||||
];
|
||||
|
||||
let mut logout_found = false;
|
||||
for selector in logout_selectors {
|
||||
if let Ok(_) = browser.click(selector, Duration::from_secs(3)).await {
|
||||
println!("✓ Clicked logout button: {}", selector);
|
||||
logout_found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !logout_found {
|
||||
println!("⚠ Could not find logout button, attempting with keyboard shortcut");
|
||||
browser.press_key("l").await.ok(); // Some apps use 'l' for logout
|
||||
}
|
||||
|
||||
// Wait for redirect to login
|
||||
let redirect_timeout = Duration::from_secs(10);
|
||||
let current_url = browser.get_current_url(redirect_timeout).await?;
|
||||
|
||||
assert!(
|
||||
current_url.contains("/login") || current_url.contains("/auth"),
|
||||
"Not redirected to login page after logout. URL: {}",
|
||||
current_url
|
||||
);
|
||||
|
||||
println!("✓ Redirected to login page after logout: {}", current_url);
|
||||
|
||||
// Verify we cannot access protected routes
|
||||
let chat_url = format!("{}/chat", ctx.base_url());
|
||||
browser.navigate(&chat_url).await?;
|
||||
|
||||
let check_url = browser.get_current_url(Duration::from_secs(5)).await?;
|
||||
assert!(
|
||||
check_url.contains("/login") || check_url.contains("/auth"),
|
||||
"Should be redirected to login when accessing protected route after logout. URL: {}",
|
||||
check_url
|
||||
);
|
||||
|
||||
println!("✓ Protected routes properly redirect to login");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Complete platform flow test
|
||||
///
|
||||
/// This test validates the entire user journey:
|
||||
/// 1. Platform loads successfully
|
||||
/// 2. BotServer is initialized and running
|
||||
/// 3. User can login with credentials
|
||||
/// 4. User can interact with chat
|
||||
/// 5. User can logout and lose access
|
||||
#[tokio::test]
|
||||
async fn test_complete_platform_flow_login_chat_logout() {
|
||||
if !should_run_e2e_tests() {
|
||||
eprintln!("Skipping: E2E tests disabled (set SKIP_E2E_TESTS env var to disable)");
|
||||
return;
|
||||
}
|
||||
|
||||
if !check_webdriver_available().await {
|
||||
eprintln!("Skipping: WebDriver not available at configured URL");
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n=== Starting Complete Platform Flow Test ===\n");
|
||||
|
||||
// Setup context
|
||||
let ctx = match E2ETestContext::setup_with_browser().await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to setup E2E context: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !ctx.has_browser() {
|
||||
eprintln!("Browser not available");
|
||||
return;
|
||||
}
|
||||
|
||||
let browser = ctx.browser.as_ref().unwrap();
|
||||
|
||||
// Test each phase
|
||||
println!("\n--- Phase 1: Platform Loading ---");
|
||||
if let Err(e) = verify_platform_loading(&ctx).await {
|
||||
eprintln!("Platform loading test failed: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n--- Phase 2: BotServer Initialization ---");
|
||||
if let Err(e) = verify_botserver_running(&ctx).await {
|
||||
eprintln!("BotServer initialization test failed: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n--- Phase 3: User Login ---");
|
||||
if let Err(e) = test_user_login(browser, &ctx).await {
|
||||
eprintln!("Login test failed: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n--- Phase 4: Chat Interaction ---");
|
||||
if let Err(e) = test_chat_interaction(browser, &ctx).await {
|
||||
eprintln!("Chat interaction test failed: {}", e);
|
||||
// Don't return here - try to logout anyway
|
||||
}
|
||||
|
||||
println!("\n--- Phase 5: User Logout ---");
|
||||
if let Err(e) = test_user_logout(browser, &ctx).await {
|
||||
eprintln!("Logout test failed: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n=== Complete Platform Flow Test PASSED ===\n");
|
||||
|
||||
ctx.close().await;
|
||||
}
|
||||
|
||||
/// Simpler test for basic platform loading without browser
|
||||
#[tokio::test]
|
||||
async fn test_platform_loading_http_only() {
|
||||
if !should_run_e2e_tests() {
|
||||
eprintln!("Skipping: E2E tests disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n=== Testing Platform Loading (HTTP Only) ===\n");
|
||||
|
||||
let ctx = match E2ETestContext::setup().await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to setup context: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = verify_platform_loading(&ctx).await {
|
||||
eprintln!("Platform loading failed: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n✓ Platform Loading Test PASSED\n");
|
||||
|
||||
ctx.close().await;
|
||||
}
|
||||
|
||||
/// Test BotServer startup and health
|
||||
#[tokio::test]
|
||||
async fn test_botserver_startup() {
|
||||
if !should_run_e2e_tests() {
|
||||
eprintln!("Skipping: E2E tests disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n=== Testing BotServer Startup ===\n");
|
||||
|
||||
let ctx = match E2ETestContext::setup().await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to setup context: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = verify_botserver_running(&ctx).await {
|
||||
eprintln!("BotServer test failed: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n✓ BotServer Startup Test PASSED\n");
|
||||
|
||||
ctx.close().await;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue