Files
agent-manager/tests/web_test.rs
T
bruno fa51cdb339
release / linux (push) Successful in 14m16s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
feat: phase 2 — am web : dashboard web local + API JSON (contrats --json, 127.0.0.1, tiny_http, frontend embarqué) (closes #54)
2026-08-18 16:40:15 -04:00

302 lines
10 KiB
Rust

//! Integration tests for `am web` (issue #54): spawn the real binary,
//! hit the HTTP routes, and verify the JSON API matches the CLI --json
//! contracts byte-for-byte (after JSON parsing).
use std::io::Read;
use std::net::TcpStream;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::Duration;
/// Serialize the network tests: each one binds its own ephemeral port and
/// spawns its own server process. A panicking test must not poison the
/// lock for the others, so the poison is recovered.
static WEB_LOCK: Mutex<()> = Mutex::new(());
fn web_lock() -> std::sync::MutexGuard<'static, ()> {
WEB_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Kill the server child even when the test panics.
struct ServerGuard(Child);
impl Drop for ServerGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
struct TestEnv {
dir: PathBuf,
cfg: PathBuf,
}
fn fresh_dir(tag: &str) -> TestEnv {
let dir = std::env::temp_dir().join(format!(
"am-web-test-{tag}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
)
.unwrap();
TestEnv { dir, cfg }
}
/// Seed a journal + one session so every route has deterministic data.
fn seed(env: &TestEnv) {
let month = chrono::Utc::now().format("%Y%m");
let journal = env.dir.join(format!("events-{month}.jsonl"));
std::fs::write(
&journal,
concat!(
"{\"ts\":\"2026-08-01T10:00:00Z\",\"kind\":\"start\",\"agent\":\"claude-code\",\"project\":\"C:/repo\",\"session\":\"s1\",\"duration_s\":3600,\"exit_code\":0}\n",
"{\"ts\":\"2026-08-01T11:00:00Z\",\"kind\":\"stop\",\"agent\":\"claude-code\",\"project\":\"C:/repo\",\"session\":\"s1\",\"duration_s\":3600}\n",
"{\"ts\":\"2026-08-02T10:00:00Z\",\"kind\":\"run\",\"agent\":\"aider\",\"project\":\"C:/repo2\",\"session\":\"s2\",\"duration_s\":600,\"exit_code\":1}\n",
"{\"ts\":\"2026-08-03T10:00:00Z\",\"kind\":\"install\",\"agent\":\"jcode\"}\n",
),
)
.unwrap();
let sessions = env.dir.join("sessions.json");
std::fs::write(
&sessions,
concat!(
"{\"version\":1,\"sessions\":{",
"\"s1\":{\"id\":\"s1\",\"kind\":\"agent\",\"agent\":\"claude-code\",\"project\":\"C:/repo\",",
"\"started_at\":\"2026-08-01T10:00:00Z\",\"ended_at\":\"2026-08-01T11:00:00Z\",\"status\":\"stopped\"},",
"\"s2\":{\"id\":\"s2\",\"kind\":\"agent\",\"agent\":\"aider\",\"project\":\"C:/repo2\",",
"\"started_at\":\"2026-08-02T10:00:00Z\",\"ended_at\":\"2026-08-02T10:10:00Z\",",
"\"exit_code\":1,\"status\":\"failed\"}",
"}}\n",
),
)
.unwrap();
}
fn am_bin() -> &'static str {
env!("CARGO_BIN_EXE_am")
}
/// Run `am <args...>` against the seeded env; returns (exit, stdout, stderr).
fn am_run(env: &TestEnv, args: &[&str]) -> (i32, String, String) {
let out = Command::new(am_bin())
.arg("--config")
.arg(&env.cfg)
.args(args)
.env("AGENT_MANAGER_STATE", &env.dir)
.env("AGENT_MANAGER_DATA", &env.dir)
.output()
.expect("am binary should run");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
/// Pick a free port, spawn `am web --no-open` on it, wait for readiness.
fn spawn_web(env: &TestEnv) -> (u16, ServerGuard) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let child = Command::new(am_bin())
.arg("--config")
.arg(&env.cfg)
.args(["web", "--port", &port.to_string(), "--no-open", "--lang", "fr"])
.env("AGENT_MANAGER_STATE", &env.dir)
.env("AGENT_MANAGER_DATA", &env.dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("am web should spawn");
let guard = ServerGuard(child);
// Wait until the server accepts connections.
let deadline = std::time::Instant::now() + Duration::from_secs(15);
loop {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"server did not come up on port {port}"
);
std::thread::sleep(Duration::from_millis(100));
}
(port, guard)
}
fn get_json(port: u16, path: &str) -> serde_json::Value {
let resp = ureq::get(&format!("http://127.0.0.1:{port}{path}"))
.call()
.unwrap_or_else(|e| panic!("GET {path}: {e}"));
assert_eq!(resp.status(), 200, "GET {path}");
let mut body = String::new();
resp.into_reader().read_to_string(&mut body).unwrap();
serde_json::from_str(&body).unwrap_or_else(|e| panic!("invalid JSON for {path}: {e}"))
}
fn get_status(port: u16, path: &str) -> u16 {
match ureq::get(&format!("http://127.0.0.1:{port}{path}")).call() {
Ok(r) => r.status(),
Err(ureq::Error::Status(code, _)) => code,
Err(e) => panic!("GET {path}: {e}"),
}
}
#[test]
fn web_serves_the_dashboard_and_health() {
let _lock = web_lock();
let env = fresh_dir("health");
seed(&env);
let (port, _guard) = spawn_web(&env);
// Frontend page, localized in French (forced via --lang fr).
let page = ureq::get(&format!("http://127.0.0.1:{port}/"))
.call()
.unwrap();
assert_eq!(page.status(), 200);
let mut body = String::new();
page.into_reader().read_to_string(&mut body).unwrap();
assert!(body.contains("<!DOCTYPE html>"), "HTML5 doctype");
assert!(body.contains("Vue d'ensemble"), "FR label injected");
assert!(!body.contains("{{"), "no leftover tokens");
assert!(body.contains("window.I18N"), "status bundle injected");
let health = get_json(port, "/api/health");
assert_eq!(health["status"], "ok");
assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
}
#[test]
fn web_api_matches_cli_json_contracts() {
let _lock = web_lock();
let env = fresh_dir("contracts");
seed(&env);
let (port, _guard) = spawn_web(&env);
// stats
let web_stats = get_json(port, "/api/stats");
let (_, cli_stats, _) = am_run(&env, &["stats", "--json"]);
assert_eq!(
web_stats,
serde_json::from_str::<serde_json::Value>(&cli_stats).unwrap(),
"/api/stats must equal 'am stats --json'"
);
// sessions
let web_sessions = get_json(port, "/api/sessions");
let (_, cli_sessions, _) = am_run(&env, &["sessions", "--json"]);
assert_eq!(
web_sessions,
serde_json::from_str::<serde_json::Value>(&cli_sessions).unwrap(),
"/api/sessions must equal 'am sessions --json'"
);
let arr = web_sessions.as_array().unwrap();
assert_eq!(arr.len(), 2, "two seeded sessions");
// Newest first: s2 (aider, 2026-08-02) then s1 (claude-code, 08-01).
assert_eq!(arr[0]["agent"], "aider");
// events (log)
let web_events = get_json(port, "/api/events");
let (_, cli_events, _) = am_run(&env, &["log", "--json"]);
assert_eq!(
web_events,
serde_json::from_str::<serde_json::Value>(&cli_events).unwrap(),
"/api/events must equal 'am log --json'"
);
assert_eq!(web_events.as_array().unwrap().len(), 4, "four seeded events");
// projects
let web_projects = get_json(port, "/api/projects");
let (_, cli_projects, _) = am_run(&env, &["projects", "--json"]);
assert_eq!(
web_projects,
serde_json::from_str::<serde_json::Value>(&cli_projects).unwrap(),
"/api/projects must equal 'am projects --json'"
);
// overview (dashboard)
let web_overview = get_json(port, "/api/overview");
let (_, cli_overview, _) = am_run(&env, &["dashboard", "--json"]);
assert_eq!(
web_overview,
serde_json::from_str::<serde_json::Value>(&cli_overview).unwrap(),
"/api/overview must equal 'am dashboard --json'"
);
assert_eq!(web_overview["counts"]["installed"], 0);
assert!(web_overview["sessions"].as_array().unwrap().len() >= 2);
}
#[test]
fn web_events_honors_query_filters() {
let _lock = web_lock();
let env = fresh_dir("filters");
seed(&env);
let (port, _guard) = spawn_web(&env);
let two = get_json(port, "/api/events?limit=2");
assert_eq!(two.as_array().unwrap().len(), 2, "?limit=2");
let agent = get_json(port, "/api/events?agent=aider");
let arr = agent.as_array().unwrap();
assert_eq!(arr.len(), 1, "one aider event");
assert_eq!(arr[0]["agent"], "aider");
let kind = get_json(port, "/api/events?kind=install");
let arr = kind.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["kind"], "install");
let stats = get_json(port, "/api/stats?agent=claude-code&period=all");
let arr = stats.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["agent"], "claude-code");
assert!(arr[0]["launches"].as_u64().unwrap_or(0) >= 1);
}
#[test]
fn web_rejects_unknown_routes_and_non_get() {
let _lock = web_lock();
let env = fresh_dir("errors");
seed(&env);
let (port, _guard) = spawn_web(&env);
assert_eq!(get_status(port, "/api/nope"), 404);
assert_eq!(get_status(port, "/api/stats?weird=1"), 200, "unknown params ignored");
// POST is refused (read-only API).
match ureq::post(&format!("http://127.0.0.1:{port}/api/stats")).call() {
Err(ureq::Error::Status(405, _)) => {}
other => panic!("expected 405, got {other:?}"),
}
}
#[test]
fn web_reports_a_busy_port_cleanly() {
let _lock = web_lock();
let env = fresh_dir("busy");
seed(&env);
let holder = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = holder.local_addr().unwrap().port();
let out = Command::new(am_bin())
.arg("--config")
.arg(&env.cfg)
.args(["web", "--port", &port.to_string(), "--no-open"])
.env("AGENT_MANAGER_STATE", &env.dir)
.env("AGENT_MANAGER_DATA", &env.dir)
.output()
.expect("am web should run");
assert_ne!(out.status.code().unwrap_or(0), 0, "busy port must fail");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("cannot bind"),
"clear error expected, got: {stderr}"
);
}