Files
IdeA/crates/application/tests/terminal_usecases.rs
Blomios 307ae71857 feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
2026-06-06 01:27:01 +02:00

536 lines
16 KiB
Rust

//! 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<u8> },
Resize { id: SessionId, size: PtySize },
Kill { id: SessionId },
}
#[derive(Default)]
struct FakePtyInner {
calls: Vec<Call>,
/// SessionId the next `spawn` will mint (defaults to random).
next_id: Option<SessionId>,
/// Exit code the next `kill` will report.
kill_code: Option<i32>,
/// 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<Mutex<FakePtyInner>>);
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<Call> {
self.0.lock().unwrap().calls.clone()
}
fn set_kill_code(&self, code: Option<i32>) {
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<PtyHandle, PtyError> {
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<OutputStream, PtyError> {
Ok(Box::new(std::iter::empty()))
}
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
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<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
fn events(&self) -> Vec<DomainEvent> {
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");
}