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
271 lines
8.9 KiB
Rust
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();
|
|
}
|