feat: phase 2 — am web : dashboard web local + API JSON (contrats --json, 127.0.0.1, tiny_http, frontend embarqué) (closes #54)
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
//! 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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user