//! An in-memory Maestro daemon for tests. //! //! Serves the real `/api/*` surface from `maestro-protocol` types, backed by a //! `Vec` instead of tmux. Use it whenever the subject under test is a //! client — request building, error decoding, the catalog reducer, the proxy — //! rather than session management itself. Tests that genuinely need tmux belong //! in `maestro-e2e` against a real daemon. #![deny(missing_debug_implementations)] use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; use std::sync::{Arc, Mutex}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::routing::{any, delete, get}; use axum::{Json, Router}; use chrono::Utc; use futures_util::{SinkExt, StreamExt}; use maestro_protocol::{ routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing, GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION, }; use tokio::net::TcpListener; use tokio::sync::{broadcast, oneshot}; use uuid::Uuid; /// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`]. pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { Session { id, cwd: cwd.into(), command: command.into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, activity: Default::default(), last_activity_at: Utc::now(), activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, } } #[derive(Debug, Default)] struct Inner { sessions: Vec, info: Option, /// When set, every route answers with this instead of its normal response. forced_error: Option<(u16, ApiError)>, } #[derive(Clone)] struct AppState { inner: Arc>, events: broadcast::Sender, } impl std::fmt::Debug for AppState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AppState").finish_non_exhaustive() } } /// A running mock daemon. Shuts down when dropped. #[derive(Debug)] pub struct MockDaemon { addr: SocketAddr, state: AppState, _shutdown: oneshot::Sender<()>, } impl MockDaemon { pub async fn start() -> Self { let state = AppState { inner: Arc::new(Mutex::new(Inner::default())), events: broadcast::channel(64).0, }; let app = Router::new() .route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::patterns::SESSION, delete(kill_session)) .route(routes::INFO, get(daemon_info)) .route(routes::DIRS, get(list_dir)) .route(routes::RECENT_DIRS, get(recent_dirs)) .route(routes::GITROOT, get(git_root)) .route(routes::EVENTS, any(events_ws)) .route(routes::patterns::ATTACH, any(attach_ws)) .with_state(state.clone()); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await .expect("bind"); let addr = listener.local_addr().expect("local_addr"); let (tx, rx) = oneshot::channel::<()>(); tokio::spawn(async move { let _ = axum::serve(listener, app) .with_graceful_shutdown(async move { let _ = rx.await; }) .await; }); Self { addr, state, _shutdown: tx, } } pub fn addr(&self) -> SocketAddr { self.addr } pub fn http_base(&self) -> String { format!("http://{}", self.addr) } /// A client bound to this daemon. pub fn client(&self) -> DaemonClient { DaemonClient::new(self.http_base()) } /// Add a session and announce it, as a real spawn would. pub fn add_session(&self, session: Session) { self.state .inner .lock() .expect("poisoned") .sessions .push(session.clone()); let _ = self .state .events .send(CatalogEvent::SessionAdded { session }); } /// Remove a session and announce it. pub fn remove_session(&self, id: Uuid) { self.state .inner .lock() .expect("poisoned") .sessions .retain(|s| s.id != id); let _ = self.state.events.send(CatalogEvent::SessionRemoved { id }); } pub fn set_info(&self, info: DaemonInfo) { self.state.inner.lock().expect("poisoned").info = Some(info); } /// Make every route answer with this error, so clients' failure paths — and /// their [`ApiError`] decoding — can be exercised. pub fn force_error(&self, status: u16, code: &str, message: &str) { self.state.inner.lock().expect("poisoned").forced_error = Some((status, ApiError::new(code, message))); } pub fn clear_error(&self) { self.state.inner.lock().expect("poisoned").forced_error = None; } pub fn session_count(&self) -> usize { self.state.inner.lock().expect("poisoned").sessions.len() } } fn forced(state: &AppState) -> Option { let guard = state.inner.lock().expect("poisoned"); guard.forced_error.as_ref().map(|(status, body)| { ( StatusCode::from_u16(*status).expect("valid status"), Json(body.clone()), ) .into_response() }) } async fn list_sessions(State(s): State) -> Response { if let Some(r) = forced(&s) { return r; } let sessions = s.inner.lock().expect("poisoned").sessions.clone(); Json(sessions).into_response() } async fn create_session( State(s): State, Json(req): Json, ) -> Response { if let Some(r) = forced(&s) { return r; } let mut created = session(Uuid::new_v4(), &req.cwd.to_string_lossy(), &req.command); created.env = req.env; created.metadata = req.metadata; s.inner .lock() .expect("poisoned") .sessions .push(created.clone()); let _ = s.events.send(CatalogEvent::SessionAdded { session: created.clone(), }); Json(created).into_response() } async fn kill_session(State(s): State, Path(id): Path) -> Response { if let Some(r) = forced(&s) { return r; } let existed = { let mut guard = s.inner.lock().expect("poisoned"); let before = guard.sessions.len(); guard.sessions.retain(|x| x.id != id); guard.sessions.len() != before }; if !existed { return ( StatusCode::NOT_FOUND, Json(ApiError::new("not_found", "no such session on this daemon")), ) .into_response(); } let _ = s.events.send(CatalogEvent::SessionRemoved { id }); StatusCode::NO_CONTENT.into_response() } async fn daemon_info(State(s): State) -> Response { if let Some(r) = forced(&s) { return r; } let info = s .inner .lock() .expect("poisoned") .info .clone() .unwrap_or_else(|| DaemonInfo { hostname: "mock-host".into(), version: PROTOCOL_VERSION.into(), home_dir: "/home/mock".into(), presets: Vec::new(), }); Json(info).into_response() } async fn list_dir(State(s): State, Query(q): Query) -> Response { if let Some(r) = forced(&s) { return r; } Json(DirListing { path: q.path.into(), entries: Vec::new(), truncated: false, }) .into_response() } async fn recent_dirs(State(s): State) -> Response { if let Some(r) = forced(&s) { return r; } Json(RecentDirs { dirs: Vec::new(), truncated: false, }) .into_response() } async fn git_root(State(s): State, Query(_q): Query) -> Response { if let Some(r) = forced(&s) { return r; } Json(GitRoot { root: None }).into_response() } /// Snapshot on connect, then live deltas — the same contract the daemon offers. async fn events_ws(State(s): State, ws: WebSocketUpgrade) -> Response { ws.on_upgrade(move |socket| events_loop(s, socket)) } async fn events_loop(s: AppState, socket: WebSocket) { let (mut sink, mut stream) = socket.split(); let mut rx = s.events.subscribe(); let snapshot = CatalogEvent::Snapshot { sessions: s.inner.lock().expect("poisoned").sessions.clone(), }; if sink .send(Message::Text( serde_json::to_string(&snapshot).expect("encode"), )) .await .is_err() { return; } loop { tokio::select! { ev = rx.recv() => { let Ok(ev) = ev else { break }; let text = serde_json::to_string(&ev).expect("encode"); if sink.send(Message::Text(text)).await.is_err() { break; } } frame = stream.next() => { match frame { None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break, Some(Ok(_)) => {} } } } } } /// Echoes whatever it is sent, so a client's PTY plumbing can be exercised. async fn attach_ws( State(s): State, Path(id): Path, ws: WebSocketUpgrade, ) -> Response { let known = s .inner .lock() .expect("poisoned") .sessions .iter() .any(|x| x.id == id); if !known { return ( StatusCode::NOT_FOUND, Json(ApiError::new("not_found", "no such session on this daemon")), ) .into_response(); } ws.on_upgrade(|mut socket| async move { while let Some(Ok(msg)) = socket.next().await { match msg { Message::Binary(b) => { if socket.send(Message::Binary(b)).await.is_err() { break; } } Message::Text(t) => { if socket.send(Message::Text(t)).await.is_err() { break; } } Message::Close(_) => break, _ => {} } } }) } #[derive(Debug, serde::Deserialize)] struct PathQuery { #[serde(default)] path: String, }