//! Integration test for the orchestrator wiring in the composition root //! (ARCHITECTURE §14.3). //! //! These tests prove that [`AppState`] actually *starts and stops* per-project //! orchestrator watchers — the gap that previously left the whole §14.3 feature //! dormant (the `OrchestratorService`/watcher existed but were never constructed //! at runtime). The per-file request→dispatch→response behaviour is covered by //! the infrastructure watcher tests; here we assert the lifecycle the open/close //! commands rely on: registration is idempotent, projects are isolated, and //! stopping unregisters. use std::path::PathBuf; use std::time::Duration; use app_tauri_lib::mcp_endpoint::mcp_endpoint; use app_tauri_lib::state::AppState; use domain::ports::IdGenerator; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::ProjectId; use infrastructure::UuidGenerator; /// A unique, absolute temp path (never written to at build time — the stores are /// lazy — so it need not exist). fn temp_path(tag: &str) -> PathBuf { let ids = UuidGenerator::new(); std::env::temp_dir().join(format!("idea-orch-test-{tag}-{}", ids.new_uuid())) } /// Builds a domain [`Project`] rooted at a fresh temp path. fn make_project() -> Project { let ids = UuidGenerator::new(); let root = temp_path("root"); Project::new( ProjectId::from_uuid(ids.new_uuid()), "demo", ProjectPath::new(root.to_string_lossy().into_owned()).unwrap(), RemoteRef::local(), 1_700_000_000_000, ) .unwrap() } fn watcher_count(state: &AppState) -> usize { state.orchestrator_watchers.lock().unwrap().len() } fn has_watcher(state: &AppState, id: &ProjectId) -> bool { state.orchestrator_watchers.lock().unwrap().contains_key(id) } fn mcp_count(state: &AppState) -> usize { state.mcp_servers.lock().unwrap().len() } fn has_mcp(state: &AppState, id: &ProjectId) -> bool { state.mcp_servers.lock().unwrap().contains_key(id) } #[tokio::test] async fn ensure_watch_registers_a_watcher_and_is_idempotent() { let state = AppState::build(temp_path("appdata")); let project = make_project(); assert_eq!(watcher_count(&state), 0, "no watcher before open"); state.ensure_orchestrator_watch(&project); assert!(has_watcher(&state, &project.id)); assert_eq!(watcher_count(&state), 1); // Opening the same project again must not spawn a second watcher. state.ensure_orchestrator_watch(&project); assert_eq!(watcher_count(&state), 1, "ensure is idempotent per project"); } #[tokio::test] async fn stop_watch_unregisters_the_watcher() { let state = AppState::build(temp_path("appdata")); let project = make_project(); state.ensure_orchestrator_watch(&project); assert!(has_watcher(&state, &project.id)); state.stop_orchestrator_watch(&project.id); assert!( !has_watcher(&state, &project.id), "watcher removed on close" ); assert_eq!(watcher_count(&state), 0); // Stopping an unknown project is a no-op (does not panic). state.stop_orchestrator_watch(&project.id); } #[tokio::test] async fn watchers_are_isolated_per_project() { let state = AppState::build(temp_path("appdata")); let a = make_project(); let b = make_project(); state.ensure_orchestrator_watch(&a); state.ensure_orchestrator_watch(&b); assert_eq!(watcher_count(&state), 2); assert!(has_watcher(&state, &a.id)); assert!(has_watcher(&state, &b.id)); // Closing one leaves the other running. state.stop_orchestrator_watch(&a.id); assert!(!has_watcher(&state, &a.id)); assert!(has_watcher(&state, &b.id)); assert_eq!(watcher_count(&state), 1); } // --- M3: IdeA MCP server lifecycle (twin of the watcher, Décision 4) --- // // The MCP server registry (`mcp_servers`) is the twin of `orchestrator_watchers`: // `ensure_orchestrator_watch` starts both side by side on open/create, and // `stop_orchestrator_watch` tears both down on close. These tests mirror the // watcher lifecycle tests above against the MCP registry. The per-project // supervision task parks on a stop signal (no blocking serve loop), so open/close // must return promptly — the `#[tokio::test]` harness itself proves no figing // (the test completes). #[tokio::test] async fn ensure_watch_starts_an_mcp_server_per_project() { let state = AppState::build(temp_path("appdata")); let project = make_project(); assert_eq!(mcp_count(&state), 0, "no MCP server before open"); state.ensure_orchestrator_watch(&project); assert!( has_mcp(&state, &project.id), "MCP server registered alongside the watcher" ); assert_eq!(mcp_count(&state), 1); } #[tokio::test] async fn ensure_mcp_server_is_idempotent_per_project() { let state = AppState::build(temp_path("appdata")); let project = make_project(); state.ensure_orchestrator_watch(&project); assert_eq!(mcp_count(&state), 1); // Opening the same project again must not spawn a second MCP server. state.ensure_orchestrator_watch(&project); assert_eq!( mcp_count(&state), 1, "MCP server start is idempotent per project" ); assert!(has_mcp(&state, &project.id)); } #[tokio::test] async fn stop_watch_unregisters_the_mcp_server() { let state = AppState::build(temp_path("appdata")); let project = make_project(); state.ensure_orchestrator_watch(&project); assert!(has_mcp(&state, &project.id)); state.stop_orchestrator_watch(&project.id); assert!(!has_mcp(&state, &project.id), "MCP server removed on close"); assert_eq!(mcp_count(&state), 0); // Stopping an unknown project is a no-op (does not panic) for the MCP twin too. state.stop_orchestrator_watch(&project.id); } #[tokio::test] async fn watcher_and_mcp_server_coexist_and_close_together() { let state = AppState::build(temp_path("appdata")); let project = make_project(); // Open: both entry doors onto the same OrchestratorService are live. state.ensure_orchestrator_watch(&project); assert!(has_watcher(&state, &project.id), "watcher live on open"); assert!(has_mcp(&state, &project.id), "MCP server live on open"); assert_eq!(watcher_count(&state), 1); assert_eq!(mcp_count(&state), 1); // Close: the symmetric teardown removes both. state.stop_orchestrator_watch(&project.id); assert!(!has_watcher(&state, &project.id), "watcher gone on close"); assert!(!has_mcp(&state, &project.id), "MCP server gone on close"); assert_eq!(watcher_count(&state), 0); assert_eq!(mcp_count(&state), 0); } #[tokio::test] async fn mcp_servers_are_isolated_per_project() { let state = AppState::build(temp_path("appdata")); let a = make_project(); let b = make_project(); state.ensure_orchestrator_watch(&a); state.ensure_orchestrator_watch(&b); assert_eq!(mcp_count(&state), 2, "one MCP server per open project"); assert!(has_mcp(&state, &a.id)); assert!(has_mcp(&state, &b.id)); // Closing one leaves the other's MCP server running. state.stop_orchestrator_watch(&a.id); assert!(!has_mcp(&state, &a.id)); assert!(has_mcp(&state, &b.id)); assert_eq!(mcp_count(&state), 1); } // --- M5a: per-project loopback MCP endpoint lifecycle --- // // `mcp_endpoint(project_id)` is the single source of truth for the loopback // address (cadrage v5 §2). `ensure_mcp_server` binds it at open; dropping the // handle on close unlinks it (Unix). On Unix the endpoint is a UDS *file* whose // existence is directly observable; these tests assert bind → idempotence → // cleanup → determinism/no-collision → coexistence with the file watcher. /// Polls until `cond()` holds or the bound elapses (cleanup is async: the handle's /// supervision task drops the listener — and unlinks the socket — only after the /// stop signal propagates). Bounded so a regression fails fast, never hangs. #[cfg(unix)] async fn wait_until(mut cond: impl FnMut() -> bool) -> bool { for _ in 0..100 { if cond() { return true; } tokio::time::sleep(Duration::from_millis(10)).await; } cond() } #[cfg(unix)] fn socket_exists(project: &Project) -> bool { mcp_endpoint(&project.id) .socket_path() .map(|p| p.exists()) .unwrap_or(false) } #[cfg(unix)] #[ignore = "requires local socket bind permission"] #[tokio::test] async fn open_binds_the_project_loopback_endpoint() { let state = AppState::build(temp_path("appdata")); let project = make_project(); assert!(!socket_exists(&project), "no socket before open"); state.ensure_orchestrator_watch(&project); assert!( wait_until(|| socket_exists(&project)).await, "the project's loopback socket is bound on open" ); state.stop_orchestrator_watch(&project.id); } #[cfg(unix)] #[ignore = "requires local socket bind permission"] #[tokio::test] async fn double_open_keeps_a_single_endpoint_no_address_in_use() { let state = AppState::build(temp_path("appdata")); let project = make_project(); state.ensure_orchestrator_watch(&project); assert!(wait_until(|| socket_exists(&project)).await); // A second open must NOT rebind (which would fail "address in use" on a live // socket) — it returns early. One endpoint, still bound, no panic. state.ensure_orchestrator_watch(&project); assert_eq!(mcp_count(&state), 1, "one endpoint per project"); assert!( socket_exists(&project), "endpoint still bound after re-open" ); state.stop_orchestrator_watch(&project.id); } #[cfg(unix)] #[ignore = "requires local socket bind permission"] #[tokio::test] async fn close_cleans_up_the_endpoint_socket_file() { let state = AppState::build(temp_path("appdata")); let project = make_project(); state.ensure_orchestrator_watch(&project); assert!(wait_until(|| socket_exists(&project)).await); state.stop_orchestrator_watch(&project.id); assert!( wait_until(|| !socket_exists(&project)).await, "the socket file is unlinked on close — no leak" ); } #[test] fn endpoint_is_deterministic_and_collision_free_across_projects() { let p1 = make_project(); let p2 = make_project(); // Stable for the same project across calls. assert_eq!(mcp_endpoint(&p1.id), mcp_endpoint(&p1.id)); // Distinct projects ⇒ distinct endpoints (no collision). assert_ne!(mcp_endpoint(&p1.id), mcp_endpoint(&p2.id)); assert_ne!( mcp_endpoint(&p1.id).as_cli_arg(), mcp_endpoint(&p2.id).as_cli_arg() ); } #[cfg(unix)] #[ignore = "requires local socket bind permission"] #[tokio::test] async fn file_watcher_and_loopback_endpoint_live_together() { let state = AppState::build(temp_path("appdata")); let project = make_project(); state.ensure_orchestrator_watch(&project); // R0/M3 invariant intact: the file watcher and the MCP server are both live... assert!(has_watcher(&state, &project.id), "watcher live"); assert!(has_mcp(&state, &project.id), "mcp server live"); // ...and on Unix the loopback endpoint is actually bound beside the watcher. assert!( wait_until(|| socket_exists(&project)).await, "endpoint bound alongside the live file watcher" ); state.stop_orchestrator_watch(&project.id); assert!(wait_until(|| !socket_exists(&project)).await); }