@@ -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 | +} |