Skip to main content

termesh_test_support/
scripted_pty.rs

1//! Deterministic PTY service for model and integration tests.
2
3use std::collections::{BTreeMap, VecDeque};
4use std::sync::{Arc, Condvar, Mutex};
5use std::time::Duration;
6
7use termesh_core::{
8    PtyEvent, PtyRequest, TerminalGeneration, TerminalId, TerminalSize, TerminalSpec,
9};
10use termesh_terminal::{PtyError, PtyEventSink, PtyResult, PtyService};
11
12#[derive(Default)]
13struct State {
14    live: BTreeMap<TerminalId, (TerminalGeneration, PtyEventSink)>,
15    history: Vec<PtyRequest>,
16    pending: VecDeque<PtyRequest>,
17}
18
19type Shared = Arc<(Mutex<State>, Condvar)>;
20
21#[derive(Clone, Default)]
22pub struct ScriptedPty {
23    shared: Shared,
24}
25
26#[derive(Clone)]
27pub struct ScriptedPtyControl {
28    shared: Shared,
29}
30
31impl ScriptedPty {
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    pub fn control(&self) -> ScriptedPtyControl {
37        ScriptedPtyControl { shared: self.shared.clone() }
38    }
39
40    fn record(&self, request: PtyRequest) {
41        let (lock, ready) = &*self.shared;
42        let mut state = lock.lock().expect("scripted PTY state poisoned");
43        state.history.push(request.clone());
44        state.pending.push_back(request);
45        ready.notify_all();
46    }
47}
48
49impl ScriptedPtyControl {
50    pub fn requests(&self) -> Vec<PtyRequest> {
51        self.shared.0.lock().expect("scripted PTY state poisoned").history.clone()
52    }
53
54    pub fn recv_request(&self, timeout: Duration) -> Option<PtyRequest> {
55        let (lock, ready) = &*self.shared;
56        let state = lock.lock().expect("scripted PTY state poisoned");
57        let (mut state, _) = ready
58            .wait_timeout_while(state, timeout, |state| state.pending.is_empty())
59            .expect("scripted PTY state poisoned");
60        state.pending.pop_front()
61    }
62
63    pub fn emit(&self, event: PtyEvent) -> bool {
64        let terminal = event_terminal(&event);
65        let sink = self
66            .shared
67            .0
68            .lock()
69            .expect("scripted PTY state poisoned")
70            .live
71            .get(&terminal)
72            .map(|(_, sink)| sink.clone());
73        if let Some(sink) = sink {
74            sink(event);
75            true
76        } else {
77            false
78        }
79    }
80}
81
82impl PtyService for ScriptedPty {
83    fn spawn(
84        &mut self,
85        terminal: TerminalId,
86        generation: TerminalGeneration,
87        spec: TerminalSpec,
88        size: TerminalSize,
89        sink: PtyEventSink,
90    ) -> PtyResult<()> {
91        {
92            let mut state = self.shared.0.lock().expect("scripted PTY state poisoned");
93            if state.live.contains_key(&terminal) {
94                return Err(PtyError::AlreadyExists(terminal));
95            }
96            state.live.insert(terminal, (generation, sink.clone()));
97        }
98        self.record(PtyRequest::Spawn { terminal, generation, spec, size });
99        sink(PtyEvent::Spawned { terminal, generation, process_id: None });
100        Ok(())
101    }
102
103    fn write(&mut self, terminal: TerminalId, bytes: &[u8]) -> PtyResult<()> {
104        self.require_live(terminal)?;
105        let generation = self.generation(terminal)?;
106        self.record(PtyRequest::Write { terminal, generation, bytes: bytes.to_vec() });
107        Ok(())
108    }
109
110    fn resize(&mut self, terminal: TerminalId, size: TerminalSize) -> PtyResult<()> {
111        self.require_live(terminal)?;
112        let generation = self.generation(terminal)?;
113        self.record(PtyRequest::Resize { terminal, generation, size });
114        Ok(())
115    }
116
117    fn kill(&mut self, terminal: TerminalId) -> PtyResult<()> {
118        let generation = self.generation(terminal)?;
119        self.record(PtyRequest::Kill { terminal, generation });
120        Ok(())
121    }
122
123    fn release(&mut self, terminal: TerminalId) -> PtyResult<()> {
124        let generation = self.generation(terminal)?;
125        // Retire the terminal *before* publishing the request, not after. `record` is what
126        // a test observes through `recv_request`, so recording first leaves a window where
127        // the request is visible but the terminal is still live — and a test that emits the
128        // moment it sees the release wins that race on some schedulers and not others.
129        self.shared.0.lock().expect("scripted PTY state poisoned").live.remove(&terminal);
130        self.record(PtyRequest::Release { terminal, generation });
131        Ok(())
132    }
133}
134
135impl ScriptedPty {
136    fn generation(&self, terminal: TerminalId) -> PtyResult<TerminalGeneration> {
137        self.shared
138            .0
139            .lock()
140            .expect("scripted PTY state poisoned")
141            .live
142            .get(&terminal)
143            .map(|(generation, _)| *generation)
144            .ok_or(PtyError::UnknownTerminal(terminal))
145    }
146
147    fn require_live(&self, terminal: TerminalId) -> PtyResult<()> {
148        if self.shared.0.lock().expect("scripted PTY state poisoned").live.contains_key(&terminal) {
149            Ok(())
150        } else {
151            Err(PtyError::UnknownTerminal(terminal))
152        }
153    }
154}
155
156fn event_terminal(event: &PtyEvent) -> TerminalId {
157    match event {
158        PtyEvent::Spawned { terminal, .. }
159        | PtyEvent::Output { terminal, .. }
160        | PtyEvent::Exited { terminal, .. }
161        | PtyEvent::Failed { terminal, .. } => *terminal,
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::sync::mpsc;
169    use std::time::Duration;
170    use termesh_terminal::PtyWorker;
171
172    fn spec() -> TerminalSpec {
173        TerminalSpec {
174            program: "helper".into(),
175            args: vec!["--one".into()],
176            cwd: "/proj".into(),
177            env: Vec::new(),
178        }
179    }
180
181    #[test]
182    fn scripted_pty_records_requests_and_emits_only_for_live_terminals() {
183        let pty = ScriptedPty::new();
184        let control = pty.control();
185        let (tx, rx) = mpsc::channel();
186        let worker = PtyWorker::spawn(pty, move |event| {
187            tx.send(event).unwrap();
188        });
189        let terminal = TerminalId::new(3);
190        let generation = TerminalGeneration::new(1);
191        assert!(!control.emit(PtyEvent::Output {
192            terminal,
193            generation,
194            bytes: b"too early".to_vec(),
195        }));
196
197        let spawn = PtyRequest::Spawn {
198            terminal,
199            generation,
200            spec: spec(),
201            size: TerminalSize { rows: 24, cols: 80 },
202        };
203        assert!(worker.request(spawn.clone()));
204        assert_eq!(control.recv_request(Duration::from_secs(1)), Some(spawn));
205        assert!(matches!(
206            rx.recv_timeout(Duration::from_secs(1)).unwrap(),
207            PtyEvent::Spawned { terminal: id, .. } if id == terminal
208        ));
209
210        assert!(control.emit(PtyEvent::Output { terminal, generation, bytes: b"ok\r\n".to_vec() }));
211        assert!(matches!(
212            rx.recv_timeout(Duration::from_secs(1)).unwrap(),
213            PtyEvent::Output { terminal: id, bytes, .. }
214                if id == terminal && bytes == b"ok\r\n"
215        ));
216
217        assert!(worker.request(PtyRequest::Release { terminal, generation }));
218        assert_eq!(
219            control.recv_request(Duration::from_secs(1)),
220            Some(PtyRequest::Release { terminal, generation })
221        );
222        assert!(!control.emit(PtyEvent::Output {
223            terminal,
224            generation,
225            bytes: b"too late".to_vec(),
226        }));
227    }
228}