初始化提交
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
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:
541
tests/e2e_api_test.rs
Normal file
541
tests/e2e_api_test.rs
Normal file
@@ -0,0 +1,541 @@
|
||||
//! Basic API endpoint tests for OpenFang.
|
||||
//!
|
||||
//! These tests verify the core API endpoints work correctly:
|
||||
//! - Health and status endpoints
|
||||
//! - Agent CRUD operations
|
||||
//! - Session management
|
||||
//! - Error handling
|
||||
|
||||
use super::e2e_common::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health & Status Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_endpoint_returns_ok() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let response = get(&daemon, "/api/health").await;
|
||||
|
||||
assert_eq!(response["status"], "ok");
|
||||
assert!(response["version"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_detail_endpoint() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let response = get(&daemon, "/api/health/detail").await;
|
||||
|
||||
assert_eq!(response["status"], "ok");
|
||||
// Detailed health includes more information
|
||||
assert!(response["version"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_status_endpoint() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let response = get(&daemon, "/api/status").await;
|
||||
|
||||
assert_eq!(response["status"], "running");
|
||||
assert_eq!(response["agent_count"], 0);
|
||||
assert!(response["uptime_seconds"].is_number());
|
||||
assert!(response["default_provider"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_version_endpoint() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let response = get(&daemon, "/api/version").await;
|
||||
|
||||
assert!(response["version"].is_string());
|
||||
assert!(response["commit"].is_string() || response["commit"].is_null());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent CRUD Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_spawn_agent() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let agent = create_test_agent(&daemon, DEFAULT_MANIFEST).await;
|
||||
|
||||
assert!(!agent.id.is_empty());
|
||||
assert_eq!(agent.name, "test-agent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_agents_empty() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let agents = list_agents(&daemon).await;
|
||||
|
||||
assert_eq!(agents.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_agents_with_agents() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
// Create two agents
|
||||
let agent1 = create_named_test_agent(&daemon, "agent-1").await;
|
||||
let agent2 = create_named_test_agent(&daemon, "agent-2").await;
|
||||
|
||||
let agents = list_agents(&daemon).await;
|
||||
|
||||
assert_eq!(agents.len(), 2);
|
||||
|
||||
// Verify both agents are present
|
||||
let ids: Vec<&str> = agents.iter().filter_map(|a| a["id"].as_str()).collect();
|
||||
assert!(ids.contains(&agent1.id.as_str()));
|
||||
assert!(ids.contains(&agent2.id.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_agent_by_id() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let agent = create_test_agent(&daemon, DEFAULT_MANIFEST).await;
|
||||
|
||||
let response = get(&daemon, &format!("/api/agents/{}", agent.id)).await;
|
||||
|
||||
assert_eq!(response["id"], agent.id);
|
||||
assert_eq!(response["name"], "test-agent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_kill_agent() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let agent = create_test_agent(&daemon, DEFAULT_MANIFEST).await;
|
||||
assert_eq!(agent_count(&daemon).await, 1);
|
||||
|
||||
kill_agent(&daemon, &agent.id).await;
|
||||
|
||||
assert_eq!(agent_count(&daemon).await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_kill_nonexistent_agent_returns_404() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let fake_id = uuid::Uuid::new_v4().to_string();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.delete(format!("{}/api/agents/{}", daemon.base_url(), fake_id))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_spawn_multiple_agents() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
// Create 5 agents
|
||||
let mut agent_ids = Vec::new();
|
||||
for i in 0..5 {
|
||||
let agent = create_named_test_agent(&daemon, &format!("agent-{}", i)).await;
|
||||
agent_ids.push(agent.id);
|
||||
}
|
||||
|
||||
assert_eq!(agent_count(&daemon).await, 5);
|
||||
|
||||
// Kill all agents
|
||||
for id in agent_ids {
|
||||
kill_agent(&daemon, &id).await;
|
||||
}
|
||||
|
||||
assert_eq!(agent_count(&daemon).await, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error Handling Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_agent_id_returns_400() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Invalid (non-UUID) agent ID
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents/not-a-uuid", daemon.base_url()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"].as_str().unwrap().contains("Invalid"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_manifest_returns_400() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", daemon.base_url()))
|
||||
.json(&serde_json::json!({"manifest_toml": "invalid {{ toml"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"].as_str().unwrap().contains("Invalid manifest"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_message_to_nonexistent_agent_returns_404() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let fake_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/message",
|
||||
daemon.base_url(),
|
||||
fake_id
|
||||
))
|
||||
.json(&serde_json::json!({"message": "hello"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_session_empty_initially() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let agent = create_test_agent(&daemon, DEFAULT_MANIFEST).await;
|
||||
|
||||
let response = get(&daemon, &format!("/api/agents/{}/session", agent.id)).await;
|
||||
|
||||
assert_eq!(response["message_count"], 0);
|
||||
assert_eq!(response["messages"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_is_public_without_auth() {
|
||||
// Start daemon with auth enabled
|
||||
let config = DaemonConfig::default().with_auth_key("secret-key-123");
|
||||
let daemon = spawn_daemon_with_config(config).await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
// Health endpoint should be accessible without auth
|
||||
let response = get(&daemon, "/api/health").await;
|
||||
assert_eq!(response["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_protected_endpoint_requires_auth() {
|
||||
// Start daemon with auth enabled
|
||||
let config = DaemonConfig::default().with_auth_key("secret-key-123");
|
||||
let daemon = spawn_daemon_with_config(config).await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Protected endpoint without auth header should return 401
|
||||
let resp = client
|
||||
.get(format!("{}/api/models", daemon.base_url()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_protected_endpoint_with_correct_auth() {
|
||||
// Start daemon with auth enabled
|
||||
let config = DaemonConfig::default().with_auth_key("secret-key-123");
|
||||
let daemon = spawn_daemon_with_config(config).await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Protected endpoint with correct auth header should succeed
|
||||
let resp = client
|
||||
.get(format!("{}/api/models", daemon.base_url()))
|
||||
.header("authorization", "Bearer secret-key-123")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_protected_endpoint_rejects_wrong_auth() {
|
||||
// Start daemon with auth enabled
|
||||
let config = DaemonConfig::default().with_auth_key("secret-key-123");
|
||||
let daemon = spawn_daemon_with_config(config).await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Protected endpoint with wrong auth header should return 401
|
||||
let resp = client
|
||||
.get(format!("{}/api/models", daemon.base_url()))
|
||||
.header("authorization", "Bearer wrong-key")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 401);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request ID Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_id_header_is_present() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/health", daemon.base_url()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let request_id = resp
|
||||
.headers()
|
||||
.get("x-request-id")
|
||||
.expect("x-request-id header should be present");
|
||||
|
||||
let id_str = request_id.to_str().unwrap();
|
||||
assert!(
|
||||
uuid::Uuid::parse_str(id_str).is_ok(),
|
||||
"x-request-id should be a valid UUID, got: {}",
|
||||
id_str
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_config() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let response = get(&daemon, "/api/config").await;
|
||||
|
||||
// Config should have basic fields
|
||||
assert!(response.is_object());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider & Model Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_providers() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let response = get(&daemon, "/api/providers").await;
|
||||
|
||||
assert!(response.is_array());
|
||||
// Should have at least the default provider
|
||||
let providers = response.as_array().unwrap();
|
||||
assert!(!providers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_models() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let response = get(&daemon, "/api/models").await;
|
||||
|
||||
assert!(response.is_array());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_workflow_crud() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
// Create agent for workflow
|
||||
let agent = create_test_agent(&daemon, DEFAULT_MANIFEST).await;
|
||||
|
||||
// Create workflow
|
||||
let workflow = create_test_workflow(&agent.name);
|
||||
let response = post(&daemon, "/api/workflows", &workflow).await;
|
||||
|
||||
assert!(response["workflow_id"].is_string());
|
||||
let workflow_id = response["workflow_id"].as_str().unwrap();
|
||||
|
||||
// List workflows
|
||||
let workflows = get(&daemon, "/api/workflows").await;
|
||||
assert_eq!(workflows.as_array().unwrap().len(), 1);
|
||||
assert_eq!(workflows[0]["name"], "test-workflow");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_trigger_crud() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
// Create agent for trigger
|
||||
let agent = create_test_agent(&daemon, DEFAULT_MANIFEST).await;
|
||||
|
||||
// Create trigger
|
||||
let trigger = create_test_trigger(&agent.id);
|
||||
let response = post(&daemon, "/api/triggers", &trigger).await;
|
||||
|
||||
assert!(response["trigger_id"].is_string());
|
||||
assert_eq!(response["agent_id"], agent.id);
|
||||
|
||||
// List triggers
|
||||
let triggers = get(&daemon, "/api/triggers").await;
|
||||
assert_eq!(triggers.as_array().unwrap().len(), 1);
|
||||
assert_eq!(triggers[0]["enabled"], true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Budget Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_budget_status() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let response = get(&daemon, "/api/budget").await;
|
||||
|
||||
// Budget should have basic fields
|
||||
assert!(response["enabled"].is_boolean() || response["daily_limit"].is_number());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Usage Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_usage_stats() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let response = get(&daemon, "/api/usage").await;
|
||||
|
||||
assert!(response.is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_usage_summary() {
|
||||
let daemon = spawn_daemon().await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let response = get(&daemon, "/api/usage/summary").await;
|
||||
|
||||
assert!(response.is_object());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM Integration Tests (require API key)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires GROQ_API_KEY environment variable"]
|
||||
async fn test_send_message_with_real_llm() {
|
||||
if !can_run_llm_tests() {
|
||||
eprintln!("{}", llm_skip_reason());
|
||||
return;
|
||||
}
|
||||
|
||||
let config = DaemonConfig::groq();
|
||||
let daemon = spawn_daemon_with_config(config).await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
let agent = create_test_agent(&daemon, LLM_MANIFEST).await;
|
||||
let response = send_message(&daemon, &agent.id, "Say hello in exactly 3 words.").await;
|
||||
|
||||
assert!(!response.response.is_empty(), "LLM response should not be empty");
|
||||
assert!(response.input_tokens > 0, "Should have input tokens");
|
||||
assert!(response.output_tokens > 0, "Should have output tokens");
|
||||
|
||||
// Verify session has messages
|
||||
let session = get(&daemon, &format!("/api/agents/{}/session", agent.id)).await;
|
||||
assert!(session["message_count"].as_u64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires GROQ_API_KEY environment variable"]
|
||||
async fn test_budget_increases_after_llm_call() {
|
||||
if !can_run_llm_tests() {
|
||||
eprintln!("{}", llm_skip_reason());
|
||||
return;
|
||||
}
|
||||
|
||||
let config = DaemonConfig::groq();
|
||||
let daemon = spawn_daemon_with_config(config).await;
|
||||
wait_for_health(daemon.base_url()).await;
|
||||
|
||||
// Get initial budget
|
||||
let initial_budget = get(&daemon, "/api/budget").await;
|
||||
|
||||
// Make an LLM call
|
||||
let agent = create_test_agent(&daemon, LLM_MANIFEST).await;
|
||||
let _ = send_message(&daemon, &agent.id, "Hello").await;
|
||||
|
||||
// Get updated budget
|
||||
let updated_budget = get(&daemon, "/api/budget").await;
|
||||
|
||||
// Budget tracking should reflect the usage
|
||||
// (The exact structure depends on the implementation)
|
||||
assert!(updated_budget.is_object());
|
||||
}
|
||||
797
tests/e2e_common.rs
Normal file
797
tests/e2e_common.rs
Normal 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
|
||||
})
|
||||
}
|
||||
6
tests/e2e_fixtures.rs
Normal file
6
tests/e2e_fixtures.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! Test fixtures module.
|
||||
|
||||
pub mod manifests;
|
||||
|
||||
// Re-export common fixtures
|
||||
pub use manifests::*;
|
||||
40
tests/e2e_test.rs
Normal file
40
tests/e2e_test.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! End-to-end (E2E) tests for OpenFang.
|
||||
//!
|
||||
//! These tests boot the real daemon with actual HTTP endpoints and test
|
||||
//! the full system integration. Tests can run in parallel using different
|
||||
//! ports.
|
||||
//!
|
||||
//! ## Running Tests
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Run all E2E tests
|
||||
//! cargo test --test e2e_test
|
||||
//!
|
||||
//! # Run specific test
|
||||
//! cargo test --test e2e_test -- test_health_endpoint
|
||||
//!
|
||||
//! # Run with LLM integration (requires GROQ_API_KEY)
|
||||
//! GROQ_API_KEY=your_key cargo test --test e2e_test -- --ignored
|
||||
//! ```
|
||||
//!
|
||||
//! ## Parallel Execution
|
||||
//!
|
||||
//! Each test gets its own isolated environment:
|
||||
//! - Unique random port (bound to 127.0.0.1:0)
|
||||
//! - Isolated temp directory for data
|
||||
//! - Separate kernel instance
|
||||
//!
|
||||
//! Tests are designed to be independent and can run concurrently.
|
||||
|
||||
// Common test utilities
|
||||
mod e2e_common;
|
||||
|
||||
// Test fixtures
|
||||
mod e2e_fixtures;
|
||||
|
||||
// Test modules
|
||||
mod e2e_api_test;
|
||||
|
||||
// Re-export for convenience
|
||||
pub use e2e_common::*;
|
||||
pub use e2e_fixtures::*;
|
||||
94
tests/fixtures/manifests.rs
vendored
Normal file
94
tests/fixtures/manifests.rs
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
//! Test fixtures for E2E tests.
|
||||
//!
|
||||
//! This module contains reusable test data and configurations.
|
||||
|
||||
/// Manifest for a chat agent with extended capabilities.
|
||||
pub const CHAT_AGENT_MANIFEST: &str = r#"
|
||||
name = "chat-agent"
|
||||
version = "0.1.0"
|
||||
description = "Chat agent with extended capabilities"
|
||||
author = "test"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "ollama"
|
||||
model = "test-model"
|
||||
system_prompt = "You are a helpful chat agent. Be concise and friendly."
|
||||
|
||||
[capabilities]
|
||||
tools = ["file_read", "file_write"]
|
||||
memory_read = ["*"]
|
||||
memory_write = ["self.*"]
|
||||
"#;
|
||||
|
||||
/// Manifest for a code assistant agent.
|
||||
pub const CODE_AGENT_MANIFEST: &str = r#"
|
||||
name = "code-agent"
|
||||
version = "0.1.0"
|
||||
description = "Code assistant agent"
|
||||
author = "test"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "ollama"
|
||||
model = "test-model"
|
||||
system_prompt = "You are a code assistant. Help with programming tasks."
|
||||
|
||||
[capabilities]
|
||||
tools = ["file_read", "file_write", "shell_exec"]
|
||||
memory_read = ["*"]
|
||||
memory_write = ["self.*"]
|
||||
"#;
|
||||
|
||||
/// Invalid manifest for error testing.
|
||||
pub const INVALID_MANIFEST: &str = "this is {{ not valid toml";
|
||||
|
||||
/// Manifest with missing required fields.
|
||||
pub const INCOMPLETE_MANIFEST: &str = r#"
|
||||
name = "incomplete-agent"
|
||||
# Missing version, description, etc.
|
||||
"#;
|
||||
|
||||
/// Sample workflow JSON for testing.
|
||||
pub const SAMPLE_WORKFLOW_JSON: &str = r#"
|
||||
{
|
||||
"name": "sample-workflow",
|
||||
"description": "A sample workflow for testing",
|
||||
"steps": [
|
||||
{
|
||||
"name": "greet",
|
||||
"agent_name": "test-agent",
|
||||
"prompt": "Say hello",
|
||||
"mode": "sequential",
|
||||
"timeout_secs": 30
|
||||
},
|
||||
{
|
||||
"name": "process",
|
||||
"agent_name": "test-agent",
|
||||
"prompt": "Process: {{input}}",
|
||||
"mode": "sequential",
|
||||
"timeout_secs": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Sample trigger JSON for testing.
|
||||
pub const SAMPLE_TRIGGER_JSON: &str = r#"
|
||||
{
|
||||
"agent_id": "00000000-0000-0000-0000-000000000001",
|
||||
"pattern": "webhook",
|
||||
"prompt_template": "Handle webhook: {{payload}}",
|
||||
"max_fires": 10
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Sample schedule JSON for testing.
|
||||
pub const SAMPLE_SCHEDULE_JSON: &str = r#"
|
||||
{
|
||||
"agent_id": "00000000-0000-0000-0000-000000000001",
|
||||
"cron": "0 */5 * * * *",
|
||||
"prompt": "Scheduled maintenance check",
|
||||
"enabled": true
|
||||
}
|
||||
"#;
|
||||
Reference in New Issue
Block a user