Rust · 7618 bytes Raw Blame History
1 //! Integration tests for `core::pty::spawn_pty`.
2 //!
3 //! These tests point `claude_bin` at the `fake_claude` example
4 //! binary running in `FAKE_CLAUDE_INTERACTIVE` mode, which reads
5 //! stdin and echoes it back to stdout with a `> ` prefix. The
6 //! interactive mode lets us exercise the full round-trip:
7 //! spawn → collect READY marker → write keystrokes → read echo →
8 //! kill → wait for exit.
9 //!
10 //! Tests run inside `tauri::async_runtime::block_on` to share the
11 //! global tokio runtime that `spawn_pty`'s internal tasks use. See
12 //! the `tauri_tokio_runtime_rule` memory for why `#[tokio::test]`
13 //! would create a wrong runtime here.
14
15 use std::path::PathBuf;
16 use std::time::Duration;
17
18 use claudex_lib::core::pty::{resize_pty, spawn_pty, write_pty, PtyHandle, PtyRequest};
19
20 fn fake_claude_path() -> PathBuf {
21 let mut exe = std::env::current_exe().expect("current_exe");
22 exe.pop(); // target/debug/deps
23 if exe.ends_with("deps") {
24 exe.pop();
25 }
26 exe.push("examples");
27 exe.push("fake_claude");
28 assert!(
29 exe.exists(),
30 "fake_claude example not built at {exe:?}. \
31 Run `cargo build --example fake_claude` first."
32 );
33 exe
34 }
35
36 fn build_interactive_request() -> PtyRequest {
37 PtyRequest {
38 pty_id: "test-pty".into(),
39 cwd: std::env::temp_dir(),
40 claude_bin: fake_claude_path(),
41 args: Vec::new(),
42 cols: 80,
43 rows: 24,
44 env: vec![("FAKE_CLAUDE_INTERACTIVE".into(), "1".into())],
45 }
46 }
47
48 /// Collect bytes from the PTY data channel until `needle` appears
49 /// in the accumulated output OR the timeout expires. Returns the
50 /// full accumulation regardless, so test code can inspect it.
51 async fn collect_until(
52 handle: &mut PtyHandle,
53 needle: &[u8],
54 timeout: Duration,
55 ) -> Vec<u8> {
56 let deadline = std::time::Instant::now() + timeout;
57 let mut acc = Vec::new();
58 loop {
59 let remaining = deadline
60 .checked_duration_since(std::time::Instant::now())
61 .unwrap_or_else(|| Duration::from_millis(0));
62 if remaining.is_zero() {
63 return acc;
64 }
65 match tokio::time::timeout(remaining, handle.data_rx.recv()).await {
66 Ok(Some(chunk)) => {
67 acc.extend_from_slice(&chunk);
68 if acc
69 .windows(needle.len())
70 .any(|window| window == needle)
71 {
72 return acc;
73 }
74 }
75 Ok(None) => return acc, // channel closed
76 Err(_) => return acc, // timeout
77 }
78 }
79 }
80
81 #[test]
82 fn spawn_read_write_echo_roundtrip() {
83 tauri::async_runtime::block_on(async {
84 let req = build_interactive_request();
85 let mut handle = spawn_pty(req).expect("spawn");
86
87 // 1. Wait for the READY marker so we know fake_claude has
88 // started reading stdin.
89 let ready_out = collect_until(&mut handle, b"READY", Duration::from_secs(5)).await;
90 assert!(
91 ready_out.windows(5).any(|w| w == b"READY"),
92 "never saw READY marker — got: {:?}",
93 String::from_utf8_lossy(&ready_out)
94 );
95
96 // 2. Write a line to the PTY's writer. The PTY will forward
97 // it to the subprocess's stdin (with line discipline
98 // cooking the CR) and fake_claude echoes it back with a
99 // "> " prefix.
100 write_pty(&handle.writer, b"hello\r").expect("write");
101
102 // 3. Wait for the echo.
103 let echo_out = collect_until(&mut handle, b"> hello", Duration::from_secs(5)).await;
104 assert!(
105 echo_out.windows(7).any(|w| w == b"> hello"),
106 "never saw echo of 'hello' — got: {:?}",
107 String::from_utf8_lossy(&echo_out)
108 );
109
110 // 4. Ring buffer should now contain the same bytes we
111 // observed on data_rx (at least partially — both paths
112 // tee from the same reader).
113 let snapshot = handle
114 .ring_buffer
115 .lock()
116 .map(|rb| rb.snapshot())
117 .unwrap_or_default();
118 assert!(
119 snapshot.windows(5).any(|w| w == b"READY"),
120 "ring buffer missing READY — got {} bytes",
121 snapshot.len()
122 );
123
124 // 5. Kill the subprocess and wait for the exit event.
125 let _ = handle.kill_tx.send(());
126 let exit = tokio::time::timeout(Duration::from_secs(5), handle.exit_rx).await;
127 assert!(exit.is_ok(), "pty exit never fired within 5s");
128 });
129 }
130
131 #[test]
132 fn resize_does_not_panic() {
133 tauri::async_runtime::block_on(async {
134 let req = build_interactive_request();
135 let mut handle = spawn_pty(req).expect("spawn");
136
137 // Wait for READY so the subprocess is alive.
138 let _ = collect_until(&mut handle, b"READY", Duration::from_secs(5)).await;
139
140 // A few resizes in rapid succession — should all succeed
141 // and not panic or deadlock with the reader task.
142 resize_pty(&handle.master, 120, 40).expect("resize 1");
143 resize_pty(&handle.master, 80, 24).expect("resize 2");
144 resize_pty(&handle.master, 200, 60).expect("resize 3");
145
146 let _ = handle.kill_tx.send(());
147 let _ = tokio::time::timeout(Duration::from_secs(5), handle.exit_rx).await;
148 });
149 }
150
151 #[test]
152 fn kill_reaps_subprocess_and_fires_exit() {
153 tauri::async_runtime::block_on(async {
154 let req = build_interactive_request();
155 let mut handle = spawn_pty(req).expect("spawn");
156
157 // Wait for fake_claude to be actively reading stdin.
158 let _ = collect_until(&mut handle, b"READY", Duration::from_secs(5)).await;
159
160 // Signal kill. The kill-watcher task should call
161 // ChildKiller::kill, and the wait task should then reap.
162 let _ = handle.kill_tx.send(());
163
164 let exit = tokio::time::timeout(Duration::from_secs(5), handle.exit_rx).await;
165 assert!(
166 exit.is_ok(),
167 "exit_rx never fired after kill signal within 5s",
168 );
169 });
170 }
171
172 #[test]
173 fn ring_buffer_captures_stdout_on_live_stream() {
174 tauri::async_runtime::block_on(async {
175 let req = build_interactive_request();
176 let mut handle = spawn_pty(req).expect("spawn");
177
178 // Wait for startup output, then feed several lines so both
179 // the local PTY echo and fake_claude's '> ' echo pile up in
180 // the ring buffer.
181 let _ = collect_until(&mut handle, b"READY", Duration::from_secs(5)).await;
182
183 for i in 0..8 {
184 let line = format!("line{}\r", i);
185 write_pty(&handle.writer, line.as_bytes()).expect("write");
186 }
187
188 // Give the reader a moment to drain.
189 let _ = collect_until(&mut handle, b"> line7", Duration::from_secs(5)).await;
190
191 let snapshot = handle
192 .ring_buffer
193 .lock()
194 .map(|rb| rb.snapshot())
195 .unwrap_or_default();
196
197 assert!(
198 snapshot.windows(5).any(|w| w == b"READY"),
199 "ring buffer missing READY"
200 );
201 assert!(
202 snapshot.windows(7).any(|w| w == b"> line0"),
203 "ring buffer missing echoed line0"
204 );
205 assert!(
206 snapshot.windows(7).any(|w| w == b"> line7"),
207 "ring buffer missing echoed line7"
208 );
209 // Capacity is 256 KB, we're nowhere near that — no truncation
210 // expected.
211 assert!(snapshot.len() < 256 * 1024);
212
213 let _ = handle.kill_tx.send(());
214 let _ = tokio::time::timeout(Duration::from_secs(5), handle.exit_rx).await;
215 });
216 }
217