test(maestro-tui): integration tests against real daemon subprocess (NAP-282)
ci / rust (push) Failing after 5m1s
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:
@@ -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"] }
|
||||
|
||||
@@ -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", ®istry.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;
|
||||
}
|
||||
Reference in New Issue
Block a user