| 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 |