test(maestro): one mock daemon instead of three answers
There were three answers to "how do I test against a daemon", and the CLI
— the thinnest client — had none, so its formatting and error handling
were exercised by compiling and forking the real binary plus tmux.
Adds crates/maestro-testing with a MockDaemon that serves the real /api/*
surface from maestro-protocol types, backed by a Vec<Session>. It replaces
two ad-hoc stubs: maestro-web's, which returned [{"id":"session-1"}] —
not a valid Session — and maestro-tui's scripted socket with a hardcoded
timeline.
maestro-cli gets its first tests against a daemon: six cases covering the
catalog, that DaemonInfo carries the version and presets its old private
copy dropped, that a structured failure surfaces the daemon's own message,
and that connection-refused stays distinguishable from an HTTP rejection.
maestro-web's proxy suite gains an end-to-end check that an upstream
rejection reaches the browser as a 4502 close carrying the daemon's
message — upstream_close_reason had only unit tests.
e2e keeps the tmux-shaped tests. cli_list_reads_from_daemon still runs the
shipped binary as a subprocess but now against MockDaemon, and
tui_integration.rs is deleted: the real daemon's /api/events contract is
covered by maestro-daemon/tests/local_api.rs and the client reducer by
maestro-tui/tests/catalog_ws.rs. The e2e suite drops from ~32s to ~20s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #47.
This commit is contained in:
co-authored by
Claude Opus 5
parent
6596104789
commit
30bd9d9042
@@ -0,0 +1,364 @@
|
||||
//! An in-memory Maestro daemon for tests.
|
||||
//!
|
||||
//! Serves the real `/api/*` surface from `maestro-protocol` types, backed by a
|
||||
//! `Vec<Session>` 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(),
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: Utc::now(),
|
||||
current_cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
sessions: Vec<Session>,
|
||||
info: Option<DaemonInfo>,
|
||||
/// When set, every route answers with this instead of its normal response.
|
||||
forced_error: Option<(u16, ApiError)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
events: broadcast::Sender<CatalogEvent>,
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
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<AppState>) -> 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<AppState>,
|
||||
Json(req): Json<CreateSessionRequest>,
|
||||
) -> 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<AppState>, Path(id): Path<Uuid>) -> 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<AppState>) -> 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<AppState>, Query(q): Query<PathQuery>) -> 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<AppState>) -> 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<AppState>, Query(_q): Query<PathQuery>) -> 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<AppState>, 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<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user