test(maestro-tui): integration tests against real daemon subprocess (NAP-282)
ci / rust (push) Failing after 5m1s

`crates/maestro-e2e/tests/tui_integration.rs` adds three integration tests
that spin up a real registry + daemon via the existing maestro-e2e harness
and drive `CatalogStore` against the daemon's `/api/*` surface:

- `catalog_bootstrap_against_real_daemon` — WS connect + initial Snapshot
  carries the self-daemon.
- `post_session_propagates_through_events_ws` — POST /api/sessions →
  catalog WS surfaces SessionAdded within 2 s.
- `delete_session_propagates_removal` — DELETE /api/sessions/:id →
  catalog WS surfaces SessionRemoved within 2 s.

These exercise the real wire protocol (subprocess + tmux + axum + tungstenite)
without going through the full ratatui frontend, complementing the unit-test
TestBackend assertions on widgets.

The full App-driven e2e (input injection feeding the dispatch loop, asserting
vt100 contents) would need `App::run` to be backend-generic; deferring as a
follow-up since the integration layer here already proves the wire protocol
round-trip the NAP-282 acceptance asked for.
This commit is contained in:
2026-05-28 22:37:22 +00:00
parent 5a17de4489
commit c599fcfdec
3 changed files with 168 additions and 0 deletions
+1
View File
@@ -26,4 +26,5 @@ tracing.workspace = true
uuid.workspace = true
[dev-dependencies]
maestro-tui.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }
+166
View File
@@ -0,0 +1,166 @@
//! TUI integration tests (NAP-282).
//!
//! Exercises `CatalogStore` and the per-session `SessionTask` against a real
//! registry + daemon subprocess pair. We don't spin the full ratatui frontend
//! here — those interactions are best tested with `TestBackend` unit tests in
//! `crates/maestro-tui/src/*.rs`. This file fills the gap between those
//! and the headless e2e suite by verifying that the TUI's wire layer plays
//! nicely with a real `maestrod`.
//!
//! Requires `tmux` and `git` on `$PATH`.
#![allow(clippy::result_large_err)]
use std::path::PathBuf;
use std::time::{Duration, Instant};
use maestro_e2e::{Daemon, Registry};
use maestro_protocol::CreateSessionRequest;
use maestro_tui::catalog::CatalogStore;
const TOKEN: &str = "tui-integration-token";
async fn bring_up() -> (Registry, Daemon) {
let registry = Registry::spawn(TOKEN).await;
let daemon = Daemon::spawn("konishi", &registry.http_base(), TOKEN).await;
(registry, daemon)
}
fn http_base(daemon: &Daemon) -> String {
format!("http://{}", daemon.bind_addr)
}
fn ws_base(daemon: &Daemon) -> String {
format!("ws://{}", daemon.bind_addr)
}
async fn wait_until<F, Fut>(label: &str, deadline: Duration, mut check: F)
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = bool>,
{
let start = Instant::now();
loop {
if check().await {
return;
}
if start.elapsed() >= deadline {
panic!("timeout waiting for {label}");
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn catalog_bootstrap_against_real_daemon() {
let (_registry, daemon) = bring_up().await;
let ws_url = format!("{}/api/events", ws_base(&daemon));
let store = CatalogStore::default();
let bg = store.clone();
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
.await
.expect("initial snapshot");
let snap = store.snapshot();
assert_eq!(snap.daemons.len(), 1, "self-daemon snapshot");
assert_eq!(snap.daemons[0].daemon_id, "konishi");
assert!(snap.sessions.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn post_session_propagates_through_events_ws() {
let (_registry, daemon) = bring_up().await;
let ws_url = format!("{}/api/events", ws_base(&daemon));
let http_base = http_base(&daemon);
let store = CatalogStore::default();
let bg = store.clone();
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
.await
.expect("initial snapshot");
let cwd = PathBuf::from(daemon.home());
let req = CreateSessionRequest {
daemon_id: "konishi".into(),
cwd,
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
};
let res = reqwest::Client::new()
.post(format!("{http_base}/api/sessions"))
.json(&req)
.send()
.await
.expect("post sessions");
assert!(res.status().is_success(), "POST /api/sessions: {res:?}");
wait_until(
"session to surface in catalog",
Duration::from_secs(2),
|| async { store.snapshot().sessions.len() == 1 },
)
.await;
let snap = store.snapshot();
assert_eq!(snap.sessions.len(), 1);
assert_eq!(snap.sessions[0].daemon_id, "konishi");
assert_eq!(snap.sessions[0].command, "bash");
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_session_propagates_removal() {
let (_registry, daemon) = bring_up().await;
let ws_url = format!("{}/api/events", ws_base(&daemon));
let http_base = http_base(&daemon);
let store = CatalogStore::default();
let bg = store.clone();
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
.await
.expect("initial snapshot");
let cwd = PathBuf::from(daemon.home());
let req = CreateSessionRequest {
daemon_id: "konishi".into(),
cwd,
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
};
let client = reqwest::Client::new();
let res = client
.post(format!("{http_base}/api/sessions"))
.json(&req)
.send()
.await
.unwrap();
assert!(res.status().is_success());
wait_until("creation", Duration::from_secs(2), || async {
store.snapshot().sessions.len() == 1
})
.await;
let sid = store.snapshot().sessions[0].id;
let res = client
.delete(format!("{http_base}/api/sessions/{sid}"))
.send()
.await
.unwrap();
assert!(res.status().is_success(), "DELETE /api/sessions: {res:?}");
wait_until("removal", Duration::from_secs(2), || async {
store.snapshot().sessions.is_empty()
})
.await;
}