//! L3 tests for the terminal use cases (`OpenTerminal`, `WriteToTerminal`, //! `ResizeTerminal`, `CloseTerminal`) and the [`TerminalSessions`] registry. //! //! Every port is faked in-memory so the use cases run without any real PTY: //! - [`FakePty`] — a recording [`PtyPort`] that mints a deterministic //! [`SessionId`] on `spawn` and records every `write`/`resize`/`kill`, //! - [`SpyBus`] — records published [`DomainEvent`]s. use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::events::DomainEvent; use domain::ports::{ EventBus, EventStream, ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec, }; use domain::{PtySize, SessionId}; use application::{ CloseTerminal, CloseTerminalInput, OpenTerminal, OpenTerminalInput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal, WriteToTerminalInput, }; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- /// One recorded PTY call. #[derive(Debug, Clone, PartialEq, Eq)] enum Call { Spawn { spec: SpawnSpec, size: PtySize }, Write { id: SessionId, data: Vec }, Resize { id: SessionId, size: PtySize }, Kill { id: SessionId }, } #[derive(Default)] struct FakePtyInner { calls: Vec, /// SessionId the next `spawn` will mint (defaults to random). next_id: Option, /// Exit code the next `kill` will report. kill_code: Option, /// When set, `write`/`resize` fail to exercise error propagation. fail_io: bool, } /// A recording [`PtyPort`]: no real OS PTY, just bookkeeping. #[derive(Default, Clone)] struct FakePty(Arc>); impl FakePty { fn with_next_id(id: SessionId) -> Self { let pty = Self::default(); pty.0.lock().unwrap().next_id = Some(id); pty } fn calls(&self) -> Vec { self.0.lock().unwrap().calls.clone() } fn set_kill_code(&self, code: Option) { self.0.lock().unwrap().kill_code = code; } fn set_fail_io(&self, fail: bool) { self.0.lock().unwrap().fail_io = fail; } } #[async_trait] impl PtyPort for FakePty { async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result { let mut inner = self.0.lock().unwrap(); inner.calls.push(Call::Spawn { spec, size }); let session_id = inner.next_id.unwrap_or_else(SessionId::new_random); Ok(PtyHandle { session_id }) } fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> { let mut inner = self.0.lock().unwrap(); if inner.fail_io { return Err(PtyError::Io("boom".to_owned())); } inner.calls.push(Call::Write { id: handle.session_id, data: data.to_vec(), }); Ok(()) } fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> { let mut inner = self.0.lock().unwrap(); if inner.fail_io { return Err(PtyError::Io("boom".to_owned())); } inner.calls.push(Call::Resize { id: handle.session_id, size, }); Ok(()) } fn subscribe_output(&self, _handle: &PtyHandle) -> Result { Ok(Box::new(std::iter::empty())) } fn scrollback(&self, _handle: &PtyHandle) -> Result, PtyError> { Ok(Vec::new()) } async fn kill(&self, handle: &PtyHandle) -> Result { let mut inner = self.0.lock().unwrap(); inner.calls.push(Call::Kill { id: handle.session_id, }); Ok(ExitStatus { code: inner.kill_code, }) } } /// Records published events. #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { fn events(&self) -> Vec { self.0.lock().unwrap().clone() } } impl EventBus for SpyBus { fn publish(&self, event: DomainEvent) { self.0.lock().unwrap().push(event); } fn subscribe(&self) -> EventStream { Box::new(std::iter::empty()) } } fn sid(n: u128) -> SessionId { SessionId::from_uuid(uuid::Uuid::from_u128(n)) } fn open_input(cwd: &str) -> OpenTerminalInput { OpenTerminalInput { cwd: cwd.to_owned(), rows: 24, cols: 80, command: None, args: Vec::new(), node_id: None, } } // --------------------------------------------------------------------------- // OpenTerminal // --------------------------------------------------------------------------- #[tokio::test] async fn open_spawns_with_resolved_spec_and_size() { let pty = FakePty::with_next_id(sid(42)); let sessions = Arc::new(TerminalSessions::new()); let bus = SpyBus::default(); let open = OpenTerminal::new( Arc::new(pty.clone()), Arc::clone(&sessions), Arc::new(bus.clone()), ); let input = OpenTerminalInput { command: Some("/bin/zsh".to_owned()), args: vec!["-l".to_owned()], ..open_input("/home/me/proj") }; let out = open.execute(input).await.expect("open succeeds"); // The session adopts the PTY-minted id. assert_eq!(out.session.id, sid(42)); let calls = pty.calls(); assert_eq!(calls.len(), 1, "exactly one spawn"); match &calls[0] { Call::Spawn { spec, size } => { assert_eq!(spec.command, "/bin/zsh"); assert_eq!(spec.args, vec!["-l".to_owned()]); assert_eq!(spec.cwd.as_str(), "/home/me/proj"); assert_eq!(*size, PtySize::new(24, 80).unwrap()); } other => panic!("expected spawn, got {other:?}"), } } #[tokio::test] async fn open_defaults_command_when_none() { let pty = FakePty::default(); let open = OpenTerminal::new( Arc::new(pty.clone()), Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), ); open.execute(open_input("/p")).await.unwrap(); match &pty.calls()[0] { Call::Spawn { spec, .. } => assert!( !spec.command.is_empty(), "a default shell command is filled in" ), other => panic!("expected spawn, got {other:?}"), } } #[tokio::test] async fn open_registers_session_in_registry() { let pty = FakePty::with_next_id(sid(7)); let sessions = Arc::new(TerminalSessions::new()); let open = OpenTerminal::new( Arc::new(pty), Arc::clone(&sessions), Arc::new(SpyBus::default()), ); assert!(sessions.is_empty()); open.execute(open_input("/p")).await.unwrap(); assert_eq!(sessions.len(), 1); assert!(sessions.handle(&sid(7)).is_some(), "handle registered"); assert!(sessions.session(&sid(7)).is_some(), "snapshot registered"); } #[tokio::test] async fn open_publishes_pty_output_open_event() { let pty = FakePty::with_next_id(sid(9)); let bus = SpyBus::default(); let open = OpenTerminal::new( Arc::new(pty), Arc::new(TerminalSessions::new()), Arc::new(bus.clone()), ); open.execute(open_input("/p")).await.unwrap(); assert_eq!( bus.events(), vec![DomainEvent::PtyOutput { session_id: sid(9), bytes: Vec::new(), }] ); } #[tokio::test] async fn open_rejects_non_absolute_cwd() { let open = OpenTerminal::new( Arc::new(FakePty::default()), Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), ); let err = open .execute(open_input("relative/path")) .await .expect_err("non-absolute cwd rejected"); assert_eq!(err.code(), "INVALID", "got {err:?}"); } #[tokio::test] async fn open_rejects_zero_sized_terminal() { let pty = FakePty::default(); let open = OpenTerminal::new( Arc::new(pty.clone()), Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), ); let err = open .execute(OpenTerminalInput { rows: 0, ..open_input("/p") }) .await .expect_err("zero-sized terminal rejected"); assert_eq!(err.code(), "INVALID", "got {err:?}"); assert!(pty.calls().is_empty(), "must not spawn on invalid size"); } // --------------------------------------------------------------------------- // WriteToTerminal // --------------------------------------------------------------------------- #[tokio::test] async fn write_routes_bytes_to_the_right_session() { let pty = FakePty::with_next_id(sid(1)); let sessions = Arc::new(TerminalSessions::new()); let open = OpenTerminal::new( Arc::new(pty.clone()), Arc::clone(&sessions), Arc::new(SpyBus::default()), ); open.execute(open_input("/p")).await.unwrap(); let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); write .execute(WriteToTerminalInput { session_id: sid(1), data: b"ls\n".to_vec(), }) .expect("write succeeds"); let writes: Vec<_> = pty .calls() .into_iter() .filter(|c| matches!(c, Call::Write { .. })) .collect(); assert_eq!( writes, vec![Call::Write { id: sid(1), data: b"ls\n".to_vec(), }] ); } #[tokio::test] async fn write_to_unknown_session_is_not_found() { let pty = FakePty::default(); let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::new(TerminalSessions::new())); let err = write .execute(WriteToTerminalInput { session_id: sid(404), data: b"x".to_vec(), }) .expect_err("unknown session rejected"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert!(pty.calls().is_empty(), "no PTY call for unknown session"); } #[tokio::test] async fn write_propagates_pty_io_error() { let pty = FakePty::with_next_id(sid(2)); let sessions = Arc::new(TerminalSessions::new()); OpenTerminal::new( Arc::new(pty.clone()), Arc::clone(&sessions), Arc::new(SpyBus::default()), ) .execute(open_input("/p")) .await .unwrap(); pty.set_fail_io(true); let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); let err = write .execute(WriteToTerminalInput { session_id: sid(2), data: b"x".to_vec(), }) .expect_err("io failure surfaces"); assert_eq!(err.code(), "PROCESS", "got {err:?}"); } // --------------------------------------------------------------------------- // ResizeTerminal // --------------------------------------------------------------------------- #[tokio::test] async fn resize_calls_pty_with_new_size() { let pty = FakePty::with_next_id(sid(3)); let sessions = Arc::new(TerminalSessions::new()); OpenTerminal::new( Arc::new(pty.clone()), Arc::clone(&sessions), Arc::new(SpyBus::default()), ) .execute(open_input("/p")) .await .unwrap(); let resize = ResizeTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); resize .execute(ResizeTerminalInput { session_id: sid(3), rows: 40, cols: 120, }) .expect("resize succeeds"); let resizes: Vec<_> = pty .calls() .into_iter() .filter(|c| matches!(c, Call::Resize { .. })) .collect(); assert_eq!( resizes, vec![Call::Resize { id: sid(3), size: PtySize::new(40, 120).unwrap(), }] ); } #[tokio::test] async fn resize_unknown_session_is_not_found() { let resize = ResizeTerminal::new( Arc::new(FakePty::default()), Arc::new(TerminalSessions::new()), ); let err = resize .execute(ResizeTerminalInput { session_id: sid(404), rows: 40, cols: 120, }) .expect_err("unknown session rejected"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } #[tokio::test] async fn resize_rejects_zero_size() { let resize = ResizeTerminal::new( Arc::new(FakePty::default()), Arc::new(TerminalSessions::new()), ); let err = resize .execute(ResizeTerminalInput { session_id: sid(1), rows: 0, cols: 80, }) .expect_err("zero size rejected"); assert_eq!(err.code(), "INVALID", "got {err:?}"); } // --------------------------------------------------------------------------- // CloseTerminal // --------------------------------------------------------------------------- #[tokio::test] async fn close_kills_pty_removes_session_and_returns_code() { let pty = FakePty::with_next_id(sid(5)); pty.set_kill_code(Some(0)); let sessions = Arc::new(TerminalSessions::new()); OpenTerminal::new( Arc::new(pty.clone()), Arc::clone(&sessions), Arc::new(SpyBus::default()), ) .execute(open_input("/p")) .await .unwrap(); assert_eq!(sessions.len(), 1); let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); let out = close .execute(CloseTerminalInput { session_id: sid(5), }) .await .expect("close succeeds"); assert_eq!(out.code, Some(0)); assert!(sessions.is_empty(), "session removed from registry"); assert!( pty.calls().iter().any(|c| matches!(c, Call::Kill { id } if *id == sid(5))), "kill called for the session" ); } #[tokio::test] async fn close_surfaces_signal_exit_as_none_code() { let pty = FakePty::with_next_id(sid(6)); pty.set_kill_code(None); let sessions = Arc::new(TerminalSessions::new()); OpenTerminal::new( Arc::new(pty.clone()), Arc::clone(&sessions), Arc::new(SpyBus::default()), ) .execute(open_input("/p")) .await .unwrap(); let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); let out = close .execute(CloseTerminalInput { session_id: sid(6), }) .await .unwrap(); assert_eq!(out.code, None); } #[tokio::test] async fn close_unknown_session_is_not_found() { let pty = FakePty::default(); let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::new(TerminalSessions::new())); let err = close .execute(CloseTerminalInput { session_id: sid(404), }) .await .expect_err("unknown session rejected"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert!(pty.calls().is_empty(), "no kill for unknown session"); } // --------------------------------------------------------------------------- // TerminalSessions registry (unit-level) // --------------------------------------------------------------------------- #[test] fn registry_insert_handle_session_remove_len() { use domain::{NodeId, ProjectPath, SessionKind, TerminalSession}; let registry = TerminalSessions::new(); assert!(registry.is_empty()); assert_eq!(registry.len(), 0); let id = sid(100); let handle = PtyHandle { session_id: id }; let session = TerminalSession::starting( id, NodeId::new_random(), ProjectPath::new("/p").unwrap(), SessionKind::Plain, PtySize::new(24, 80).unwrap(), ); registry.insert(handle.clone(), session); assert_eq!(registry.len(), 1); assert!(!registry.is_empty()); assert_eq!(registry.handle(&id), Some(handle.clone())); assert!(registry.session(&id).is_some()); assert_eq!(registry.session(&id).unwrap().id, id); // Unknown id resolves to None. assert!(registry.handle(&sid(999)).is_none()); assert!(registry.session(&sid(999)).is_none()); // Remove returns the handle and empties the registry. assert_eq!(registry.remove(&id), Some(handle)); assert!(registry.is_empty()); assert!(registry.remove(&id).is_none(), "second remove is a no-op"); }