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
maestro/crates/maestro-e2e/tests/e2e.rs
T
Miguel Palhas e9368bb242 fix(maestro): fourth-review findings across daemon, tui and web
The existing-branch picker could never succeed in either client: both
treated the main checkout's HEAD as available, and since that is usually
`main` it became the default pick — git then refuses the second checkout.
A TUI test had baked the bug in by asserting the selection lands on the
`is_head` entry. Both pickers now treat "checked out anywhere" as held.

Worktree paths were still not injective. Flattening `/` and `.` to `-`
made `feat/x`, `feat.x` and `feat-x` share one directory, and the
override root keyed on the repo basename alone, so `~/a/maestro` and
`~/b/maestro` collided. A branch name is already a valid relative path —
git stores it as refs/heads/feat/example — so it is used verbatim and
nests under `worktrees/feat/`; the override root gains a digest of the
full repo path. Git forbids holding both `feat` and `feat/x`, so the
nesting cannot collide for valid refs.

Also:

- Recovered sessions dropped their worktree association, so after a
  restart a kill cleaned up nothing and the leftover directory blocked
  that branch forever. It is persisted on Session now.
- The nesting guard compared git's resolved paths against an unresolved
  one, so it silently passed on any symlinked repo path — and that guard
  is what stands between a kill and an inner tree's uncommitted work.
- Draft loads ran on the TUI event loop: /api/projects on a cold cache
  walks all of $HOME, freezing keys, frames and attached panes for up to
  the request timeout. They are detached tasks posting to a channel now.
- Fast-forward in the web client created the session and never mounted
  it, so `N` looked like a dead key while the TUI attached.
- The fast-forward fallback seed forced the worktree toggle off, against
  both a normal draft and the TUI.
- The projects background refresh honoured the TTL, so two of every three
  ticks were no-ops and requests paid for the rescan instead.
2026-08-24 00:10:35 +01:00

901 lines
30 KiB
Rust

//! Cross-process end-to-end tests for the headless Maestro slice.
//!
//! Each test brings up a real daemon subprocess, isolated in temp dirs, and
//! drives it 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, TestClient};
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
use tempfile::TempDir;
use tokio_tungstenite::tungstenite::Message as WsMessage;
async fn bring_up() -> (Daemon, TestClient) {
let daemon = Daemon::spawn().await;
let client = TestClient::new(&daemon.http_base());
(daemon, client)
}
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
}
}
/// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest {
cwd: base.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.to_path_buf(),
branch: branch.into(),
cleanup_on_kill: true,
create_branch,
}),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn lists_spawned_sessions() {
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let a = client
.spawn_session(&create_req(cwd.path(), "sleep 30"))
.await
.expect("spawn a");
let b = client
.spawn_session(&create_req(cwd.path(), "sleep 30"))
.await
.expect("spawn b");
let sessions = client.list_sessions().await.expect("list");
assert!(sessions.iter().any(|s| s.id == a.id));
assert!(sessions.iter().any(|s| s.id == b.id));
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 (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let s = client
.spawn_session(&create_req(cwd.path(), "bash --noprofile --norc"))
.await
.expect("spawn");
let mut a = client.attach(s.id).await.expect("attach a");
let mut b = client.attach(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 kill_removes_clean_worktree() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let req = CreateSessionRequest {
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,
create_branch: true,
}),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
// Worktrees live inside the base repo at `<repo>/worktrees/<branch>`
// (#56), so `git worktree list` must show the predictable in-repo path.
let worktrees = git_worktree_list(base.path());
assert!(
worktrees
.iter()
.any(|w| w.contains("worktrees/feature/e2e")),
"expected a worktree for feature/e2e under <repo>/worktrees, 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())
);
}
/// The guard (#56): killing a session whose worktree holds uncommitted
/// changes must leave the tree on disk — cleanup is unconditional, so this
/// guard is what keeps a stray kill from destroying work.
#[tokio::test(flavor = "multi_thread")]
async fn kill_spares_worktree_with_uncommitted_changes() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let wt_path = base
.path()
.join("worktrees")
.join("feature")
.join("unsaved");
let req = CreateSessionRequest {
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/unsaved".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
assert!(wt_path.exists());
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
client.kill_session(s.id).await.expect("kill");
// The session is gone but the dirty worktree stays findable on disk.
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
let sessions = client.list_sessions().await.unwrap_or_default();
if sessions.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(
wt_path.exists(),
"guarded worktree must be left on disk after kill"
);
// Left findable, not silently half-deleted: git still registers it and
// the uncommitted edit survives byte for byte (#66).
assert!(
git_worktree_list(base.path())
.iter()
.any(|w| w.contains("feature/unsaved")),
"guarded worktree must stay registered in git worktree list"
);
assert_eq!(
std::fs::read(wt_path.join("README")).unwrap(),
b"unsaved edits\n",
"uncommitted changes must survive the guarded kill"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn spawn_on_existing_branch_checks_it_out() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
run_git(&["branch", "feature/existing"], base.path());
let req = CreateSessionRequest {
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/existing".into(),
cleanup_on_kill: true,
create_branch: false,
}),
};
let s = client
.spawn_session(&req)
.await
.expect("spawn on existing branch");
let worktrees = git_worktree_list(base.path());
assert!(
worktrees.iter().any(|w| w.contains("feature/existing")),
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
);
client.kill_session(s.id).await.expect("kill");
}
/// #66: a fresh branch spawns a worktree at `<repo>/worktrees/<branch>` and
/// the session actually runs inside it — `current_cwd` comes from tmux's live
/// pane state, not from the spawn request.
#[tokio::test(flavor = "multi_thread")]
async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let wt_path = base.path().join("worktrees").join("feature").join("fresh");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/fresh", true))
.await
.expect("spawn new-branch worktree");
assert_eq!(
s.cwd, wt_path,
"session must report the worktree as its cwd"
);
assert!(wt_path.is_dir(), "worktree directory must exist on disk");
// The activity watcher fills `current_cwd` from tmux's pane_current_path,
// so this is the daemon confirming where the process really runs.
wait_for_async(
"session's live cwd is the worktree",
Duration::from_secs(20),
|| {
let client = client.clone();
let id = s.id;
let want = wt_path.clone();
async move {
client
.list_sessions()
.await
.ok()
.and_then(|ss| ss.iter().find(|s| s.id == id).cloned())
.map(|s| s.current_cwd == Some(want.clone()))
.unwrap_or(false)
}
},
)
.await;
client.kill_session(s.id).await.expect("kill");
}
/// #66: a second spawn onto a branch that already has a worktree is rejected
/// with a structured worktree error; the first worktree stays intact.
#[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_is_rejected() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let first = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await
.expect("first spawn");
let dup = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await;
match dup {
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
assert_eq!(
info.code, "worktree_failed",
"expected worktree error: {info:?}"
);
}
other => panic!("expected rejection for duplicate branch, got {other:?}"),
}
// Exactly one registration, one directory, still usable.
// Count branch records only: the worktree path now contains the branch
// name too (`worktrees/feature/dup`), so a substring match over every line
// would count the same tree twice.
let holders = git_worktree_list(base.path())
.iter()
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
.count();
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
assert!(
base.path()
.join("worktrees")
.join("feature")
.join("dup")
.is_dir(),
"original worktree must be untouched"
);
client.kill_session(first.id).await.expect("kill");
}
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
/// resolves through `--git-common-dir` to the main checkout, so the new tree
/// lands at `<main>/worktrees/<branch>` instead of nesting inside another
/// worktree.
#[tokio::test(flavor = "multi_thread")]
async fn spawn_from_linked_worktree_places_in_main_checkout() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let linked = base.path().join("linked");
run_git(
&[
"worktree",
"add",
"-q",
"-b",
"other",
&linked.to_string_lossy(),
],
base.path(),
);
let s = client
.spawn_session(&worktree_req(&linked, "feature/nested", true))
.await
.expect("spawn from linked worktree");
let want = base.path().join("worktrees").join("feature").join("nested");
assert_eq!(
s.cwd, want,
"worktree must be placed under the main checkout"
);
assert!(want.is_dir());
assert!(
!s.cwd.starts_with(&linked),
"worktrees must never nest inside another worktree"
);
client.kill_session(s.id).await.expect("kill");
}
// ---------------------------------------------------------------------------
// GET /api/projects + /api/branches (#54/#55/#66)
// ---------------------------------------------------------------------------
/// #66: two repos under a configured `[spawn].project_roots` root are both
/// discovered by `GET /api/projects`; git activity gives every repo a
/// `last_activity`, and spawning inside one merges its command into the
/// project entry via spawn history.
#[tokio::test(flavor = "multi_thread")]
async fn projects_endpoint_discovers_roots_and_merges_activity() {
let root = TempDir::new().unwrap();
let alpha = make_repo(root.path(), "alpha");
make_repo(root.path(), "beta");
let daemon = Daemon::spawn_with_config(&format!(
"[spawn]\nproject_roots = [\"{}\"]\nproject_scan_depth = 2\n",
root.path().display()
))
.await;
let client = TestClient::new(&daemon.http_base());
let projects = client.list_projects().await.expect("list projects");
let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
assert!(
names.contains(&"alpha") && names.contains(&"beta"),
"both repos must be discovered, got: {names:?}"
);
let find = |name: &str| {
projects
.iter()
.find(|p| p.name == name)
.cloned()
.expect("project present")
};
// Both have commits, so git activity alone populates last_activity…
assert!(
find("alpha").last_activity.is_some(),
"git activity must surface"
);
assert!(
find("beta").last_activity.is_some(),
"git activity must surface"
);
// …and neither has been spawned into yet.
assert_eq!(find("alpha").last_command, None);
assert_eq!(find("beta").last_command, None);
// Spawning inside alpha writes durable history; /api/projects folds it in.
let s = client
.spawn_session(&create_req(&alpha, "sleep 30"))
.await
.expect("spawn in alpha");
wait_for_async(
"alpha's project entry reflects spawn history",
Duration::from_secs(5),
|| {
let client = client.clone();
async move {
client
.list_projects()
.await
.ok()
.map(|ps| {
ps.iter()
.find(|p| p.name == "alpha")
.map(|p| p.last_command.as_deref() == Some("sleep 30"))
.unwrap_or(false)
})
.unwrap_or(false)
}
},
)
.await;
// beta was never spawned into: no command recorded there.
let beta_after = client
.list_projects()
.await
.expect("list after spawn")
.into_iter()
.find(|p| p.name == "beta")
.unwrap();
assert_eq!(beta_after.last_command, None);
client.kill_session(s.id).await.expect("kill");
}
/// #66: `GET /api/branches` lists every local branch, flags the main
/// checkout's HEAD with `is_head`, and reports which linked worktree holds an
/// occupied branch.
#[tokio::test(flavor = "multi_thread")]
async fn branches_endpoint_lists_branches_and_flags_occupied() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
run_git(&["branch", "feature/taken"], base.path());
run_git(&["branch", "feature/free"], base.path());
let holder = base.path().join("holder-wt");
run_git(
&[
"worktree",
"add",
"-q",
&holder.to_string_lossy(),
"feature/taken",
],
base.path(),
);
let branches = client
.list_branches(&base.path().to_string_lossy())
.await
.expect("list branches");
let by_name = |name: &str| {
branches
.iter()
.find(|b| b.name == name)
.cloned()
.unwrap_or_else(|| panic!("branch {name} missing from {branches:?}"))
};
let main = by_name("main");
assert!(main.is_head, "main is the main checkout's HEAD");
assert_eq!(main.worktree, None);
let taken = by_name("feature/taken");
assert!(!taken.is_head);
assert_eq!(
taken.worktree.as_deref(),
Some(holder.canonicalize().unwrap().as_path()),
"occupied branch must report its holding worktree"
);
let free = by_name("feature/free");
assert!(!free.is_head);
assert_eq!(free.worktree, None, "unoccupied branch must be spawnable");
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_restart_recovers_session() {
let (mut daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let s = client
.spawn_session(&create_req(cwd.path(), "sleep 300"))
.await
.expect("spawn");
// Restart against the same tmux server + state dir: the daemon must adopt
// the still-running tmux session on boot.
daemon.restart().await;
wait_for_async(
"session reappears after restart",
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 (_daemon, client) = bring_up().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(&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");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn spawn_records_history_and_infers_gitroot_for_form() {
// #24/#25/#26/#28: a real spawn writes a durable spawn-history record, which
// the daemon's recent-dirs endpoint surfaces on the spawn form's next open
// (#27's recents picker), and gitroot infers the worktree base_repo from the
// same dir. Exercised end to end against real tmux + git.
let (daemon, client) = bring_up().await;
// A git repo as the working dir, so gitroot has a repo to infer.
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let cwd = repo.path().to_string_lossy().to_string();
// Spawn in that dir (the command stands in for a preset tile selection).
let s = client
.spawn_session(&create_req(repo.path(), "sleep 30"))
.await
.expect("spawn");
// The recents picker reads GET /recent-dirs on open: the just-spawned dir
// must surface, tagged with the command it was last used with. The history
// write is best-effort right after the spawn, so poll briefly.
wait_for_async(
"recent-dirs surfaces the spawned cwd",
Duration::from_secs(5),
|| {
let client = daemon.client();
let want = cwd.clone();
async move {
let Ok(recents) = client.recent_dirs().await else {
return false;
};
recents
.dirs
.iter()
.any(|d| d.cwd.to_string_lossy() == want && d.last_command == "sleep 30")
}
},
)
.await;
// gitroot infers the worktree base_repo from the same dir (#25/#26): the dir
// is itself the repo toplevel (it holds `.git`).
let gr = daemon.client().git_root(&cwd).await.expect("gitroot");
assert_eq!(
gr.root.as_deref().map(|p| p.to_string_lossy().to_string()),
Some(cwd.clone()),
"gitroot should infer the enclosing repo"
);
client.kill_session(s.id).await.expect("kill");
}
#[tokio::test(flavor = "multi_thread")]
async fn activity_reflects_foreground_and_input_blocking() {
// NAP-340 (#18): the watcher infers each session's foreground command and
// activity from structured tmux/proc state. `cat` parks in read() on the
// pty → AwaitingInput; `sleep` is quiescent but not reading the tty → Idle
// (the distinction the /proc/<pid>/syscall check buys us). Both report the
// right foreground. Exercised against real tmux.
use maestro_protocol::ActivityState;
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let cat = client
.spawn_session(&create_req(cwd.path(), "cat"))
.await
.expect("spawn cat");
let slp = client
.spawn_session(&create_req(cwd.path(), "sleep 300"))
.await
.expect("spawn sleep");
// Initial state is Active; the watcher needs a couple ticks past the
// quiescence window (3s) to settle into the steady states.
wait_for_async(
"cat → AwaitingInput, sleep → Idle, with correct foreground",
Duration::from_secs(20),
|| {
let client = client.clone();
let (cat_id, sleep_id) = (cat.id, slp.id);
async move {
let sessions = match client.list_sessions().await {
Ok(s) => s,
Err(_) => return false,
};
let cat_ok = sessions
.iter()
.find(|s| s.id == cat_id)
.map(|s| {
s.activity == ActivityState::AwaitingInput
&& s.foreground.as_deref() == Some("cat")
})
.unwrap_or(false);
let sleep_ok = sessions
.iter()
.find(|s| s.id == sleep_id)
.map(|s| {
s.activity == ActivityState::Idle
&& s.foreground.as_deref() == Some("sleep")
})
.unwrap_or(false);
cat_ok && sleep_ok
}
},
)
.await;
client.kill_session(cat.id).await.expect("kill cat");
client.kill_session(slp.id).await.expect("kill sleep");
}
// ---------------------------------------------------------------------------
// 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);
}
/// Real git repo at `<root>/<name>`, canonicalized (the scanner canonicalizes
/// discovered paths too, so comparisons must agree on the real path).
fn make_repo(root: &std::path::Path, name: &str) -> std::path::PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
dir.canonicalize().unwrap()
}
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()
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_events_ws_streams_snapshot_then_session_added() {
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
// Snapshot, then a SessionAdded after we spawn a session via REST.
let (daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let ws_url = daemon.client().events_url().unwrap();
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url)
.await
.expect("events WS handshake");
// First frame must be a Snapshot (the WS sends it eagerly on connect).
let snapshot = next_event(&mut ws).await;
match &snapshot {
maestro_protocol::CatalogEvent::Snapshot { sessions } => {
assert!(sessions.is_empty());
}
other => panic!("expected initial Snapshot, got {other:?}"),
}
let session = client
.spawn_session(&create_req(cwd.path(), "sleep 30"))
.await
.expect("spawn");
// SessionAdded must show up on the WS within a reasonable window.
let saw_added = tokio::time::timeout(Duration::from_secs(5), async {
loop {
match next_event(&mut ws).await {
maestro_protocol::CatalogEvent::SessionAdded { session: s }
if s.id == session.id =>
{
return true;
}
_ => continue,
}
}
})
.await
.unwrap_or(false);
assert!(
saw_added,
"events WS never delivered SessionAdded for own session"
);
client.kill_session(session.id).await.expect("kill");
}
// ---------------------------------------------------------------------------
// CLI against the local daemon
// ---------------------------------------------------------------------------
/// Run the real `maestro` binary with an isolated env and capture its output.
fn run_cli(args: &[&str], envs: &[(&str, &str)]) -> std::process::Output {
let bin = maestro_e2e::workspace_target_bin("maestro", "maestro");
let mut cmd = Command::new(&bin);
cmd.args(args);
cmd.env_clear().env(
"PATH",
std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into()),
);
for (k, v) in envs {
cmd.env(k, v);
}
cmd.output().expect("run maestro cli")
}
#[tokio::test(flavor = "multi_thread")]
async fn cli_list_reads_from_daemon() {
// The subject is the shipped `maestro` binary — arg parsing, config
// precedence, output. It needs a daemon to talk to, not tmux, so this runs
// against an in-memory one.
let mock = maestro_testing::MockDaemon::start().await;
let id = uuid::Uuid::new_v4();
mock.add_session(maestro_testing::session(id, "/home/mock/work", "sleep 30"));
let home = TempDir::new().unwrap();
let cfg_path = home.path().join("config.toml");
let out = run_cli(
&["list", "--json"],
&[
("HOME", home.path().to_str().unwrap()),
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
("MAESTRO_DAEMON_URL", &mock.http_base()),
],
);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "list failed: {stderr}\n{stdout}");
assert!(
stdout.contains(&id.to_string()),
"session not listed: {stdout}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cli_list_fails_when_daemon_is_down() {
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
let home = TempDir::new().unwrap();
let cfg_path = home.path().join("config.toml");
let out = run_cli(
&["list"],
&[
("HOME", home.path().to_str().unwrap()),
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
("MAESTRO_DAEMON_URL", &dead_daemon),
],
);
assert!(!out.status.success(), "expected a failure with no daemon");
}
async fn next_event<S>(ws: &mut S) -> maestro_protocol::CatalogEvent
where
S: StreamExt<Item = Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
{
loop {
match ws.next().await {
Some(Ok(WsMessage::Text(t))) => return serde_json::from_str(&t).expect("CatalogEvent"),
Some(Ok(_)) => continue,
other => panic!("WS closed waiting for event: {other:?}"),
}
}
}