feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
535
crates/application/tests/terminal_usecases.rs
Normal file
535
crates/application/tests/terminal_usecases.rs
Normal file
@ -0,0 +1,535 @@
|
||||
//! 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");
|
||||
}
|
||||
Reference in New Issue
Block a user