mod common;
use common::call_tool_raw;
async fn call_exec_command_raw(params: serde_json::Value) -> serde_json::Value {
call_tool_raw("exec_command", params).await
}
#[tokio::test]
async fn exec_command_happy_path() {
let command = "echo hello";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let status = child.wait().expect("should wait for child");
let exit_code = status.code();
assert_eq!(exit_code, Some(0), "exit code should be 0");
assert!(
stdout.contains("hello"),
"stdout should contain 'hello', got: {}",
stdout
);
}
#[tokio::test]
async fn exec_command_nonzero_exit() {
let command = "exit 42";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let _stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let status = child.wait().expect("should wait for child");
let exit_code = status.code();
assert_eq!(exit_code, Some(42), "exit code should be 42");
}
#[tokio::test]
async fn exec_command_timeout() {
let command = "sleep 60";
let timeout_duration = std::time::Duration::from_millis(500);
let cmd = command.to_string();
let wait_result = tokio::time::timeout(
timeout_duration,
tokio::task::spawn_blocking(move || {
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(&cmd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let _stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
child.wait().ok()
}),
)
.await;
assert!(wait_result.is_err(), "timeout should occur");
}
#[tokio::test]
async fn exec_command_working_dir_rejection() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hi",
"working_dir": "/tmp"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for working_dir outside CWD: {resp}"
);
let content_text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
content_text.contains("outside"),
"error message should contain 'outside': {content_text}"
);
}
#[tokio::test]
async fn exec_command_output_truncation() {
let command = "seq 1 3000";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _status = child.wait().expect("should wait for child");
let line_count = stdout.lines().count();
assert!(
line_count > 2000,
"output should have >2000 lines, got: {}",
line_count
);
}
#[test]
fn test_truncate_output_by_lines() {
fn truncate_output(output: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
let lines: Vec<&str> = output.lines().collect();
let output_to_use = if lines.len() > max_lines {
lines[..max_lines].join("\n")
} else {
output.to_string()
};
if output_to_use.len() > max_bytes {
(output_to_use[..max_bytes].to_string(), true)
} else {
(output_to_use, lines.len() > max_lines)
}
}
let output = (1..=2500)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("\n");
let (truncated, was_truncated) = truncate_output(&output, 2000, 50 * 1024);
assert!(was_truncated, "should be truncated");
let line_count = truncated.lines().count();
assert_eq!(line_count, 2000, "should have exactly 2000 lines");
}
#[test]
fn test_truncate_output_by_bytes() {
fn truncate_output(output: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
let lines: Vec<&str> = output.lines().collect();
let output_to_use = if lines.len() > max_lines {
lines[..max_lines].join("\n")
} else {
output.to_string()
};
if output_to_use.len() > max_bytes {
(output_to_use[..max_bytes].to_string(), true)
} else {
(output_to_use, lines.len() > max_lines)
}
}
let output = "x".repeat(100 * 1024);
let (truncated, was_truncated) = truncate_output(&output, 2000, 50 * 1024);
assert!(was_truncated, "should be truncated");
assert!(
truncated.len() <= 50 * 1024,
"truncated output should not exceed 50KB"
);
}
#[tokio::test]
async fn test_handler_structured_output() {
let resp = call_exec_command_raw(serde_json::json!({"command": "echo hello"})).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "exit_code mismatch: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("hello"),
"stdout missing 'hello': {sc}"
);
assert!(
!sc["timed_out"].as_bool().unwrap_or(true),
"unexpected timed_out: {sc}"
);
}
#[tokio::test]
async fn test_handler_timeout_respected() {
let resp =
call_exec_command_raw(serde_json::json!({"command": "sleep 10", "timeout_secs": 1})).await;
let sc = &resp["result"]["structuredContent"];
assert!(
sc["timed_out"].as_bool().unwrap_or(false),
"expected timed_out=true: {sc}"
);
}
#[tokio::test]
async fn test_handler_invalid_working_dir() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hi",
"working_dir": "/nonexistent-absolute-path-for-test"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
}
#[tokio::test]
async fn test_handler_nonzero_exit() {
let resp = call_exec_command_raw(serde_json::json!({"command": "exit 42"})).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 42, "exit_code mismatch: {sc}");
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for non-zero exit: {resp}"
);
}
#[tokio::test]
async fn test_handler_timeout_partial_output() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo partial_output && sleep 10",
"timeout_secs": 1
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["timed_out"], true, "expected timed_out=true: {sc}");
let stdout = sc["stdout"].as_str().unwrap_or("");
assert!(
stdout.contains("partial_output"),
"expected partial_output in stdout on timeout, got: {stdout}"
);
}
#[tokio::test]
async fn test_handler_shell_preference() {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("APTU_SHELL", "sh") };
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo $0"
}))
.await;
unsafe { std::env::remove_var("APTU_SHELL") };
let sc = &resp["result"]["structuredContent"];
let stdout = sc["stdout"].as_str().unwrap_or("");
assert!(
stdout.contains("sh"),
"expected sh in $0 output, got: {stdout}"
);
}
#[tokio::test]
async fn test_handler_stderr_populated() {
let resp = call_exec_command_raw(serde_json::json!({"command": "sh -c 'echo err >&2'"})).await;
let sc = &resp["result"]["structuredContent"];
assert!(
sc["stderr"].as_str().unwrap_or("").contains("err"),
"stderr missing 'err': {sc}"
);
}
#[tokio::test]
async fn test_handler_resource_limits_none_unchanged() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo test",
"memory_limit_mb": null,
"cpu_limit_secs": null
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "exit code should be 0: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("test"),
"stdout should contain 'test': {sc}"
);
assert!(
!sc["timed_out"].as_bool().unwrap_or(true),
"should not timeout: {sc}"
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn test_handler_cpu_limit_kills_spin() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "sh -c 'while true; do :; done'",
"cpu_limit_secs": 1,
"timeout_secs": 10
}))
.await;
let sc = &resp["result"]["structuredContent"];
let exit_code = sc["exit_code"].as_i64();
assert!(
exit_code.is_none() || exit_code != Some(0),
"exit code should be null (signal kill) or non-zero: {sc}"
);
}
#[tokio::test]
async fn test_handler_memory_limit_accepted() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hello",
"memory_limit_mb": 512
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "exit code should be 0: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("hello"),
"stdout should contain 'hello': {sc}"
);
}
#[tokio::test]
async fn test_exec_command_large_stdout_no_deadlock() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 500",
"timeout_secs": 10
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(
sc["timed_out"], false,
"large stdout must not trigger timeout: {sc}"
);
assert_eq!(sc["exit_code"], 0, "exit code should be 0: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("1"),
"stdout should contain output: {sc}"
);
}
#[tokio::test]
async fn test_exec_command_backgrounded_process() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo 'parent done'",
"timeout_secs": 5
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(
sc["timed_out"], false,
"normal command should not timeout: {sc}"
);
assert_eq!(
sc["output_truncated"], false,
"normal command should not truncate: {sc}"
);
assert!(
sc["stdout"].as_str().unwrap_or("").contains("parent done"),
"stdout should contain output: {sc}"
);
}
#[tokio::test]
async fn test_exec_command_overflow_to_temp_file() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 3000",
"timeout_secs": 10
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["output_truncated"], true, "should be truncated: {sc}");
let stdout_path = sc["stdout_path"].as_str();
assert!(
stdout_path.is_some(),
"stdout_path should be set on overflow: {sc}"
);
assert!(
stdout_path.unwrap().contains("aptu-coder-overflow"),
"stdout_path should reference the overflow directory: {sc}"
);
assert!(
stdout_path.unwrap().contains("slot-"),
"stdout_path should contain slot identifier: {sc}"
);
}
#[tokio::test]
async fn test_exec_command_slot_isolation() {
let mut slot_ids = std::collections::HashSet::new();
for _ in 0..8 {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 3000",
"timeout_secs": 10
}))
.await;
let sc = &resp["result"]["structuredContent"];
if let Some(path_str) = sc["stdout_path"].as_str() {
if let Some(slot_start) = path_str.find("slot-") {
let rest = &path_str[slot_start..];
let slot_end = rest.find('/').unwrap_or(rest.len());
let slot_id = &rest[..slot_end];
slot_ids.insert(slot_id.to_string());
}
}
}
assert!(
!slot_ids.is_empty(),
"should have extracted at least one slot identifier"
);
}
#[tokio::test]
async fn test_handler_interleaved_ordering() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo stdout_line && echo stderr_line >&2"
}))
.await;
let sc = &resp["result"]["structuredContent"];
let interleaved = sc["interleaved"].as_str().unwrap_or("");
assert!(
interleaved.contains("stdout_line"),
"interleaved missing stdout_line: {interleaved}"
);
assert!(
interleaved.contains("stderr_line"),
"interleaved missing stderr_line: {interleaved}"
);
assert!(
sc["stdout"].as_str().unwrap_or("").contains("stdout_line"),
"stdout field missing stdout_line: {sc}"
);
assert!(
sc["stderr"].as_str().unwrap_or("").contains("stderr_line"),
"stderr field missing stderr_line: {sc}"
);
}
#[test]
fn test_handler_output_collection_error() {
use aptu_coder::ShellOutput;
let mut output = ShellOutput::new(
"out".into(),
"err".into(),
"out\nerr\n".into(),
Some(0),
false,
false,
);
assert!(
output.output_collection_error.is_none(),
"output_collection_error must be None by default"
);
output.output_collection_error =
Some("post-exit drain timeout: background process held pipes".into());
assert!(
output.output_collection_error.is_some(),
"output_collection_error should be settable"
);
}
#[tokio::test]
async fn test_handler_content_priority() {
let resp = call_exec_command_raw(serde_json::json!({"command": "echo hello"})).await;
let content = &resp["result"]["content"];
let first = &content[0];
let priority = &first["annotations"]["priority"];
assert!(
!priority.is_null(),
"first content block should have annotations.priority: {first}"
);
let pval = priority.as_f64().unwrap_or(f64::NAN);
assert!(
(pval - 0.0).abs() < f64::EPSILON,
"priority should be 0.0, got: {pval}"
);
}
#[tokio::test]
async fn test_exec_cache_hit_on_sequential_repeat() {
let cmd = "echo cache_test_123";
let params1 = serde_json::json!({"command": cmd});
let params2 = serde_json::json!({"command": cmd});
let resp1 = call_exec_command_raw(params1).await;
let sc1 = &resp1["result"]["structuredContent"];
let stdout1 = sc1["stdout"].as_str().unwrap_or("").to_string();
let resp2 = call_exec_command_raw(params2).await;
let sc2 = &resp2["result"]["structuredContent"];
let stdout2 = sc2["stdout"].as_str().unwrap_or("").to_string();
assert_eq!(sc1["exit_code"], 0, "first call should succeed: {sc1}");
assert_eq!(sc2["exit_code"], 0, "second call should succeed: {sc2}");
assert_eq!(stdout1, stdout2, "cached output should match original");
assert!(
stdout1.contains("cache_test_123"),
"output should contain the echo string"
);
}
#[tokio::test]
async fn test_exec_cache_skipped_with_stdin() {
let cmd = "cat";
let stdin_content = "test_stdin_data";
let params = serde_json::json!({
"command": cmd,
"stdin": stdin_content
});
let resp = call_exec_command_raw(params).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "cat with stdin should succeed: {sc}");
assert!(
sc["stdout"]
.as_str()
.unwrap_or("")
.contains("test_stdin_data"),
"stdout should contain the stdin content: {sc}"
);
}
#[tokio::test]
async fn test_exec_cache_not_populated_on_failure() {
let cmd = "false";
let params1 = serde_json::json!({"command": cmd});
let params2 = serde_json::json!({"command": cmd});
let resp1 = call_exec_command_raw(params1).await;
let sc1 = &resp1["result"]["structuredContent"];
let resp2 = call_exec_command_raw(params2).await;
let sc2 = &resp2["result"]["structuredContent"];
assert_ne!(sc1["exit_code"], 0, "false command should fail: {sc1}");
assert_ne!(
sc2["exit_code"], 0,
"false command should fail on second call too: {sc2}"
);
}
#[tokio::test]
async fn test_exec_cache_bypassed_with_false_param() {
let cmd = "echo bypass_cache";
let params = serde_json::json!({
"command": cmd,
"cache": false
});
let resp = call_exec_command_raw(params).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "command should succeed: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("bypass_cache"),
"output should contain the echo string: {sc}"
);
}
#[tokio::test]
async fn test_exec_slot_files_not_written_for_small_output() {
let cmd = "echo slot_file_test";
let params = serde_json::json!({"command": cmd});
let resp = call_exec_command_raw(params).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(
sc["output_truncated"], false,
"small output must not be truncated: {sc}"
);
assert!(
sc["stdout_path"].is_null(),
"stdout_path must be absent for small output: {sc}"
);
assert!(
sc["stderr_path"].is_null(),
"stderr_path must be absent for small output: {sc}"
);
}