mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs.
3854 lines
121 KiB
Rust
3854 lines
121 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,
|
|
};
|
|
|
|
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 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_shell_hook(asset_path: &str, args: &[&str], hook_input: &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 child = Command::new("bash")
|
|
.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())
|
|
.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 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 (v2)"));
|
|
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 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 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"
|
|
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"
|
|
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"
|
|
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());
|
|
|
|
let managed_checkout = config_home
|
|
.join("herdr-dev")
|
|
.join("plugins")
|
|
.join("github")
|
|
.join("example.build-fail");
|
|
assert!(
|
|
!managed_checkout.exists(),
|
|
"failed build should not create managed checkout"
|
|
);
|
|
|
|
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"
|
|
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"
|
|
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"
|
|
platforms = ["linux", "macos", "windows"]
|
|
|
|
[[actions]]
|
|
id = "run"
|
|
title = "Run changed command"
|
|
command = ["sh", "-c", "echo changed"]
|
|
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());
|
|
|
|
let managed_checkout = config_home
|
|
.join("herdr-dev")
|
|
.join("plugins")
|
|
.join("github")
|
|
.join("example.manifest-mutator");
|
|
assert!(
|
|
!managed_checkout.exists(),
|
|
"manifest mutation should not create managed checkout"
|
|
);
|
|
|
|
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"
|
|
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("example.worktree-bootstrap");
|
|
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",
|
|
"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"
|
|
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("example.worktree-bootstrap");
|
|
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",
|
|
"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"
|
|
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("example.worktree-bootstrap");
|
|
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",
|
|
"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);
|
|
}
|