Daemon now loads `[shared].registry_url`, `[shared].token`, and the `[daemon]` section from the unified file via maestro-config. The registry-WS endpoint is derived from `[shared].registry_url` (scheme swap + `/daemon`), so operators configure a single base URL instead of duplicating the daemon-specific endpoint. Env-var renames (all per the new `MAESTRO_<SECTION>_<FIELD>` convention): - MAESTROD_REGISTRY_URL → MAESTRO_SHARED_REGISTRY_URL (and the value is now an http(s) base, not the ws daemon endpoint) - MAESTROD_DAEMON_URL → MAESTRO_DAEMON_URL - MAESTROD_DAEMON_ID → MAESTRO_DAEMON_ID - MAESTROD_BIND_ADDR → MAESTRO_DAEMON_BIND_ADDR - MAESTROD_CONFIG → MAESTRO_CONFIG - MAESTRO_TOKEN → MAESTRO_SHARED_TOKEN `maestrod configure` now writes the unified file with read-modify-write so any existing `[cli]` / `[registry]` sections on the same host are preserved. The interactive prompts reflect the new shape: registry URL is asked for as a base URL, and the token prompt names the new env var. Missing-file behaviour is hard cutover: if the config doesn't exist and not every required field comes from env, the daemon errors with a message pointing at `maestrod configure` and explicitly noting that the legacy paths (~/.config/maestrod/config.toml) are no longer read. e2e harness updated to match: `Daemon::spawn` takes the registry's HTTP base, and the spawned subprocess sees the renamed env vars.
330 lines
10 KiB
Rust
330 lines
10 KiB
Rust
//! Cross-process end-to-end tests for the headless Maestro slice.
|
|
//!
|
|
//! Each test brings up a real `maestro-registry` plus 1-2 real `maestrod`
|
|
//! subprocesses, isolates them in temp dirs, and drives them through the
|
|
//! public HTTP+WS surface using the same client APIs a real CLI/web client
|
|
//! would.
|
|
//!
|
|
//! These tests require `tmux` and `git` on `$PATH`.
|
|
|
|
#![allow(clippy::result_large_err)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::process::Command;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use maestro_e2e::util::wait_for_async;
|
|
use maestro_e2e::{Daemon, Registry, TestClient};
|
|
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
|
|
use tempfile::TempDir;
|
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
|
|
|
const TOKEN: &str = "e2e-shared-token";
|
|
|
|
async fn bring_up(daemon_ids: &[&str]) -> (Registry, Vec<Daemon>, TestClient) {
|
|
let registry = Registry::spawn(TOKEN).await;
|
|
let mut daemons = Vec::with_capacity(daemon_ids.len());
|
|
for id in daemon_ids {
|
|
let d = Daemon::spawn(id, ®istry.http_base(), TOKEN).await;
|
|
daemons.push(d);
|
|
}
|
|
let client = TestClient::new(®istry.http_base(), ®istry.ws_base());
|
|
client.bootstrap().await.expect("bootstrap");
|
|
|
|
// Wait until both daemons have connected and announced themselves.
|
|
let expected: Vec<String> = daemon_ids.iter().map(|s| s.to_string()).collect();
|
|
wait_for_async("daemons to register", Duration::from_secs(10), || {
|
|
let client = client.clone();
|
|
let expected = expected.clone();
|
|
async move {
|
|
match client.list_daemons().await {
|
|
Ok(ds) => expected
|
|
.iter()
|
|
.all(|id| ds.iter().any(|d| &d.daemon_id == id)),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
(registry, daemons, client)
|
|
}
|
|
|
|
fn create_req(daemon_id: &str, cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
|
CreateSessionRequest {
|
|
daemon_id: daemon_id.into(),
|
|
cwd: cwd.to_path_buf(),
|
|
command: command.into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn lists_sessions_from_both_daemons() {
|
|
let (_reg, _daemons, client) = bring_up(&["alpha", "beta"]).await;
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let a = client
|
|
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
|
|
.await
|
|
.expect("spawn alpha");
|
|
let b = client
|
|
.spawn_session(&create_req("beta", cwd.path(), "sleep 30"))
|
|
.await
|
|
.expect("spawn beta");
|
|
|
|
let sessions = client.list_sessions().await.expect("list");
|
|
assert!(sessions
|
|
.iter()
|
|
.any(|s| s.id == a.id && s.daemon_id == "alpha"));
|
|
assert!(sessions
|
|
.iter()
|
|
.any(|s| s.id == b.id && s.daemon_id == "beta"));
|
|
|
|
client.kill_session(a.id).await.expect("kill a");
|
|
client.kill_session(b.id).await.expect("kill b");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn multi_attach_sees_same_output() {
|
|
let (_reg, _daemons, client) = bring_up(&["solo"]).await;
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = client
|
|
.spawn_session(&create_req("solo", cwd.path(), "bash --noprofile --norc"))
|
|
.await
|
|
.expect("spawn");
|
|
|
|
let info = client.connect_info(s.id).await.expect("connect-info");
|
|
let mut a = client.attach(&info, s.id).await.expect("attach a");
|
|
let mut b = client.attach(&info, s.id).await.expect("attach b");
|
|
|
|
// Drive input through client A; both attachments should observe it via
|
|
// the daemon's PTY broadcast.
|
|
let send = b"echo MAESTRO_E2E_SHARED\n".to_vec();
|
|
a.send(WsMessage::Binary(send)).await.expect("send keys");
|
|
|
|
let want = b"MAESTRO_E2E_SHARED";
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let mut buf_a = Vec::new();
|
|
let mut buf_b = Vec::new();
|
|
while Instant::now() < deadline {
|
|
tokio::select! {
|
|
msg = a.next() => match msg {
|
|
Some(Ok(WsMessage::Binary(bytes))) => buf_a.extend(bytes),
|
|
Some(Ok(_)) => {}
|
|
_ => break,
|
|
},
|
|
msg = b.next() => match msg {
|
|
Some(Ok(WsMessage::Binary(bytes))) => buf_b.extend(bytes),
|
|
Some(Ok(_)) => {}
|
|
_ => break,
|
|
},
|
|
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
|
|
}
|
|
if buf_a.windows(want.len()).any(|w| w == want)
|
|
&& buf_b.windows(want.len()).any(|w| w == want)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
buf_a.windows(want.len()).any(|w| w == want),
|
|
"client A never saw the echo"
|
|
);
|
|
assert!(
|
|
buf_b.windows(want.len()).any(|w| w == want),
|
|
"client B never saw the echo"
|
|
);
|
|
|
|
let _ = a.close(None).await;
|
|
let _ = b.close(None).await;
|
|
client.kill_session(s.id).await.expect("kill");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn cleanup_on_kill_removes_worktree() {
|
|
let (_reg, _daemons, client) = bring_up(&["host"]).await;
|
|
|
|
let base = TempDir::new().unwrap();
|
|
init_repo(base.path());
|
|
|
|
let req = CreateSessionRequest {
|
|
daemon_id: "host".into(),
|
|
cwd: base.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: base.path().to_path_buf(),
|
|
branch: "feature/e2e".into(),
|
|
cleanup_on_kill: true,
|
|
}),
|
|
};
|
|
|
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
|
// The daemon's WorktreeManager puts new worktrees under
|
|
// `<state_dir>/worktrees/<branch>`. We can't peek the daemon's state dir
|
|
// directly from here, but we can verify by asking git: a worktree must
|
|
// exist that lives outside the original repo.
|
|
let worktrees = git_worktree_list(base.path());
|
|
assert!(
|
|
worktrees.iter().any(|w| w.contains("feature/e2e")),
|
|
"expected a worktree for feature/e2e branch, got: {worktrees:?}"
|
|
);
|
|
|
|
client.kill_session(s.id).await.expect("kill");
|
|
|
|
// Give the daemon a moment to remove the worktree, then verify.
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while Instant::now() < deadline {
|
|
let after = git_worktree_list(base.path());
|
|
if !after.iter().any(|w| w.contains("feature/e2e")) {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
panic!(
|
|
"worktree feature/e2e still present after kill: {:?}",
|
|
git_worktree_list(base.path())
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn daemon_restart_recovers_session() {
|
|
let (_reg, mut daemons, client) = bring_up(&["restarter"]).await;
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = client
|
|
.spawn_session(&create_req("restarter", cwd.path(), "sleep 300"))
|
|
.await
|
|
.expect("spawn");
|
|
assert_eq!(s.daemon_id, "restarter");
|
|
|
|
// Stop the daemon and wait for the registry to notice the disconnect.
|
|
// The catalog drops sessions for a daemon that's no longer connected.
|
|
daemons[0].stop();
|
|
wait_for_async(
|
|
"registry drops disconnected daemon",
|
|
Duration::from_secs(10),
|
|
|| {
|
|
let client = client.clone();
|
|
async move {
|
|
client
|
|
.list_daemons()
|
|
.await
|
|
.map(|ds| ds.iter().all(|d| d.daemon_id != "restarter"))
|
|
.unwrap_or(false)
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
|
|
// Bring the daemon back. It should recover its tmux session and the
|
|
// registry catalog should repopulate it.
|
|
daemons[0].restart().await;
|
|
|
|
wait_for_async("restarter re-registers", Duration::from_secs(10), || {
|
|
let client = client.clone();
|
|
async move {
|
|
client
|
|
.list_daemons()
|
|
.await
|
|
.map(|ds| ds.iter().any(|d| d.daemon_id == "restarter"))
|
|
.unwrap_or(false)
|
|
}
|
|
})
|
|
.await;
|
|
|
|
wait_for_async(
|
|
"session reappears in catalog",
|
|
Duration::from_secs(10),
|
|
|| {
|
|
let client = client.clone();
|
|
let id = s.id;
|
|
async move {
|
|
client
|
|
.list_sessions()
|
|
.await
|
|
.map(|ss| ss.iter().any(|x| x.id == id))
|
|
.unwrap_or(false)
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
|
|
client.kill_session(s.id).await.expect("kill");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn concurrent_spawns_each_get_unique_ids() {
|
|
let (_reg, _daemons, client) = bring_up(&["parallel"]).await;
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let mut handles = Vec::new();
|
|
for _ in 0..5 {
|
|
let client = client.clone();
|
|
let path = cwd.path().to_path_buf();
|
|
handles.push(tokio::spawn(async move {
|
|
client
|
|
.spawn_session(&create_req("parallel", &path, "sleep 30"))
|
|
.await
|
|
.expect("spawn")
|
|
}));
|
|
}
|
|
let mut ids = Vec::new();
|
|
for h in handles {
|
|
ids.push(h.await.unwrap().id);
|
|
}
|
|
ids.sort();
|
|
ids.dedup();
|
|
assert_eq!(ids.len(), 5, "expected 5 distinct session ids");
|
|
|
|
for id in ids {
|
|
client.kill_session(id).await.expect("kill");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn init_repo(dir: &std::path::Path) {
|
|
run_git(&["init", "-q", "-b", "main"], dir);
|
|
run_git(&["config", "user.email", "e2e@example.com"], dir);
|
|
run_git(&["config", "user.name", "E2E"], dir);
|
|
std::fs::write(dir.join("README"), b"hello\n").unwrap();
|
|
run_git(&["add", "."], dir);
|
|
run_git(&["commit", "-q", "-m", "initial"], dir);
|
|
}
|
|
|
|
fn run_git(args: &[&str], cwd: &std::path::Path) {
|
|
let out = Command::new("git")
|
|
.arg("-C")
|
|
.arg(cwd)
|
|
.args(args)
|
|
.output()
|
|
.expect("git spawn");
|
|
assert!(
|
|
out.status.success(),
|
|
"git {args:?} failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
|
|
let out = Command::new("git")
|
|
.arg("-C")
|
|
.arg(dir)
|
|
.args(["worktree", "list", "--porcelain"])
|
|
.output()
|
|
.expect("git worktree list");
|
|
String::from_utf8_lossy(&out.stdout)
|
|
.lines()
|
|
.map(str::to_string)
|
|
.collect()
|
|
}
|