302 lines
10 KiB
Rust
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}"
|
|
);
|
|
}
|