初始化提交
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled

This commit is contained in:
iven
2026-03-01 16:24:24 +08:00
commit 92e5def702
492 changed files with 211343 additions and 0 deletions

797
tests/e2e_common.rs Normal file
View File

@@ -0,0 +1,797 @@
//! Common test utilities for E2E testing.
//!
//! This module provides utilities for:
//! - Spawning and managing test daemon instances
//! - Creating test agents
//! - Making API calls
//! - Waiting for service readiness
//!
//! ## Example
//!
//! ```rust,ignore
//! use e2e::common::*;
//!
//! #[tokio::test]
//! async fn my_test() {
//! let daemon = spawn_daemon().await;
//! wait_for_health(&daemon.base_url()).await;
//!
//! let agent = create_test_agent(&daemon, DEFAULT_MANIFEST).await;
//! let response = send_message(&daemon, &agent.id, "Hello!").await;
//!
//! assert!(!response.is_empty());
//! }
use axum::Router;
use openfang_api::middleware;
use openfang_api::rate_limiter;
use openfang_api::routes::{self, AppState};
use openfang_api::ws;
use openfang_kernel::OpenFangKernel;
use openfang_types::config::{DefaultModelConfig, KernelConfig};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tempfile::TempDir;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Default test manifest using ollama (no API key required).
pub const DEFAULT_MANIFEST: &str = r#"
name = "test-agent"
version = "0.1.0"
description = "E2E test agent"
author = "test"
module = "builtin:chat"
[model]
provider = "ollama"
model = "test-model"
system_prompt = "You are a test agent. Reply concisely."
[capabilities]
tools = ["file_read"]
memory_read = ["*"]
memory_write = ["self.*"]
"#;
/// Manifest for real LLM tests using Groq.
pub const LLM_MANIFEST: &str = r#"
name = "llm-test-agent"
version = "0.1.0"
description = "E2E test agent with real LLM"
author = "test"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
system_prompt = "You are a test agent. Reply concisely in 5 words or less."
[capabilities]
tools = ["file_read"]
memory_read = ["*"]
memory_write = ["self.*"]
"#;
/// Maximum time to wait for daemon health check.
pub const HEALTH_CHECK_TIMEOUT_SECS: u64 = 30;
/// Interval between health check retries.
pub const HEALTH_CHECK_INTERVAL_MS: u64 = 100;
// ---------------------------------------------------------------------------
// Test Daemon
// ---------------------------------------------------------------------------
/// A running test daemon instance.
///
/// This struct manages the lifecycle of a test daemon:
/// - Holds a reference to the temp directory (keeps it alive)
/// - Holds a reference to the kernel state
/// - Provides the base URL for API calls
///
/// When dropped, the daemon is automatically shut down.
pub struct TestDaemon {
base_url: String,
state: Arc<AppState>,
_tmp: TempDir,
addr: SocketAddr,
}
impl Drop for TestDaemon {
fn drop(&mut self) {
self.state.kernel.shutdown();
}
}
impl TestDaemon {
/// Get the base URL for API calls.
pub fn base_url(&self) -> &str {
&self.base_url
}
/// Get the socket address the daemon is listening on.
pub fn addr(&self) -> SocketAddr {
self.addr
}
/// Get a reference to the kernel state.
pub fn state(&self) -> &Arc<AppState> {
&self.state
}
/// Get the temp directory path.
pub fn temp_dir(&self) -> &PathBuf {
self._tmp.path()
}
/// Trigger a graceful shutdown.
pub async fn shutdown(&self) {
self.state.shutdown_notify.notify_one();
}
}
/// Configuration for spawning a test daemon.
#[derive(Debug, Clone)]
pub struct DaemonConfig {
/// LLM provider to use (e.g., "ollama", "groq").
pub provider: String,
/// Model name for the LLM.
pub model: String,
/// Environment variable name for the API key.
pub api_key_env: String,
/// Optional API key for authentication.
pub auth_key: Option<String>,
/// Base URL for the LLM API (optional).
pub base_url: Option<String>,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
provider: "ollama".to_string(),
model: "test-model".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
auth_key: None,
base_url: None,
}
}
}
impl DaemonConfig {
/// Create a config for ollama (no API key needed).
pub fn ollama() -> Self {
Self::default()
}
/// Create a config for Groq (requires GROQ_API_KEY).
pub fn groq() -> Self {
Self {
provider: "groq".to_string(),
model: "llama-3.3-70b-versatile".to_string(),
api_key_env: "GROQ_API_KEY".to_string(),
auth_key: None,
base_url: None,
}
}
/// Set the authentication key for the daemon.
pub fn with_auth_key(mut self, key: impl Into<String>) -> Self {
self.auth_key = Some(key.into());
self
}
}
/// Spawn a test daemon with default configuration (ollama, no auth).
///
/// This creates an isolated daemon instance with:
/// - A random available port
/// - A temporary directory for data
/// - Ollama as the default provider (no API key needed)
///
/// # Example
///
/// ```rust,ignore
/// let daemon = spawn_daemon().await;
/// wait_for_health(&daemon.base_url()).await;
/// ```
pub async fn spawn_daemon() -> TestDaemon {
spawn_daemon_with_config(DaemonConfig::default()).await
}
/// Spawn a test daemon with custom configuration.
///
/// # Example
///
/// ```rust,ignore
/// let config = DaemonConfig::groq();
/// let daemon = spawn_daemon_with_config(config).await;
/// ```
pub async fn spawn_daemon_with_config(config: DaemonConfig) -> TestDaemon {
let tmp = tempfile::tempdir().expect("Failed to create temp dir");
let kernel_config = KernelConfig {
home_dir: tmp.path().to_path_buf(),
data_dir: tmp.path().join("data"),
api_key: config.auth_key.clone().unwrap_or_default(),
default_model: DefaultModelConfig {
provider: config.provider.clone(),
model: config.model.clone(),
api_key_env: config.api_key_env.clone(),
base_url: config.base_url.clone(),
},
..KernelConfig::default()
};
let kernel = OpenFangKernel::boot_with_config(kernel_config).expect("Kernel should boot");
let kernel = Arc::new(kernel);
kernel.set_self_handle();
let state = Arc::new(AppState {
kernel: kernel.clone(),
started_at: Instant::now(),
peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())),
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
});
let app = build_test_router(state.clone(), config.auth_key.is_some());
// Bind to random available port
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("Failed to bind test server");
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestDaemon {
base_url: format!("http://{}", addr),
state,
_tmp: tmp,
addr,
}
}
/// Build the test router with all routes.
fn build_test_router(state: Arc<AppState>, with_auth: bool) -> Router<()> {
let api_key = if with_auth {
state.kernel.config.api_key.clone()
} else {
String::new()
};
let gcra_limiter = rate_limiter::create_rate_limiter();
let mut app = Router::new()
// Health and status
.route("/api/health", axum::routing::get(routes::health))
.route(
"/api/health/detail",
axum::routing::get(routes::health_detail),
)
.route("/api/status", axum::routing::get(routes::status))
.route("/api/version", axum::routing::get(routes::version))
// Agent endpoints
.route(
"/api/agents",
axum::routing::get(routes::list_agents).post(routes::spawn_agent),
)
.route(
"/api/agents/{id}",
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
)
.route(
"/api/agents/{id}/message",
axum::routing::post(routes::send_message),
)
.route(
"/api/agents/{id}/message/stream",
axum::routing::post(routes::send_message_stream),
)
.route(
"/api/agents/{id}/session",
axum::routing::get(routes::get_agent_session),
)
.route(
"/api/agents/{id}/sessions",
axum::routing::get(routes::list_agent_sessions)
.post(routes::create_agent_session),
)
.route(
"/api/agents/{id}/sessions/{session_id}/switch",
axum::routing::post(routes::switch_agent_session),
)
.route(
"/api/agents/{id}/session/reset",
axum::routing::post(routes::reset_session),
)
.route(
"/api/agents/{id}/stop",
axum::routing::post(routes::stop_agent),
)
.route(
"/api/agents/{id}/model",
axum::routing::put(routes::set_model),
)
.route(
"/api/agents/{id}/skills",
axum::routing::get(routes::get_agent_skills).put(routes::set_agent_skills),
)
.route(
"/api/agents/{id}/clone",
axum::routing::post(routes::clone_agent),
)
.route("/api/agents/{id}/ws", axum::routing::get(ws::agent_ws))
// Profile endpoints
.route("/api/profiles", axum::routing::get(routes::list_profiles))
// Memory endpoints
.route(
"/api/memory/agents/{id}/kv",
axum::routing::get(routes::get_agent_kv),
)
.route(
"/api/memory/agents/{id}/kv/{key}",
axum::routing::get(routes::get_agent_kv_key)
.put(routes::set_agent_kv_key)
.delete(routes::delete_agent_kv_key),
)
// Trigger endpoints
.route(
"/api/triggers",
axum::routing::get(routes::list_triggers).post(routes::create_trigger),
)
.route(
"/api/triggers/{id}",
axum::routing::delete(routes::delete_trigger).put(routes::update_trigger),
)
// Schedule endpoints
.route(
"/api/schedules",
axum::routing::get(routes::list_schedules).post(routes::create_schedule),
)
.route(
"/api/schedules/{id}",
axum::routing::delete(routes::delete_schedule).put(routes::update_schedule),
)
.route(
"/api/schedules/{id}/run",
axum::routing::post(routes::run_schedule),
)
// Workflow endpoints
.route(
"/api/workflows",
axum::routing::get(routes::list_workflows).post(routes::create_workflow),
)
.route(
"/api/workflows/{id}/run",
axum::routing::post(routes::run_workflow),
)
.route(
"/api/workflows/{id}/runs",
axum::routing::get(routes::list_workflow_runs),
)
// Budget endpoints
.route(
"/api/budget",
axum::routing::get(routes::budget_status).put(routes::update_budget),
)
.route(
"/api/budget/agents",
axum::routing::get(routes::agent_budget_ranking),
)
.route(
"/api/budget/agents/{id}",
axum::routing::get(routes::agent_budget_status),
)
// Usage endpoints
.route("/api/usage", axum::routing::get(routes::usage_stats))
.route(
"/api/usage/summary",
axum::routing::get(routes::usage_summary),
)
.route(
"/api/usage/by-model",
axum::routing::get(routes::usage_by_model),
)
.route("/api/usage/daily", axum::routing::get(routes::usage_daily))
// Peer/Network endpoints
.route("/api/peers", axum::routing::get(routes::list_peers))
.route(
"/api/network/status",
axum::routing::get(routes::network_status),
)
// A2A endpoints
.route("/api/a2a/agents", axum::routing::get(routes::a2a_list_external_agents))
.route(
"/api/a2a/discover",
axum::routing::post(routes::a2a_discover_external),
)
.route(
"/api/a2a/send",
axum::routing::post(routes::a2a_send_external),
)
.route(
"/api/a2a/tasks/{id}/status",
axum::routing::get(routes::a2a_external_task_status),
)
// Model endpoints
.route("/api/models", axum::routing::get(routes::list_models))
.route("/api/providers", axum::routing::get(routes::list_providers))
// Config endpoints
.route("/api/config", axum::routing::get(routes::get_config))
.route("/api/config/set", axum::routing::post(routes::config_set))
// Shutdown
.route("/api/shutdown", axum::routing::post(routes::shutdown))
// Middleware
.layer(axum::middleware::from_fn(middleware::security_headers))
.layer(axum::middleware::from_fn(middleware::request_logging))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state.clone());
if with_auth {
app = app.layer(axum::middleware::from_fn_with_state(
api_key,
middleware::auth,
));
}
app = app.layer(axum::middleware::from_fn_with_state(
gcra_limiter,
rate_limiter::gcra_rate_limit,
));
app
}
// ---------------------------------------------------------------------------
// Health Check Utilities
// ---------------------------------------------------------------------------
/// Wait for the daemon to become healthy.
///
/// Polls the `/api/health` endpoint until it returns 200 or times out.
///
/// # Panics
///
/// Panics if the daemon doesn't become healthy within the timeout.
pub async fn wait_for_health(base_url: &str) {
wait_for_health_with_timeout(base_url, Duration::from_secs(HEALTH_CHECK_TIMEOUT_SECS)).await
}
/// Wait for the daemon to become healthy with a custom timeout.
pub async fn wait_for_health_with_timeout(base_url: &str, timeout: Duration) {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.build()
.unwrap();
let start = Instant::now();
while start.elapsed() < timeout {
match client.get(format!("{}/api/health", base_url)).send().await {
Ok(resp) if resp.status().is_success() => return,
_ => tokio::time::sleep(Duration::from_millis(HEALTH_CHECK_INTERVAL_MS)).await,
}
}
panic!(
"Daemon did not become healthy within {:?}",
timeout
);
}
// ---------------------------------------------------------------------------
// Agent Utilities
// ---------------------------------------------------------------------------
/// Information about a spawned test agent.
#[derive(Debug, Clone)]
pub struct TestAgent {
/// Agent UUID.
pub id: String,
/// Agent name.
pub name: String,
/// Model provider.
pub provider: String,
/// Model name.
pub model: String,
}
/// Create a test agent with the given manifest.
///
/// # Example
///
/// ```rust,ignore
/// let daemon = spawn_daemon().await;
/// let agent = create_test_agent(&daemon, DEFAULT_MANIFEST).await;
/// println!("Created agent: {}", agent.id);
/// ```
pub async fn create_test_agent(daemon: &TestDaemon, manifest: &str) -> TestAgent {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/api/agents", daemon.base_url()))
.json(&serde_json::json!({"manifest_toml": manifest}))
.send()
.await
.expect("Failed to create agent");
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
panic!("Failed to create agent: {} - {}", status, body);
}
let body: serde_json::Value = resp.json().await.expect("Invalid JSON response");
TestAgent {
id: body["agent_id"].as_str().unwrap().to_string(),
name: body["name"].as_str().unwrap().to_string(),
provider: body["model_provider"].as_str().unwrap_or("unknown").to_string(),
model: body["model_name"].as_str().unwrap_or("unknown").to_string(),
}
}
/// Create a test agent with a custom name.
pub async fn create_named_test_agent(daemon: &TestDaemon, name: &str) -> TestAgent {
let manifest = format!(
r#"
name = "{}"
version = "0.1.0"
description = "Named test agent"
author = "test"
module = "builtin:chat"
[model]
provider = "ollama"
model = "test-model"
system_prompt = "You are a test agent."
[capabilities]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
name
);
create_test_agent(daemon, &manifest).await
}
/// Kill an agent by ID.
pub async fn kill_agent(daemon: &TestDaemon, agent_id: &str) {
let client = reqwest::Client::new();
let resp = client
.delete(format!("{}/api/agents/{}", daemon.base_url(), agent_id))
.send()
.await
.expect("Failed to kill agent");
assert!(resp.status().is_success(), "Failed to kill agent: {}", resp.status());
}
// ---------------------------------------------------------------------------
// Message Utilities
// ---------------------------------------------------------------------------
/// Response from sending a message to an agent.
#[derive(Debug, Clone)]
pub struct MessageResponse {
/// The agent's response text.
pub response: String,
/// Number of input tokens used.
pub input_tokens: u64,
/// Number of output tokens used.
pub output_tokens: u64,
/// Raw JSON response.
pub raw: serde_json::Value,
}
/// Send a message to an agent.
///
/// # Example
///
/// ```rust,ignore
/// let daemon = spawn_daemon().await;
/// let agent = create_test_agent(&daemon, LLM_MANIFEST).await;
/// let response = send_message(&daemon, &agent.id, "Hello!").await;
/// println!("Response: {}", response.response);
/// ```
pub async fn send_message(daemon: &TestDaemon, agent_id: &str, message: &str) -> MessageResponse {
let client = reqwest::Client::new();
let resp = client
.post(format!(
"{}/api/agents/{}/message",
daemon.base_url(),
agent_id
))
.json(&serde_json::json!({"message": message}))
.send()
.await
.expect("Failed to send message");
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
panic!("Failed to send message: {} - {}", status, body);
}
let raw: serde_json::Value = resp.json().await.expect("Invalid JSON response");
MessageResponse {
response: raw["response"].as_str().unwrap_or_default().to_string(),
input_tokens: raw["input_tokens"].as_u64().unwrap_or(0),
output_tokens: raw["output_tokens"].as_u64().unwrap_or(0),
raw,
}
}
// ---------------------------------------------------------------------------
// API Helpers
// ---------------------------------------------------------------------------
/// Make a GET request to the daemon.
pub async fn get(daemon: &TestDaemon, path: &str) -> serde_json::Value {
let client = reqwest::Client::new();
let resp = client
.get(format!("{}{}", daemon.base_url(), path))
.send()
.await
.expect("GET request failed");
resp.json().await.expect("Invalid JSON response")
}
/// Make a POST request to the daemon.
pub async fn post(daemon: &TestDaemon, path: &str, body: &serde_json::Value) -> serde_json::Value {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}{}", daemon.base_url(), path))
.json(body)
.send()
.await
.expect("POST request failed");
resp.json().await.expect("Invalid JSON response")
}
/// Make a PUT request to the daemon.
pub async fn put(daemon: &TestDaemon, path: &str, body: &serde_json::Value) -> serde_json::Value {
let client = reqwest::Client::new();
let resp = client
.put(format!("{}{}", daemon.base_url(), path))
.json(body)
.send()
.await
.expect("PUT request failed");
resp.json().await.expect("Invalid JSON response")
}
/// Make a DELETE request to the daemon.
pub async fn delete(daemon: &TestDaemon, path: &str) -> serde_json::Value {
let client = reqwest::Client::new();
let resp = client
.delete(format!("{}{}", daemon.base_url(), path))
.send()
.await
.expect("DELETE request failed");
resp.json().await.expect("Invalid JSON response")
}
/// List all agents.
pub async fn list_agents(daemon: &TestDaemon) -> Vec<serde_json::Value> {
get(daemon, "/api/agents").await.as_array().unwrap().clone()
}
/// Get agent count.
pub async fn agent_count(daemon: &TestDaemon) -> usize {
list_agents(daemon).await.len()
}
/// Check if LLM tests can run (API key is available).
pub fn can_run_llm_tests() -> bool {
std::env::var("GROQ_API_KEY").is_ok()
}
/// Skip reason for LLM tests.
pub fn llm_skip_reason() -> &'static str {
"GROQ_API_KEY not set, skipping LLM integration test"
}
// ---------------------------------------------------------------------------
// Assertion Helpers
// ---------------------------------------------------------------------------
/// Assert that a response has a successful status code.
pub fn assert_success(response: &reqwest::Response) {
assert!(
response.status().is_success(),
"Expected success, got: {}",
response.status()
);
}
/// Assert that a response has a specific status code.
pub fn assert_status(response: &reqwest::Response, expected: u16) {
let status = response.status();
assert_eq!(
status.as_u16(),
expected,
"Expected status {}, got: {}",
expected,
status
);
}
/// Assert that an agent exists.
pub async fn assert_agent_exists(daemon: &TestDaemon, agent_id: &str) {
let agents = list_agents(daemon).await;
let exists = agents.iter().any(|a| a["id"] == agent_id);
assert!(exists, "Agent {} should exist", agent_id);
}
/// Assert that an agent does not exist.
pub async fn assert_agent_not_exists(daemon: &TestDaemon, agent_id: &str) {
let agents = list_agents(daemon).await;
let exists = agents.iter().any(|a| a["id"] == agent_id);
assert!(!exists, "Agent {} should not exist", agent_id);
}
// ---------------------------------------------------------------------------
// Test Fixture Helpers
// ---------------------------------------------------------------------------
/// Create a workflow definition for testing.
pub fn create_test_workflow(agent_name: &str) -> serde_json::Value {
serde_json::json!({
"name": "test-workflow",
"description": "E2E test workflow",
"steps": [
{
"name": "step1",
"agent_name": agent_name,
"prompt": "Echo: {{input}}",
"mode": "sequential",
"timeout_secs": 30
}
]
})
}
/// Create a trigger definition for testing.
pub fn create_test_trigger(agent_id: &str) -> serde_json::Value {
serde_json::json!({
"agent_id": agent_id,
"pattern": "lifecycle",
"prompt_template": "Handle: {{event}}",
"max_fires": 5
})
}
/// Create a schedule definition for testing.
pub fn create_test_schedule(agent_id: &str) -> serde_json::Value {
serde_json::json!({
"agent_id": agent_id,
"cron": "0 * * * * *", // Every hour
"prompt": "Scheduled task check",
"enabled": true
})
}