Rust · 9248 bytes Raw Blame History
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