返回 DeepSeek-TUI-2026
tests.rs
根目录 / crates / tui / src / tools / shell / tests.rs
1 use super::*;
2
3 use crate::tools::spec::ToolContext;
4 use serde_json::{Value, json};
5 use tempfile::tempdir;
6
7 fn echo_command(message: &str) -> String {
8 format!("echo {message}")
9 }
10
11 fn sleep_command(seconds: u64) -> String {
12 #[cfg(windows)]
13 {
14 let ping_count = seconds.saturating_add(1);
15 let ps_path = r#"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"#;
16 format!(
17 "\"{ps_path}\" -NoProfile -Command \"Start-Sleep -Seconds {seconds}\" || ping 127.0.0.1 -n {ping_count} > NUL"
18 )
19 }
20 #[cfg(not(windows))]
21 {
22 format!("sleep {seconds}")
23 }
24 }
25
26 fn sleep_then_echo_command(seconds: u64, message: &str) -> String {
27 #[cfg(windows)]
28 {
29 let ping_count = seconds.saturating_add(1);
30 let ps_path = r#"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"#;
31 format!(
32 "\"{ps_path}\" -NoProfile -Command \"Start-Sleep -Seconds {seconds}; Write-Output {message}\" || (ping 127.0.0.1 -n {ping_count} > NUL && echo {message})"
33 )
34 }
35 #[cfg(not(windows))]
36 {
37 format!("sleep {seconds} && echo {message}")
38 }
39 }
40
41 fn echo_stdin_command() -> String {
42 #[cfg(windows)]
43 {
44 "more".to_string()
45 }
46 #[cfg(not(windows))]
47 {
48 "cat".to_string()
49 }
50 }
51
52 #[test]
53 fn test_sync_execution() {
54 let tmp = tempdir().expect("tempdir");
55 let mut manager = ShellManager::new(tmp.path().to_path_buf());
56
57 let result = manager
58 .execute(&echo_command("hello"), None, 5000, false)
59 .expect("execute");
60
61 assert_eq!(result.status, ShellStatus::Completed);
62 assert!(result.stdout.contains("hello"));
63 assert!(result.task_id.is_none());
64 }
65
66 #[test]
67 fn test_background_execution() {
68 let tmp = tempdir().expect("tempdir");
69 let mut manager = ShellManager::new(tmp.path().to_path_buf());
70
71 let result = manager
72 .execute(&sleep_then_echo_command(1, "done"), None, 5000, true)
73 .expect("execute");
74
75 assert_eq!(result.status, ShellStatus::Running);
76 assert!(result.task_id.is_some());
77
78 let task_id = result
79 .task_id
80 .expect("background execution should return task_id");
81
82 // Wait for completion
83 let final_result = manager
84 .get_output(&task_id, true, 5000)
85 .expect("get_output");
86
87 assert_eq!(final_result.status, ShellStatus::Completed);
88 assert!(final_result.stdout.contains("done"));
89 }
90
91 #[test]
92 fn test_timeout() {
93 let tmp = tempdir().expect("tempdir");
94 let mut manager = ShellManager::new(tmp.path().to_path_buf());
95
96 let result = manager
97 .execute(&sleep_command(10), None, 1000, false)
98 .expect("execute");
99
100 assert_eq!(result.status, ShellStatus::TimedOut);
101 }
102
103 #[test]
104 fn test_kill() {
105 let tmp = tempdir().expect("tempdir");
106 let mut manager = ShellManager::new(tmp.path().to_path_buf());
107
108 let result = manager
109 .execute(&sleep_command(60), None, 5000, true)
110 .expect("execute");
111
112 let task_id = result
113 .task_id
114 .expect("background execution should return task_id");
115
116 // Kill it
117 let killed = manager.kill(&task_id).expect("kill");
118 assert_eq!(killed.status, ShellStatus::Killed);
119 }
120
121 #[test]
122 fn test_write_stdin_streams_output() {
123 let tmp = tempdir().expect("tempdir");
124 let mut manager = ShellManager::new(tmp.path().to_path_buf());
125
126 let result = manager
127 .execute_with_options(&echo_stdin_command(), None, 5000, true, None, false, None)
128 .expect("execute");
129
130 let task_id = result
131 .task_id
132 .expect("background execution should return task_id");
133
134 manager
135 .write_stdin(&task_id, "hello\n", true)
136 .expect("write stdin");
137
138 let delta = manager
139 .get_output_delta(&task_id, true, 5000)
140 .expect("get_output_delta");
141
142 assert!(delta.result.stdout.contains("hello"));
143
144 let delta2 = manager
145 .get_output_delta(&task_id, false, 0)
146 .expect("get_output_delta");
147 assert!(delta2.result.stdout.is_empty());
148 }
149
150 #[test]
151 fn test_job_list_poll_cancel_and_stale_snapshot() {
152 let tmp = tempdir().expect("tempdir");
153 let mut manager = ShellManager::new(tmp.path().to_path_buf());
154
155 let started = manager
156 .execute(&sleep_then_echo_command(1, "done"), None, 5000, true)
157 .expect("execute");
158 let task_id = started.task_id.expect("task id");
159 manager
160 .tag_linked_task(&task_id, Some("task_123".to_string()))
161 .expect("tag linked task");
162
163 let running = manager.list_jobs();
164 let job = running
165 .iter()
166 .find(|job| job.id == task_id)
167 .expect("running job");
168 assert_eq!(job.status, ShellStatus::Running);
169 assert_eq!(job.linked_task_id.as_deref(), Some("task_123"));
170 assert!(job.command.contains("done"));
171 assert_eq!(job.cwd, tmp.path());
172
173 let completed = manager
174 .poll_delta(&task_id, true, 5000)
175 .expect("poll delta");
176 assert_eq!(completed.result.status, ShellStatus::Completed);
177 assert!(completed.result.stdout.contains("done"));
178
179 let detail = manager.inspect_job(&task_id).expect("inspect");
180 assert!(detail.stdout.contains("done"));
181 assert_eq!(detail.snapshot.status, ShellStatus::Completed);
182
183 manager.remember_stale_job(
184 "shell_stale",
185 "cargo test",
186 tmp.path().to_path_buf(),
187 Some("task_old".to_string()),
188 );
189 let stale = manager
190 .list_jobs()
191 .into_iter()
192 .find(|job| job.id == "shell_stale")
193 .expect("stale job");
194 assert!(stale.stale);
195 assert_eq!(stale.linked_task_id.as_deref(), Some("task_old"));
196 }
197
198 #[test]
199 fn test_job_cancel_updates_completion_state() {
200 let tmp = tempdir().expect("tempdir");
201 let mut manager = ShellManager::new(tmp.path().to_path_buf());
202
203 let started = manager
204 .execute(&sleep_command(60), None, 5000, true)
205 .expect("execute");
206 let task_id = started.task_id.expect("task id");
207
208 let killed = manager.kill(&task_id).expect("kill");
209 assert_eq!(killed.status, ShellStatus::Killed);
210 let job = manager.inspect_job(&task_id).expect("inspect");
211 assert_eq!(job.snapshot.status, ShellStatus::Killed);
212 assert!(!job.snapshot.stdin_available);
213 }
214
215 #[test]
216 fn test_output_truncation() {
217 let long_output = "x".repeat(50_000);
218 let (truncated, _meta) = truncate_with_meta(&long_output);
219
220 assert!(truncated.len() < long_output.len());
221 assert!(truncated.contains("truncated"));
222 }
223
224 #[test]
225 fn test_truncate_with_meta_reports_omission_counts() {
226 let long_output = format!("line1\nline2\n{}", "x".repeat(60_000));
227 let (truncated, meta) = truncate_with_meta(&long_output);
228
229 assert!(meta.truncated);
230 assert!(meta.original_len >= long_output.len());
231 assert!(meta.omitted > 0);
232 assert!(truncated.contains("bytes omitted"));
233 }
234
235 #[test]
236 fn test_summarize_output_strips_truncation_note() {
237 let long_output = "x".repeat(60_000);
238 let (truncated, _meta) = truncate_with_meta(&long_output);
239 let summary = summarize_output(&truncated);
240 assert!(!summary.contains("Output truncated at"));
241 }
242
243 #[tokio::test]
244 async fn test_exec_shell_metadata_includes_summaries() {
245 let tmp = tempdir().expect("tempdir");
246 let ctx = ToolContext::new(tmp.path());
247 let tool = ExecShellTool;
248
249 let result = tool
250 .execute(json!({"command": echo_command("hello")}), &ctx)
251 .await
252 .expect("execute");
253 assert!(result.success);
254
255 let meta = result.metadata.expect("metadata");
256 let summary = meta
257 .get("summary")
258 .and_then(Value::as_str)
259 .unwrap_or_default()
260 .to_string();
261 assert!(summary.contains("hello"));
262 assert!(meta.get("stdout_len").is_some());
263 assert!(meta.get("stdout_truncated").is_some());
264 }
265
266 #[tokio::test]
267 async fn test_exec_shell_foreground_timeout_guides_background_rerun() {
268 let tmp = tempdir().expect("tempdir");
269 let ctx = ToolContext::new(tmp.path());
270 let tool = ExecShellTool;
271
272 let result = tool
273 .execute(
274 json!({
275 "command": sleep_command(10),
276 "timeout_ms": 1000
277 }),
278 &ctx,
279 )
280 .await
281 .expect("execute");
282
283 assert!(!result.success);
284 assert!(result.content.contains("task_shell_start"));
285 assert!(result.content.contains("background: true"));
286 assert!(result.content.contains("process killed"));
287 let meta = result.metadata.expect("metadata");
288 assert_eq!(meta.get("status").and_then(Value::as_str), Some("TimedOut"));
289 let recovery = meta
290 .get("foreground_timeout_recovery")
291 .expect("timeout recovery metadata");
292 assert_eq!(
293 recovery
294 .get("exec_shell_background")
295 .and_then(Value::as_bool),
296 Some(true)
297 );
298 assert!(
299 recovery
300 .get("hint")
301 .and_then(Value::as_str)
302 .unwrap_or_default()
303 .contains("exec_shell_wait")
304 );
305 }
306
307 #[tokio::test]
308 async fn test_exec_shell_foreground_cancel_kills_process() {
309 let tmp = tempdir().expect("tempdir");
310 let cancel_token = tokio_util::sync::CancellationToken::new();
311 let ctx = ToolContext::new(tmp.path()).with_cancel_token(cancel_token.clone());
312 let command = sleep_command(30);
313
314 let task = tokio::spawn(async move {
315 ExecShellTool
316 .execute(
317 json!({
318 "command": command,
319 "timeout_ms": 600_000
320 }),
321 &ctx,
322 )
323 .await
324 .expect("execute")
325 });
326
327 tokio::time::sleep(Duration::from_millis(150)).await;
328 cancel_token.cancel();
329
330 let result = tokio::time::timeout(Duration::from_secs(5), task)
331 .await
332 .expect("foreground shell should observe cancellation")
333 .expect("task should not panic");
334
335 assert!(!result.success);
336 assert!(result.content.contains("Command canceled"));
337 let meta = result.metadata.expect("metadata");
338 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Killed"));
339 assert_eq!(meta.get("canceled").and_then(Value::as_bool), Some(true));
340 }
341
342 #[tokio::test]
343 async fn test_exec_shell_foreground_can_move_to_background() {
344 let tmp = tempdir().expect("tempdir");
345 let ctx = ToolContext::new(tmp.path());
346 let shell_manager = ctx.shell_manager.clone();
347 let command = sleep_command(30);
348 let task_ctx = ctx.clone();
349
350 let task = tokio::spawn(async move {
351 ExecShellTool
352 .execute(
353 json!({
354 "command": command,
355 "timeout_ms": 600_000
356 }),
357 &task_ctx,
358 )
359 .await
360 .expect("execute")
361 });
362
363 tokio::time::sleep(Duration::from_millis(150)).await;
364 shell_manager
365 .lock()
366 .expect("shell manager lock")
367 .request_foreground_background();
368
369 let result = tokio::time::timeout(Duration::from_secs(5), task)
370 .await
371 .expect("foreground shell should detach")
372 .expect("task should not panic");
373
374 assert!(result.success);
375 assert!(result.content.contains("Command moved to background"));
376 assert!(result.content.contains("exec_shell_cancel"));
377
378 let meta = result.metadata.expect("metadata");
379 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Running"));
380 assert_eq!(
381 meta.get("backgrounded").and_then(Value::as_bool),
382 Some(true)
383 );
384 let task_id = meta
385 .get("task_id")
386 .and_then(Value::as_str)
387 .expect("task id")
388 .to_string();
389
390 let mut manager = shell_manager.lock().expect("shell manager lock");
391 let job = manager.inspect_job(&task_id).expect("inspect job");
392 assert_eq!(job.snapshot.status, ShellStatus::Running);
393 let killed = manager.kill(&task_id).expect("kill");
394 assert_eq!(killed.status, ShellStatus::Killed);
395 }
396
397 #[tokio::test]
398 async fn test_exec_shell_wait_cancel_leaves_background_process_running() {
399 let tmp = tempdir().expect("tempdir");
400 let cancel_token = tokio_util::sync::CancellationToken::new();
401 let ctx = ToolContext::new(tmp.path()).with_cancel_token(cancel_token.clone());
402 let shell_manager = ctx.shell_manager.clone();
403 let started = shell_manager
404 .lock()
405 .expect("shell manager lock")
406 .execute(&sleep_command(30), None, 600_000, true)
407 .expect("execute");
408 let task_id = started.task_id.expect("task id");
409 let wait_task_id = task_id.clone();
410 let task_ctx = ctx.clone();
411
412 let task = tokio::spawn(async move {
413 ShellWaitTool::new("exec_shell_wait")
414 .execute(
415 json!({
416 "task_id": wait_task_id,
417 "wait": true,
418 "timeout_ms": 600_000
419 }),
420 &task_ctx,
421 )
422 .await
423 .expect("wait")
424 });
425
426 tokio::time::sleep(Duration::from_millis(150)).await;
427 cancel_token.cancel();
428
429 let result = tokio::time::timeout(Duration::from_secs(5), task)
430 .await
431 .expect("wait should observe cancellation")
432 .expect("task should not panic");
433
434 assert!(result.success);
435 assert!(result.content.contains("still running"));
436 let meta = result.metadata.expect("metadata");
437 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Running"));
438 assert_eq!(
439 meta.get("wait_canceled").and_then(Value::as_bool),
440 Some(true)
441 );
442
443 let mut manager = shell_manager.lock().expect("shell manager lock");
444 let job = manager.inspect_job(&task_id).expect("inspect job");
445 assert_eq!(job.snapshot.status, ShellStatus::Running);
446 let killed = manager.kill(&task_id).expect("kill");
447 assert_eq!(killed.status, ShellStatus::Killed);
448 }
449
450 #[tokio::test]
451 async fn test_completed_background_shell_releases_process_handles() {
452 let tmp = tempdir().expect("tempdir");
453 let ctx = ToolContext::new(tmp.path());
454 let shell_manager = ctx.shell_manager.clone();
455 let started = shell_manager
456 .lock()
457 .expect("shell manager lock")
458 .execute(&echo_command("done"), None, 600_000, true)
459 .expect("execute");
460 let task_id = started.task_id.expect("task id");
461
462 let result = ShellWaitTool::new("exec_shell_wait")
463 .execute(
464 json!({
465 "task_id": task_id.clone(),
466 "wait": true,
467 "timeout_ms": 5_000
468 }),
469 &ctx,
470 )
471 .await
472 .expect("wait");
473
474 assert!(result.success);
475 let mut manager = shell_manager.lock().expect("shell manager lock");
476 let shell = manager.processes.get_mut(&task_id).expect("tracked shell");
477 shell.poll();
478 assert_eq!(shell.status, ShellStatus::Completed);
479 assert!(shell.stdin.is_none());
480 assert!(shell.child.is_none());
481 assert!(shell.stdout_thread.is_none());
482 assert!(shell.stderr_thread.is_none());
483 }
484
485 #[tokio::test]
486 async fn test_exec_shell_cancel_tool_kills_background_process() {
487 let tmp = tempdir().expect("tempdir");
488 let ctx = ToolContext::new(tmp.path());
489 let shell_manager = ctx.shell_manager.clone();
490 let started = shell_manager
491 .lock()
492 .expect("shell manager lock")
493 .execute(&sleep_command(30), None, 600_000, true)
494 .expect("execute");
495 let task_id = started.task_id.expect("task id");
496
497 let result = ShellCancelTool
498 .execute(json!({ "task_id": task_id }), &ctx)
499 .await
500 .expect("cancel");
501
502 assert!(result.success);
503 assert!(result.content.contains("Canceled background shell job"));
504 let meta = result.metadata.expect("metadata");
505 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Killed"));
506
507 let task_id = meta
508 .get("task_id")
509 .and_then(Value::as_str)
510 .expect("task id");
511 let mut manager = shell_manager.lock().expect("shell manager lock");
512 let job = manager.inspect_job(task_id).expect("inspect job");
513 assert_eq!(job.snapshot.status, ShellStatus::Killed);
514 }
515
516 #[tokio::test]
517 async fn test_exec_shell_cancel_tool_can_kill_all_running_processes() {
518 let tmp = tempdir().expect("tempdir");
519 let ctx = ToolContext::new(tmp.path());
520 let shell_manager = ctx.shell_manager.clone();
521 let first = shell_manager
522 .lock()
523 .expect("shell manager lock")
524 .execute(&sleep_command(30), None, 600_000, true)
525 .expect("execute first")
526 .task_id
527 .expect("first task id");
528 let second = shell_manager
529 .lock()
530 .expect("shell manager lock")
531 .execute(&sleep_command(30), None, 600_000, true)
532 .expect("execute second")
533 .task_id
534 .expect("second task id");
535
536 let result = ShellCancelTool
537 .execute(json!({ "all": true }), &ctx)
538 .await
539 .expect("cancel all");
540
541 assert!(result.success);
542 let meta = result.metadata.expect("metadata");
543 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Killed"));
544 assert_eq!(meta.get("canceled").and_then(Value::as_u64), Some(2));
545
546 let mut manager = shell_manager.lock().expect("shell manager lock");
547 let first_job = manager.inspect_job(&first).expect("inspect first");
548 let second_job = manager.inspect_job(&second).expect("inspect second");
549 assert_eq!(first_job.snapshot.status, ShellStatus::Killed);
550 assert_eq!(second_job.snapshot.status, ShellStatus::Killed);
551 }
552
552 lines RUST