use assert_cmd::Command;
use predicates::prelude::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
fn unique_socket_path() -> PathBuf {
let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
PathBuf::from(format!("/tmp/vessel-cli-test-{pid}-{id}.sock"))
}
struct TestEnv {
socket_path: PathBuf,
server_process: Option<std::process::Child>,
}
impl TestEnv {
fn new() -> Self {
let socket_path = unique_socket_path();
Self {
socket_path,
server_process: None,
}
}
fn socket_arg(&self) -> String {
format!("--socket={}", self.socket_path.display())
}
fn start_server(&mut self) {
let child = std::process::Command::new(env!("CARGO_BIN_EXE_vessel"))
.arg(&self.socket_arg())
.arg("server")
.spawn()
.expect("failed to start server");
self.server_process = Some(child);
std::thread::sleep(Duration::from_millis(200));
}
fn vessel(&self) -> Command {
let mut cmd = Command::cargo_bin("vessel").unwrap();
cmd.arg(&self.socket_arg());
cmd
}
}
impl Drop for TestEnv {
fn drop(&mut self) {
if self.server_process.is_some() {
let _ = std::process::Command::new(env!("CARGO_BIN_EXE_vessel"))
.arg(&self.socket_arg())
.arg("shutdown")
.output();
}
if let Some(mut child) = self.server_process.take() {
let _ = child.kill();
let _ = child.wait();
}
std::fs::remove_file(&self.socket_path).ok();
}
}
#[test]
fn test_help() {
Command::cargo_bin("vessel")
.unwrap()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("PTY-based runtime"))
.stdout(predicate::str::contains("spawn"))
.stdout(predicate::str::contains("list"))
.stdout(predicate::str::contains("kill"));
}
#[test]
fn test_version() {
Command::cargo_bin("vessel")
.unwrap()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains("vessel"));
}
#[test]
fn test_spawn_help() {
Command::cargo_bin("vessel")
.unwrap()
.args(["spawn", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("Spawn a new agent"))
.stdout(predicate::str::contains("--rows"))
.stdout(predicate::str::contains("--cols"));
}
#[test]
fn test_spawn_list_kill_workflow() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--", "sleep", "30"])
.output()
.expect("failed to run spawn");
assert!(output.status.success(), "spawn should succeed");
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert!(!agent_id.is_empty(), "should return agent ID");
env.vessel()
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains(&agent_id))
.stdout(predicate::str::contains("sleep 30"))
.stdout(predicate::str::contains("running"));
env.vessel()
.args(["kill", &agent_id])
.assert()
.success()
.stdout(predicate::str::contains("Signal sent"));
std::thread::sleep(Duration::from_millis(200));
env.vessel()
.args(["list", "--all"])
.assert()
.success()
.stdout(predicate::str::contains("exited"));
}
#[test]
fn test_send_and_snapshot() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--", "bash"])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
std::thread::sleep(Duration::from_millis(200));
env.vessel()
.args([
"send",
&agent_id,
"echo UNIQUE_TEST_STRING_12345",
"--newline",
])
.assert()
.success();
std::thread::sleep(Duration::from_millis(300));
env.vessel()
.args(["snapshot", &agent_id])
.assert()
.success()
.stdout(predicate::str::contains("UNIQUE_TEST_STRING_12345"));
env.vessel().args(["kill", &agent_id]).assert().success();
}
#[test]
fn test_tail() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args([
"spawn",
"--",
"sh",
"-c",
"echo FIRST_LINE; echo SECOND_LINE; sleep 30",
])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
std::thread::sleep(Duration::from_millis(300));
env.vessel()
.args(["tail", &agent_id])
.assert()
.success()
.stdout(predicate::str::contains("FIRST_LINE"))
.stdout(predicate::str::contains("SECOND_LINE"));
env.vessel().args(["kill", &agent_id]).assert().success();
}
#[test]
fn test_agent_not_found() {
let mut env = TestEnv::new();
env.start_server();
env.vessel()
.args(["snapshot", "nonexistent-agent"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
#[test]
fn test_spawn_requires_command() {
Command::cargo_bin("vessel")
.unwrap()
.args(["spawn", "--"])
.assert()
.failure();
}
#[test]
fn test_send_bytes_hex() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--", "bash"])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
std::thread::sleep(Duration::from_millis(200));
env.vessel()
.args(["send-bytes", &agent_id, "68690a"])
.assert()
.success();
env.vessel().args(["kill", &agent_id]).assert().success();
}
#[test]
fn test_shutdown() {
let mut env = TestEnv::new();
env.start_server();
env.vessel()
.arg("shutdown")
.assert()
.success()
.stdout(predicate::str::contains("shutting down"));
env.server_process = None;
}
#[test]
fn test_wait_for_content() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args([
"spawn",
"--",
"sh",
"-c",
"sleep 0.2; echo MARKER_READY; sleep 30",
])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
env.vessel()
.args([
"wait",
&agent_id,
"--contains",
"MARKER_READY",
"--timeout",
"5",
"--print",
])
.assert()
.success()
.stdout(predicate::str::contains("MARKER_READY"));
env.vessel().args(["kill", &agent_id]).assert().success();
}
#[test]
fn test_wait_timeout() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--", "sleep", "30"])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
env.vessel()
.args([
"wait",
&agent_id,
"--contains",
"NEVER_APPEARS",
"--timeout",
"1",
])
.assert()
.failure()
.stderr(predicate::str::contains("timeout"));
env.vessel().args(["kill", &agent_id]).assert().success();
}
#[test]
fn test_spawn_with_custom_name() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--name", "my-worker", "--", "sleep", "30"])
.output()
.expect("failed to run spawn");
assert!(output.status.success(), "spawn should succeed");
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(agent_id, "my-worker", "should return the custom name");
env.vessel()
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("my-worker"));
env.vessel().args(["kill", "my-worker"]).assert().success();
}
#[test]
fn test_spawn_duplicate_name_fails() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--name", "unique-name", "--", "sleep", "30"])
.output()
.expect("failed to run spawn");
assert!(output.status.success(), "first spawn should succeed");
env.vessel()
.args(["spawn", "--name", "unique-name", "--", "sleep", "30"])
.assert()
.failure()
.stderr(predicate::str::contains("already in use"));
env.vessel()
.args(["kill", "unique-name"])
.assert()
.success();
}
#[test]
fn test_exec_command() {
let mut env = TestEnv::new();
env.start_server();
env.vessel()
.args(["exec", "--timeout", "5", "--", "echo", "EXEC_TEST_OUTPUT"])
.assert()
.success()
.stdout(predicate::str::contains("EXEC_TEST_OUTPUT"));
}
#[test]
fn test_exec_multiline_output() {
let mut env = TestEnv::new();
env.start_server();
env.vessel()
.args([
"exec",
"--timeout",
"5",
"--",
"echo first; echo second; echo third",
])
.assert()
.success()
.stdout(predicate::str::contains("first"))
.stdout(predicate::str::contains("second"))
.stdout(predicate::str::contains("third"));
}
#[test]
fn test_exec_exit_code_propagation() {
let mut env = TestEnv::new();
env.start_server();
env.vessel()
.args(["exec", "--timeout", "5", "--", "false"])
.assert()
.failure()
.code(1);
env.vessel()
.args(["exec", "--timeout", "5", "--", "ls /nonexistent_path_12345"])
.assert()
.failure()
.code(2);
}
#[test]
fn test_kill_idempotent() {
let mut env = TestEnv::new();
env.start_server();
env.vessel()
.args(["kill", "nonexistent-agent"])
.assert()
.success();
env.vessel().args(["kill", "--all"]).assert().success();
let output = env
.vessel()
.args(["spawn", "--", "sleep", "100"])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
env.vessel().args(["kill", &agent_id]).assert().success();
std::thread::sleep(Duration::from_millis(100));
env.vessel().args(["kill", &agent_id]).assert().success();
}
#[test]
fn test_send_key() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--", "bash"])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
std::thread::sleep(Duration::from_millis(200));
env.vessel()
.args(["send-keys", &agent_id, "up"])
.assert()
.success();
env.vessel()
.args(["send-keys", &agent_id, "down"])
.assert()
.success();
env.vessel()
.args(["send-keys", &agent_id, "enter"])
.assert()
.success();
env.vessel()
.args(["send-keys", &agent_id, "tab"])
.assert()
.success();
env.vessel()
.args(["send-keys", &agent_id, "ctrl-c"])
.assert()
.success();
env.vessel()
.args(["send-keys", &agent_id, "up", "down", "enter"])
.assert()
.success();
env.vessel()
.args(["send-keys", &agent_id, "invalid-key"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown key"));
env.vessel().args(["kill", &agent_id]).assert().success();
}
#[test]
fn test_wait_combined_conditions() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--", "bash"])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
std::thread::sleep(Duration::from_millis(300));
env.vessel()
.args(["wait", &agent_id, "--stable", "100", "--timeout", "5"])
.assert()
.success();
env.vessel()
.args(["send", &agent_id, "echo test123", "--newline"])
.assert()
.success();
env.vessel()
.args(["wait", &agent_id, "--contains", "test123", "--timeout", "5"])
.assert()
.success();
env.vessel()
.args(["send", &agent_id, "echo hello-combined", "--newline"])
.assert()
.success();
env.vessel()
.args([
"wait",
&agent_id,
"--stable",
"100",
"--contains",
"hello-combined",
"--timeout",
"5",
])
.assert()
.success();
env.vessel().args(["kill", &agent_id]).assert().success();
}
#[test]
fn test_wait_exited() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args([
"spawn",
"--name",
"exit-ok",
"--",
"sh",
"-c",
"echo hello; exit 0",
])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
env.vessel()
.args(["wait", "--exited", "exit-ok", "--timeout", "10"])
.assert()
.success()
.code(0);
}
#[test]
fn test_wait_exited_nonzero() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--name", "exit-42", "--", "sh", "-c", "exit 42"])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
env.vessel()
.args(["wait", "--exited", "exit-42", "--timeout", "10"])
.assert()
.failure()
.code(42);
}
#[test]
fn test_wait_exited_already_exited() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--name", "already-done", "--", "true"])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
std::thread::sleep(Duration::from_millis(500));
env.vessel()
.args(["wait", "--exited", "already-done", "--timeout", "5"])
.assert()
.success()
.code(0);
}
#[test]
fn test_wait_exited_timeout() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--name", "long-run", "--", "sleep", "999"])
.output()
.expect("failed to run spawn");
assert!(output.status.success());
env.vessel()
.args(["wait", "--exited", "long-run", "--timeout", "1"])
.assert()
.failure()
.stderr(predicate::str::contains("timeout"));
env.vessel().args(["kill", "long-run"]).assert().success();
}
#[test]
fn test_wait_exited_any_returns_first_exit_and_prints_id() {
let mut env = TestEnv::new();
env.start_server();
env.vessel()
.args([
"spawn",
"--name",
"fast-exit",
"--",
"sh",
"-c",
"sleep 0.2; exit 0",
])
.assert()
.success();
env.vessel()
.args(["spawn", "--name", "slow-exit", "--", "sleep", "999"])
.assert()
.success();
env.vessel()
.args([
"wait",
"--exited",
"--any",
"fast-exit",
"slow-exit",
"--timeout",
"10",
])
.assert()
.success()
.stdout(predicate::str::diff("fast-exit\n"));
env.vessel().args(["kill", "slow-exit"]).assert().success();
}
#[test]
fn test_wait_exited_any_reports_all_already_exited_agents() {
let mut env = TestEnv::new();
env.start_server();
env.vessel()
.args(["spawn", "--name", "done-ok", "--", "true"])
.assert()
.success();
env.vessel()
.args(["spawn", "--name", "done-bad", "--", "sh", "-c", "exit 7"])
.assert()
.success();
std::thread::sleep(Duration::from_millis(500));
env.vessel()
.args([
"wait",
"--exited",
"--any",
"done-ok",
"done-bad",
"--timeout",
"5",
])
.assert()
.failure()
.code(7)
.stdout(predicate::str::diff("done-ok\ndone-bad\n"));
}
#[test]
fn test_wait_any_requires_exited() {
let mut env = TestEnv::new();
env.start_server();
env.vessel()
.args([
"spawn",
"--name",
"wait-any-no-exited",
"--",
"sleep",
"999",
])
.assert()
.success();
env.vessel()
.args(["wait", "--any", "wait-any-no-exited", "--timeout", "1"])
.assert()
.failure()
.stderr(predicate::str::contains("--any requires --exited"));
env.vessel()
.args(["kill", "wait-any-no-exited"])
.assert()
.success();
}
#[test]
fn test_spawn_slash_name() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--name", "parent/child", "--", "sleep", "30"])
.output()
.expect("failed to run spawn");
assert!(output.status.success(), "spawn should succeed");
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(agent_id, "parent/child", "should return the slash name");
env.vessel()
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("parent/child"));
env.vessel()
.args(["kill", "parent/child"])
.assert()
.success();
}
#[test]
fn test_spawn_slash_name_invalid() {
let mut env = TestEnv::new();
env.start_server();
env.vessel()
.args(["spawn", "--name", "/leading", "--", "sleep", "30"])
.assert()
.failure()
.stderr(predicate::str::contains("must not start/end with '/'"));
env.vessel()
.args(["spawn", "--name", "trailing/", "--", "sleep", "30"])
.assert()
.failure()
.stderr(predicate::str::contains("must not start/end with '/'"));
env.vessel()
.args(["spawn", "--name", "a//b", "--", "sleep", "30"])
.assert()
.failure()
.stderr(predicate::str::contains(
"must not start/end with '/' or contain '//'",
));
}
#[test]
fn test_spawn_multi_level_slash() {
let mut env = TestEnv::new();
env.start_server();
let output = env
.vessel()
.args(["spawn", "--name", "a/b/c", "--", "sleep", "30"])
.output()
.expect("failed to run spawn");
assert!(output.status.success(), "spawn should succeed");
let agent_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(agent_id, "a/b/c");
std::thread::sleep(Duration::from_millis(200));
env.vessel().args(["snapshot", "a/b/c"]).assert().success();
env.vessel().args(["kill", "a/b/c"]).assert().success();
}