#![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())) } struct SpawnedHerdr { _master: Box, child: Box, } 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 { 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::() { 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 { 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(); stream .write_all(br#"{"id":"test","result":{"type":"ok"}}"#) .unwrap(); stream.write_all(b"\n").unwrap(); stream.flush().unwrap(); 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("src/integration/assets/claude/herdr-agent-state.sh"); let mut child = Command::new("bash") .arg(hook_path) .arg(action) .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_reports_subagent_working_and_blocked() { let subagent_input = r#"{"hook_event_name":"Notification","agent_id":"agent-abc123","agent_type":"Explore","notification_type":"permission_prompt"}"#; let working = run_claude_hook("working", subagent_input).expect("subagent working should report working"); assert_eq!(working["method"], "pane.report_agent"); assert_eq!(working["params"]["state"], "working"); let blocked = run_claude_hook("blocked", subagent_input).expect("subagent blocked should report blocked"); assert_eq!(blocked["method"], "pane.report_agent"); assert_eq!(blocked["params"]["state"], "blocked"); } #[test] fn claude_hook_converts_subagent_idle_and_release_to_working() { let subagent_input = r#"{"hook_event_name":"SubagentStop","agent_id":"agent-abc123","agent_type":"Explore"}"#; let idle = run_claude_hook("idle", subagent_input) .expect("subagent idle should keep parent pane working"); assert_eq!(idle["method"], "pane.report_agent"); assert_eq!(idle["params"]["state"], "working"); let release = run_claude_hook("release", subagent_input) .expect("subagent release should keep parent pane working"); assert_eq!(release["method"], "pane.report_agent"); assert_eq!(release["params"]["state"], "working"); } #[test] fn claude_hook_keeps_parent_agent_type_only_blocked() { let request = run_claude_hook( "blocked", r#"{"hook_event_name":"PermissionRequest","agent_type":"Explore"}"#, ) .expect("parent blocked should still report blocked"); assert_eq!(request["method"], "pane.report_agent"); assert_eq!(request["params"]["state"], "blocked"); } #[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 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 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 = 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 = 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); }