//! Integration tests for the daemon's HTTP + WS API (NAP-243). mod common; use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; use std::time::Duration; use common::DaemonFixture; use futures_util::{SinkExt, StreamExt}; use maestro_daemon::local_api; use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec}; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use uuid::Uuid; type WsClient = WebSocketStream>; struct Harness { addr: SocketAddr, manager: maestro_daemon::SessionManager, _shutdown: oneshot::Sender<()>, _fx: DaemonFixture, } async fn spawn(hostname: &str) -> Harness { spawn_with( hostname, std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), ) .await } async fn spawn_with( hostname: &str, projects: std::sync::Arc, ) -> Harness { let fx = DaemonFixture::new(); let manager = fx.manager.clone(); // Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises // a known set (#26). let presets = maestro_config::SpawnSection::default().resolve().unwrap(); let router = local_api::router(local_api::state_for( manager.clone(), hostname, "/home/test", presets, projects, )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await .unwrap(); let addr = listener.local_addr().unwrap(); let (tx, rx) = oneshot::channel::<()>(); tokio::spawn(async move { let _ = axum::serve(listener, router) .with_graceful_shutdown(async move { let _ = rx.await; }) .await; }); Harness { addr, manager, _shutdown: tx, _fx: fx, } } fn url(addr: SocketAddr, path: &str) -> String { format!("http://{addr}{path}") } fn ws_url(addr: SocketAddr, path: &str) -> String { format!("ws://{addr}{path}") } #[tokio::test] async fn info_returns_daemon_identity() { let h = spawn("naps-laptop").await; let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) .await .unwrap() .json() .await .unwrap(); assert_eq!(resp["hostname"], "naps-laptop"); assert_eq!(resp["home_dir"], "/home/test"); assert!(resp["version"].is_string()); } #[tokio::test] async fn info_reports_build_and_instance() { // The status indicator needs a release to name and a process to compare // against after a reconnect. `commit`/`commit_date` are empty when the // binary was built outside a checkout, so only their presence is asserted; // `instance` is a fresh uuid per daemon process. let h = spawn("naps-laptop").await; let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) .await .unwrap() .json() .await .unwrap(); assert!(resp["build"]["commit"].is_string()); assert!(resp["build"]["commit_date"].is_string()); assert!(resp["build"]["dirty"].is_boolean()); let instance = resp["instance"].as_str().unwrap(); assert!(Uuid::parse_str(instance).is_ok(), "instance is a uuid"); // Same process, same id — a client only treats a *changed* id as a restart. let again: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) .await .unwrap() .json() .await .unwrap(); assert_eq!(again["instance"].as_str().unwrap(), instance); } #[tokio::test] async fn list_sessions_starts_empty_and_reflects_creates() { let h = spawn("naps-laptop").await; let empty: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await .unwrap() .json() .await .unwrap(); assert!(empty.is_empty()); let req = CreateSessionRequest { cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }; let created: Session = reqwest::Client::new() .post(url(h.addr, "/api/sessions")) .json(&req) .send() .await .unwrap() .json() .await .unwrap(); let after: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await .unwrap() .json() .await .unwrap(); assert_eq!(after.len(), 1); assert_eq!(after[0].id, created.id); } #[tokio::test] async fn info_advertises_spawn_presets() { // #26: the daemon's presets ride the info payload so the spawn form needs // no extra round-trip. Built-ins + the implicit custom. let h = spawn("naps-laptop").await; let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) .await .unwrap() .json() .await .unwrap(); let ids: Vec<&str> = resp["presets"] .as_array() .unwrap() .iter() .map(|p| p["id"].as_str().unwrap()) .collect(); assert_eq!(ids, ["claude", "opencode", "zsh", "custom"]); } #[tokio::test] async fn recent_dirs_returns_empty_without_history() { // #26: reads this daemon's spawn history. The test fixture wires no // history, so the list is empty but the endpoint is well-formed. let h = spawn("naps-laptop").await; let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/recent-dirs")) .await .unwrap() .json() .await .unwrap(); assert_eq!(resp["dirs"].as_array().unwrap().len(), 0); assert_eq!(resp["truncated"], false); } /// #54: `GET /api/projects` lists every git repo under the scan roots, with /// real git checkouts — not mocks. #[tokio::test] async fn projects_lists_repos_under_the_scan_roots() { let tmp = tempfile::tempdir().unwrap(); for name in ["alpha", "beta"] { let repo = tmp.path().join(name); std::fs::create_dir_all(&repo).unwrap(); for args in [ vec!["init", "-q"], vec![ "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init", "--allow-empty", ], ] { let out = std::process::Command::new("git") .arg("-C") .arg(&repo) .args(&args) .output() .unwrap(); assert!(out.status.success()); } } let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new( vec![tmp.path().to_path_buf()], 3, tmp.path().to_path_buf(), )); let h = spawn_with("naps-laptop", scanner).await; let projects: Vec = reqwest::get(url(h.addr, "/api/projects")) .await .unwrap() .json() .await .unwrap(); let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect(); names.sort(); assert_eq!(names, ["alpha", "beta"]); for p in &projects { assert!(!p.is_worktree); assert_eq!(p.base_repo, None); assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known"); } } #[tokio::test] async fn projects_returns_empty_for_a_disabled_scanner() { let h = spawn("naps-laptop").await; let projects: Vec = reqwest::get(url(h.addr, "/api/projects")) .await .unwrap() .json() .await .unwrap(); assert!(projects.is_empty()); } /// #53: every returned handle is one word from the shared list. fn assert_valid_names(batch: &maestro_protocol::NameBatch) { for name in &batch.names { assert!(!name.contains('-'), "handle {name:?} is not one word"); assert!( maestro_names::WORDS.contains(&name.as_str()), "{name} not in WORDS" ); } } #[tokio::test] async fn names_defaults_to_one_handle() { let h = spawn("naps-laptop").await; let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names")) .await .unwrap() .json() .await .unwrap(); assert_eq!(batch.names.len(), 1); assert_valid_names(&batch); } #[tokio::test] async fn names_honors_count_and_caps_it() { use maestro_names::MAX_BATCH; let h = spawn("naps-laptop").await; let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names?count=5")) .await .unwrap() .json() .await .unwrap(); assert_eq!(batch.names.len(), 5); assert_valid_names(&batch); let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, &format!("/api/names?count={}", MAX_BATCH * 10))) .await .unwrap() .json() .await .unwrap(); assert_eq!(batch.names.len(), MAX_BATCH); } #[tokio::test] async fn gitroot_probes_path() { // #26: resolves the query against the daemon home dir and walks up for a // `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null. let h = spawn("naps-laptop").await; let tmp = tempfile::tempdir().unwrap(); let resp: serde_json::Value = reqwest::get(url( h.addr, &format!( "/api/gitroot?path={}", urlencoding::encode(&tmp.path().to_string_lossy()) ), )) .await .unwrap() .json() .await .unwrap(); assert!(resp.get("root").is_none() || resp["root"].is_null()); } #[tokio::test] async fn delete_unknown_session_returns_404() { let h = spawn("naps-laptop").await; let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4()))) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); } /// #125: the rename endpoint answers with the updated session, and the name /// it stored is the trimmed one. #[tokio::test] async fn patch_session_renames_it() { let h = spawn("naps-laptop").await; let session = h .manager .create(CreateSessionRequest { cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .unwrap(); let resp = reqwest::Client::new() .patch(url(h.addr, &format!("/api/sessions/{}", session.id))) .json(&serde_json::json!({ "name": " crimson-otter " })) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::OK); let updated: Session = resp.json().await.unwrap(); assert_eq!( updated.metadata.get("name").and_then(|v| v.as_str()), Some("crimson-otter") ); } #[tokio::test] async fn patch_unknown_session_returns_404() { let h = spawn("naps-laptop").await; let resp = reqwest::Client::new() .patch(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4()))) .json(&serde_json::json!({ "name": "nope" })) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); } #[tokio::test] async fn delete_owned_session_kills_it() { let h = spawn("naps-laptop").await; let session = h .manager .create(CreateSessionRequest { cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .unwrap(); let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); assert!(!h.manager.has(session.id).await); } /// Spawn a session through the manager and return its id. async fn spawn_via_manager(h: &Harness) -> Uuid { h.manager .create(CreateSessionRequest { cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .unwrap() .id } /// The agent activity endpoint on the wire (#105): a report lands in the /// session snapshot with its detail. #[tokio::test] async fn post_activity_reports_error_with_detail() { let h = spawn("naps-laptop").await; let id = spawn_via_manager(&h).await; let resp = reqwest::Client::new() .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) .json(&serde_json::json!({ "state": "Error", "detail": "cargo build failed" })) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await .unwrap() .json() .await .unwrap(); let s = sessions.iter().find(|s| s.id == id).unwrap(); assert_eq!(s.activity, maestro_protocol::ActivityState::Error); assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed")); } #[tokio::test] async fn post_activity_to_unknown_session_returns_404() { let h = spawn("naps-laptop").await; let resp = reqwest::Client::new() .post(url( h.addr, &format!("/api/sessions/{}/activity", Uuid::new_v4()), )) .json(&serde_json::json!({ "state": "Idle" })) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); } /// Unknown state values are rejected rather than coerced (#105). #[tokio::test] async fn post_activity_rejects_an_unknown_state_value() { let h = spawn("naps-laptop").await; let id = spawn_via_manager(&h).await; let resp = reqwest::Client::new() .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) .json(&serde_json::json!({ "state": "Teleported" })) .send() .await .unwrap(); assert!(resp.status().is_client_error(), "got {}", resp.status()); } #[tokio::test] async fn post_activity_caps_an_overlong_detail_server_side() { let h = spawn("naps-laptop").await; let id = spawn_via_manager(&h).await; let resp = reqwest::Client::new() .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) .json(&serde_json::json!({ "state": "Error", "detail": "x".repeat(10_000) })) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await .unwrap() .json() .await .unwrap(); let s = sessions.iter().find(|s| s.id == id).unwrap(); assert_eq!( s.activity_detail.as_ref().map(|d| d.chars().count()), Some(maestro_daemon::MAX_REPORT_DETAIL_CHARS) ); } /// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409 /// with the structured breakdown, and the session keeps running. #[tokio::test] async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() { let h = spawn("naps-laptop").await; let repo = tempfile::TempDir::new().unwrap(); common::init_repo(repo.path()); let session = h .manager .create(CreateSessionRequest { cwd: repo.path().to_path_buf(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/guarded".into(), create_branch: true, }), }) .await .unwrap(); std::fs::write( repo.path() .join("worktrees") .join("feat") .join("guarded") .join("README"), b"unsaved edits\n", ) .unwrap(); let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT); let body: ApiError = resp.json().await.unwrap(); assert_eq!(body.error.code, "kill_guarded"); let breakdown = body.error.unsaved_work.expect("breakdown on the wire"); assert_eq!(breakdown.uncommitted_files, 1); assert!(h.manager.has(session.id).await, "the session keeps running"); } /// `?force=true` on the wire (#81): the refusal is overridden and the session /// ends, while the guarded worktree survives on disk. #[tokio::test] async fn delete_with_force_ends_the_session_and_keeps_the_worktree() { let h = spawn("naps-laptop").await; let repo = tempfile::TempDir::new().unwrap(); common::init_repo(repo.path()); let wt_path = repo .path() .join("worktrees") .join("feat") .join("guarded") .to_path_buf(); let session = h .manager .create(CreateSessionRequest { cwd: repo.path().to_path_buf(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/guarded".into(), create_branch: true, }), }) .await .unwrap(); std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) .query(&[("force", "true")]) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); // The tree would have blocked; force must not have removed it. assert!(wt_path.exists(), "a forced kill never removes the worktree"); assert!(!h.manager.has(session.id).await, "the session is gone"); } #[tokio::test] async fn attach_ws_streams_pty_bytes() { let h = spawn("naps-laptop").await; let session = h .manager .create(CreateSessionRequest { cwd: "/tmp".into(), command: "bash --noprofile --norc".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .unwrap(); let ws_url = format!("ws://{}/api/attach/{}", h.addr, session.id); let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap(); // Provoke output. The bash process should echo this back via the PTY. ws.send(WsMessage::Binary(b"echo hello-attach\n".to_vec())) .await .unwrap(); let saw_hello = tokio::time::timeout(Duration::from_secs(2), async { loop { match ws.next().await { Some(Ok(WsMessage::Binary(bytes))) => { if String::from_utf8_lossy(&bytes).contains("hello-attach") { return true; } } Some(Ok(_)) => continue, Some(Err(_)) | None => return false, } } }) .await .unwrap_or(false); assert!(saw_hello, "PTY did not echo through the attach WS"); } #[tokio::test] async fn attach_ws_for_unknown_session_returns_404() { let h = spawn("naps-laptop").await; let ws_url = format!("ws://{}/api/attach/{}", h.addr, Uuid::new_v4()); let err = connect_async(ws_url) .await .expect_err("expected handshake fail"); let msg = err.to_string(); assert!( msg.contains("404") || msg.to_lowercase().contains("not found"), "{msg}" ); } /// `WS /api/events` had no daemon-level test: the Snapshot-then-deltas contract /// and the subscribe-before-snapshot ordering were only described in a comment. #[tokio::test] async fn events_ws_sends_a_snapshot_then_streams_deltas() { let h = spawn("konishi").await; let cwd = tempfile::TempDir::new().unwrap(); let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events")) .await .expect("events ws"); match next_event(&mut ws).await { maestro_protocol::CatalogEvent::Snapshot { sessions } => assert!(sessions.is_empty()), other => panic!("first frame must be a Snapshot, got {other:?}"), } let created = h .manager .create(CreateSessionRequest { cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .expect("create"); match next_event(&mut ws).await { maestro_protocol::CatalogEvent::SessionAdded { session } => { assert_eq!(session.id, created.id); } other => panic!("expected SessionAdded, got {other:?}"), } h.manager.kill(created.id, false).await.expect("kill"); // Activity ticks may interleave; the removal is what we are waiting for. let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { assert!( tokio::time::Instant::now() < deadline, "timed out waiting for SessionRemoved" ); if let maestro_protocol::CatalogEvent::SessionRemoved { id } = next_event(&mut ws).await { assert_eq!(id, created.id); break; } } } /// Each subscriber has its own lag domain now that the publisher task is gone: /// two clients connected at different times both get their own Snapshot. #[tokio::test] async fn every_events_subscriber_gets_its_own_snapshot() { let h = spawn("konishi").await; let cwd = tempfile::TempDir::new().unwrap(); let (mut first, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap(); let _ = next_event(&mut first).await; h.manager .create(CreateSessionRequest { cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .expect("create"); let (mut second, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap(); match next_event(&mut second).await { maestro_protocol::CatalogEvent::Snapshot { sessions } => { assert_eq!( sessions.len(), 1, "a late subscriber must see current state" ); } other => panic!("expected Snapshot, got {other:?}"), } } async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent { loop { match tokio::time::timeout(Duration::from_secs(5), ws.next()) .await .expect("events ws timed out") { Some(Ok(WsMessage::Text(t))) => { return serde_json::from_str(&t).expect("decode catalog event") } Some(Ok(_)) => continue, other => panic!("unexpected events frame: {other:?}"), } } }