//! Integration tests for the daemon's HTTP + WS API (NAP-243). mod common; use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; use std::path::Path; use std::time::Duration; use common::DaemonFixture; use futures_util::{SinkExt, StreamExt}; use maestro_daemon::local_api; use maestro_protocol::{ ActivityState, ApiError, CreateSessionRequest, Session, SessionStatus, SessionView, WorktreeSpec, }; use tempfile::TempDir; 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 { spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await } async fn spawn_with_leader( hostname: &str, projects: std::sync::Arc, leader: &str, ) -> 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, leader, )); 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}") } struct FakeAcp { dir: TempDir, } impl FakeAcp { fn new() -> Self { use std::os::unix::fs::PermissionsExt; let dir = TempDir::new().unwrap(); let bin = dir.path().join("bin"); std::fs::create_dir_all(&bin).unwrap(); let adapter = bin.join("claude-code-acp"); std::fs::copy( Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py"), &adapter, ) .unwrap(); std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o755)).unwrap(); Self { dir } } fn env(&self) -> HashMap { HashMap::from([ ( "PATH".into(), format!( "{}:{}", self.dir.path().join("bin").display(), std::env::var("PATH").unwrap() ), ), ( "FAKE_ACP_LOG".into(), self.dir.path().join("agent.log").display().to_string(), ), ]) } fn pid(&self) -> u32 { std::fs::read_to_string(self.dir.path().join("agent.log")) .unwrap_or_default() .lines() .filter_map(|line| { line.strip_prefix("start pid=")? .split(' ') .next()? .parse() .ok() }) .next_back() .expect("fake ACP started") } fn log(&self) -> String { std::fs::read_to_string(self.dir.path().join("agent.log")).unwrap_or_default() } } async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid { structured_session_with_env(h, fake.env()).await } async fn structured_session_with_env(h: &Harness, env: HashMap) -> Uuid { h.manager .create(CreateSessionRequest { view: SessionView::Structured, cwd: "/tmp".into(), command: "claude".into(), env, metadata: HashMap::new(), worktree: None, }) .await .unwrap() .id } async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) { tokio::time::timeout(Duration::from_secs(2), async { loop { let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await .unwrap() .json() .await .unwrap(); if sessions .iter() .find(|session| session.id == id) .is_some_and(|session| session.activity == expected) { return; } tokio::time::sleep(Duration::from_millis(10)).await; } }) .await .unwrap_or_else(|_| panic!("session {id} did not reach {expected:?}")); } #[tokio::test] async fn structured_ws_sends_snapshot_then_live_projection() { let h = spawn("naps-laptop").await; let fake = FakeAcp::new(); let id = structured_session(&h, &fake).await; let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}"))) .await .unwrap(); let first = ws.next().await.unwrap().unwrap().into_text().unwrap(); let second = ws.next().await.unwrap().unwrap().into_text().unwrap(); assert_eq!( serde_json::from_str::(&first).unwrap()["type"], "transcript_snapshot" ); assert_eq!( serde_json::from_str::(&second).unwrap()["type"], "reduced_state" ); let response = reqwest::Client::new() .post(url( h.addr, &format!("/api/sessions/{id}/structured/prompt"), )) .json(&serde_json::json!({"prompt": "hello"})) .send() .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); let live = tokio::time::timeout(Duration::from_secs(1), ws.next()) .await .expect("projection update arrives on the subscribed stream") .unwrap() .unwrap() .into_text() .unwrap(); assert_eq!( serde_json::from_str::(&live).unwrap()["type"], "projection_update" ); } #[tokio::test] async fn structured_attachments_preserve_bytes_and_reject_paths() { let h = spawn("attachments").await; let fake = FakeAcp::new(); let id = structured_session(&h, &fake).await; let client = reqwest::Client::new(); let endpoint = url( h.addr, &format!("/api/sessions/{id}/structured/attachments"), ); let bytes = vec![0, 255, 10, 13]; let response = client .post(&endpoint) .query(&[("name", "sample.bin")]) .body(bytes.clone()) .send() .await .unwrap(); assert_eq!(response.status(), 200); let body: serde_json::Value = response.json().await.unwrap(); let path = body["path"].as_str().unwrap(); assert!(Path::new(path).is_absolute()); assert_eq!(std::fs::read(path).unwrap(), bytes); for name in ["../escape", "a/b", "a\\b", "..", ""] { let response = client .post(&endpoint) .query(&[("name", name)]) .body("text") .send() .await .unwrap(); assert_eq!(response.status(), 400, "{name}"); } let response = client .post(&endpoint) .query(&[("name", "large.bin")]) .body(vec![0; 20 * 1024 * 1024 + 1]) .send() .await .unwrap(); assert_eq!(response.status(), 413); let response = client .post(url( h.addr, &format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()), )) .query(&[("name", "file.txt")]) .body("text") .send() .await .unwrap(); assert_eq!(response.status(), 404); } #[tokio::test] async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() { let h = spawn("naps-laptop").await; let fake = FakeAcp::new(); let mut env = fake.env(); env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "200".into()); let id = structured_session_with_env(&h, env).await; let client = reqwest::Client::new(); let first = client .post(url( h.addr, &format!("/api/sessions/{id}/structured/prompt"), )) .json(&serde_json::json!({"prompt": "first"})) .send() .await .unwrap(); assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT); let second = client .post(url( h.addr, &format!("/api/sessions/{id}/structured/prompt"), )) .json(&serde_json::json!({"prompt": "second"})) .send() .await .unwrap(); assert_eq!(second.status(), reqwest::StatusCode::CONFLICT); let error: ApiError = second.json().await.unwrap(); assert_eq!(error.error.code, "turn_in_flight"); let cancel = client .post(url( h.addr, &format!("/api/sessions/{id}/structured/cancel"), )) .send() .await .unwrap(); assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT); let next = client .post(url( h.addr, &format!("/api/sessions/{id}/structured/prompt"), )) .json(&serde_json::json!({"prompt": "next"})) .send() .await .unwrap(); assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT); tokio::time::timeout(Duration::from_secs(2), async { loop { if fake.log().matches("recv session/prompt").count() == 2 { break; } tokio::time::sleep(Duration::from_millis(10)).await; } }) .await .expect("both prompt commands reach ACP"); let log = fake.log(); let first_prompt = log.find("recv session/prompt").unwrap(); let cancel = log.find("recv session/cancel").unwrap(); let second_prompt = log.rfind("recv session/prompt").unwrap(); assert!(first_prompt < cancel && cancel < second_prompt, "{log}"); } #[tokio::test] async fn structured_approval_is_secret_single_use_and_race_safe() { let h = spawn("naps-laptop").await; let fake = FakeAcp::new(); let mut env = fake.env(); env.insert("FAKE_ACP_REQUEST_PERMISSION".into(), "1".into()); let id = structured_session_with_env(&h, env).await; let client = reqwest::Client::new(); let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}"))) .await .unwrap(); ws.next().await.unwrap().unwrap(); ws.next().await.unwrap().unwrap(); assert_eq!( client .post(url( h.addr, &format!("/api/sessions/{id}/structured/prompt") )) .json(&serde_json::json!({"prompt":"needs permission"})) .send() .await .unwrap() .status(), reqwest::StatusCode::NO_CONTENT ); let nonce = tokio::time::timeout(Duration::from_secs(2), async { loop { let event: serde_json::Value = serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap()) .unwrap(); if let Some(nonce) = event["state"]["pending_approval"]["nonce"].as_str() { assert_eq!(event["transcript"]["row"]["kind"], "approval"); assert!(event["transcript"]["row"]["data"].get("nonce").is_none()); break nonce.to_owned(); } } }) .await .unwrap(); wait_for_session_activity(&h, id, ActivityState::AwaitingInput).await; assert!( !fake.log().contains(&nonce), "daemon nonce leaked to ACP agent" ); let path = format!("/api/sessions/{id}/structured/approvals/{nonce}"); let (a, b) = tokio::join!( client .post(url(h.addr, &path)) .json(&serde_json::json!("allow_once")) .send(), client .post(url(h.addr, &path)) .json(&serde_json::json!("deny")) .send(), ); assert_eq!(a.unwrap().status(), reqwest::StatusCode::NO_CONTENT); assert_eq!(b.unwrap().status(), reqwest::StatusCode::NO_CONTENT); assert_eq!( client .post(url(h.addr, &path)) .json(&serde_json::json!("deny")) .send() .await .unwrap() .status(), reqwest::StatusCode::NO_CONTENT ); tokio::time::timeout(Duration::from_secs(2), async { loop { if fake.log().contains("permission response") { break; } tokio::time::sleep(Duration::from_millis(10)).await; } }) .await .unwrap(); let log = fake.log(); assert!( log.contains("\"optionId\": \"once\"") || log.contains("\"optionId\": \"deny\""), "{log}" ); tokio::time::timeout(Duration::from_secs(1), async { loop { let event: serde_json::Value = serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap()) .unwrap(); if event["state"]["pending_approval"].is_null() { break; } } }) .await .expect("resolved approval clears the reduced state"); wait_for_session_activity(&h, id, ActivityState::Idle).await; assert_eq!( client .post(url( h.addr, &format!( "/api/sessions/{}/structured/approvals/{nonce}", Uuid::new_v4() ) )) .json(&serde_json::json!("deny")) .send() .await .unwrap() .status(), reqwest::StatusCode::NOT_FOUND ); } #[tokio::test] async fn structured_prompt_failure_clears_the_turn() { let h = spawn("naps-laptop").await; let fake = FakeAcp::new(); let mut env = fake.env(); env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into()); let id = structured_session_with_env(&h, env).await; let client = reqwest::Client::new(); let path = format!("/api/sessions/{id}/structured/prompt"); let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}"))) .await .unwrap(); ws.next().await.unwrap().unwrap(); ws.next().await.unwrap().unwrap(); let first = client .post(url(h.addr, &path)) .json(&serde_json::json!({"prompt":"fails"})) .send() .await .unwrap(); assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT); let mut saw_active_turn = false; tokio::time::timeout(Duration::from_secs(1), async { while let Some(Ok(message)) = ws.next().await { let event: serde_json::Value = serde_json::from_str(&message.into_text().unwrap()).unwrap(); let turn = &event["state"]["turn"]; if turn["responding"] == true { saw_active_turn = true; } if saw_active_turn && turn["responding"] == false && turn["thinking"] == false { return; } } panic!("structured websocket closed before the failed turn cleared"); }) .await .expect("failed turn clears promptly"); wait_for_session_activity(&h, id, ActivityState::Error).await; let second = client .post(url(h.addr, &path)) .json(&serde_json::json!({"prompt":"available"})) .send() .await .unwrap(); assert_eq!(second.status(), reqwest::StatusCode::NO_CONTENT); assert!(second.bytes().await.unwrap().is_empty()); } #[tokio::test] async fn structured_prompt_rejects_unknown_and_unavailable_sessions() { let h = spawn("naps-laptop").await; let client = reqwest::Client::new(); let prompt = |id| { client .post(url( h.addr, &format!("/api/sessions/{id}/structured/prompt"), )) .json(&serde_json::json!({"prompt": "hello"})) }; let unknown = prompt(Uuid::new_v4()).send().await.unwrap(); assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND); let error: ApiError = unknown.json().await.unwrap(); assert_eq!(error.error.code, "not_found"); let terminal = h .manager .create(CreateSessionRequest { view: SessionView::Terminal, cwd: "/tmp".into(), command: "sh".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .unwrap(); let non_structured = prompt(terminal.id).send().await.unwrap(); assert_eq!(non_structured.status(), reqwest::StatusCode::CONFLICT); let error: ApiError = non_structured.json().await.unwrap(); assert_eq!(error.error.code, "not_structured"); let fake = FakeAcp::new(); let dead = structured_session(&h, &fake).await; h.manager.shutdown().await; assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead)); let unavailable = prompt(dead).send().await.unwrap(); assert_eq!(unavailable.status(), reqwest::StatusCode::CONFLICT); let error: ApiError = unavailable.json().await.unwrap(); assert_eq!(error.error.code, "not_structured"); } #[tokio::test] async fn structured_replay_honors_cursors_and_limit() { let h = spawn("naps-laptop").await; let fake = FakeAcp::new(); let id = structured_session(&h, &fake).await; let client = reqwest::Client::new(); let events: Vec = tokio::time::timeout(Duration::from_secs(2), async { loop { let events: Vec = client .get(url( h.addr, &format!("/api/sessions/{id}/structured/replay?since=0&limit=1"), )) .send() .await .unwrap() .json() .await .unwrap(); if !events.is_empty() { return events; } tokio::time::sleep(Duration::from_millis(10)).await; } }) .await .unwrap(); assert_eq!(events.len(), 1); assert_eq!(events[0]["cursor"], 1); assert!(events[0].get("notification").is_none()); let invalid = client .get(url( h.addr, &format!("/api/sessions/{id}/structured/replay?since=0&before=2"), )) .send() .await .unwrap(); assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST); } #[tokio::test] async fn structured_replay_pages_projected_events_in_cursor_order() { let h = spawn("naps-laptop").await; let fake = FakeAcp::new(); let id = structured_session(&h, &fake).await; let client = reqwest::Client::new(); unsafe { libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1) }; let events: Vec = tokio::time::timeout(Duration::from_secs(2), async { loop { let events: Vec = client .get(url( h.addr, &format!("/api/sessions/{id}/structured/replay?before=99&limit=2"), )) .send() .await .unwrap() .json() .await .unwrap(); if events.len() == 2 { return events; } tokio::time::sleep(Duration::from_millis(10)).await; } }) .await .unwrap(); assert_eq!( events .iter() .map(|event| event["cursor"].as_u64().unwrap()) .collect::>(), vec![1, 2] ); let boundary: Vec = client .get(url( h.addr, &format!("/api/sessions/{id}/structured/replay?before=2&limit=10"), )) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(boundary.len(), 1); assert_eq!(boundary[0]["cursor"], 1); } #[tokio::test] async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() { let h = spawn("naps-laptop").await; let client = reqwest::Client::new(); let unknown = client .get(url( h.addr, &format!("/api/sessions/{}/structured/replay", Uuid::new_v4()), )) .send() .await .unwrap(); assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND); let terminal = h .manager .create(CreateSessionRequest { view: SessionView::Terminal, cwd: "/tmp".into(), command: "sh".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .unwrap(); let response = client .get(url( h.addr, &format!("/api/sessions/{}/structured/replay", terminal.id), )) .send() .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::CONFLICT); let fake = FakeAcp::new(); let dead = structured_session(&h, &fake).await; h.manager.shutdown().await; assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead)); let response = client .get(url( h.addr, &format!("/api/sessions/{dead}/structured/replay"), )) .send() .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::CONFLICT); let error: ApiError = response.json().await.unwrap(); assert_eq!(error.error.code, "not_structured"); } #[tokio::test] async fn structured_ws_rejects_a_dead_session() { let h = spawn("naps-laptop").await; let fake = FakeAcp::new(); let id = structured_session(&h, &fake).await; h.manager.shutdown().await; assert_eq!(h.manager.status(id).await, Some(SessionStatus::Dead)); let err = connect_async(ws_url(h.addr, &format!("/api/structured/{id}"))) .await .expect_err("dead structured session must not upgrade"); let message = err.to_string(); assert!( message.contains("409") || message.to_lowercase().contains("conflict"), "{message}" ); } #[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()); } /// #159: the web client reads its leader off `/api/info` instead of a /// hardcoded constant, so the payload has to carry the configured one — not /// the default dressed up as one. #[tokio::test] async fn info_advertises_the_configured_leader() { let h = spawn_with_leader( "naps-laptop", std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), "ctrl-b", ) .await; let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) .await .unwrap() .json() .await .unwrap(); assert_eq!(resp["leader"], "ctrl-b"); } #[tokio::test] async fn info_reports_the_default_leader_when_unconfigured() { 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["leader"], "ctrl-a"); } #[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 { view: Default::default(), 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 { view: Default::default(), 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); } /// #145: `POST /.../focus` with `focused: true` over HTTP stamps /// `last_focused_at`, observable on the next `GET /api/sessions`. #[tokio::test] async fn post_focus_stamps_last_focused_at() { let h = spawn("naps-laptop").await; let session = h .manager .create(CreateSessionRequest { view: Default::default(), cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .unwrap(); assert_eq!(session.last_focused_at, None); let resp = reqwest::Client::new() .post(url(h.addr, &format!("/api/sessions/{}/focus", session.id))) .json(&serde_json::json!({ "focused": true })) .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(); assert!(sessions[0].last_focused_at.is_some()); } #[tokio::test] async fn delete_owned_session_kills_it() { let h = spawn("naps-laptop").await; let session = h .manager .create(CreateSessionRequest { view: Default::default(), 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 { view: Default::default(), 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 { view: Default::default(), 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 { view: Default::default(), 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 { view: Default::default(), 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 pty_routes_reject_structured_sessions_as_not_terminal() { let h = spawn("naps-laptop").await; let fake = FakeAcp::new(); let id = structured_session(&h, &fake).await; let response = reqwest::Client::new() .post(url(h.addr, &format!("/api/sessions/{id}/input"))) .json(&maestro_protocol::SendInputRequest { text: "ignored".into(), submit: false, }) .send() .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::CONFLICT); let error: ApiError = response.json().await.unwrap(); assert_eq!(error.error.code, "not_terminal"); assert_eq!(error.error.message, "session is not a terminal session"); let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}"))) .await .expect_err("structured session must not upgrade to a PTY socket"); let tokio_tungstenite::tungstenite::Error::Http(response) = error else { panic!("expected an HTTP handshake rejection, got {error}"); }; assert_eq!(response.status(), reqwest::StatusCode::CONFLICT); let body = response.body().as_deref().expect("ApiError response body"); let error: ApiError = serde_json::from_slice(body).unwrap(); assert_eq!(error.error.code, "not_terminal"); assert_eq!(error.error.message, "session is not a terminal session"); } #[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 { view: Default::default(), 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; } } } #[tokio::test] async fn events_ws_streams_structured_activity_without_hook_overrides() { let h = spawn("konishi").await; let fake = FakeAcp::new(); let mut env = fake.env(); env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "500".into()); let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events")) .await .expect("events ws"); assert!(matches!( next_event(&mut ws).await, maestro_protocol::CatalogEvent::Snapshot { .. } )); let id = structured_session_with_env(&h, env).await; let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else { panic!("expected structured SessionAdded") }; assert_eq!(session.activity, ActivityState::Idle); let client = reqwest::Client::new(); let prompt = client .post(url( h.addr, &format!("/api/sessions/{id}/structured/prompt"), )) .json(&serde_json::json!({"prompt": "hello"})) .send() .await .unwrap(); assert_eq!(prompt.status(), reqwest::StatusCode::NO_CONTENT); let report = client .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) .json(&serde_json::json!({"state": "Error"})) .send() .await .unwrap(); assert_eq!(report.status(), reqwest::StatusCode::NO_CONTENT); wait_for_session_activity(&h, id, ActivityState::Active).await; let mut activities = Vec::new(); tokio::time::timeout(Duration::from_secs(2), async { loop { if let maestro_protocol::CatalogEvent::SessionUpdated { session } = next_event(&mut ws).await { activities.push(session.activity); if session.activity == ActivityState::Idle { return; } } } }) .await .expect("structured turn returns to idle"); assert!( activities.contains(&ActivityState::Active), "{activities:?}" ); assert!( !activities.contains(&ActivityState::Error), "{activities:?}" ); h.manager.kill(id, false).await.unwrap(); } /// 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 { view: Default::default(), 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:?}"), } } }