| 1 | //! Integration tests for `core::chat::spawn_turn`. |
| 2 | //! |
| 3 | //! These tests point `claude_bin` at the `fake_claude` example |
| 4 | //! binary (built via `cargo test --test chat_driver_test` which |
| 5 | //! compiles all examples), so they're deterministic and offline. |
| 6 | //! |
| 7 | //! Tests run inside `tauri::async_runtime::block_on` to share the |
| 8 | //! same global runtime as the tasks `spawn_turn` creates via |
| 9 | //! `tauri::async_runtime::spawn`. Using `#[tokio::test]` would |
| 10 | //! create a second runtime and the spawned tasks wouldn't be |
| 11 | //! polled by the test's executor. |
| 12 | |
| 13 | use std::path::PathBuf; |
| 14 | use std::time::Duration; |
| 15 | |
| 16 | use claudex_lib::core::chat::{spawn_turn, TurnEvent, TurnHandle, TurnRequest}; |
| 17 | use claudex_lib::core::schema::{ContentBlock, Message, PermissionMode}; |
| 18 | |
| 19 | /// Locates `target/<profile>/examples/fake_claude` relative to the |
| 20 | /// integration test's own binary. Works for debug and release. |
| 21 | fn fake_claude_path() -> PathBuf { |
| 22 | let mut exe = std::env::current_exe().expect("current_exe"); |
| 23 | exe.pop(); // target/debug/deps |
| 24 | if exe.ends_with("deps") { |
| 25 | exe.pop(); |
| 26 | } |
| 27 | exe.push("examples"); |
| 28 | exe.push("fake_claude"); |
| 29 | assert!( |
| 30 | exe.exists(), |
| 31 | "fake_claude example not built at {exe:?}. \ |
| 32 | Run `cargo build --example fake_claude` first." |
| 33 | ); |
| 34 | exe |
| 35 | } |
| 36 | |
| 37 | fn fixture_path(name: &str) -> PathBuf { |
| 38 | let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); |
| 39 | p.push("tests"); |
| 40 | p.push("fixtures"); |
| 41 | p.push("chat"); |
| 42 | p.push(name); |
| 43 | assert!(p.exists(), "fixture missing: {p:?}"); |
| 44 | p |
| 45 | } |
| 46 | |
| 47 | /// Collect turn events until a terminal one (Completed/Failed/ |
| 48 | /// Cancelled) or `timeout` expires. |
| 49 | async fn collect_events(mut handle: TurnHandle, timeout: Duration) -> Vec<TurnEvent> { |
| 50 | let deadline = std::time::Instant::now() + timeout; |
| 51 | let mut events = Vec::new(); |
| 52 | loop { |
| 53 | let remaining = deadline |
| 54 | .checked_duration_since(std::time::Instant::now()) |
| 55 | .unwrap_or_else(|| Duration::from_millis(0)); |
| 56 | match tokio::time::timeout(remaining, handle.receiver.recv()).await { |
| 57 | Ok(Some(ev)) => { |
| 58 | let is_terminal = matches!( |
| 59 | ev, |
| 60 | TurnEvent::Completed { .. } | TurnEvent::Failed { .. } | TurnEvent::Cancelled |
| 61 | ); |
| 62 | events.push(ev); |
| 63 | if is_terminal { |
| 64 | break; |
| 65 | } |
| 66 | } |
| 67 | Ok(None) => break, // channel closed |
| 68 | Err(_) => break, // timeout |
| 69 | } |
| 70 | } |
| 71 | events |
| 72 | } |
| 73 | |
| 74 | fn build_request( |
| 75 | fixture: &str, |
| 76 | exit_code: u8, |
| 77 | delay_ms: u64, |
| 78 | resume_id: Option<&str>, |
| 79 | new_session_id: Option<&str>, |
| 80 | ) -> TurnRequest { |
| 81 | // Per-process env rather than std::env::set_var — keeps parallel |
| 82 | // test runs race-free. |
| 83 | let env = vec![ |
| 84 | ( |
| 85 | "FAKE_CLAUDE_STDOUT_FIXTURE".into(), |
| 86 | fixture_path(fixture).to_string_lossy().into_owned(), |
| 87 | ), |
| 88 | ("FAKE_CLAUDE_EXIT_CODE".into(), exit_code.to_string()), |
| 89 | ("FAKE_CLAUDE_LINE_DELAY_MS".into(), delay_ms.to_string()), |
| 90 | ]; |
| 91 | TurnRequest { |
| 92 | turn_id: "test-turn".into(), |
| 93 | cwd: std::env::temp_dir(), |
| 94 | resume_session_id: resume_id.map(str::to_owned), |
| 95 | new_session_id: new_session_id.map(str::to_owned), |
| 96 | prompt: "anything".into(), |
| 97 | permission_mode: PermissionMode::Auto, |
| 98 | claude_bin: fake_claude_path(), |
| 99 | env, |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | fn is_session_bound(ev: &TurnEvent) -> bool { |
| 104 | matches!(ev, TurnEvent::SessionBound { .. }) |
| 105 | } |
| 106 | |
| 107 | fn session_id_of(ev: &TurnEvent) -> Option<&str> { |
| 108 | match ev { |
| 109 | TurnEvent::SessionBound { session_id } => Some(session_id), |
| 110 | _ => None, |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | fn assistant_text(ev: &TurnEvent) -> Option<String> { |
| 115 | match ev { |
| 116 | TurnEvent::Message(Message::Assistant { blocks, .. }) => blocks |
| 117 | .iter() |
| 118 | .filter_map(|b| match b { |
| 119 | ContentBlock::Text { text } => Some(text.as_str()), |
| 120 | _ => None, |
| 121 | }) |
| 122 | .collect::<Vec<_>>() |
| 123 | .join(" ") |
| 124 | .into(), |
| 125 | _ => None, |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | #[test] |
| 130 | fn happy_resume_emits_session_bound_plus_messages_plus_completed() { |
| 131 | tauri::async_runtime::block_on(async { |
| 132 | let req = build_request( |
| 133 | "happy_resume.jsonl", |
| 134 | 0, |
| 135 | 0, |
| 136 | Some("sess-happy-resume"), |
| 137 | None, |
| 138 | ); |
| 139 | let handle = spawn_turn(req).expect("spawn"); |
| 140 | let events = collect_events(handle, Duration::from_secs(5)).await; |
| 141 | |
| 142 | // Exactly one SessionBound. |
| 143 | let bound_count = events.iter().filter(|e| is_session_bound(e)).count(); |
| 144 | assert_eq!(bound_count, 1, "expected one SessionBound, got {bound_count}"); |
| 145 | let sid = events.iter().find_map(session_id_of).unwrap(); |
| 146 | assert_eq!(sid, "sess-happy-resume"); |
| 147 | |
| 148 | // Terminal event: Completed with exit 0. |
| 149 | let terminal = events.last().expect("at least one event"); |
| 150 | assert!( |
| 151 | matches!(terminal, TurnEvent::Completed { exit_code: 0 }), |
| 152 | "expected Completed{{0}}, got {terminal:?}" |
| 153 | ); |
| 154 | |
| 155 | // At least one assistant message carrying "Hi!" from our fixture. |
| 156 | let saw_greeting = events |
| 157 | .iter() |
| 158 | .filter_map(assistant_text) |
| 159 | .any(|t| t.contains("Hi!")); |
| 160 | assert!(saw_greeting, "didn't see 'Hi!' in assistant messages"); |
| 161 | }); |
| 162 | } |
| 163 | |
| 164 | #[test] |
| 165 | fn new_session_passes_session_id_through() { |
| 166 | tauri::async_runtime::block_on(async { |
| 167 | let req = build_request( |
| 168 | "new_session.jsonl", |
| 169 | 0, |
| 170 | 0, |
| 171 | None, |
| 172 | Some("sess-new-123"), |
| 173 | ); |
| 174 | let handle = spawn_turn(req).expect("spawn"); |
| 175 | let events = collect_events(handle, Duration::from_secs(5)).await; |
| 176 | |
| 177 | // Fixture's own sessionId field matches what we claimed. |
| 178 | let bound_sid = events.iter().find_map(session_id_of).unwrap(); |
| 179 | assert_eq!(bound_sid, "sess-new-123"); |
| 180 | |
| 181 | let terminal = events.last().unwrap(); |
| 182 | assert!(matches!(terminal, TurnEvent::Completed { exit_code: 0 })); |
| 183 | }); |
| 184 | } |
| 185 | |
| 186 | #[test] |
| 187 | fn cancellation_kills_child_and_emits_cancelled() { |
| 188 | tauri::async_runtime::block_on(async { |
| 189 | let req = build_request( |
| 190 | "cancel_slow.jsonl", |
| 191 | 0, |
| 192 | 500, // 500ms between lines — enough headroom to cancel |
| 193 | Some("sess-cancel"), |
| 194 | None, |
| 195 | ); |
| 196 | let handle = spawn_turn(req).expect("spawn"); |
| 197 | |
| 198 | // Cancel after ~150ms so we catch at least one event first. |
| 199 | let kill_tx = handle.kill_tx; |
| 200 | let receiver = handle.receiver; |
| 201 | tauri::async_runtime::spawn(async move { |
| 202 | tokio::time::sleep(Duration::from_millis(150)).await; |
| 203 | let _ = kill_tx.send(()); |
| 204 | }); |
| 205 | |
| 206 | let collected = collect_events( |
| 207 | TurnHandle { |
| 208 | receiver, |
| 209 | // Dummy kill_tx — the real one was moved into the |
| 210 | // spawn above. `collect_events` only touches the |
| 211 | // receiver so this never fires. |
| 212 | kill_tx: { |
| 213 | let (tx, _rx) = tokio::sync::oneshot::channel(); |
| 214 | tx |
| 215 | }, |
| 216 | }, |
| 217 | Duration::from_secs(5), |
| 218 | ) |
| 219 | .await; |
| 220 | |
| 221 | let terminal = collected.last().expect("some events"); |
| 222 | assert!( |
| 223 | matches!(terminal, TurnEvent::Cancelled), |
| 224 | "expected Cancelled, got {terminal:?}" |
| 225 | ); |
| 226 | }); |
| 227 | } |
| 228 | |
| 229 | #[test] |
| 230 | fn mid_stream_crash_reports_nonzero_exit() { |
| 231 | tauri::async_runtime::block_on(async { |
| 232 | let req = build_request( |
| 233 | "crash_midstream.jsonl", |
| 234 | 2, // non-zero exit after pumping |
| 235 | 0, |
| 236 | Some("sess-crash"), |
| 237 | None, |
| 238 | ); |
| 239 | let handle = spawn_turn(req).expect("spawn"); |
| 240 | let events = collect_events(handle, Duration::from_secs(5)).await; |
| 241 | |
| 242 | // We should see at least one assistant partial before the |
| 243 | // terminal event. |
| 244 | let saw_partial = events.iter().any(|e| matches!(e, TurnEvent::Message(_))); |
| 245 | assert!(saw_partial, "expected a partial message before crash"); |
| 246 | |
| 247 | let terminal = events.last().unwrap(); |
| 248 | assert!( |
| 249 | matches!(terminal, TurnEvent::Completed { exit_code: 2 }), |
| 250 | "expected Completed{{2}}, got {terminal:?}" |
| 251 | ); |
| 252 | }); |
| 253 | } |
| 254 | |
| 255 | #[test] |
| 256 | fn malformed_stdout_lines_are_skipped() { |
| 257 | tauri::async_runtime::block_on(async { |
| 258 | let req = build_request( |
| 259 | "garbage_line.jsonl", |
| 260 | 0, |
| 261 | 0, |
| 262 | Some("sess-garbage"), |
| 263 | None, |
| 264 | ); |
| 265 | let handle = spawn_turn(req).expect("spawn"); |
| 266 | let events = collect_events(handle, Duration::from_secs(5)).await; |
| 267 | |
| 268 | // The garbage line in the middle should be silently dropped; |
| 269 | // we still see the trailing assistant message and a clean |
| 270 | // Completed. |
| 271 | let saw_assistant = events |
| 272 | .iter() |
| 273 | .filter_map(assistant_text) |
| 274 | .any(|t| t.contains("still works")); |
| 275 | assert!(saw_assistant, "trailing assistant text missing"); |
| 276 | |
| 277 | let terminal = events.last().unwrap(); |
| 278 | assert!(matches!(terminal, TurnEvent::Completed { exit_code: 0 })); |
| 279 | }); |
| 280 | } |
| 281 |