mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 16:01:07 +00:00
Make herdr persistent by default. Launching herdr now starts or reattaches to a background session server. Clients can detach and reattach while panes and agent processes keep running. Session mode now supports multi-client attach, auto-detect startup, and a thin-client/headless-server split. This also refactors the large app, ui, input, pane, config, workspace, and persistence modules into smaller focused submodules, while preserving behavior and colocating tests with the code they exercise. Upgrade notes: - persistence mode is now the default - in-app quit detaches the current client instead of stopping the server - use `herdr server stop` to stop the background session - use `--no-session` for the old single-process behavior - default socket paths now live under the config directory
1289 lines
42 KiB
Rust
1289 lines
42 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;
|
|
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()))
|
|
}
|
|
|
|
struct SpawnedHerdr {
|
|
_master: Box<dyn MasterPty + Send>,
|
|
child: Box<dyn Child + Send + Sync>,
|
|
}
|
|
|
|
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_path(config_home, runtime_dir, socket_path, None)
|
|
}
|
|
|
|
fn spawn_herdr_with_path(
|
|
config_home: &Path,
|
|
runtime_dir: &Path,
|
|
socket_path: &Path,
|
|
path_override: Option<&Path>,
|
|
) -> SpawnedHerdr {
|
|
fs::create_dir_all(config_home.join("herdr")).unwrap();
|
|
fs::create_dir_all(runtime_dir).unwrap();
|
|
register_runtime_dir(runtime_dir);
|
|
fs::write(
|
|
config_home.join("herdr/config.toml"),
|
|
"onboarding = false\n",
|
|
)
|
|
.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_json(socket_path: &Path, args: &[&str]) -> serde_json::Value {
|
|
let output = run_cli(socket_path, 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_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 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_partial_write_race_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, "12").unwrap();
|
|
thread::sleep(Duration::from_millis(40));
|
|
fs::write(&pid_file, "123").unwrap();
|
|
thread::sleep(Duration::from_millis(40));
|
|
fs::write(&pid_file, "1234").unwrap();
|
|
thread::sleep(Duration::from_millis(200));
|
|
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()
|
|
}
|
|
|
|
#[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 integration_commands_honor_socket_override_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(1));
|
|
assert!(
|
|
!expected_extension.exists(),
|
|
"integration install should not run local install logic when socket is missing"
|
|
);
|
|
|
|
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(1));
|
|
assert!(
|
|
!expected_extension.exists(),
|
|
"integration uninstall should also be socket-backed when socket is missing"
|
|
);
|
|
|
|
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 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 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 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}:2"));
|
|
|
|
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 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));
|
|
|
|
let created = send_request(
|
|
&socket_path,
|
|
&format!(
|
|
r#"{{"id":"req_cli_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
|
|
base.display()
|
|
),
|
|
);
|
|
assert!(created["result"]["workspace"]["workspace_id"].is_string());
|
|
|
|
let create = run_cli(
|
|
&socket_path,
|
|
&[
|
|
"pane",
|
|
"run",
|
|
"1-1",
|
|
"echo alpha && echo beta && printf 'ready\\n'",
|
|
],
|
|
);
|
|
assert!(create.status.success());
|
|
|
|
let waited = run_cli(
|
|
&socket_path,
|
|
&[
|
|
"wait",
|
|
"output",
|
|
"1-1",
|
|
"--match",
|
|
"ready",
|
|
"--source",
|
|
"recent",
|
|
"--lines",
|
|
"40",
|
|
"--timeout",
|
|
"5000",
|
|
],
|
|
);
|
|
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["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_are_stable_and_pane_numbers_stay_compact() {
|
|
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}-2")
|
|
);
|
|
|
|
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}-3")
|
|
);
|
|
|
|
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}-2")
|
|
);
|
|
|
|
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}-1")
|
|
);
|
|
|
|
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}-1"), format!("{ws1_id}-2")]);
|
|
|
|
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()
|
|
),
|
|
);
|
|
assert!(created["result"]["workspace"]["workspace_id"].is_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);
|
|
while Instant::now() < deadline && !env_capture.exists() {
|
|
thread::sleep(Duration::from_millis(25));
|
|
}
|
|
assert!(env_capture.exists(), "env capture file was not created");
|
|
let text = fs::read_to_string(&env_capture).unwrap();
|
|
assert!(
|
|
text.contains(&socket_path.display().to_string()),
|
|
"env file was: {text:?}"
|
|
);
|
|
assert!(text.contains("p_"), "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 '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",
|
|
"5000",
|
|
],
|
|
);
|
|
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 '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",
|
|
"5000",
|
|
],
|
|
);
|
|
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);
|
|
}
|