//! Mock host adapter for testing use async_trait::async_trait; use shepherd_api::EntryKind; use shepherd_util::SessionId; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::mpsc; use crate::{ ExitStatus, HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload, HostResult, HostSessionHandle, SpawnOptions, StopMode, }; /// Mock session state for testing #[derive(Debug, Clone)] pub struct MockSession { pub session_id: SessionId, pub mock_id: u64, pub running: bool, pub exit_delay: Option, } /// Mock host adapter for unit/integration testing pub struct MockHost { capabilities: HostCapabilities, next_id: AtomicU64, sessions: Arc>>, event_tx: mpsc::UnboundedSender, event_rx: Arc>>>, /// Configure spawn to fail pub fail_spawn: Arc>, /// Configure stop to fail pub fail_stop: Arc>, /// Auto-exit delay (simulates process exiting on its own) pub auto_exit_delay: Arc>>, } impl MockHost { pub fn new() -> Self { let (tx, rx) = mpsc::unbounded_channel(); Self { capabilities: HostCapabilities::minimal(), next_id: AtomicU64::new(1), sessions: Arc::new(Mutex::new(HashMap::new())), event_tx: tx, event_rx: Arc::new(Mutex::new(Some(rx))), fail_spawn: Arc::new(Mutex::new(false)), fail_stop: Arc::new(Mutex::new(false)), auto_exit_delay: Arc::new(Mutex::new(None)), } } pub fn with_capabilities(mut self, caps: HostCapabilities) -> Self { self.capabilities = caps; self } /// Get list of running sessions pub fn running_sessions(&self) -> Vec { self.sessions .lock() .unwrap() .values() .filter(|s| s.running) .map(|s| s.session_id.clone()) .collect() } /// Simulate process exit pub fn simulate_exit(&self, session_id: &SessionId, status: ExitStatus) { let sessions = self.sessions.lock().unwrap(); if let Some(session) = sessions.values().find(|s| &s.session_id == session_id) { let handle = HostSessionHandle::new( session.session_id.clone(), HostHandlePayload::Mock { id: session.mock_id }, ); let _ = self.event_tx.send(HostEvent::Exited { handle, status }); } } /// Set auto-exit behavior pub fn set_auto_exit(&self, delay: Option) { *self.auto_exit_delay.lock().unwrap() = delay; } } impl Default for MockHost { fn default() -> Self { Self::new() } } #[async_trait] impl HostAdapter for MockHost { fn capabilities(&self) -> &HostCapabilities { &self.capabilities } async fn spawn( &self, session_id: SessionId, _entry_kind: &EntryKind, _options: SpawnOptions, ) -> HostResult { if *self.fail_spawn.lock().unwrap() { return Err(HostError::SpawnFailed("Mock spawn failure".into())); } let mock_id = self.next_id.fetch_add(1, Ordering::SeqCst); let session = MockSession { session_id: session_id.clone(), mock_id, running: true, exit_delay: *self.auto_exit_delay.lock().unwrap(), }; self.sessions.lock().unwrap().insert(mock_id, session.clone()); let handle = HostSessionHandle::new( session_id.clone(), HostHandlePayload::Mock { id: mock_id }, ); // If auto-exit is configured, spawn a task to send exit event if let Some(delay) = session.exit_delay { let tx = self.event_tx.clone(); let exit_handle = handle.clone(); tokio::spawn(async move { tokio::time::sleep(delay).await; let _ = tx.send(HostEvent::Exited { handle: exit_handle, status: ExitStatus::success(), }); }); } Ok(handle) } async fn stop(&self, handle: &HostSessionHandle, _mode: StopMode) -> HostResult<()> { if *self.fail_stop.lock().unwrap() { return Err(HostError::StopFailed("Mock stop failure".into())); } let mock_id = match handle.payload() { HostHandlePayload::Mock { id } => *id, _ => return Err(HostError::SessionNotFound), }; let mut sessions = self.sessions.lock().unwrap(); if let Some(session) = sessions.get_mut(&mock_id) { session.running = false; let _ = self.event_tx.send(HostEvent::Exited { handle: handle.clone(), status: ExitStatus::signaled(15), // SIGTERM }); Ok(()) } else { Err(HostError::SessionNotFound) } } fn subscribe(&self) -> mpsc::UnboundedReceiver { self.event_rx .lock() .unwrap() .take() .expect("subscribe() can only be called once") } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; #[tokio::test] async fn mock_spawn_and_stop() { let host = MockHost::new(); let _rx = host.subscribe(); let session_id = SessionId::new(); let entry = EntryKind::Process { command: "test".into(), args: vec![], env: HashMap::new(), cwd: None, }; let handle = host .spawn(session_id.clone(), &entry, SpawnOptions::default()) .await .unwrap(); assert_eq!(host.running_sessions().len(), 1); host.stop(&handle, StopMode::Force).await.unwrap(); // Session marked as not running let sessions = host.sessions.lock().unwrap(); let session = sessions.values().next().unwrap(); assert!(!session.running); } #[tokio::test] async fn mock_spawn_failure() { let host = MockHost::new(); let _rx = host.subscribe(); *host.fail_spawn.lock().unwrap() = true; let session_id = SessionId::new(); let entry = EntryKind::Process { command: "test".into(), args: vec![], env: HashMap::new(), cwd: None, }; let result = host .spawn(session_id, &entry, SpawnOptions::default()) .await; assert!(result.is_err()); } }