herdr/tests/cli_wrapper.rs

4174 lines
132 KiB
Rust

#![cfg(not(target_os = "macos"))]
mod support;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use support::{
cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
unregister_spawned_herdr_pid,
};
const WORKTREE_BOOTSTRAP_MANAGED_COMPONENT: &str = "example.worktree-bootstrap-ef876653ffc3";
fn unique_test_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
PathBuf::from(format!("/tmp/hcli-{}-{nanos}", std::process::id()))
}
fn managed_github_plugin_dir(config_home: &Path) -> PathBuf {
config_home.join("herdr-dev").join("plugins").join("github")
}
fn path_missing_or_empty(path: &Path) -> bool {
match fs::read_dir(path) {
Ok(mut entries) => entries.next().is_none(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
Err(err) => panic!("failed to read {}: {err}", path.display()),
}
}
fn run_git(repo: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.status()
.unwrap();
assert!(
status.success(),
"git command failed: git -C {} {}",
repo.display(),
args.join(" ")
);
}
fn create_committed_repo(path: &Path) {
fs::create_dir_all(path).unwrap();
run_git(path, &["init", "--quiet"]);
run_git(path, &["config", "user.email", "herdr@example.invalid"]);
run_git(path, &["config", "user.name", "Herdr Test"]);
fs::write(path.join("README.md"), "test\n").unwrap();
run_git(path, &["add", "README.md"]);
run_git(path, &["commit", "--quiet", "-m", "initial"]);
}
struct SpawnedHerdr {
_master: Box<dyn MasterPty + Send>,
child: Box<dyn Child + Send + Sync>,
}
struct SpawnedServerProcess {
child: std::process::Child,
}
impl Drop for SpawnedServerProcess {
fn drop(&mut self) {
let pid = self.child.id();
let _ = self.child.kill();
let _ = self.child.wait();
unregister_spawned_herdr_pid(Some(pid));
}
}
impl Drop for SpawnedHerdr {
fn drop(&mut self) {
let pid = self.child.process_id();
let _ = self.child.kill();
if let Some(pid) = pid {
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
let mut status = 0;
let result =
unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
if result == pid as libc::pid_t || result == -1 {
break;
}
thread::sleep(Duration::from_millis(20));
}
unregister_spawned_herdr_pid(Some(pid));
}
}
}
fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
drop(spawned);
cleanup_test_base(&base);
}
fn wait_for_socket(path: &Path, timeout: Duration) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if path.exists() && std::os::unix::net::UnixStream::connect(path).is_ok() {
return;
}
thread::sleep(Duration::from_millis(25));
}
panic!("socket did not appear at {}", path.display());
}
fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> SpawnedHerdr {
spawn_herdr_with_config(
config_home,
runtime_dir,
socket_path,
None,
"onboarding = false\n",
)
}
fn spawn_herdr_with_pane_history(
config_home: &Path,
runtime_dir: &Path,
socket_path: &Path,
) -> SpawnedHerdr {
spawn_herdr_with_config(
config_home,
runtime_dir,
socket_path,
None,
"onboarding = false\n[experimental]\npane_history = true\n",
)
}
fn app_dir_name() -> &'static str {
if cfg!(debug_assertions) {
"herdr-dev"
} else {
"herdr"
}
}
fn named_session_socket(config_home: &Path, session: &str) -> PathBuf {
config_home
.join(app_dir_name())
.join("sessions")
.join(session)
.join("herdr.sock")
}
fn spawn_named_server(
config_home: &Path,
runtime_dir: &Path,
session: &str,
) -> SpawnedServerProcess {
fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
fs::create_dir_all(runtime_dir).unwrap();
register_runtime_dir(runtime_dir);
fs::write(
config_home.join(app_dir_name()).join("config.toml"),
"onboarding = false\n",
)
.unwrap();
let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
command
.args(["--session", session, "server"])
.env("XDG_CONFIG_HOME", config_home)
.env("XDG_RUNTIME_DIR", runtime_dir)
.env_remove("HERDR_SOCKET_PATH")
.env_remove("HERDR_CLIENT_SOCKET_PATH")
.env_remove("HERDR_ENV")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let child = command.spawn().unwrap();
register_spawned_herdr_pid(Some(child.id()));
SpawnedServerProcess { child }
}
fn run_named_cli(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> std::process::Output {
run_named_cli_with_socket_override(config_home, runtime_dir, args, None)
}
fn run_named_cli_with_socket_override(
config_home: &Path,
runtime_dir: &Path,
args: &[&str],
socket_override: Option<&Path>,
) -> std::process::Output {
run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, &[], socket_override)
}
fn run_named_cli_with_env(
config_home: &Path,
runtime_dir: &Path,
args: &[&str],
envs: &[(&str, &Path)],
) -> std::process::Output {
run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, envs, None)
}
fn run_named_cli_with_env_and_socket_override(
config_home: &Path,
runtime_dir: &Path,
args: &[&str],
envs: &[(&str, &Path)],
socket_override: Option<&Path>,
) -> std::process::Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
command
.args(args)
.env("XDG_CONFIG_HOME", config_home)
.env("XDG_RUNTIME_DIR", runtime_dir)
.env_remove("HERDR_CLIENT_SOCKET_PATH")
.env_remove("HERDR_ENV");
for (key, value) in envs {
command.env(key, value);
}
if let Some(socket_override) = socket_override {
command.env("HERDR_SOCKET_PATH", socket_override);
} else {
command.env_remove("HERDR_SOCKET_PATH");
}
command.output().unwrap()
}
fn run_named_cli_json(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> serde_json::Value {
let output = run_named_cli(config_home, runtime_dir, args);
assert!(
output.status.success(),
"command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}",
args.join(" "),
output.status.code(),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
serde_json::from_slice(&output.stdout).unwrap()
}
fn spawn_herdr_with_path(
config_home: &Path,
runtime_dir: &Path,
socket_path: &Path,
path_override: Option<&Path>,
) -> SpawnedHerdr {
spawn_herdr_with_config(
config_home,
runtime_dir,
socket_path,
path_override,
"onboarding = false\n",
)
}
fn spawn_herdr_with_config(
config_home: &Path,
runtime_dir: &Path,
socket_path: &Path,
path_override: Option<&Path>,
config_toml: &str,
) -> SpawnedHerdr {
fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
fs::create_dir_all(runtime_dir).unwrap();
register_runtime_dir(runtime_dir);
fs::write(
config_home.join(app_dir_name()).join("config.toml"),
config_toml,
)
.unwrap();
let pair = native_pty_system()
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
cmd.arg("server");
cmd.env("XDG_CONFIG_HOME", config_home);
cmd.env("XDG_RUNTIME_DIR", runtime_dir);
cmd.env("HERDR_SOCKET_PATH", socket_path);
cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
cmd.env("SHELL", "/bin/sh");
cmd.env_remove("HERDR_ENV");
if let Some(path) = path_override {
cmd.env("PATH", path);
}
let child = pair.slave.spawn_command(cmd).unwrap();
register_spawned_herdr_pid(child.process_id());
SpawnedHerdr {
_master: pair.master,
child,
}
}
fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
command.args(args);
command.env("HERDR_SOCKET_PATH", socket_path);
command.output().unwrap()
}
fn run_cli_in_dir(socket_path: &Path, args: &[&str], current_dir: &Path) -> std::process::Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
command.args(args);
command.current_dir(current_dir);
command.env("HERDR_SOCKET_PATH", socket_path);
command.output().unwrap()
}
fn run_cli_json(socket_path: &Path, args: &[&str]) -> serde_json::Value {
let output = run_cli(socket_path, args);
parse_cli_json_output(args, output)
}
fn run_cli_json_in_dir(socket_path: &Path, args: &[&str], current_dir: &Path) -> serde_json::Value {
let output = run_cli_in_dir(socket_path, args, current_dir);
parse_cli_json_output(args, output)
}
fn parse_cli_json_output(args: &[&str], output: std::process::Output) -> serde_json::Value {
assert!(
output.status.success(),
"command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}",
args.join(" "),
output.status.code(),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
serde_json::from_slice(&output.stdout).unwrap_or_else(|err| {
panic!(
"failed to parse JSON response for `herdr {}`: {}\nstdout: {}\nstderr: {}",
args.join(" "),
err,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
})
}
fn wait_until(timeout: Duration, interval: Duration, mut condition: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if condition() {
return true;
}
thread::sleep(interval);
}
false
}
fn pane_read_recent_contains(socket_path: &Path, pane_id: &str, expected: &str) -> bool {
let output = run_cli(
socket_path,
&["pane", "read", pane_id, "--source", "recent"],
);
if !output.status.success() {
return false;
}
String::from_utf8_lossy(&output.stdout).contains(expected)
}
fn process_exists(pid: u32) -> bool {
let result = unsafe { libc::kill(pid as i32, 0) };
if result == 0 {
true
} else {
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if !process_exists(pid) {
return true;
}
thread::sleep(Duration::from_millis(25));
}
!process_exists(pid)
}
fn wait_for_pid_file(pid_file: &Path, timeout: Duration) -> Result<u32, String> {
const STABLE_PID_CONTENT_WINDOW: Duration = Duration::from_millis(250);
let deadline = Instant::now() + timeout;
let mut last_contents = String::new();
let mut stable_candidate: Option<(String, u32, Instant)> = None;
while Instant::now() < deadline {
if let Ok(contents) = fs::read_to_string(pid_file) {
let trimmed = contents.trim().to_string();
last_contents = contents;
if let Ok(pid) = trimmed.parse::<u32>() {
match &stable_candidate {
Some((candidate_text, candidate_pid, stable_since))
if candidate_text == &trimmed && *candidate_pid == pid =>
{
if stable_since.elapsed() >= STABLE_PID_CONTENT_WINDOW {
return Ok(pid);
}
}
_ => {
stable_candidate = Some((trimmed, pid, Instant::now()));
}
}
} else {
stable_candidate = None;
}
}
thread::sleep(Duration::from_millis(25));
}
Err(format!(
"pid file {} did not contain stable parseable pid before timeout; last contents={:?}",
pid_file.display(),
last_contents
))
}
#[test]
fn wait_for_pid_file_retries_until_pid_is_written() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let pid_file = base.join("delayed.pid");
fs::write(&pid_file, "").unwrap();
let writer = thread::spawn({
let pid_file = pid_file.clone();
move || {
thread::sleep(Duration::from_millis(100));
fs::write(pid_file, "424242\n").unwrap();
}
});
let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap();
assert_eq!(pid, 424242);
writer.join().unwrap();
cleanup_test_base(&base);
}
#[test]
fn wait_for_pid_file_errors_when_file_never_contains_pid() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let pid_file = base.join("empty.pid");
fs::write(&pid_file, "").unwrap();
let err = wait_for_pid_file(&pid_file, Duration::from_millis(150)).unwrap_err();
assert!(
err.contains("did not contain stable parseable pid"),
"unexpected error: {err}"
);
cleanup_test_base(&base);
}
#[test]
fn wait_for_pid_file_rejects_unparseable_partial_write_until_stable_contents() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let pid_file = base.join("partial-race.pid");
fs::write(&pid_file, "").unwrap();
let writer = thread::spawn({
let pid_file = pid_file.clone();
move || {
thread::sleep(Duration::from_millis(40));
fs::write(&pid_file, "pid=").unwrap();
thread::sleep(Duration::from_millis(40));
fs::write(&pid_file, "pid=424242").unwrap();
thread::sleep(Duration::from_millis(40));
fs::write(&pid_file, "424242\n").unwrap();
}
});
let start = Instant::now();
let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap();
assert_eq!(pid, 424242);
assert!(
start.elapsed() >= Duration::from_millis(300),
"helper should wait for stable complete contents, elapsed={:?}",
start.elapsed()
);
writer.join().unwrap();
cleanup_test_base(&base);
}
fn send_request(socket_path: &Path, json: &str) -> serde_json::Value {
let mut stream = UnixStream::connect(socket_path).unwrap();
stream.write_all(json.as_bytes()).unwrap();
stream.write_all(b"\n").unwrap();
stream.flush().unwrap();
let mut line = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut line).unwrap();
serde_json::from_str(&line).unwrap()
}
fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> {
run_shell_hook(
"src/integration/assets/claude/herdr-agent-state.sh",
&[action],
hook_input,
)
}
fn run_codex_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> {
run_shell_hook(
"src/integration/assets/codex/herdr-agent-state.sh",
&[action],
hook_input,
)
}
fn run_copilot_hook(hook_input: &str) -> Option<serde_json::Value> {
run_shell_hook(
"src/integration/assets/copilot/herdr-agent-state.sh",
&[],
hook_input,
)
}
fn run_devin_hook(
action: &str,
hook_input: &str,
envs: &[(&str, &str)],
) -> Option<serde_json::Value> {
run_shell_hook_with_env(
"src/integration/assets/devin/herdr-agent-state.sh",
&[action],
hook_input,
envs,
)
}
fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<serde_json::Value> {
run_shell_hook_with_env(asset_path, args, hook_input, &[])
}
fn run_shell_hook_with_env(
asset_path: &str,
args: &[&str],
hook_input: &str,
envs: &[(&str, &str)],
) -> Option<serde_json::Value> {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let socket_path = base.join("herdr.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = thread::spawn(move || {
listener.set_nonblocking(true).unwrap();
let deadline = Instant::now() + Duration::from_millis(700);
while Instant::now() < deadline {
match listener.accept() {
Ok((mut stream, _)) => {
let mut line = String::new();
let mut reader = BufReader::new(stream.try_clone().unwrap());
reader.read_line(&mut line).unwrap();
let _ = stream.write_all(br#"{"id":"test","result":{"type":"ok"}}"#);
let _ = stream.write_all(b"\n");
let _ = stream.flush();
return Some(line);
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("accept failed: {err}"),
}
}
None
});
let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path);
let mut command = Command::new("bash");
command
.arg(hook_path)
.args(args)
.env("HERDR_ENV", "1")
.env("HERDR_SOCKET_PATH", &socket_path)
.env("HERDR_PANE_ID", "p_test")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (key, value) in envs {
command.env(key, value);
}
let mut child = command.spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(hook_input.as_bytes()).unwrap();
drop(stdin);
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"hook failed: status={:?} stderr={} stdout={}",
output.status.code(),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
let request = server.join().unwrap();
cleanup_test_base(&base);
request.map(|line| serde_json::from_str(&line).unwrap())
}
#[test]
fn claude_hook_ignores_state_actions() {
let subagent_input = r#"{"hook_event_name":"Notification","agent_id":"agent-abc123","agent_type":"Explore","notification_type":"permission_prompt"}"#;
assert!(run_claude_hook("working", subagent_input).is_none());
assert!(run_claude_hook("blocked", subagent_input).is_none());
}
#[test]
fn claude_hook_ignores_subagent_completion_reports() {
let subagent_input =
r#"{"hook_event_name":"SubagentStop","agent_id":"agent-abc123","agent_type":"Explore"}"#;
assert!(run_claude_hook("working", subagent_input).is_none());
assert!(run_claude_hook("idle", subagent_input).is_none());
assert!(run_claude_hook("release", subagent_input).is_none());
}
#[test]
fn claude_hook_keeps_parent_agent_type_only_blocked() {
let request = run_claude_hook(
"blocked",
r#"{"hook_event_name":"PermissionRequest","agent_type":"Explore"}"#,
);
assert!(request.is_none());
}
#[test]
fn claude_hook_reports_session_id_from_stdin() {
let request = run_claude_hook(
"session",
r#"{"hook_event_name":"SessionStart","session_id":"claude-session"}"#,
)
.expect("session start should report session identity");
assert_eq!(request["method"], "pane.report_agent_session");
assert_eq!(request["params"]["agent_session_id"], "claude-session");
assert!(request["params"].get("state").is_none());
}
#[test]
fn codex_hook_reports_session_id_from_stdin() {
let request = run_codex_hook(
"session",
r#"{"hook_event_name":"SessionStart","session_id":"codex-session"}"#,
)
.expect("codex hook should report session identity");
assert_eq!(request["method"], "pane.report_agent_session");
assert_eq!(request["params"]["agent_session_id"], "codex-session");
assert!(request["params"].get("state").is_none());
}
#[test]
fn copilot_hook_reports_session_id_from_stdin() {
let request = run_copilot_hook(
r#"{"hook_event_name":"SessionStart","session_id":"copilot-session","source":"resume"}"#,
)
.expect("copilot session start should report session identity");
assert_eq!(request["method"], "pane.report_agent_session");
assert_eq!(request["params"]["agent"], "copilot");
assert_eq!(request["params"]["agent_session_id"], "copilot-session");
assert!(request["params"].get("state").is_none());
let camel = run_copilot_hook(
r#"{"sessionId":"copilot-camel-session","source":"new","initialPrompt":"run tests"}"#,
)
.expect("copilot camelCase session start should report session identity");
assert_eq!(camel["method"], "pane.report_agent_session");
assert_eq!(camel["params"]["agent_session_id"], "copilot-camel-session");
assert!(camel["params"].get("state").is_none());
}
#[test]
fn copilot_hook_does_not_report_lifecycle_state() {
for payload in [
r#"{"hook_event_name":"UserPromptSubmit","session_id":"copilot-session","prompt":"run tests"}"#,
r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"ask_user"}"#,
r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"permission_prompt"}"#,
r#"{"hook_event_name":"agentStop","session_id":"copilot-session","stop_reason":"end_turn"}"#,
r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"user_exit"}"#,
] {
assert!(
run_copilot_hook(payload).is_none(),
"copilot session-only hook should ignore lifecycle payload {payload}"
);
}
}
#[test]
fn devin_hook_ignores_prompt_session_list_fallback() {
let request = run_devin_hook(
"session",
r#"{"hook_event_name":"UserPromptSubmit","prompt":"run tests"}"#,
&[
("DEVIN_PROJECT_DIR", "/tmp/project"),
(
"HERDR_DEVIN_LIST_JSON",
r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#,
),
],
);
assert!(request.is_none());
}
#[test]
fn devin_hook_reports_session_id_from_stdin_without_state() {
let request = run_devin_hook(
"session",
r#"{"hook_event_name":"SessionStart","session_id":"devin-session","source":"startup"}"#,
&[("HERDR_DEVIN_LIST_JSON", r#"[{"id":"older-session"}]"#)],
)
.expect("devin session start should report session identity");
assert_eq!(request["method"], "pane.report_agent_session");
assert_eq!(request["params"]["agent"], "devin");
assert_eq!(request["params"]["agent_session_id"], "devin-session");
assert!(request["params"].get("state").is_none());
}
#[test]
fn devin_hook_prefers_hook_session_id_over_list() {
let request = run_devin_hook(
"session",
r#"{"hook_event_name":"PreToolUse","sessionId":"fresh-session","tool_name":"exec"}"#,
&[
("DEVIN_PROJECT_DIR", "/tmp/project"),
(
"HERDR_DEVIN_LIST_JSON",
r#"[{"id":"older-session","working_directory":"/tmp/project"}]"#,
),
],
)
.expect("devin tool hook should report session identity");
assert_eq!(request["method"], "pane.report_agent_session");
assert_eq!(request["params"]["agent_session_id"], "fresh-session");
assert!(request["params"].get("state").is_none());
}
#[test]
fn devin_hook_reports_tool_session_from_list_without_state() {
let request = run_devin_hook(
"session",
r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#,
&[
("DEVIN_PROJECT_DIR", "/tmp/project"),
(
"HERDR_DEVIN_LIST_JSON",
r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#,
),
],
)
.expect("devin tool hook should report session identity");
assert_eq!(request["method"], "pane.report_agent_session");
assert_eq!(request["params"]["agent"], "devin");
assert_eq!(request["params"]["agent_session_id"], "devin-session");
assert!(request["params"].get("state").is_none());
}
#[test]
fn devin_hook_ignores_startup_session_list_fallback() {
let request = run_devin_hook(
"session",
r#"{"hook_event_name":"SessionStart","source":"startup"}"#,
&[
("DEVIN_PROJECT_DIR", "/tmp/project"),
(
"HERDR_DEVIN_LIST_JSON",
r#"[{"id":"stale-session","working_directory":"/tmp/project"}]"#,
),
],
);
assert!(request.is_none());
}
#[test]
fn devin_hook_ignores_non_matching_session_list_entries() {
let request = run_devin_hook(
"session",
r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#,
&[
("DEVIN_PROJECT_DIR", "/tmp/project"),
(
"HERDR_DEVIN_LIST_JSON",
r#"[{"id":"other-session","working_directory":"/tmp/other"}]"#,
),
],
);
assert!(request.is_none());
}
#[test]
fn pane_run_sends_one_send_input_request_with_enter_key() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let socket_path = base.join("herdr.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = thread::spawn(move || {
let (mut first_stream, _) = listener.accept().unwrap();
let mut first_line = String::new();
let mut first_reader = BufReader::new(first_stream.try_clone().unwrap());
first_reader.read_line(&mut first_line).unwrap();
first_stream
.write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
.unwrap();
first_stream.write_all(b"\n").unwrap();
first_stream.flush().unwrap();
let mut second_line = None;
listener.set_nonblocking(true).unwrap();
let deadline = Instant::now() + Duration::from_millis(250);
while Instant::now() < deadline {
match listener.accept() {
Ok((mut second_stream, _)) => {
let mut line = String::new();
let mut reader = BufReader::new(second_stream.try_clone().unwrap());
reader.read_line(&mut line).unwrap();
second_stream
.write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
.unwrap();
second_stream.write_all(b"\n").unwrap();
second_stream.flush().unwrap();
second_line = Some(line);
break;
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("second accept failed: {err}"),
}
}
(first_line, second_line)
});
let run = run_cli(&socket_path, &["pane", "run", "1-1", "echo hello"]);
assert!(
run.status.success(),
"stderr: {}",
String::from_utf8_lossy(&run.stderr)
);
let (first_line, second_line) = server.join().unwrap();
let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
assert_eq!(first_request["method"], "pane.send_input");
assert_eq!(first_request["params"]["pane_id"], "1-1");
assert_eq!(first_request["params"]["text"], "echo hello");
assert_eq!(
first_request["params"]["keys"],
serde_json::json!(["Enter"])
);
assert!(
second_line.is_none(),
"pane run sent an unexpected second request: {:?}",
second_line
);
cleanup_test_base(&base);
}
#[test]
fn pane_report_metadata_sends_presentation_request() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let socket_path = base.join("herdr.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut line = String::new();
let mut reader = BufReader::new(stream.try_clone().unwrap());
reader.read_line(&mut line).unwrap();
stream
.write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
.unwrap();
stream.write_all(b"\n").unwrap();
stream.flush().unwrap();
line
});
let run = run_cli(
&socket_path,
&[
"pane",
"report-metadata",
"1-1",
"--source",
"user:claude-title",
"--agent",
"claude",
"--title",
"Refactor auth",
"--display-agent",
"Claude auth",
"--custom-status",
"middleware",
"--state-label",
"working=deep in the mines",
"--ttl-ms",
"3600000",
],
);
assert!(
run.status.success(),
"stderr: {}",
String::from_utf8_lossy(&run.stderr)
);
let line = server.join().unwrap();
let request: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(request["method"], "pane.report_metadata");
assert_eq!(request["params"]["pane_id"], "1-1");
assert_eq!(request["params"]["source"], "user:claude-title");
assert_eq!(request["params"]["agent"], "claude");
assert!(request["params"]["applies_to_source"].is_null());
assert_eq!(request["params"]["title"], "Refactor auth");
assert_eq!(request["params"]["display_agent"], "Claude auth");
assert_eq!(request["params"]["custom_status"], "middleware");
assert_eq!(
request["params"]["state_labels"]["working"],
"deep in the mines"
);
assert_eq!(request["params"]["ttl_ms"], 3_600_000);
cleanup_test_base(&base);
}
#[test]
fn pane_report_metadata_rejects_blank_source_before_socket_request() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let socket_path = base.join("missing.sock");
let run = run_cli(
&socket_path,
&[
"pane",
"report-metadata",
"1-1",
"--source",
" ",
"--custom-status",
"middleware",
],
);
assert_eq!(run.status.code(), Some(2));
assert!(
String::from_utf8_lossy(&run.stderr).contains("missing required --source"),
"stderr: {}",
String::from_utf8_lossy(&run.stderr)
);
cleanup_test_base(&base);
}
#[test]
fn pane_report_metadata_rejects_blank_applies_to_source_before_socket_request() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let socket_path = base.join("missing.sock");
let run = run_cli(
&socket_path,
&[
"pane",
"report-metadata",
"1-1",
"--source",
"user:claude-title",
"--applies-to-source",
" ",
"--custom-status",
"middleware",
],
);
assert_eq!(run.status.code(), Some(2));
assert!(
String::from_utf8_lossy(&run.stderr).contains("missing value for --applies-to-source"),
"stderr: {}",
String::from_utf8_lossy(&run.stderr)
);
cleanup_test_base(&base);
}
#[test]
fn help_commands_exit_successfully() {
let help_cases: &[&[&str]] = &[
&["-h"],
&["--help"],
&["status", "-h"],
&["server", "-h"],
&["workspace", "-h"],
&["worktree", "-h"],
&["tab", "-h"],
&["pane", "-h"],
&["wait", "-h"],
&["session", "-h"],
&["session", "attach", "-h"],
&["integration", "-h"],
];
for args in help_cases {
let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(*args)
.output()
.unwrap();
assert!(
output.status.success(),
"herdr {} failed: status={:?} stdout={} stderr={}",
args.join(" "),
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}
#[test]
fn root_help_hides_explicit_client_command() {
let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
.arg("--help")
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("herdr client"),
"root help should not advertise the internal client command: {stdout}"
);
}
#[test]
fn explicit_client_command_respects_nested_guard() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
.arg("client")
.env("HERDR_ENV", "1")
.env("XDG_CONFIG_HOME", &base)
.env_remove("HERDR_CONFIG_PATH")
.output()
.unwrap();
cleanup_test_base(&base);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("nested herdr is disabled by default"),
"client should fail at the nested guard before connecting: {stderr}"
);
}
#[test]
fn removed_show_changelog_flag_fails_before_nested_guard() {
let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
.arg("--show-changelog")
.env("HERDR_ENV", "1")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unknown option: --show-changelog"),
"stderr: {stderr}"
);
assert!(
!stderr.contains("nested herdr"),
"unknown flag should be rejected before nested guard: {stderr}"
);
}
#[test]
fn named_sessions_use_separate_servers_and_workspace_state() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha");
let beta = spawn_named_server(&config_home, &runtime_dir, "beta");
wait_for_socket(
&named_session_socket(&config_home, "alpha"),
Duration::from_secs(5),
);
wait_for_socket(
&named_session_socket(&config_home, "beta"),
Duration::from_secs(5),
);
run_named_cli_json(
&config_home,
&runtime_dir,
&[
"--session",
"alpha",
"workspace",
"create",
"--label",
"alpha-ws",
"--no-focus",
],
);
run_named_cli_json(
&config_home,
&runtime_dir,
&[
"--session",
"beta",
"workspace",
"create",
"--label",
"beta-ws",
"--no-focus",
],
);
let alpha_list = run_named_cli_json(
&config_home,
&runtime_dir,
&["--session", "alpha", "workspace", "list"],
);
let beta_list = run_named_cli_json(
&config_home,
&runtime_dir,
&["--session", "beta", "workspace", "list"],
);
let alpha_labels: Vec<_> = alpha_list["result"]["workspaces"]
.as_array()
.unwrap()
.iter()
.map(|workspace| workspace["label"].as_str().unwrap())
.collect();
let beta_labels: Vec<_> = beta_list["result"]["workspaces"]
.as_array()
.unwrap()
.iter()
.map(|workspace| workspace["label"].as_str().unwrap())
.collect();
assert_eq!(alpha_labels, vec!["alpha-ws"]);
assert_eq!(beta_labels, vec!["beta-ws"]);
let beta_via_explicit_session = run_named_cli_with_socket_override(
&config_home,
&runtime_dir,
&["--session", "beta", "workspace", "list"],
Some(&named_session_socket(&config_home, "alpha")),
);
assert!(
beta_via_explicit_session.status.success(),
"stderr: {}",
String::from_utf8_lossy(&beta_via_explicit_session.stderr)
);
let beta_via_explicit_session: serde_json::Value =
serde_json::from_slice(&beta_via_explicit_session.stdout).unwrap();
let labels_via_explicit: Vec<_> = beta_via_explicit_session["result"]["workspaces"]
.as_array()
.unwrap()
.iter()
.map(|workspace| workspace["label"].as_str().unwrap())
.collect();
assert_eq!(labels_via_explicit, vec!["beta-ws"]);
let human_sessions = run_named_cli(&config_home, &runtime_dir, &["session", "list"]);
assert!(human_sessions.status.success());
let human_sessions = String::from_utf8_lossy(&human_sessions.stdout);
assert!(human_sessions.contains("name"), "stdout: {human_sessions}");
assert!(
human_sessions.contains("status"),
"stdout: {human_sessions}"
);
assert!(human_sessions.contains("alpha"), "stdout: {human_sessions}");
assert!(
human_sessions.contains("running"),
"stdout: {human_sessions}"
);
assert!(
human_sessions.contains("/sessions/beta"),
"stdout: {human_sessions}"
);
let sessions = run_named_cli_json(&config_home, &runtime_dir, &["session", "list", "--json"]);
let sessions = sessions["sessions"].as_array().unwrap();
let default_session = sessions
.iter()
.find(|session| session["name"] == "default")
.unwrap();
let alpha_session = sessions
.iter()
.find(|session| session["name"] == "alpha")
.unwrap();
let beta_session = sessions
.iter()
.find(|session| session["name"] == "beta")
.unwrap();
assert_eq!(default_session["default"], true);
assert_eq!(default_session["running"], false);
assert_eq!(alpha_session["running"], true);
assert_eq!(beta_session["running"], true);
assert!(alpha_session["socket_path"]
.as_str()
.unwrap()
.ends_with("/sessions/alpha/herdr.sock"));
assert!(beta_session["session_dir"]
.as_str()
.unwrap()
.ends_with("/sessions/beta"));
let delete_running = run_named_cli(&config_home, &runtime_dir, &["session", "delete", "alpha"]);
assert_eq!(delete_running.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&delete_running.stderr).contains("stop it before deleting"),
"stderr: {}",
String::from_utf8_lossy(&delete_running.stderr)
);
let delete_default = run_named_cli(
&config_home,
&runtime_dir,
&["session", "delete", "default"],
);
assert_eq!(delete_default.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&delete_default.stderr).contains("default session"),
"stderr: {}",
String::from_utf8_lossy(&delete_default.stderr)
);
let stopped_alpha = run_named_cli_json(
&config_home,
&runtime_dir,
&["session", "stop", "alpha", "--json"],
);
assert_eq!(stopped_alpha["stopped"], true);
assert_eq!(stopped_alpha["session"]["running"], false);
let deleted_alpha = run_named_cli_json(
&config_home,
&runtime_dir,
&["session", "delete", "alpha", "--json"],
);
assert_eq!(deleted_alpha["deleted"], true);
assert!(!config_home
.join(app_dir_name())
.join("sessions")
.join("alpha")
.exists());
let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "beta"]);
drop(alpha);
drop(beta);
cleanup_test_base(&base);
}
#[test]
fn integration_commands_run_locally_when_server_is_missing() {
let base = unique_test_dir();
let home_dir = base.join("home");
let extensions_dir = home_dir.join(".pi/agent/extensions");
fs::create_dir_all(&extensions_dir).unwrap();
let runtime_dir = base.join("runtime");
fs::create_dir_all(&runtime_dir).unwrap();
register_runtime_dir(&runtime_dir);
let missing_socket = runtime_dir.join("missing.sock");
let expected_extension = extensions_dir.join("herdr-agent-state.ts");
assert!(
!expected_extension.exists(),
"test setup should start without extension file"
);
let workspace_list = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["workspace", "list"])
.env("HERDR_SOCKET_PATH", &missing_socket)
.env("HOME", &home_dir)
.output()
.unwrap();
assert_eq!(workspace_list.status.code(), Some(1));
let integration_install = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["integration", "install", "pi"])
.env("HERDR_SOCKET_PATH", &missing_socket)
.env("HOME", &home_dir)
.output()
.unwrap();
assert_eq!(integration_install.status.code(), Some(0));
assert!(
expected_extension.exists(),
"integration install should write local files without a server"
);
let integration_status = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["integration", "status"])
.env("HERDR_SOCKET_PATH", &missing_socket)
.env("HOME", &home_dir)
.output()
.unwrap();
assert_eq!(integration_status.status.code(), Some(0));
let status_stdout = String::from_utf8_lossy(&integration_status.stdout);
assert!(status_stdout.contains("pi: current (v4)"));
assert!(status_stdout.contains("claude: not installed"));
let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["integration", "uninstall", "pi"])
.env("HERDR_SOCKET_PATH", &missing_socket)
.env("HOME", &home_dir)
.output()
.unwrap();
assert_eq!(integration_uninstall.status.code(), Some(0));
assert!(
!expected_extension.exists(),
"integration uninstall should remove local files without a server"
);
cleanup_test_base(&base);
}
#[test]
fn integration_status_outdated_only_prints_action_for_legacy_install() {
let base = unique_test_dir();
let home_dir = base.join("home");
let extensions_dir = home_dir.join(".pi/agent/extensions");
fs::create_dir_all(&extensions_dir).unwrap();
fs::write(
extensions_dir.join("herdr-agent-state.ts"),
"// legacy herdr integration\n",
)
.unwrap();
let runtime_dir = base.join("runtime");
fs::create_dir_all(&runtime_dir).unwrap();
register_runtime_dir(&runtime_dir);
let missing_socket = runtime_dir.join("missing.sock");
let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["integration", "status", "--outdated-only"])
.env("HERDR_SOCKET_PATH", &missing_socket)
.env("HOME", &home_dir)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(0));
assert!(output.stdout.is_empty());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("installed herdr integrations need updating"));
assert!(stderr.contains("herdr integration install pi"));
cleanup_test_base(&base);
}
#[test]
fn integration_status_rejects_unknown_flags() {
let base = unique_test_dir();
let home_dir = base.join("home");
fs::create_dir_all(&home_dir).unwrap();
let runtime_dir = base.join("runtime");
fs::create_dir_all(&runtime_dir).unwrap();
register_runtime_dir(&runtime_dir);
let missing_socket = runtime_dir.join("missing.sock");
let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["integration", "status", "--wat"])
.env("HERDR_SOCKET_PATH", &missing_socket)
.env("HOME", &home_dir)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
cleanup_test_base(&base);
}
#[test]
fn status_commands_report_client_and_server_versions() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let full = run_cli(&socket_path, &["status"]);
assert!(
full.status.success(),
"stderr: {}",
String::from_utf8_lossy(&full.stderr)
);
let full_stdout = String::from_utf8_lossy(&full.stdout);
assert!(full_stdout.contains("client:\n"), "stdout: {full_stdout}");
assert!(
full_stdout.contains(&format!(" version: {}", env!("CARGO_PKG_VERSION"))),
"stdout: {full_stdout}"
);
assert!(
full_stdout.contains(" protocol: 14"),
"stdout: {full_stdout}"
);
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
assert!(
full_stdout.contains(" status: running"),
"stdout: {full_stdout}"
);
assert!(
full_stdout.contains(" compatible: yes"),
"stdout: {full_stdout}"
);
assert!(
full_stdout.contains(" restart_needed: no"),
"stdout: {full_stdout}"
);
assert!(
full_stdout.contains(&socket_path.display().to_string()),
"stdout: {full_stdout}"
);
let server = run_cli(&socket_path, &["status", "server"]);
assert!(server.status.success());
let server_stdout = String::from_utf8_lossy(&server.stdout);
assert!(
server_stdout.contains("status: running"),
"stdout: {server_stdout}"
);
assert!(
server_stdout.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))),
"stdout: {server_stdout}"
);
assert!(
server_stdout.contains("protocol: 14"),
"stdout: {server_stdout}"
);
let client = run_cli(&socket_path, &["status", "client"]);
assert!(client.status.success());
let client_stdout = String::from_utf8_lossy(&client.stdout);
assert!(
client_stdout.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))),
"stdout: {client_stdout}"
);
assert!(
client_stdout.contains("protocol: 14"),
"stdout: {client_stdout}"
);
assert!(
client_stdout.contains("binary: "),
"stdout: {client_stdout}"
);
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(full_json["client"]["protocol"], 14);
assert_eq!(full_json["server"]["status"], "running");
assert_eq!(full_json["server"]["running"], true);
assert_eq!(full_json["server"]["compatible"], true);
assert_eq!(
full_json["server"]["socket"],
socket_path.display().to_string()
);
assert_eq!(full_json["server"]["restart_needed"], false);
assert_eq!(full_json["update"]["restart_needed"], false);
let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]);
assert_eq!(server_json["status"], "running");
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(server_json["protocol"], 14);
assert_eq!(server_json["compatible"], true);
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(client_json["protocol"], 14);
assert!(client_json["binary"]
.as_str()
.is_some_and(|path| !path.is_empty()));
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn status_reports_not_running_when_server_socket_is_missing() {
let base = unique_test_dir();
let runtime_dir = base.join("runtime");
fs::create_dir_all(&runtime_dir).unwrap();
register_runtime_dir(&runtime_dir);
let socket_path = runtime_dir.join("missing.sock");
let status = run_cli(&socket_path, &["status"]);
assert!(status.status.success());
let stdout = String::from_utf8_lossy(&status.stdout);
assert!(stdout.contains(" status: not running"), "stdout: {stdout}");
assert!(stdout.contains(" restart_needed: no"), "stdout: {stdout}");
assert!(
stdout.contains(&socket_path.display().to_string()),
"stdout: {stdout}"
);
let status_json = run_cli_json(&socket_path, &["status", "--json"]);
assert_eq!(status_json["server"]["status"], "not_running");
assert_eq!(status_json["server"]["running"], false);
assert_eq!(
status_json["server"]["socket"],
socket_path.display().to_string()
);
assert_eq!(status_json["server"]["restart_needed"], false);
assert_eq!(status_json["update"]["restart_needed"], false);
cleanup_test_base(&base);
}
#[test]
fn server_stop_command_shuts_down_running_server() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let client_socket = runtime_dir.join("herdr-client.sock");
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
wait_for_socket(&client_socket, Duration::from_secs(5));
let stopped = run_cli(&socket_path, &["server", "stop"]);
assert!(
stopped.status.success(),
"stderr: {}",
String::from_utf8_lossy(&stopped.stderr)
);
assert!(
stopped.stdout.is_empty(),
"server stop should not print stdout: {}",
String::from_utf8_lossy(&stopped.stdout)
);
let pid = herdr.child.process_id();
let exit_status = herdr.child.wait().unwrap();
unregister_spawned_herdr_pid(pid);
assert!(exit_status.success(), "server stop should exit cleanly");
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline && (socket_path.exists() || client_socket.exists()) {
thread::sleep(Duration::from_millis(25));
}
assert!(
!socket_path.exists() || UnixStream::connect(&socket_path).is_err(),
"api socket should be removed or stale after server stop"
);
assert!(
!client_socket.exists() || UnixStream::connect(&client_socket).is_err(),
"client socket should be removed or stale after server stop"
);
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn server_stop_then_restart_restores_pane_history() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let client_socket = runtime_dir.join("herdr-client.sock");
let marker = "PERSISTED_HISTORY_AFTER_STOP";
let mut herdr = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
wait_for_socket(&client_socket, Duration::from_secs(5));
let created = run_cli_json(
&socket_path,
&[
"workspace",
"create",
"--cwd",
base.to_str().expect("test path should be utf-8"),
"--label",
"history-restart",
],
);
let pane_id = created["result"]["root_pane"]["pane_id"]
.as_str()
.expect("workspace create should return root pane id")
.to_string();
let sent = run_cli(
&socket_path,
&["pane", "send-text", &pane_id, &format!("echo {marker}\n")],
);
assert!(
sent.status.success(),
"stderr: {}",
String::from_utf8_lossy(&sent.stderr)
);
assert!(
wait_until(Duration::from_secs(3), Duration::from_millis(25), || {
pane_read_recent_contains(&socket_path, &pane_id, marker)
}),
"pane should contain marker before server stop"
);
let stopped = run_cli(&socket_path, &["server", "stop"]);
assert!(
stopped.status.success(),
"stderr: {}",
String::from_utf8_lossy(&stopped.stderr)
);
let pid = herdr.child.process_id();
let exit_status = herdr.child.wait().unwrap();
unregister_spawned_herdr_pid(pid);
assert!(exit_status.success(), "server stop should exit cleanly");
drop(herdr);
let restarted = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
wait_for_socket(&client_socket, Duration::from_secs(5));
let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
let workspace_id = workspaces["result"]["workspaces"]
.as_array()
.expect("workspace.list should return workspaces")
.iter()
.find(|workspace| workspace["label"] == "history-restart")
.and_then(|workspace| workspace["workspace_id"].as_str())
.expect("restored workspace should exist")
.to_string();
let panes = run_cli_json(
&socket_path,
&["pane", "list", "--workspace", &workspace_id],
);
let restored_pane_id = panes["result"]["panes"]
.as_array()
.expect("pane.list should return panes")
.first()
.and_then(|pane| pane["pane_id"].as_str())
.expect("restored pane should exist")
.to_string();
assert!(
wait_until(Duration::from_secs(3), Duration::from_millis(25), || {
pane_read_recent_contains(&socket_path, &restored_pane_id, marker)
}),
"restarted server should restore saved pane history"
);
cleanup_spawned_herdr(restarted, base);
}
#[test]
fn server_start_restores_legacy_session_through_api_identity() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let client_socket = runtime_dir.join("herdr-client.sock");
let data_dir = config_home.join(app_dir_name());
let pion_cwd = base.join("legacy-pion");
let herdr_cwd = base.join("legacy-herdr");
fs::create_dir_all(&pion_cwd).unwrap();
fs::create_dir_all(&herdr_cwd).unwrap();
fs::create_dir_all(&data_dir).unwrap();
let pion_cwd = pion_cwd.to_str().expect("test cwd should be UTF-8");
let herdr_cwd = herdr_cwd.to_str().expect("test cwd should be UTF-8");
let legacy_session = include_str!("fixtures/session/legacy-pre-tabs-v2.json")
.replace("/tmp/pion", pion_cwd)
.replace("/tmp/herdr", herdr_cwd);
fs::write(data_dir.join("session.json"), legacy_session).unwrap();
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
wait_for_socket(&client_socket, Duration::from_secs(5));
let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
let restored_workspace = workspaces["result"]["workspaces"]
.as_array()
.expect("workspace.list should return workspaces")
.iter()
.find(|workspace| workspace["label"] == "legacy")
.expect("legacy workspace should restore");
let workspace_id = restored_workspace["workspace_id"]
.as_str()
.expect("restored workspace should have public id")
.to_string();
assert_eq!(restored_workspace["pane_count"], 2);
assert_eq!(restored_workspace["tab_count"], 1);
assert_eq!(
restored_workspace["active_tab_id"],
format!("{workspace_id}:t1")
);
let panes = run_cli_json(
&socket_path,
&["pane", "list", "--workspace", &workspace_id],
);
let panes = panes["result"]["panes"]
.as_array()
.expect("pane.list should return panes");
assert_eq!(panes.len(), 2);
let root_pane_id = format!("{workspace_id}:p1");
let focused_pane_id = format!("{workspace_id}:p2");
assert!(panes.iter().any(|pane| {
pane["pane_id"] == root_pane_id
&& pane["tab_id"] == format!("{workspace_id}:t1")
&& pane["cwd"] == pion_cwd
&& pane["focused"] == false
}));
assert!(panes.iter().any(|pane| {
pane["pane_id"] == focused_pane_id
&& pane["tab_id"] == format!("{workspace_id}:t1")
&& pane["cwd"] == herdr_cwd
&& pane["focused"] == true
}));
let reported = run_cli(
&socket_path,
&[
"pane",
"report-agent",
&focused_pane_id,
"--source",
"test",
"--agent",
"pi",
"--state",
"working",
],
);
assert!(
reported.status.success(),
"stderr: {}",
String::from_utf8_lossy(&reported.stderr)
);
let agents = run_cli_json(&socket_path, &["agent", "list"]);
let agents = agents["result"]["agents"]
.as_array()
.expect("agent.list should return agents");
assert_eq!(agents.len(), 1);
assert_eq!(agents[0]["pane_id"], focused_pane_id);
assert_eq!(agents[0]["workspace_id"], workspace_id);
assert_eq!(agents[0]["agent"], "pi");
assert_eq!(agents[0]["agent_status"], "working");
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn workspace_and_pane_management_commands_work() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let reloaded = run_cli(&socket_path, &["server", "reload-config"]);
assert!(
reloaded.status.success(),
"stderr: {}",
String::from_utf8_lossy(&reloaded.stderr)
);
let reload_json: serde_json::Value = serde_json::from_slice(&reloaded.stdout).unwrap();
assert_eq!(reload_json["result"]["type"], "config_reload");
assert_eq!(reload_json["result"]["status"], "applied");
let listed = run_cli(&socket_path, &["workspace", "list"]);
assert!(listed.status.success());
let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();
assert_eq!(listed_json["result"]["type"], "workspace_list");
assert_eq!(
listed_json["result"]["workspaces"]
.as_array()
.unwrap()
.len(),
0
);
let created = run_cli(
&socket_path,
&["workspace", "create", "--cwd", base.to_str().unwrap()],
);
assert!(created.status.success());
let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
let workspace_id = created_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
let panes = run_cli(&socket_path, &["pane", "list", "--workspace", "1"]);
assert!(panes.status.success());
let panes_json: serde_json::Value = serde_json::from_slice(&panes.stdout).unwrap();
assert_eq!(panes_json["result"]["panes"].as_array().unwrap().len(), 1);
let split = run_cli(
&socket_path,
&["pane", "split", "1-1", "--direction", "right"],
);
assert!(
split.status.success(),
"stderr: {}",
String::from_utf8_lossy(&split.stderr)
);
let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap();
let split_pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap();
let fetched = run_cli(&socket_path, &["pane", "get", split_pane_id]);
assert!(fetched.status.success());
let fetched_json: serde_json::Value = serde_json::from_slice(&fetched.stdout).unwrap();
assert_eq!(fetched_json["result"]["pane"]["pane_id"], split_pane_id);
let closed = run_cli(&socket_path, &["pane", "close", split_pane_id]);
assert!(closed.status.success());
let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap();
assert_eq!(closed_json["result"]["type"], "ok");
let renamed = run_cli(
&socket_path,
&["workspace", "rename", &workspace_id, "demo"],
);
assert!(renamed.status.success());
let renamed_json: serde_json::Value = serde_json::from_slice(&renamed.stdout).unwrap();
assert_eq!(renamed_json["result"]["workspace"]["label"], "demo");
let focused = run_cli(&socket_path, &["workspace", "focus", &workspace_id]);
assert!(focused.status.success());
let closed_workspace = run_cli(&socket_path, &["workspace", "close", &workspace_id]);
assert!(closed_workspace.status.success());
let closed_workspace_json: serde_json::Value =
serde_json::from_slice(&closed_workspace.stdout).unwrap();
assert_eq!(closed_workspace_json["result"]["type"], "ok");
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn worktree_management_commands_work() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let repo = base.join("repo");
let checkout = base.join("checkout");
create_committed_repo(&repo);
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let branch = "worktree/cli-wrapper";
let created = run_cli_json(
&socket_path,
&[
"worktree",
"create",
"--cwd",
repo.to_str().unwrap(),
"--branch",
branch,
"--path",
checkout.to_str().unwrap(),
"--json",
],
);
assert_eq!(created["result"]["type"], "worktree_created");
assert_eq!(created["result"]["worktree"]["branch"], branch);
let child_workspace_id = created["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
assert!(checkout.join("README.md").exists());
let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap();
let parent_workspace_id = workspace_list
.iter()
.find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false))
.and_then(|workspace| workspace["workspace_id"].as_str())
.unwrap()
.to_string();
assert!(workspace_list.iter().any(|workspace| {
workspace["workspace_id"].as_str() == Some(child_workspace_id.as_str())
&& workspace["worktree"]["is_linked_worktree"].as_bool() == Some(true)
}));
let listed = run_cli_json(
&socket_path,
&[
"worktree",
"list",
"--workspace",
&parent_workspace_id,
"--json",
],
);
let listed_entry = listed["result"]["worktrees"]
.as_array()
.unwrap()
.iter()
.find(|entry| entry["branch"].as_str() == Some(branch))
.unwrap();
assert_eq!(
listed_entry["open_workspace_id"].as_str(),
Some(child_workspace_id.as_str())
);
let opened = run_cli_json(
&socket_path,
&[
"worktree",
"open",
"--workspace",
&parent_workspace_id,
"--branch",
branch,
"--json",
],
);
assert_eq!(opened["result"]["type"], "worktree_opened");
assert_eq!(opened["result"]["already_open"], true);
assert_eq!(
opened["result"]["workspace"]["workspace_id"].as_str(),
Some(child_workspace_id.as_str())
);
fs::write(checkout.join("README.md"), "dirty\n").unwrap();
let safe_remove = run_cli(
&socket_path,
&[
"worktree",
"remove",
"--workspace",
&child_workspace_id,
"--json",
],
);
assert_eq!(safe_remove.status.code(), Some(1));
let safe_remove_json: serde_json::Value = serde_json::from_slice(&safe_remove.stderr).unwrap();
assert_eq!(
safe_remove_json["error"]["code"],
"dirty_worktree_requires_force"
);
assert!(checkout.exists());
let force_removed = run_cli_json(
&socket_path,
&[
"worktree",
"remove",
"--workspace",
&child_workspace_id,
"--force",
"--json",
],
);
assert_eq!(force_removed["result"]["type"], "worktree_removed");
assert_eq!(force_removed["result"]["forced"], true);
assert!(!checkout.exists());
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn forced_worktree_remove_terminates_processes_inside_checkout() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let repo = base.join("repo");
let checkout = base.join("checkout-with-process");
create_committed_repo(&repo);
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli_json(
&socket_path,
&[
"worktree",
"create",
"--cwd",
repo.to_str().unwrap(),
"--branch",
"worktree/force-process",
"--path",
checkout.to_str().unwrap(),
"--json",
],
);
let child_workspace_id = created["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
let pane_id = created["result"]["root_pane"]["pane_id"]
.as_str()
.unwrap()
.to_string();
let pid_file = base.join("worktree-remove-force.pid");
let command = format!(
"python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
pid_file.display()
);
let ran = run_cli(&socket_path, &["pane", "run", &pane_id, &command]);
assert!(
ran.status.success(),
"stderr: {}",
String::from_utf8_lossy(&ran.stderr)
);
let pid = wait_for_pid_file(&pid_file, Duration::from_secs(5)).unwrap_or_else(|err| {
panic!("failed to read pane child pid: {err}");
});
assert!(process_exists(pid), "child process was not running");
let removed = run_cli_json(
&socket_path,
&[
"worktree",
"remove",
"--workspace",
&child_workspace_id,
"--force",
"--json",
],
);
assert_eq!(removed["result"]["type"], "worktree_removed");
assert!(wait_for_pid_exit(pid, Duration::from_secs(3)));
assert!(!checkout.exists());
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn worktree_open_existing_checkout_by_path_and_branch() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let repo = base.join("repo");
let checkout = base.join("external-checkout");
create_committed_repo(&repo);
let branch = "worktree/cli-open-existing";
run_git(
&repo,
&[
"worktree",
"add",
"--quiet",
"-b",
branch,
checkout.to_str().unwrap(),
"HEAD",
],
);
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let opened = run_cli_json_in_dir(
&socket_path,
&[
"worktree",
"open",
"--cwd",
"repo",
"--path",
"external-checkout",
"--json",
],
&base,
);
assert_eq!(opened["result"]["type"], "worktree_opened");
assert_eq!(opened["result"]["already_open"], false);
assert_eq!(opened["result"]["worktree"]["branch"], branch);
assert_eq!(
opened["result"]["workspace"]["worktree"]["is_linked_worktree"],
true
);
let child_workspace_id = opened["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap();
let parent_workspace_id = workspace_list
.iter()
.find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false))
.and_then(|workspace| workspace["workspace_id"].as_str())
.unwrap()
.to_string();
let listed = run_cli_json(
&socket_path,
&[
"worktree",
"list",
"--workspace",
&parent_workspace_id,
"--json",
],
);
let listed_entry = listed["result"]["worktrees"]
.as_array()
.unwrap()
.iter()
.find(|entry| entry["branch"].as_str() == Some(branch))
.unwrap();
assert_eq!(
listed_entry["open_workspace_id"].as_str(),
Some(child_workspace_id.as_str())
);
let reopened = run_cli_json(
&socket_path,
&[
"worktree",
"open",
"--workspace",
&parent_workspace_id,
"--branch",
branch,
"--json",
],
);
assert_eq!(reopened["result"]["type"], "worktree_opened");
assert_eq!(reopened["result"]["already_open"], true);
assert_eq!(
reopened["result"]["workspace"]["workspace_id"].as_str(),
Some(child_workspace_id.as_str())
);
let removed = run_cli_json(
&socket_path,
&[
"worktree",
"remove",
"--workspace",
&child_workspace_id,
"--force",
"--json",
],
);
assert_eq!(removed["result"]["type"], "worktree_removed");
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn worktree_cli_rejects_local_argument_errors_before_socket_use() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let socket_path = base.join("missing.sock");
let cases: &[&[&str]] = &[
&["worktree", "list", "--workspace", "1", "--cwd", "/tmp"],
&["worktree", "create", "--workspace", "1", "--cwd", "/tmp"],
&["worktree", "open", "--workspace", "1"],
&[
"worktree",
"open",
"--workspace",
"1",
"--path",
"a",
"--branch",
"b",
],
&[
"worktree",
"open",
"--workspace",
"1",
"--cwd",
"/tmp",
"--branch",
"b",
],
];
for args in cases {
let output = run_cli(&socket_path, args);
assert_eq!(
output.status.code(),
Some(2),
"herdr {} should fail as local parse error; stdout={} stderr={}",
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
cleanup_test_base(&base);
}
#[test]
fn tab_management_commands_work() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli(
&socket_path,
&["workspace", "create", "--cwd", base.to_str().unwrap()],
);
assert!(created.status.success());
let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
let workspace_id = created_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
let first_tab_id = created_json["result"]["workspace"]["active_tab_id"]
.as_str()
.unwrap()
.to_string();
let created_tab = run_cli(
&socket_path,
&["tab", "create", "--workspace", &workspace_id],
);
assert!(created_tab.status.success());
let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap();
let second_tab_id = created_tab_json["result"]["tab"]["tab_id"]
.as_str()
.unwrap()
.to_string();
assert_eq!(second_tab_id, format!("{workspace_id}:t2"));
let listed_tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]);
assert!(listed_tabs.status.success());
let listed_tabs_json: serde_json::Value = serde_json::from_slice(&listed_tabs.stdout).unwrap();
assert_eq!(
listed_tabs_json["result"]["tabs"].as_array().unwrap().len(),
2
);
let renamed_tab = run_cli(&socket_path, &["tab", "rename", &second_tab_id, "logs"]);
assert!(renamed_tab.status.success());
let renamed_tab_json: serde_json::Value = serde_json::from_slice(&renamed_tab.stdout).unwrap();
assert_eq!(renamed_tab_json["result"]["tab"]["label"], "logs");
let focused_tab = run_cli(&socket_path, &["tab", "focus", &first_tab_id]);
assert!(focused_tab.status.success());
let focused_tab_json: serde_json::Value = serde_json::from_slice(&focused_tab.stdout).unwrap();
assert_eq!(focused_tab_json["result"]["tab"]["tab_id"], first_tab_id);
let tab_get = run_cli(&socket_path, &["tab", "get", &second_tab_id]);
assert!(tab_get.status.success());
let tab_get_json: serde_json::Value = serde_json::from_slice(&tab_get.stdout).unwrap();
assert_eq!(tab_get_json["result"]["tab"]["tab_id"], second_tab_id);
let closed_tab = run_cli(&socket_path, &["tab", "close", &second_tab_id]);
assert!(closed_tab.status.success());
let closed_tab_json: serde_json::Value = serde_json::from_slice(&closed_tab.stdout).unwrap();
assert_eq!(closed_tab_json["result"]["type"], "ok");
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn agent_start_command_works() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let started = run_cli_json(
&socket_path,
&[
"agent",
"start",
"main",
"--cwd",
base.to_str().unwrap(),
"--",
"/bin/sh",
"-c",
"printf cli-agent-start-ok; sleep 2",
"--session",
"child-session",
],
);
assert_eq!(started["result"]["type"], "agent_started");
assert_eq!(started["result"]["agent"]["name"], "main");
assert_eq!(started["result"]["argv"][0], "/bin/sh");
assert_eq!(started["result"]["argv"][3], "--session");
assert_eq!(started["result"]["argv"][4], "child-session");
let terminal_id = started["result"]["agent"]["terminal_id"]
.as_str()
.unwrap()
.to_string();
let listed = run_cli_json(&socket_path, &["agent", "list"]);
assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id);
assert_eq!(listed["result"]["agents"][0]["name"], "main");
let duplicate = run_cli(
&socket_path,
&[
"agent",
"start",
"main",
"--cwd",
base.to_str().unwrap(),
"--",
"/bin/sh",
"-c",
"true",
],
);
assert!(!duplicate.status.success());
let duplicate_json: serde_json::Value = serde_json::from_slice(&duplicate.stderr).unwrap();
assert_eq!(duplicate_json["error"]["code"], "agent_name_taken");
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn agent_commands_work() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli(
&socket_path,
&["workspace", "create", "--cwd", base.to_str().unwrap()],
);
assert!(created.status.success());
let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
let root_pane_id = created_json["result"]["root_pane"]["pane_id"]
.as_str()
.unwrap()
.to_string();
let terminal_id = created_json["result"]["root_pane"]["terminal_id"]
.as_str()
.unwrap()
.to_string();
let renamed = run_cli(&socket_path, &["agent", "rename", &root_pane_id, "worker"]);
assert!(renamed.status.success());
let listed = run_cli_json(&socket_path, &["agent", "list"]);
assert_eq!(listed["result"]["type"], "agent_list");
assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id);
assert_eq!(listed["result"]["agents"][0]["name"], "worker");
let fetched = run_cli_json(&socket_path, &["agent", "get", "worker"]);
assert_eq!(fetched["result"]["agent"]["pane_id"], root_pane_id);
let waited = run_cli_json(
&socket_path,
&[
"agent",
"wait",
"worker",
"--status",
"unknown",
"--timeout",
"100",
],
);
assert_eq!(waited["result"]["agent"]["pane_id"], root_pane_id);
let read = run_cli_json(
&socket_path,
&["agent", "read", &terminal_id, "--source", "visible"],
);
assert_eq!(read["result"]["type"], "pane_read");
let sent = run_cli(
&socket_path,
&["agent", "send", "worker", "echo cli-agent-ok\n"],
);
assert!(sent.status.success());
let agent_renamed = run_cli_json(&socket_path, &["agent", "rename", "worker", "reviewer"]);
assert_eq!(agent_renamed["result"]["agent"]["name"], "reviewer");
let focused = run_cli_json(&socket_path, &["agent", "focus", "reviewer"]);
assert_eq!(focused["result"]["agent"]["focused"], true);
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn pane_close_only_removes_the_target_tab_when_other_tabs_exist() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli(
&socket_path,
&["workspace", "create", "--cwd", base.to_str().unwrap()],
);
assert!(created.status.success());
let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
let workspace_id = created_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
let created_tab = run_cli(
&socket_path,
&["tab", "create", "--workspace", &workspace_id],
);
assert!(created_tab.status.success());
let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap();
let second_root_pane_id = created_tab_json["result"]["root_pane"]["pane_id"]
.as_str()
.unwrap()
.to_string();
let closed = run_cli(&socket_path, &["pane", "close", &second_root_pane_id]);
assert!(closed.status.success());
let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap();
assert_eq!(closed_json["result"]["type"], "ok");
let workspaces = run_cli(&socket_path, &["workspace", "list"]);
assert!(workspaces.status.success());
let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap();
assert_eq!(
workspaces_json["result"]["workspaces"]
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(
workspaces_json["result"]["workspaces"][0]["workspace_id"],
workspace_id
);
let tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]);
assert!(tabs.status.success());
let tabs_json: serde_json::Value = serde_json::from_slice(&tabs.stdout).unwrap();
assert_eq!(tabs_json["result"]["tabs"].as_array().unwrap().len(), 1);
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn pane_close_removes_the_workspace_when_it_closes_the_last_pane() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli(
&socket_path,
&["workspace", "create", "--cwd", base.to_str().unwrap()],
);
assert!(created.status.success());
let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
let root_pane_id = created_json["result"]["root_pane"]["pane_id"]
.as_str()
.unwrap()
.to_string();
let closed = run_cli(&socket_path, &["pane", "close", &root_pane_id]);
assert!(closed.status.success());
let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap();
assert_eq!(closed_json["result"]["type"], "ok");
let workspaces = run_cli(&socket_path, &["workspace", "list"]);
assert!(workspaces.status.success());
let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap();
assert!(workspaces_json["result"]["workspaces"]
.as_array()
.unwrap()
.is_empty());
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn pane_run_read_and_wait_commands_work() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
send_request(
&socket_path,
&format!(
r#"{{"id":"req_cli_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
base.display()
),
);
let create = run_cli(
&socket_path,
&[
"pane",
"run",
"1-1",
"echo alpha && echo beta && printf 'ready\\n'",
],
);
assert!(create.status.success());
let started = Instant::now();
let waited = run_cli(
&socket_path,
&[
"wait",
"output",
"1-1",
"--match",
"ready",
"--source",
"recent",
"--lines",
"40",
"--timeout",
"5000",
],
);
let elapsed = started.elapsed();
assert!(
waited.status.success(),
"stderr: {}",
String::from_utf8_lossy(&waited.stderr)
);
assert!(
elapsed < Duration::from_millis(500),
"already-matching wait took {elapsed:?}"
);
let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
assert_eq!(waited_json["result"]["type"], "output_matched");
let read = run_cli(
&socket_path,
&["pane", "read", "1-1", "--source", "recent", "--lines", "40"],
);
assert!(read.status.success());
let text = String::from_utf8(read.stdout).unwrap();
assert!(text.contains("alpha"));
assert!(text.contains("ready"));
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn wait_output_matches_recent_unwrapped_text() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli(
&socket_path,
&["workspace", "create", "--cwd", base.to_str().unwrap()],
);
assert!(created.status.success());
let token = "WRAP_WAIT_TEST_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789";
let script = base.join("emit-long-token.sh");
std::fs::write(&script, format!("#!/bin/sh\nprintf '%s\\n' '{token}'\n")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
}
let run = run_cli(
&socket_path,
&["pane", "run", "1-1", &format!("sh {}", script.display())],
);
assert!(run.status.success());
let waited = run_cli(
&socket_path,
&[
"wait",
"output",
"1-1",
"--match",
token,
"--source",
"recent",
"--lines",
"80",
"--timeout",
"5000",
],
);
assert!(
waited.status.success(),
"stderr: {} stdout: {}",
String::from_utf8_lossy(&waited.stderr),
String::from_utf8_lossy(&waited.stdout)
);
let read = run_cli(
&socket_path,
&[
"pane",
"read",
"1-1",
"--source",
"recent-unwrapped",
"--lines",
"80",
],
);
assert!(read.status.success());
let text = String::from_utf8(read.stdout).unwrap();
assert!(text.contains(token));
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn closing_pane_terminates_processes_inside_it() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli(
&socket_path,
&["workspace", "create", "--cwd", base.to_str().unwrap()],
);
assert!(created.status.success());
let split = run_cli(
&socket_path,
&["pane", "split", "1-1", "--direction", "right"],
);
assert!(split.status.success());
let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap();
let pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap();
let pid_file = base.join("pane-close.pid");
let command = format!(
"python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
pid_file.display()
);
let ran = run_cli(&socket_path, &["pane", "run", pane_id, &command]);
assert!(
ran.status.success(),
"stderr: {}",
String::from_utf8_lossy(&ran.stderr)
);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline && !pid_file.exists() {
thread::sleep(Duration::from_millis(25));
}
assert!(pid_file.exists(), "pid file was not created");
let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| {
panic!("failed to read pane child pid: {err}");
});
assert!(process_exists(pid), "child process was not running");
let closed = run_cli(&socket_path, &["pane", "close", pane_id]);
assert!(
closed.status.success(),
"stderr: {}",
String::from_utf8_lossy(&closed.stderr)
);
assert!(
wait_for_pid_exit(pid, Duration::from_secs(3)),
"process {pid} survived pane close"
);
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn closing_workspace_terminates_processes_inside_it() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli(
&socket_path,
&["workspace", "create", "--cwd", base.to_str().unwrap()],
);
assert!(created.status.success());
let pid_file = base.join("workspace-close.pid");
let command = format!(
"python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
pid_file.display()
);
let ran = run_cli(&socket_path, &["pane", "run", "1-1", &command]);
assert!(
ran.status.success(),
"stderr: {}",
String::from_utf8_lossy(&ran.stderr)
);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline && !pid_file.exists() {
thread::sleep(Duration::from_millis(25));
}
assert!(pid_file.exists(), "pid file was not created");
let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| {
panic!("failed to read pane child pid: {err}");
});
assert!(process_exists(pid), "child process was not running");
let closed = run_cli(&socket_path, &["workspace", "close", "1"]);
assert!(
closed.status.success(),
"stderr: {}",
String::from_utf8_lossy(&closed.stderr)
);
assert!(
wait_for_pid_exit(pid, Duration::from_secs(3)),
"process {pid} survived workspace close"
);
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn workspace_ids_and_public_pane_ids_are_stable() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let ws1_json = run_cli_json(
&socket_path,
&["workspace", "create", "--cwd", base.to_str().unwrap()],
);
let ws1_id = ws1_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
let split_12_json = run_cli_json(
&socket_path,
&["pane", "split", "1-1", "--direction", "right", "--no-focus"],
);
assert_eq!(
split_12_json["result"]["pane"]["pane_id"],
format!("{ws1_id}:p2")
);
let split_13_json = run_cli_json(
&socket_path,
&["pane", "split", "1-1", "--direction", "down", "--no-focus"],
);
assert_eq!(
split_13_json["result"]["pane"]["pane_id"],
format!("{ws1_id}:p3")
);
let ws2_json = run_cli_json(
&socket_path,
&["workspace", "create", "--cwd", "/tmp", "--no-focus"],
);
let ws2_id = ws2_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
assert_ne!(ws2_id, ws1_id);
let ws2_focus = run_cli(&socket_path, &["workspace", "focus", &ws2_id]);
assert!(
ws2_focus.status.success(),
"stderr: {}",
String::from_utf8_lossy(&ws2_focus.stderr)
);
let ws2_split_json = run_cli_json(
&socket_path,
&["pane", "split", "2-1", "--direction", "right", "--no-focus"],
);
assert_eq!(
ws2_split_json["result"]["pane"]["pane_id"],
format!("{ws2_id}:p2")
);
let ws3_json = run_cli_json(
&socket_path,
&["workspace", "create", "--cwd", "/", "--no-focus"],
);
let ws3_id = ws3_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
assert_ne!(ws3_id, ws1_id);
assert_ne!(ws3_id, ws2_id);
let close_ws2 = run_cli(&socket_path, &["workspace", "close", &ws2_id]);
assert!(
close_ws2.status.success(),
"stderr: {}",
String::from_utf8_lossy(&close_ws2.stderr)
);
let workspaces_json = run_cli_json(&socket_path, &["workspace", "list"]);
let ids: Vec<String> = workspaces_json["result"]["workspaces"]
.as_array()
.unwrap()
.iter()
.map(|ws| ws["workspace_id"].as_str().unwrap().to_string())
.collect();
assert_eq!(ids, vec![ws1_id.clone(), ws3_id.clone()]);
let new_ws_json = run_cli_json(
&socket_path,
&["workspace", "create", "--cwd", "/var/tmp", "--no-focus"],
);
let new_ws_id = new_ws_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
assert_ne!(new_ws_id, ws1_id);
assert_ne!(new_ws_id, ws2_id);
assert_ne!(new_ws_id, ws3_id);
let ws3_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws3_id]);
assert_eq!(
ws3_panes_json["result"]["panes"][0]["pane_id"],
format!("{ws3_id}:p1")
);
let close_middle = run_cli(&socket_path, &["pane", "close", &format!("{ws1_id}-2")]);
assert!(
close_middle.status.success(),
"stderr: {}",
String::from_utf8_lossy(&close_middle.stderr)
);
let ws1_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws1_id]);
let pane_ids: Vec<String> = ws1_panes_json["result"]["panes"]
.as_array()
.unwrap()
.iter()
.map(|pane| pane["pane_id"].as_str().unwrap().to_string())
.collect();
assert_eq!(
pane_ids,
vec![format!("{ws1_id}:p1"), format!("{ws1_id}:p3")]
);
let closed_lookup = run_cli(&socket_path, &["pane", "get", &format!("{ws1_id}:p2")]);
assert!(
!closed_lookup.status.success(),
"closed pane id should not retarget: {}",
String::from_utf8_lossy(&closed_lookup.stdout)
);
let split_14_json = run_cli_json(
&socket_path,
&[
"pane",
"split",
&format!("{ws1_id}:p1"),
"--direction",
"right",
"--no-focus",
],
);
assert_eq!(
split_14_json["result"]["pane"]["pane_id"],
format!("{ws1_id}:p4")
);
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn pane_shell_gets_herdr_socket_and_pane_env() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = send_request(
&socket_path,
&format!(
r#"{{"id":"req_env_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
base.display()
),
);
let pane_id = created["result"]["root_pane"]["pane_id"]
.as_str()
.unwrap()
.to_string();
let env_capture = base.join("pane-env.txt");
let ran = run_cli(
&socket_path,
&[
"pane",
"run",
"1-1",
&format!(
"printf '%s\\n%s\\n' \"$HERDR_SOCKET_PATH\" \"$HERDR_PANE_ID\" > {}",
env_capture.display()
),
],
);
assert!(ran.status.success());
let deadline = Instant::now() + Duration::from_secs(3);
let mut text = String::new();
while Instant::now() < deadline {
if env_capture.exists() {
text = fs::read_to_string(&env_capture).unwrap();
if text.contains(&socket_path.display().to_string()) && text.contains(&pane_id) {
break;
}
}
thread::sleep(Duration::from_millis(25));
}
assert!(env_capture.exists(), "env capture file was not created");
assert!(
text.contains(&socket_path.display().to_string()),
"env file was: {text:?}"
);
assert!(text.contains(&pane_id), "env file was: {text:?}");
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn wait_agent_status_exits_when_idle_status_matches() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let bin_dir = base.join("bin");
fs::create_dir_all(&bin_dir).unwrap();
let fake_pi = bin_dir.join("pi");
fs::write(
&fake_pi,
"#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&fake_pi).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&fake_pi, perms).unwrap();
}
let inherited_path = std::env::var("PATH").unwrap_or_default();
let path_override = format!("{}:{}", bin_dir.display(), inherited_path);
let herdr = spawn_herdr_with_path(
&config_home,
&runtime_dir,
&socket_path,
Some(Path::new(&path_override)),
);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = send_request(
&socket_path,
&format!(
r#"{{"id":"req_cli_2","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
base.display()
),
);
assert!(created["result"]["workspace"]["workspace_id"].is_string());
let start_pi = run_cli(&socket_path, &["pane", "run", "1-1", "pi"]);
assert!(start_pi.status.success());
let waited = run_cli(
&socket_path,
&[
"wait",
"agent-status",
"1-1",
"--status",
"idle",
"--timeout",
"10000",
],
);
assert!(
waited.status.success(),
"stderr: {}",
String::from_utf8_lossy(&waited.stderr)
);
let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
assert_eq!(waited_json["event"], "pane.agent_status_changed");
assert_eq!(waited_json["data"]["agent_status"], "idle");
assert_eq!(waited_json["data"]["agent"], "pi");
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn plugin_link_list_unlink_cli_smoke_test() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let plugin_dir = base.join("plugins").join("layout");
fs::create_dir_all(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("herdr-plugin.toml"),
r#"
id = "example.layout"
name = "Layout"
version = "0.1.0"
min_herdr_version = "0.6.10"
description = "Apply a preferred Herdr layout"
[[actions]]
id = "apply"
title = "Apply layout"
contexts = ["workspace"]
command = ["sh", "-c", "echo layout"]
[[events]]
on = "worktree.created"
command = ["sh", "-c", "echo worktree"]
[[panes]]
id = "board"
title = "Board"
placement = "tab"
command = ["sh", "-c", "sleep 5"]
"#,
)
.unwrap();
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let workspace = run_cli_json(
&socket_path,
&[
"workspace",
"create",
"--cwd",
base.to_str().unwrap(),
"--focus",
],
);
assert_eq!(workspace["result"]["type"], "workspace_created");
let linked = run_cli_json_in_dir(&socket_path, &["plugin", "link", "plugins/layout"], &base);
assert_eq!(linked["result"]["type"], "plugin_linked");
assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.layout");
assert_eq!(linked["result"]["plugin"]["actions"][0]["id"], "apply");
assert_eq!(
linked["result"]["plugin"]["events"][0]["on"],
"worktree.created"
);
assert_eq!(linked["result"]["plugin"]["panes"][0]["id"], "board");
let listed_human = run_cli(&socket_path, &["plugin", "list"]);
assert!(listed_human.status.success());
assert!(String::from_utf8_lossy(&listed_human.stdout).contains("example.layout"));
let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]);
assert_eq!(listed["result"]["type"], "plugin_list");
assert_eq!(
listed["result"]["plugins"][0]["plugin_id"],
"example.layout"
);
let invoked = run_cli_json(
&socket_path,
&[
"plugin",
"action",
"invoke",
"apply",
"--plugin",
"example.layout",
],
);
assert_eq!(invoked["result"]["type"], "plugin_action_invoked");
assert_eq!(invoked["result"]["action"]["action_id"], "apply");
let logs = run_cli_json(
&socket_path,
&[
"plugin",
"log",
"list",
"--plugin",
"example.layout",
"--limit",
"5",
],
);
assert_eq!(logs["result"]["type"], "plugin_log_list");
assert!(!logs["result"]["logs"].as_array().unwrap().is_empty());
let pane = run_cli_json(
&socket_path,
&[
"plugin",
"pane",
"open",
"--plugin",
"example.layout",
"--entrypoint",
"board",
"--env",
"HERDR_ROLE=board",
"--no-focus",
],
);
assert_eq!(pane["result"]["type"], "plugin_pane_opened");
assert_eq!(pane["result"]["plugin_pane"]["entrypoint"], "board");
let missing_plugin_value = run_cli(&socket_path, &["plugin", "list", "--plugin"]);
assert_eq!(missing_plugin_value.status.code(), Some(2));
assert!(String::from_utf8_lossy(&missing_plugin_value.stderr)
.contains("missing value for --plugin"));
let invalid_limit = run_cli(
&socket_path,
&["plugin", "log", "list", "--limit", "not-a-number"],
);
assert_eq!(invalid_limit.status.code(), Some(2));
assert!(String::from_utf8_lossy(&invalid_limit.stderr).contains("invalid --limit value"));
let unlinked = run_cli_json(&socket_path, &["plugin", "unlink", "example.layout"]);
assert_eq!(unlinked["result"]["type"], "plugin_unlinked");
assert_eq!(unlinked["result"]["removed"], true);
let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]);
assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn plugin_install_list_uninstall_offline_cli_smoke_test() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let source_repo = base.join("source-repo");
let plugin_dir = source_repo.join("worktree-bootstrap");
fs::create_dir_all(&plugin_dir).unwrap();
create_committed_repo(&source_repo);
fs::write(
plugin_dir.join("herdr-plugin.toml"),
r#"
id = "example.worktree-bootstrap"
name = "Worktree Bootstrap"
version = "0.1.0"
min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[build]]
command = ["sh", "-c", "echo built > built.txt; if [ -n \"$HERDR_SESSION\" ]; then echo \"$HERDR_SESSION\" > leaked-session.txt; fi"]
[[actions]]
id = "bootstrap"
title = "Bootstrap"
command = ["sh", "-c", "echo bootstrap"]
"#,
)
.unwrap();
run_git(
&source_repo,
&["add", "worktree-bootstrap/herdr-plugin.toml"],
);
run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
fs::create_dir_all(&config_home).unwrap();
fs::create_dir_all(&runtime_dir).unwrap();
let git_config = base.join("gitconfig");
fs::write(
&git_config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
source_repo.display()
),
)
.unwrap();
let install = run_named_cli_with_env(
&config_home,
&runtime_dir,
&[
"--session",
"plugins",
"plugin",
"install",
"ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
"--yes",
],
&[
("GIT_CONFIG_GLOBAL", &git_config),
("HERDR_SESSION", Path::new("leaked-session")),
],
);
assert!(
install.status.success(),
"install failed\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&install.stdout),
String::from_utf8_lossy(&install.stderr)
);
let listed = run_named_cli_json(
&config_home,
&runtime_dir,
&["--session", "plugins", "plugin", "list", "--json"],
);
let plugin = &listed["result"]["plugins"][0];
assert_eq!(plugin["plugin_id"], "example.worktree-bootstrap");
assert_eq!(plugin["source"]["kind"], "github");
assert_eq!(plugin["source"]["owner"], "ogulcancelik");
assert_eq!(plugin["source"]["repo"], "herdr-plugin-examples");
assert_eq!(plugin["source"]["subdir"], "worktree-bootstrap");
assert!(plugin["source"]["resolved_commit"].as_str().is_some());
let managed_path = PathBuf::from(plugin["source"]["managed_path"].as_str().unwrap());
assert!(managed_path.exists(), "managed checkout should exist");
assert!(
managed_path
.join("worktree-bootstrap")
.join("built.txt")
.exists(),
"build artifact should be preserved in managed checkout"
);
assert!(
!managed_path
.join("worktree-bootstrap")
.join("leaked-session.txt")
.exists(),
"build command should not inherit HERDR_SESSION"
);
let uninstall = run_named_cli(
&config_home,
&runtime_dir,
&[
"--session",
"plugins",
"plugin",
"uninstall",
"example.worktree-bootstrap",
],
);
assert!(
uninstall.status.success(),
"uninstall failed\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&uninstall.stdout),
String::from_utf8_lossy(&uninstall.stderr)
);
assert!(
!managed_path.exists(),
"managed checkout should be deleted on uninstall"
);
let listed = run_named_cli_json(
&config_home,
&runtime_dir,
&["--session", "plugins", "plugin", "list", "--json"],
);
assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
cleanup_test_base(&base);
}
#[test]
fn plugin_install_build_failure_does_not_register_or_create_checkout() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let source_repo = base.join("source-repo");
let plugin_dir = source_repo.join("build-fail");
fs::create_dir_all(&plugin_dir).unwrap();
create_committed_repo(&source_repo);
fs::write(
plugin_dir.join("herdr-plugin.toml"),
r#"
id = "example.build-fail"
name = "Build Fail"
version = "0.1.0"
min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[build]]
command = ["sh", "-c", "echo before-fail && echo failed-build >&2 && exit 7"]
[[actions]]
id = "run"
title = "Run"
command = ["sh", "-c", "echo should-not-install"]
"#,
)
.unwrap();
run_git(&source_repo, &["add", "build-fail/herdr-plugin.toml"]);
run_git(
&source_repo,
&["commit", "--quiet", "-m", "add failing plugin"],
);
fs::create_dir_all(&config_home).unwrap();
fs::create_dir_all(&runtime_dir).unwrap();
let git_config = base.join("gitconfig");
fs::write(
&git_config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
source_repo.display()
),
)
.unwrap();
let install = run_named_cli_with_env(
&config_home,
&runtime_dir,
&[
"--session",
"plugins",
"plugin",
"install",
"ogulcancelik/herdr-plugin-examples/build-fail",
"--yes",
],
&[("GIT_CONFIG_GLOBAL", &git_config)],
);
assert!(
!install.status.success(),
"install should fail when build command fails"
);
assert_eq!(install.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&install.stderr);
assert!(stderr.contains("error: plugin build failed"), "{stderr}");
assert!(stderr.contains(" plugin: example.build-fail"), "{stderr}");
assert!(stderr.contains(" build: 1/1"), "{stderr}");
assert!(stderr.contains(" cwd: "), "{stderr}");
assert!(
stderr.contains(" command: sh -c echo before-fail && echo failed-build >&2 && exit 7"),
"{stderr}"
);
assert!(stderr.contains(" status: exit status: 7"), "{stderr}");
assert!(stderr.contains("stdout:\nbefore-fail"), "{stderr}");
assert!(stderr.contains("stderr:\nfailed-build"), "{stderr}");
assert!(stderr.contains("Plugin was not installed."), "{stderr}");
assert!(!stderr.contains("Error: Custom"), "{stderr}");
let listed = run_named_cli_json(
&config_home,
&runtime_dir,
&["--session", "plugins", "plugin", "list", "--json"],
);
assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
assert!(
path_missing_or_empty(&managed_github_plugin_dir(&config_home)),
"failed build should not leave managed checkouts"
);
cleanup_test_base(&base);
}
#[test]
fn plugin_install_build_spawn_failure_prints_clean_error() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let source_repo = base.join("source-repo");
let plugin_dir = source_repo.join("missing-tool");
fs::create_dir_all(&plugin_dir).unwrap();
create_committed_repo(&source_repo);
fs::write(
plugin_dir.join("herdr-plugin.toml"),
r#"
id = "example.missing-tool"
name = "Missing Tool"
version = "0.1.0"
min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[build]]
command = ["definitely-missing-herdr-build-tool-xyz"]
[[actions]]
id = "run"
title = "Run"
command = ["sh", "-c", "echo should-not-install"]
"#,
)
.unwrap();
run_git(&source_repo, &["add", "missing-tool/herdr-plugin.toml"]);
run_git(
&source_repo,
&["commit", "--quiet", "-m", "add missing tool plugin"],
);
fs::create_dir_all(&config_home).unwrap();
fs::create_dir_all(&runtime_dir).unwrap();
let git_config = base.join("gitconfig");
fs::write(
&git_config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
source_repo.display()
),
)
.unwrap();
let install = run_named_cli_with_env(
&config_home,
&runtime_dir,
&[
"--session",
"plugins",
"plugin",
"install",
"ogulcancelik/herdr-plugin-examples/missing-tool",
"--yes",
],
&[("GIT_CONFIG_GLOBAL", &git_config)],
);
assert!(
!install.status.success(),
"install should fail when build command cannot start"
);
assert_eq!(install.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&install.stderr);
assert!(stderr.contains("error: plugin build failed"), "{stderr}");
assert!(
stderr.contains(" plugin: example.missing-tool"),
"{stderr}"
);
assert!(stderr.contains(" build: 1/1"), "{stderr}");
assert!(
stderr.contains(" command: definitely-missing-herdr-build-tool-xyz"),
"{stderr}"
);
assert!(stderr.contains(" error: failed to start:"), "{stderr}");
assert!(stderr.contains("Plugin was not installed."), "{stderr}");
assert!(!stderr.contains("Error: Custom"), "{stderr}");
let listed = run_named_cli_json(
&config_home,
&runtime_dir,
&["--session", "plugins", "plugin", "list", "--json"],
);
assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
cleanup_test_base(&base);
}
#[test]
fn plugin_install_rejects_manifest_changed_by_build() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let source_repo = base.join("source-repo");
let plugin_dir = source_repo.join("manifest-mutator");
fs::create_dir_all(&plugin_dir).unwrap();
create_committed_repo(&source_repo);
fs::write(
plugin_dir.join("herdr-plugin.toml"),
r#"
id = "example.manifest-mutator"
name = "Manifest Mutator"
version = "0.1.0"
min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[build]]
command = ["sh", "mutate.sh"]
[[actions]]
id = "run"
title = "Run reviewed command"
command = ["sh", "-c", "echo reviewed"]
"#,
)
.unwrap();
fs::write(
plugin_dir.join("mutate.sh"),
r#"cat > herdr-plugin.toml <<'EOF'
id = "example.manifest-mutator"
name = "Manifest Mutator"
version = "0.1.0"
min_herdr_version = "0.0.1"
platforms = ["linux", "macos", "windows"]
[[build]]
command = ["sh", "mutate.sh"]
[[actions]]
id = "run"
title = "Run reviewed command"
command = ["sh", "-c", "echo reviewed"]
EOF
"#,
)
.unwrap();
run_git(&source_repo, &["add", "manifest-mutator"]);
run_git(
&source_repo,
&["commit", "--quiet", "-m", "add mutating plugin"],
);
fs::create_dir_all(&config_home).unwrap();
fs::create_dir_all(&runtime_dir).unwrap();
let git_config = base.join("gitconfig");
fs::write(
&git_config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
source_repo.display()
),
)
.unwrap();
let install = run_named_cli_with_env(
&config_home,
&runtime_dir,
&[
"--session",
"plugins",
"plugin",
"install",
"ogulcancelik/herdr-plugin-examples/manifest-mutator",
"--yes",
],
&[("GIT_CONFIG_GLOBAL", &git_config)],
);
assert!(
!install.status.success(),
"install should fail when build changes reviewed manifest"
);
let stderr = String::from_utf8_lossy(&install.stderr);
assert!(
stderr.contains("plugin build changed herdr-plugin.toml after install preview"),
"{stderr}"
);
let listed = run_named_cli_json(
&config_home,
&runtime_dir,
&["--session", "plugins", "plugin", "list", "--json"],
);
assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
assert!(
path_missing_or_empty(&managed_github_plugin_dir(&config_home)),
"manifest mutation should not leave managed checkouts"
);
cleanup_test_base(&base);
}
#[test]
fn plugin_install_restores_previous_checkout_when_registration_fails() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("fake-herdr.sock");
let source_repo = base.join("source-repo");
let plugin_dir = source_repo.join("worktree-bootstrap");
fs::create_dir_all(&plugin_dir).unwrap();
create_committed_repo(&source_repo);
fs::write(
plugin_dir.join("herdr-plugin.toml"),
r#"
id = "example.worktree-bootstrap"
name = "Worktree Bootstrap"
version = "0.2.0"
min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
id = "bootstrap"
title = "Bootstrap"
command = ["sh", "-c", "echo new"]
"#,
)
.unwrap();
run_git(
&source_repo,
&["add", "worktree-bootstrap/herdr-plugin.toml"],
);
run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
fs::create_dir_all(&config_home).unwrap();
fs::create_dir_all(&runtime_dir).unwrap();
let managed_checkout = config_home
.join("herdr-dev")
.join("plugins")
.join("github")
.join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT);
fs::create_dir_all(&managed_checkout).unwrap();
fs::write(managed_checkout.join("old-marker"), "old checkout\n").unwrap();
let git_config = base.join("gitconfig");
fs::write(
&git_config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
source_repo.display()
),
)
.unwrap();
let listener = UnixListener::bind(&socket_path).unwrap();
let managed_checkout_for_server = managed_checkout.clone();
let server = thread::spawn(move || {
let (mut first, _) = listener.accept().unwrap();
let mut first_line = String::new();
let mut first_reader = BufReader::new(first.try_clone().unwrap());
first_reader.read_line(&mut first_line).unwrap();
let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
assert_eq!(first_request["method"], "plugin.list");
writeln!(
first,
"{}",
serde_json::json!({
"id": "cli:plugin",
"result": {
"type": "plugin_list",
"plugins": [{
"plugin_id": "example.worktree-bootstrap",
"name": "Worktree Bootstrap",
"version": "0.1.0",
"min_herdr_version": "0.6.10",
"manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
"plugin_root": managed_checkout_for_server.display().to_string(),
"enabled": true,
"source": {
"kind": "github",
"owner": "ogulcancelik",
"repo": "herdr-plugin-examples",
"subdir": "worktree-bootstrap",
"resolved_commit": "old",
"managed_path": managed_checkout_for_server.display().to_string(),
"installed_unix_ms": 1
}
}]
}
})
)
.unwrap();
first.flush().unwrap();
let (mut second, _) = listener.accept().unwrap();
let mut second_line = String::new();
let mut second_reader = BufReader::new(second.try_clone().unwrap());
second_reader.read_line(&mut second_line).unwrap();
let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap();
assert_eq!(second_request["method"], "plugin.link");
second
.write_all(
br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced failure"}}"#,
)
.unwrap();
second.write_all(b"\n").unwrap();
second.flush().unwrap();
});
let install = run_named_cli_with_env_and_socket_override(
&config_home,
&runtime_dir,
&[
"plugin",
"install",
"ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
"--yes",
],
&[("GIT_CONFIG_GLOBAL", &git_config)],
Some(&socket_path),
);
assert!(
!install.status.success(),
"install should fail when plugin.link fails"
);
server.join().unwrap();
assert!(
managed_checkout.join("old-marker").exists(),
"old checkout should be restored after registration failure"
);
cleanup_test_base(&base);
}
#[test]
fn plugin_install_rejects_server_that_drops_source_metadata() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("fake-herdr.sock");
let source_repo = base.join("source-repo");
let plugin_dir = source_repo.join("worktree-bootstrap");
fs::create_dir_all(&plugin_dir).unwrap();
create_committed_repo(&source_repo);
fs::write(
plugin_dir.join("herdr-plugin.toml"),
r#"
id = "example.worktree-bootstrap"
name = "Worktree Bootstrap"
version = "0.1.0"
min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
id = "bootstrap"
title = "Bootstrap"
command = ["sh", "-c", "echo install"]
"#,
)
.unwrap();
run_git(
&source_repo,
&["add", "worktree-bootstrap/herdr-plugin.toml"],
);
run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
fs::create_dir_all(&config_home).unwrap();
fs::create_dir_all(&runtime_dir).unwrap();
let managed_checkout = config_home
.join("herdr-dev")
.join("plugins")
.join("github")
.join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT);
let git_config = base.join("gitconfig");
fs::write(
&git_config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
source_repo.display()
),
)
.unwrap();
let listener = UnixListener::bind(&socket_path).unwrap();
let managed_checkout_for_server = managed_checkout.clone();
let server = thread::spawn(move || {
let (mut first, _) = listener.accept().unwrap();
let mut first_line = String::new();
let mut first_reader = BufReader::new(first.try_clone().unwrap());
first_reader.read_line(&mut first_line).unwrap();
let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
assert_eq!(first_request["method"], "plugin.list");
first
.write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#)
.unwrap();
first.write_all(b"\n").unwrap();
first.flush().unwrap();
let (mut second, _) = listener.accept().unwrap();
let mut second_line = String::new();
let mut second_reader = BufReader::new(second.try_clone().unwrap());
second_reader.read_line(&mut second_line).unwrap();
let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap();
assert_eq!(second_request["method"], "plugin.link");
writeln!(
second,
"{}",
serde_json::json!({
"id": "cli:plugin",
"result": {
"type": "plugin_linked",
"plugin": {
"plugin_id": "example.worktree-bootstrap",
"name": "Worktree Bootstrap",
"version": "0.1.0",
"min_herdr_version": "0.6.10",
"manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
"plugin_root": managed_checkout_for_server.display().to_string(),
"enabled": true,
"source": {"kind": "local"}
}
}
})
)
.unwrap();
second.flush().unwrap();
let (mut third, _) = listener.accept().unwrap();
let mut third_line = String::new();
let mut third_reader = BufReader::new(third.try_clone().unwrap());
third_reader.read_line(&mut third_line).unwrap();
let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap();
assert_eq!(third_request["method"], "plugin.unlink");
assert_eq!(
third_request["params"]["plugin_id"],
"example.worktree-bootstrap"
);
third
.write_all(
br#"{"id":"cli:plugin","result":{"type":"plugin_unlinked","plugin_id":"example.worktree-bootstrap","removed":true}}"#,
)
.unwrap();
third.write_all(b"\n").unwrap();
third.flush().unwrap();
});
let install = run_named_cli_with_env_and_socket_override(
&config_home,
&runtime_dir,
&[
"plugin",
"install",
"ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
"--yes",
],
&[("GIT_CONFIG_GLOBAL", &git_config)],
Some(&socket_path),
);
assert!(
!install.status.success(),
"install should fail when server drops GitHub source metadata"
);
server.join().unwrap();
assert!(
!managed_checkout.exists(),
"new checkout should be removed after incompatible plugin.link response"
);
cleanup_test_base(&base);
}
#[test]
fn plugin_install_keeps_checkout_when_incompatible_server_cleanup_fails() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("fake-herdr.sock");
let source_repo = base.join("source-repo");
let plugin_dir = source_repo.join("worktree-bootstrap");
fs::create_dir_all(&plugin_dir).unwrap();
create_committed_repo(&source_repo);
fs::write(
plugin_dir.join("herdr-plugin.toml"),
r#"
id = "example.worktree-bootstrap"
name = "Worktree Bootstrap"
version = "0.1.0"
min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
id = "bootstrap"
title = "Bootstrap"
command = ["sh", "-c", "echo install"]
"#,
)
.unwrap();
run_git(
&source_repo,
&["add", "worktree-bootstrap/herdr-plugin.toml"],
);
run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
fs::create_dir_all(&config_home).unwrap();
fs::create_dir_all(&runtime_dir).unwrap();
let managed_checkout = config_home
.join("herdr-dev")
.join("plugins")
.join("github")
.join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT);
let git_config = base.join("gitconfig");
fs::write(
&git_config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
source_repo.display()
),
)
.unwrap();
let listener = UnixListener::bind(&socket_path).unwrap();
let managed_checkout_for_server = managed_checkout.clone();
let server = thread::spawn(move || {
let (mut first, _) = listener.accept().unwrap();
let mut first_line = String::new();
let mut first_reader = BufReader::new(first.try_clone().unwrap());
first_reader.read_line(&mut first_line).unwrap();
first
.write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#)
.unwrap();
first.write_all(b"\n").unwrap();
first.flush().unwrap();
let (mut second, _) = listener.accept().unwrap();
let mut second_line = String::new();
let mut second_reader = BufReader::new(second.try_clone().unwrap());
second_reader.read_line(&mut second_line).unwrap();
writeln!(
second,
"{}",
serde_json::json!({
"id": "cli:plugin",
"result": {
"type": "plugin_linked",
"plugin": {
"plugin_id": "example.worktree-bootstrap",
"name": "Worktree Bootstrap",
"version": "0.1.0",
"min_herdr_version": "0.6.10",
"manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
"plugin_root": managed_checkout_for_server.display().to_string(),
"enabled": true,
"source": {"kind": "local"}
}
}
})
)
.unwrap();
second.flush().unwrap();
let (mut third, _) = listener.accept().unwrap();
let mut third_line = String::new();
let mut third_reader = BufReader::new(third.try_clone().unwrap());
third_reader.read_line(&mut third_line).unwrap();
let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap();
assert_eq!(third_request["method"], "plugin.unlink");
third
.write_all(
br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced unlink failure"}}"#,
)
.unwrap();
third.write_all(b"\n").unwrap();
third.flush().unwrap();
});
let install = run_named_cli_with_env_and_socket_override(
&config_home,
&runtime_dir,
&[
"plugin",
"install",
"ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
"--yes",
],
&[("GIT_CONFIG_GLOBAL", &git_config)],
Some(&socket_path),
);
assert!(
!install.status.success(),
"install should fail when source metadata is dropped and cleanup fails"
);
server.join().unwrap();
assert!(
managed_checkout.exists(),
"checkout should stay when server cleanup fails"
);
cleanup_test_base(&base);
}
#[test]
fn wait_agent_status_exits_immediately_when_status_already_matches() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = send_request(
&socket_path,
&format!(
r#"{{"id":"req_cli_immediate_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
base.display()
),
);
let workspace_id = created["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
let pane_id = format!("{workspace_id}:p1");
let reported = send_request(
&socket_path,
&format!(
r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"idle"}}}}"#,
pane_id
),
);
assert_eq!(reported["result"]["type"], "ok");
let waited = run_cli(
&socket_path,
&[
"wait",
"agent-status",
"1-1",
"--status",
"idle",
"--timeout",
"1000",
],
);
assert!(
waited.status.success(),
"stderr: {}",
String::from_utf8_lossy(&waited.stderr)
);
let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
assert_eq!(waited_json["event"], "pane.agent_status_changed");
assert_eq!(waited_json["data"]["agent_status"], "idle");
assert_eq!(waited_json["data"]["agent"], "pi");
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn wait_agent_status_exits_when_done_status_matches() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let bin_dir = base.join("bin");
fs::create_dir_all(&bin_dir).unwrap();
let fake_pi = bin_dir.join("pi");
fs::write(
&fake_pi,
"#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&fake_pi).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&fake_pi, perms).unwrap();
}
let inherited_path = std::env::var("PATH").unwrap_or_default();
let path_override = format!("{}:{}", bin_dir.display(), inherited_path);
let herdr = spawn_herdr_with_path(
&config_home,
&runtime_dir,
&socket_path,
Some(Path::new(&path_override)),
);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = send_request(
&socket_path,
&format!(
r#"{{"id":"req_cli_status_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
base.display()
),
);
let workspace_id = created["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
let tab_created = send_request(
&socket_path,
&format!(
r#"{{"id":"req_cli_status_2","method":"tab.create","params":{{"workspace_id":"{}","focus":true}}}}"#,
workspace_id
),
);
assert_eq!(tab_created["result"]["type"], "tab_created");
let start_pi = run_cli(&socket_path, &["pane", "run", "1-1", "pi"]);
assert!(start_pi.status.success());
let waited = run_cli(
&socket_path,
&[
"wait",
"agent-status",
"1-1",
"--status",
"done",
"--timeout",
"10000",
],
);
assert!(
waited.status.success(),
"stderr: {}",
String::from_utf8_lossy(&waited.stderr)
);
let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
assert_eq!(waited_json["event"], "pane.agent_status_changed");
assert_eq!(waited_json["data"]["agent_status"], "done");
assert_eq!(waited_json["data"]["agent"], "pi");
cleanup_spawned_herdr(herdr, base);
}