Files
IdeA/crates/app-tauri/tests/orchestrator_wiring.rs
Blomios cf89b3b9a5 feat(agent): bind transport S-MCP — outils idea_* vivants de bout en bout (M5a-e) — §14.3.1
Dernier kilomètre de l'orchestration native : une CLI MCP réellement lancée
joint le serveur MCP du projet et ses outils idea_* aboutissent au vrai dispatch.

- M5a endpoint loopback par projet (interprocess UDS/named pipe, source unique
  mcp_endpoint, cleanup au close) — zéro port réseau, AppImage/SSH-safe.
- M5b sous-commande `idea mcp-server` : pont stdio↔loopback headless (avant init
  Tauri), handshake {"project","requester"}, endpoint-absent borné, EOF propre.
- M5c McpServerHandle boucle accept + serve_peer par pair ; requester réel
  propagé jusqu'à OrchestratorRequestProcessed (fin du "mcp" figé) ; isolation
  des pairs ; terminaison propre.
- M5d apply_mcp_config écrit la déclaration réelle (current_exe + --endpoint
  mcp_endpoint + --project simple-uuid + --requester) ; McpRuntime injecté comme
  donnée (application ne dépend pas de app-tauri).
- M5e smoke e2e sur vrai loopback : list/ask inline, cible PTY → erreur typée,
  JSON malformé → pas de panic, requester propagé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:09:41 +02:00

333 lines
11 KiB
Rust

//! 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)]
#[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)]
#[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)]
#[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)]
#[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);
}