//! 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; }