tenseleyflow/claudex / eb02a40

Browse files

tests: pty driver integration coverage

Authored by espadonne
SHA
eb02a4084da4b6425c6c8a90ce5e27956e7a2033
Parents
ba8409a
Tree
5091096

1 changed file

StatusFile+-
A src-tauri/tests/pty_driver_test.rs 216 0
src-tauri/tests/pty_driver_test.rsadded
@@ -0,0 +1,216 @@
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
+}