This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
naps62-yoloandClaude Opus 4.8 458c81094b feat(maestro): nest sessions by live working directory, drop labels
Replace the freeform `metadata.label` session grouping with nesting derived
from each session's working directory. The daemon's activity watcher already
polls each tmux pane every tick, so it now also captures
`#{pane_current_path}` and surfaces it as a new `Session.current_cwd`
(serde-default, back-compat); `cwd` stays the spawn dir for worktree
inference, spawn-similar, and recents.

Web catalog builds the tree from `current_cwd ?? cwd`, collapses the home
dir to `~`, and folds single-child folder chains so `~/git/maestro` reads as
one node. CLI swaps its LABEL column for an abbreviated DIR; the spawn forms
(web + TUI) and `--label` flag are removed.

The pane format stays space-delimited (parser splits the first three tokens
and keeps the remainder as the path) so a cwd containing spaces survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:11:23 +00:00

392 lines
11 KiB
Rust

//! End-to-end tests for the `WS /daemon` endpoint, driven by a real
//! `tokio-tungstenite` client against an axum server bound to `:0`.
use std::collections::HashMap;
use std::future::Future;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
CreateSessionRequest, DaemonInbound, DaemonOutbound, RpcResult, Session, SessionStatus,
};
use maestro_registry::{router_with_timeout, Registry};
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;
const TOKEN: &str = "test-token-please-allow";
const TEST_HEARTBEAT: Duration = Duration::from_millis(500);
struct Harness {
addr: SocketAddr,
registry: Registry,
_shutdown_tx: oneshot::Sender<()>,
}
async fn spawn_registry() -> Harness {
spawn_registry_with_timeout(TEST_HEARTBEAT).await
}
async fn spawn_registry_with_timeout(heartbeat: Duration) -> Harness {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let registry = Registry::new();
let app = router_with_timeout(registry.clone(), TOKEN.to_string(), heartbeat);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
Harness {
addr,
registry,
_shutdown_tx: shutdown_tx,
}
}
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
async fn connect(addr: SocketAddr) -> WsClient {
let url = format!("ws://{addr}/daemon");
let (ws, _) = connect_async(url).await.expect("ws connect");
ws
}
async fn send_json(ws: &mut WsClient, msg: &DaemonOutbound) {
let text = serde_json::to_string(msg).unwrap();
ws.send(WsMessage::Text(text)).await.unwrap();
}
async fn recv_inbound(ws: &mut WsClient) -> Option<DaemonInbound> {
while let Some(Ok(msg)) = ws.next().await {
if let WsMessage::Text(t) = msg {
let parsed: DaemonInbound = serde_json::from_str(&t).unwrap();
// NAP-246: skip catalog frames — these are informational and not
// what most tests are asserting on.
if matches!(parsed, DaemonInbound::CatalogEvent { .. }) {
continue;
}
return Some(parsed);
}
}
None
}
fn hello(token: &str) -> DaemonOutbound {
DaemonOutbound::Hello {
daemon_id: "laptop".into(),
hostname: "naps-laptop".into(),
version: "0.1.0".into(),
token: token.into(),
daemon_url: "ws://127.0.0.1:8081".into(),
home_dir: String::new(),
}
}
fn sample_session(id: Uuid, daemon_id: &str) -> Session {
Session {
id,
daemon_id: daemon_id.to_string(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
status: SessionStatus::Running,
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
async fn wait_for<F, Fut>(timeout: Duration, mut cond: F)
where
F: FnMut() -> Fut,
Fut: Future<Output = bool>,
{
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if cond().await {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out after {timeout:?} waiting for condition");
}
// --------------------------------------------------------------------------
// Tests
// --------------------------------------------------------------------------
#[tokio::test]
async fn rejects_bad_token() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello("wrong")).await;
let reply = recv_inbound(&mut ws).await.expect("error frame");
match reply {
DaemonInbound::Error(e) => assert_eq!(e.code, "auth_failed"),
other => panic!("expected Error, got {other:?}"),
}
let _ = ws.next().await;
assert!(h.registry.daemons().await.is_empty());
}
#[tokio::test]
async fn accepts_good_token_and_snapshot() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let ack = recv_inbound(&mut ws).await.expect("ack");
assert!(matches!(ack, DaemonInbound::Ack));
let sessions = vec![
sample_session(Uuid::new_v4(), "laptop"),
sample_session(Uuid::new_v4(), "laptop"),
];
send_json(
&mut ws,
&DaemonOutbound::SessionsSnapshot {
sessions: sessions.clone(),
},
)
.await;
let expected = sessions.len();
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == expected }
})
.await;
let daemons = h.registry.daemons().await;
assert_eq!(daemons.len(), 1);
assert_eq!(daemons[0].daemon_id, "laptop");
}
#[tokio::test]
async fn applies_individual_diffs() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws).await;
let s1 = sample_session(Uuid::new_v4(), "laptop");
let s2 = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws,
&DaemonOutbound::SessionAdded {
session: s1.clone(),
},
)
.await;
send_json(
&mut ws,
&DaemonOutbound::SessionAdded {
session: s2.clone(),
},
)
.await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == 2 }
})
.await;
let mut s1_updated = s1.clone();
s1_updated.command = "fish".into();
send_json(
&mut ws,
&DaemonOutbound::SessionUpdated {
session: s1_updated.clone(),
},
)
.await;
send_json(&mut ws, &DaemonOutbound::SessionRemoved { id: s2.id }).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move {
let s = r.sessions().await;
s.len() == 1 && s[0].command == "fish"
}
})
.await;
}
#[tokio::test]
async fn heartbeat_timeout_drops_daemon() {
let h = spawn_registry_with_timeout(Duration::from_millis(200)).await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws).await;
send_json(
&mut ws,
&DaemonOutbound::SessionAdded {
session: sample_session(Uuid::new_v4(), "laptop"),
},
)
.await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == 1 }
})
.await;
// Hold the WS open but never send another frame. The server's per-read
// timeout should fire after 200ms and drop the daemon.
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.daemons().await.is_empty() && r.sessions().await.is_empty() }
})
.await;
}
#[tokio::test]
async fn re_registration_replaces_catalog() {
let h = spawn_registry().await;
let mut ws1 = connect(h.addr).await;
send_json(&mut ws1, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws1).await;
let s_old = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws1,
&DaemonOutbound::SessionAdded {
session: s_old.clone(),
},
)
.await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == 1 }
})
.await;
drop(ws1);
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.daemons().await.is_empty() }
})
.await;
let mut ws2 = connect(h.addr).await;
send_json(&mut ws2, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws2).await;
let s_new = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws2,
&DaemonOutbound::SessionsSnapshot {
sessions: vec![s_new.clone()],
},
)
.await;
let registry = h.registry.clone();
let want_id = s_new.id;
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move {
let s = r.sessions().await;
s.len() == 1 && s[0].id == want_id
}
})
.await;
}
#[tokio::test]
async fn create_session_rpc_round_trip() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws).await;
let registry = h.registry.clone();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let rpc = tokio::spawn(async move { registry.create_session("laptop", request).await });
let inbound = recv_inbound(&mut ws).await.expect("CreateSession");
let (reply_to, _request) = match inbound {
DaemonInbound::CreateSession { reply_to, request } => (reply_to, request),
other => panic!("expected CreateSession, got {other:?}"),
};
let session = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws,
&DaemonOutbound::CreateSessionResult {
reply_to,
result: RpcResult::Ok {
value: session.clone(),
},
},
)
.await;
let outcome = rpc.await.unwrap().expect("rpc transport ok");
let returned = outcome.expect("rpc daemon ok");
assert_eq!(returned.id, session.id);
}
#[tokio::test]
async fn create_session_to_unknown_daemon_errors() {
let h = spawn_registry().await;
let err = h
.registry
.create_session(
"no-such-daemon",
CreateSessionRequest {
daemon_id: "no-such-daemon".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
},
)
.await
.unwrap_err();
assert!(matches!(
err,
maestro_registry::RegistryError::DaemonNotConnected(_)
));
}