diff --git a/Cargo.lock b/Cargo.lock index 6235e04..d737895 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1193,6 +1193,7 @@ dependencies = [ "chrono", "futures-util", "maestro-protocol", + "maestro-tui", "reqwest", "serde", "serde_json", diff --git a/crates/maestro-e2e/Cargo.toml b/crates/maestro-e2e/Cargo.toml index 125d6f0..9edb70c 100644 --- a/crates/maestro-e2e/Cargo.toml +++ b/crates/maestro-e2e/Cargo.toml @@ -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"] } diff --git a/crates/maestro-e2e/tests/tui_integration.rs b/crates/maestro-e2e/tests/tui_integration.rs new file mode 100644 index 0000000..4f96c95 --- /dev/null +++ b/crates/maestro-e2e/tests/tui_integration.rs @@ -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(label: &str, deadline: Duration, mut check: F) +where + F: FnMut() -> Fut, + Fut: std::future::Future, +{ + 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; +}