Two web spawn QoL improvements plus the wire support they need: - Command field now prefills `claude` instead of an empty box. - Working directory prefills the selected daemon's home dir, updating on daemon change unless the user has typed their own value. The daemon resolves its `$HOME` via `dirs::home_dir()` at startup and advertises it end-to-end: a new `home_dir` field on `DaemonOutbound::Hello` flows through the registry's `register` into `DaemonInfo` (served on `/api/daemons` and `/api/events`). The field is `#[serde(default)]`, so older daemons that omit it deserialize to an empty string — backward compatible, and the UI simply skips prefill when it's empty. Hierarchical `/`-separated labels in the web sidebar already worked (web/src/catalog.ts); no change there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
215 lines
6.3 KiB
Rust
215 lines
6.3 KiB
Rust
//! End-to-end tests for the daemon's outbound registry client.
|
|
//!
|
|
//! Uses the `maestro-registry` crate as the real registry under test. Verifies
|
|
//! the daemon connects, sends Hello + snapshot, forwards diffs, replies to
|
|
//! inbound CreateSession/KillSession, and reconnects after a registry kill.
|
|
|
|
mod common;
|
|
|
|
use std::collections::HashMap;
|
|
use std::net::{Ipv4Addr, SocketAddr};
|
|
use std::time::Duration;
|
|
|
|
use common::{wait_for_async, DaemonFixture};
|
|
use maestro_daemon::RegistryClient;
|
|
use maestro_protocol::CreateSessionRequest;
|
|
use maestro_registry::{router, Registry};
|
|
use tempfile::TempDir;
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::oneshot;
|
|
|
|
const TOKEN: &str = "shared-registry-token";
|
|
|
|
struct RegistryHarness {
|
|
url: String,
|
|
state: Registry,
|
|
shutdown: Option<oneshot::Sender<()>>,
|
|
}
|
|
|
|
async fn spawn_registry() -> RegistryHarness {
|
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
|
.await
|
|
.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let state = Registry::new();
|
|
let app = router(state.clone(), TOKEN.to_string());
|
|
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;
|
|
});
|
|
RegistryHarness {
|
|
url: format!("ws://{addr}/daemon"),
|
|
state,
|
|
shutdown: Some(shutdown_tx),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn daemon_registers_and_streams_snapshot() {
|
|
let reg = spawn_registry().await;
|
|
let fx = DaemonFixture::new("laptop");
|
|
|
|
let client = RegistryClient::new(
|
|
reg.url.clone(),
|
|
"laptop",
|
|
"naps-laptop",
|
|
"ws://127.0.0.1:8081",
|
|
"/home/naps62",
|
|
TOKEN,
|
|
fx.manager.clone(),
|
|
);
|
|
tokio::spawn(async move { client.run().await });
|
|
|
|
// wait for the Hello + snapshot to land
|
|
let state = reg.state.clone();
|
|
wait_for_async(Duration::from_secs(5), || {
|
|
let r = state.clone();
|
|
async move {
|
|
let daemons = r.daemons().await;
|
|
daemons.len() == 1 && daemons[0].daemon_id == "laptop"
|
|
}
|
|
})
|
|
.await;
|
|
|
|
// create a session via the manager — should trigger a SessionAdded diff
|
|
let cwd = TempDir::new().unwrap();
|
|
let session = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
daemon_id: "laptop".into(),
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let state = reg.state.clone();
|
|
let want_id = session.id;
|
|
wait_for_async(Duration::from_secs(5), || {
|
|
let r = state.clone();
|
|
async move {
|
|
let s = r.sessions().await;
|
|
s.len() == 1 && s[0].id == want_id
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let _ = fx.manager.kill(session.id).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn daemon_reconnects_after_registry_drop() {
|
|
// first registry: accept connection, then drop
|
|
let mut reg1 = spawn_registry().await;
|
|
let url = reg1.url.clone();
|
|
let fx = DaemonFixture::new("laptop");
|
|
|
|
let client = RegistryClient::new(
|
|
url.clone(),
|
|
"laptop",
|
|
"naps-laptop",
|
|
"ws://127.0.0.1:8081",
|
|
"/home/naps62",
|
|
TOKEN,
|
|
fx.manager.clone(),
|
|
);
|
|
tokio::spawn(async move { client.run().await });
|
|
|
|
// wait for daemon to register on the first registry
|
|
let state1 = reg1.state.clone();
|
|
wait_for_async(Duration::from_secs(5), || {
|
|
let r = state1.clone();
|
|
async move { !r.daemons().await.is_empty() }
|
|
})
|
|
.await;
|
|
|
|
// drop registry1; daemon should reconnect once we bring another up on the
|
|
// same port. Easier: bring up a second registry on a *different* port
|
|
// requires changing url, which RegistryClient doesn't support — instead,
|
|
// just verify the daemon's task survives the drop and the original
|
|
// registry's state becomes empty (the daemon connection died).
|
|
let _ = reg1.shutdown.take().unwrap().send(());
|
|
// give some time for the daemon to notice
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
|
|
// RegistryClient::run loops forever, so we can't easily assert
|
|
// reconnection without standing up the registry on the same port again.
|
|
// The hard part — graceful failure of the inner connection — is what we're
|
|
// really testing: the daemon must not panic.
|
|
// (A full reconnect-against-fresh-server test belongs in the e2e crate
|
|
// NAP-217.)
|
|
let _ = fx;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_create_session_round_trips() {
|
|
let reg = spawn_registry().await;
|
|
let fx = DaemonFixture::new("laptop");
|
|
|
|
let client = RegistryClient::new(
|
|
reg.url.clone(),
|
|
"laptop",
|
|
"naps-laptop",
|
|
"ws://127.0.0.1:8081",
|
|
"/home/naps62",
|
|
TOKEN,
|
|
fx.manager.clone(),
|
|
);
|
|
tokio::spawn(async move { client.run().await });
|
|
|
|
let state = reg.state.clone();
|
|
wait_for_async(Duration::from_secs(5), || {
|
|
let r = state.clone();
|
|
async move { !r.daemons().await.is_empty() }
|
|
})
|
|
.await;
|
|
|
|
let cwd = TempDir::new().unwrap();
|
|
let request = CreateSessionRequest {
|
|
daemon_id: "laptop".into(),
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
};
|
|
|
|
let result = reg
|
|
.state
|
|
.create_session("laptop", request)
|
|
.await
|
|
.expect("transport ok");
|
|
let session = result.expect("rpc ok");
|
|
assert_eq!(session.command, "sleep 30");
|
|
|
|
// the registry should also see the SessionAdded diff in its catalog
|
|
let state = reg.state.clone();
|
|
wait_for_async(Duration::from_secs(5), || {
|
|
let r = state.clone();
|
|
async move { r.sessions().await.len() == 1 }
|
|
})
|
|
.await;
|
|
|
|
// and KillSession should round-trip too
|
|
let kill = reg
|
|
.state
|
|
.kill_session("laptop", session.id)
|
|
.await
|
|
.expect("transport ok");
|
|
kill.expect("rpc ok");
|
|
|
|
let state = reg.state.clone();
|
|
wait_for_async(Duration::from_secs(5), || {
|
|
let r = state.clone();
|
|
async move { r.sessions().await.is_empty() }
|
|
})
|
|
.await;
|
|
}
|