Le runner de tâches de fond détectait la fin d'un process via l'EOF du drain de sortie PTY, couplant le cycle de vie du process au flux d'output. Un output encore ouvert (ou drainé ailleurs) pouvait masquer ou retarder la détection de fin. Ajoute wait/try_wait au port figé PtyPort : - wait : bloquant, rend un ExitStatus idempotent ; - try_wait : non bloquant, rend Option<ExitStatus> ; - exit status mémorisé pour garder wait/try_wait/kill cohérents. Implémentation dans PortablePtyAdapter (état d'exit mémorisé). Le CommandBackgroundRunner détecte désormais la fin via pty.wait (select sur cancel/deadline) au lieu de l'EOF du drain. Tous les fakes PtyPort du workspace sont complétés en conséquence. Le tee live UI reste hors périmètre (traité en #58). Aucun breaking IPC/front. Tests : infrastructure/tests/background_task_runner.rs (nouveau) + pty_adapter.rs verts, non-régression application/app-tauri OK (hors échecs réseau du sandbox). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
546 lines
16 KiB
Rust
546 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()))
|
|
}
|
|
|
|
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn wait(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
|
|
fn try_wait(&self, _handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
|
|
Ok(Some(ExitStatus { code: Some(0) }))
|
|
}
|
|
|
|
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");
|
|
}
|