//! 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 ` 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(""), "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::(&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::(&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::(&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::(&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::(&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}" ); }