Files
openfang/crates/openfang-api/tests/daemon_lifecycle_test.rs
iven 92e5def702
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
初始化提交
2026-03-01 16:24:24 +08:00

271 lines
8.9 KiB
Rust

//! Daemon lifecycle integration tests.
//!
//! Tests the real daemon startup, PID file management, health serving,
//! and graceful shutdown sequence.
use axum::Router;
use openfang_api::middleware;
use openfang_api::routes::{self, AppState};
use openfang_api::server::{read_daemon_info, DaemonInfo};
use openfang_kernel::OpenFangKernel;
use openfang_types::config::{DefaultModelConfig, KernelConfig};
use std::sync::Arc;
use std::time::Instant;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
/// Test DaemonInfo serialization and deserialization round-trip.
#[test]
fn test_daemon_info_serde_roundtrip() {
let info = DaemonInfo {
pid: 12345,
listen_addr: "127.0.0.1:4200".to_string(),
started_at: "2024-01-01T00:00:00Z".to_string(),
version: "0.1.0".to_string(),
platform: "linux".to_string(),
};
let json = serde_json::to_string_pretty(&info).unwrap();
let parsed: DaemonInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.pid, 12345);
assert_eq!(parsed.listen_addr, "127.0.0.1:4200");
assert_eq!(parsed.version, "0.1.0");
assert_eq!(parsed.platform, "linux");
}
/// Test read_daemon_info from a file on disk.
#[test]
fn test_read_daemon_info_from_file() {
let tmp = tempfile::tempdir().unwrap();
// Write a daemon.json
let info = DaemonInfo {
pid: std::process::id(),
listen_addr: "127.0.0.1:9999".to_string(),
started_at: chrono::Utc::now().to_rfc3339(),
version: "0.1.0".to_string(),
platform: "test".to_string(),
};
let json = serde_json::to_string_pretty(&info).unwrap();
std::fs::write(tmp.path().join("daemon.json"), json).unwrap();
// Read it back
let loaded = read_daemon_info(tmp.path());
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.pid, std::process::id());
assert_eq!(loaded.listen_addr, "127.0.0.1:9999");
}
/// Test read_daemon_info returns None when file doesn't exist.
#[test]
fn test_read_daemon_info_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let loaded = read_daemon_info(tmp.path());
assert!(loaded.is_none());
}
/// Test read_daemon_info returns None for corrupt JSON.
#[test]
fn test_read_daemon_info_corrupt_json() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("daemon.json"), "not json at all").unwrap();
let loaded = read_daemon_info(tmp.path());
assert!(loaded.is_none());
}
/// Test the full daemon lifecycle:
/// 1. Boot kernel + start server on random port
/// 2. Write daemon info file
/// 3. Verify health endpoint
/// 4. Verify daemon info file contents match
/// 5. Shut down and verify cleanup
#[tokio::test]
async fn test_full_daemon_lifecycle() {
let tmp = tempfile::tempdir().unwrap();
let daemon_info_path = tmp.path().join("daemon.json");
let config = KernelConfig {
home_dir: tmp.path().to_path_buf(),
data_dir: tmp.path().join("data"),
default_model: DefaultModelConfig {
provider: "ollama".to_string(),
model: "test".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
},
..KernelConfig::default()
};
let kernel = OpenFangKernel::boot_with_config(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: None,
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 = Router::new()
.route("/api/health", axum::routing::get(routes::health))
.route("/api/status", axum::routing::get(routes::status))
.route("/api/shutdown", axum::routing::post(routes::shutdown))
.layer(axum::middleware::from_fn(middleware::request_logging))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state.clone());
// Bind to random port
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// Spawn server
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
// Write daemon info file (like run_daemon does)
let daemon_info = DaemonInfo {
pid: std::process::id(),
listen_addr: addr.to_string(),
started_at: chrono::Utc::now().to_rfc3339(),
version: env!("CARGO_PKG_VERSION").to_string(),
platform: std::env::consts::OS.to_string(),
};
let json = serde_json::to_string_pretty(&daemon_info).unwrap();
std::fs::write(&daemon_info_path, &json).unwrap();
// --- Verify daemon info file ---
assert!(daemon_info_path.exists());
let loaded = read_daemon_info(tmp.path()).unwrap();
assert_eq!(loaded.pid, std::process::id());
assert_eq!(loaded.listen_addr, addr.to_string());
// --- Verify health endpoint ---
let client = reqwest::Client::new();
let resp = client
.get(format!("http://{}/api/health", addr))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
// --- Verify status endpoint ---
let resp = client
.get(format!("http://{}/api/status", addr))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "running");
// --- Shutdown ---
let resp = client
.post(format!("http://{}/api/shutdown", addr))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// Clean up daemon info file (like run_daemon does)
let _ = std::fs::remove_file(&daemon_info_path);
assert!(!daemon_info_path.exists());
kernel.shutdown();
}
/// Test that stale daemon info is detected when no process is running at that PID.
#[test]
fn test_stale_daemon_info_detection() {
let tmp = tempfile::tempdir().unwrap();
// Write daemon.json with a PID that almost certainly doesn't exist
// (using a very high PID number)
let info = DaemonInfo {
pid: 99999999, // unlikely to be running
listen_addr: "127.0.0.1:9999".to_string(),
started_at: "2024-01-01T00:00:00Z".to_string(),
version: "0.1.0".to_string(),
platform: "test".to_string(),
};
let json = serde_json::to_string_pretty(&info).unwrap();
std::fs::write(tmp.path().join("daemon.json"), json).unwrap();
// read_daemon_info just reads the file — it doesn't check if the PID is alive
// (that check happens in run_daemon). So the file is readable:
let loaded = read_daemon_info(tmp.path());
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().pid, 99999999);
}
/// Test that the server starts and immediately responds to requests.
#[tokio::test]
async fn test_server_immediate_responsiveness() {
let tmp = tempfile::tempdir().unwrap();
let config = KernelConfig {
home_dir: tmp.path().to_path_buf(),
data_dir: tmp.path().join("data"),
default_model: DefaultModelConfig {
provider: "ollama".to_string(),
model: "test".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
},
..KernelConfig::default()
};
let kernel = OpenFangKernel::boot_with_config(config).unwrap();
let kernel = Arc::new(kernel);
let state = Arc::new(AppState {
kernel: kernel.clone(),
started_at: Instant::now(),
peer_registry: None,
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 = Router::new()
.route("/api/health", axum::routing::get(routes::health))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
// Hit health endpoint immediately — should respond fast
let client = reqwest::Client::new();
let start = Instant::now();
let resp = client
.get(format!("http://{}/api/health", addr))
.send()
.await
.unwrap();
let latency = start.elapsed();
assert_eq!(resp.status(), 200);
assert!(
latency.as_millis() < 1000,
"Health endpoint should respond in <1s, took {}ms",
latency.as_millis()
);
kernel.shutdown();
}