diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-19 23:52:20 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-19 23:56:07 +0300 |
| commit | 3f80947678a9723434aa7fddfe23750b5393e24c (patch) | |
| tree | 6f463797132acfc51dfb6e135820a052954b4f13 /tests/cli_wrapper.rs | |
| parent | f76f895efe5b9f19aa9db909354c6c0f2195630b (diff) | |
feat: make agent automation a first-class cli primitive
Diffstat (limited to 'tests/cli_wrapper.rs')
| -rw-r--r-- | tests/cli_wrapper.rs | 5651 |
1 files changed, 0 insertions, 5651 deletions
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs deleted file mode 100644 index d218c230..00000000 --- a/tests/cli_wrapper.rs +++ /dev/null @@ -1,5651 +0,0 @@ -#![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, CURRENT_PROTOCOL, -}; - -const WORKTREE_BOOTSTRAP_MANAGED_COMPONENT: &str = "example.worktree-bootstrap-ef876653ffc3"; - -fn unique_test_dir() -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - PathBuf::from(format!("/tmp/hcli-{}-{nanos}", std::process::id())) -} - -fn managed_github_plugin_dir(config_home: &Path) -> PathBuf { - config_home.join("herdr-dev").join("plugins").join("github") -} - -fn path_missing_or_empty(path: &Path) -> bool { - match fs::read_dir(path) { - Ok(mut entries) => entries.next().is_none(), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, - Err(err) => panic!("failed to read {}: {err}", path.display()), - } -} - -fn run_git(repo: &Path, args: &[&str]) { - let status = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .status() - .unwrap(); - assert!( - status.success(), - "git command failed: git -C {} {}", - repo.display(), - args.join(" ") - ); -} - -fn create_committed_repo(path: &Path) { - fs::create_dir_all(path).unwrap(); - run_git(path, &["init", "--quiet"]); - run_git(path, &["config", "user.email", "herdr@example.invalid"]); - run_git(path, &["config", "user.name", "Herdr Test"]); - fs::write(path.join("README.md"), "test\n").unwrap(); - run_git(path, &["add", "README.md"]); - run_git(path, &["commit", "--quiet", "-m", "initial"]); -} - -struct SpawnedHerdr { - _master: Box<dyn MasterPty + Send>, - child: Box<dyn Child + Send + Sync>, -} - -struct SpawnedServerProcess { - child: std::process::Child, -} - -impl Drop for SpawnedServerProcess { - fn drop(&mut self) { - let pid = self.child.id(); - let _ = self.child.kill(); - let _ = self.child.wait(); - unregister_spawned_herdr_pid(Some(pid)); - } -} - -impl Drop for SpawnedHerdr { - fn drop(&mut self) { - let pid = self.child.process_id(); - let _ = self.child.kill(); - - if let Some(pid) = pid { - let deadline = Instant::now() + Duration::from_secs(2); - while Instant::now() < deadline { - let mut status = 0; - let result = - unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) }; - if result == pid as libc::pid_t || result == -1 { - break; - } - thread::sleep(Duration::from_millis(20)); - } - - unregister_spawned_herdr_pid(Some(pid)); - } - } -} - -fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) { - drop(spawned); - cleanup_test_base(&base); -} - -fn wait_for_socket(path: &Path, timeout: Duration) { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - if path.exists() && std::os::unix::net::UnixStream::connect(path).is_ok() { - return; - } - thread::sleep(Duration::from_millis(25)); - } - panic!("socket did not appear at {}", path.display()); -} - -fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> SpawnedHerdr { - spawn_herdr_with_config( - config_home, - runtime_dir, - socket_path, - None, - "onboarding = false\n", - ) -} - -fn spawn_herdr_with_pane_history( - config_home: &Path, - runtime_dir: &Path, - socket_path: &Path, -) -> SpawnedHerdr { - spawn_herdr_with_config( - config_home, - runtime_dir, - socket_path, - None, - "onboarding = false\n[experimental]\npane_history = true\n", - ) -} - -fn app_dir_name() -> &'static str { - if cfg!(debug_assertions) { - "herdr-dev" - } else { - "herdr" - } -} - -fn named_session_socket(config_home: &Path, session: &str) -> PathBuf { - config_home - .join(app_dir_name()) - .join("sessions") - .join(session) - .join("herdr.sock") -} - -fn spawn_named_server( - config_home: &Path, - runtime_dir: &Path, - session: &str, -) -> SpawnedServerProcess { - fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); - fs::create_dir_all(runtime_dir).unwrap(); - register_runtime_dir(runtime_dir); - fs::write( - config_home.join(app_dir_name()).join("config.toml"), - "onboarding = false\n", - ) - .unwrap(); - - let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); - command - .args(["--session", session, "server"]) - .env("XDG_CONFIG_HOME", config_home) - .env("XDG_RUNTIME_DIR", runtime_dir) - .env_remove("HERDR_SOCKET_PATH") - .env_remove("HERDR_CLIENT_SOCKET_PATH") - .env_remove("HERDR_ENV") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - - let child = command.spawn().unwrap(); - register_spawned_herdr_pid(Some(child.id())); - SpawnedServerProcess { child } -} - -fn run_named_cli(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> std::process::Output { - run_named_cli_with_socket_override(config_home, runtime_dir, args, None) -} - -fn run_named_cli_with_socket_override( - config_home: &Path, - runtime_dir: &Path, - args: &[&str], - socket_override: Option<&Path>, -) -> std::process::Output { - run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, &[], socket_override) -} - -fn run_named_cli_with_env( - config_home: &Path, - runtime_dir: &Path, - args: &[&str], - envs: &[(&str, &Path)], -) -> std::process::Output { - run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, envs, None) -} - -fn run_named_cli_with_env_and_socket_override( - config_home: &Path, - runtime_dir: &Path, - args: &[&str], - envs: &[(&str, &Path)], - socket_override: Option<&Path>, -) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); - command - .args(args) - .env("XDG_CONFIG_HOME", config_home) - .env("XDG_RUNTIME_DIR", runtime_dir) - .env_remove("HERDR_CLIENT_SOCKET_PATH") - .env_remove("HERDR_ENV"); - for (key, value) in envs { - command.env(key, value); - } - if let Some(socket_override) = socket_override { - command.env("HERDR_SOCKET_PATH", socket_override); - } else { - command.env_remove("HERDR_SOCKET_PATH"); - } - command.output().unwrap() -} - -fn run_named_cli_json(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> serde_json::Value { - let output = run_named_cli(config_home, runtime_dir, args); - assert!( - output.status.success(), - "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}", - args.join(" "), - output.status.code(), - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - ); - serde_json::from_slice(&output.stdout).unwrap() -} - -fn spawn_herdr_with_path( - config_home: &Path, - runtime_dir: &Path, - socket_path: &Path, - path_override: Option<&Path>, -) -> SpawnedHerdr { - spawn_herdr_with_config( - config_home, - runtime_dir, - socket_path, - path_override, - "onboarding = false\n", - ) -} - -fn spawn_herdr_with_config( - config_home: &Path, - runtime_dir: &Path, - socket_path: &Path, - path_override: Option<&Path>, - config_toml: &str, -) -> SpawnedHerdr { - fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); - fs::create_dir_all(runtime_dir).unwrap(); - register_runtime_dir(runtime_dir); - fs::write( - config_home.join(app_dir_name()).join("config.toml"), - config_toml, - ) - .unwrap(); - - let pair = native_pty_system() - .openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - }) - .unwrap(); - - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); - cmd.arg("server"); - cmd.env("XDG_CONFIG_HOME", config_home); - cmd.env("XDG_RUNTIME_DIR", runtime_dir); - cmd.env("HERDR_SOCKET_PATH", socket_path); - cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); - cmd.env("SHELL", "/bin/sh"); - cmd.env_remove("HERDR_ENV"); - if let Some(path) = path_override { - cmd.env("PATH", path); - } - - let child = pair.slave.spawn_command(cmd).unwrap(); - register_spawned_herdr_pid(child.process_id()); - SpawnedHerdr { - _master: pair.master, - child, - } -} - -fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); - command.args(args); - command.env("HERDR_SOCKET_PATH", socket_path); - command.output().unwrap() -} - -fn run_cli_in_dir(socket_path: &Path, args: &[&str], current_dir: &Path) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); - command.args(args); - command.current_dir(current_dir); - command.env("HERDR_SOCKET_PATH", socket_path); - command.output().unwrap() -} - -fn pane_topology_snapshot(list_response: &serde_json::Value) -> Vec<serde_json::Value> { - list_response["result"]["panes"] - .as_array() - .unwrap() - .iter() - .map(|pane| { - serde_json::json!({ - "pane_id": pane["pane_id"], - "terminal_id": pane["terminal_id"], - "workspace_id": pane["workspace_id"], - "tab_id": pane["tab_id"], - "focused": pane["focused"], - }) - }) - .collect() -} - -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 write_fake_pong( - stream: &mut UnixStream, - request: &serde_json::Value, - version: &str, - protocol: u32, -) { - writeln!( - stream, - "{}", - serde_json::json!({ - "id": request["id"], - "result": { - "type": "pong", - "version": version, - "protocol": protocol, - "capabilities": { - "live_handoff": true, - "detached_server_daemon": true - } - } - }) - ) - .unwrap(); - stream.flush().unwrap(); -} - -fn accept_fake_cli_operation(listener: &UnixListener) -> (UnixStream, String) { - loop { - 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(); - let request: serde_json::Value = serde_json::from_str(&line).unwrap(); - if request["method"] != "ping" { - return (stream, line); - } - - write_fake_pong( - &mut stream, - &request, - "different-build-same-protocol", - CURRENT_PROTOCOL, - ); - } -} - -fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> { - run_shell_hook( - "src/integration/assets/claude/herdr-agent-state.sh", - &[action], - hook_input, - ) -} - -fn run_codex_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> { - run_shell_hook( - "src/integration/assets/codex/herdr-agent-state.sh", - &[action], - hook_input, - ) -} - -fn run_copilot_hook(hook_input: &str) -> Option<serde_json::Value> { - run_shell_hook( - "src/integration/assets/copilot/herdr-agent-state.sh", - &[], - hook_input, - ) -} - -fn run_devin_hook( - action: &str, - hook_input: &str, - envs: &[(&str, &str)], -) -> Option<serde_json::Value> { - run_shell_hook_with_env( - "src/integration/assets/devin/herdr-agent-state.sh", - &[action], - hook_input, - envs, - ) -} - -fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<serde_json::Value> { - run_shell_hook_with_env(asset_path, args, hook_input, &[]) -} - -fn run_shell_hook_with_env( - asset_path: &str, - args: &[&str], - hook_input: &str, - envs: &[(&str, &str)], -) -> Option<serde_json::Value> { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - listener.set_nonblocking(true).unwrap(); - let deadline = Instant::now() + Duration::from_millis(700); - while Instant::now() < deadline { - match listener.accept() { - Ok((mut stream, _)) => { - let mut line = String::new(); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - reader.read_line(&mut line).unwrap(); - let _ = stream.write_all(br#"{"id":"test","result":{"type":"ok"}}"#); - let _ = stream.write_all(b"\n"); - let _ = stream.flush(); - return Some(line); - } - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("accept failed: {err}"), - } - } - None - }); - - let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path); - let mut command = Command::new("bash"); - command - .arg(hook_path) - .args(args) - .env("HERDR_ENV", "1") - .env("HERDR_SOCKET_PATH", &socket_path) - .env("HERDR_PANE_ID", "p_test") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - for (key, value) in envs { - command.env(key, value); - } - let mut child = command.spawn().unwrap(); - let mut stdin = child.stdin.take().unwrap(); - stdin.write_all(hook_input.as_bytes()).unwrap(); - drop(stdin); - - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "hook failed: status={:?} stderr={} stdout={}", - output.status.code(), - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - ); - - let request = server.join().unwrap(); - cleanup_test_base(&base); - request.map(|line| serde_json::from_str(&line).unwrap()) -} - -#[test] -fn claude_hook_ignores_state_actions() { - let subagent_input = r#"{"hook_event_name":"Notification","agent_id":"agent-abc123","agent_type":"Explore","notification_type":"permission_prompt"}"#; - - assert!(run_claude_hook("working", subagent_input).is_none()); - assert!(run_claude_hook("blocked", subagent_input).is_none()); -} - -#[test] -fn claude_hook_ignores_subagent_completion_reports() { - let subagent_input = - r#"{"hook_event_name":"SubagentStop","agent_id":"agent-abc123","agent_type":"Explore"}"#; - - assert!(run_claude_hook("working", subagent_input).is_none()); - assert!(run_claude_hook("idle", subagent_input).is_none()); - assert!(run_claude_hook("release", subagent_input).is_none()); -} - -#[test] -fn claude_hook_keeps_parent_agent_type_only_blocked() { - let request = run_claude_hook( - "blocked", - r#"{"hook_event_name":"PermissionRequest","agent_type":"Explore"}"#, - ); - - assert!(request.is_none()); -} - -#[test] -fn claude_hook_reports_session_id_from_stdin() { - let request = run_claude_hook( - "session", - r#"{"hook_event_name":"SessionStart","session_id":"claude-session"}"#, - ) - .expect("session start should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent_session_id"], "claude-session"); - assert!(request["params"].get("state").is_none()); -} - -#[test] -fn codex_hook_reports_session_id_from_stdin() { - let request = run_codex_hook( - "session", - r#"{"hook_event_name":"SessionStart","session_id":"codex-session"}"#, - ) - .expect("codex hook should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent_session_id"], "codex-session"); - assert!(request["params"].get("state").is_none()); -} - -#[test] -fn copilot_hook_reports_session_id_from_stdin() { - let request = run_copilot_hook( - r#"{"hook_event_name":"SessionStart","session_id":"copilot-session","source":"resume"}"#, - ) - .expect("copilot session start should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent"], "copilot"); - assert_eq!(request["params"]["agent_session_id"], "copilot-session"); - assert!(request["params"].get("state").is_none()); - - let camel = run_copilot_hook( - r#"{"sessionId":"copilot-camel-session","source":"new","initialPrompt":"run tests"}"#, - ) - .expect("copilot camelCase session start should report session identity"); - - assert_eq!(camel["method"], "pane.report_agent_session"); - assert_eq!(camel["params"]["agent_session_id"], "copilot-camel-session"); - assert!(camel["params"].get("state").is_none()); -} - -#[test] -fn copilot_hook_does_not_report_lifecycle_state() { - for payload in [ - r#"{"hook_event_name":"UserPromptSubmit","session_id":"copilot-session","prompt":"run tests"}"#, - r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"ask_user"}"#, - r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"permission_prompt"}"#, - r#"{"hook_event_name":"agentStop","session_id":"copilot-session","stop_reason":"end_turn"}"#, - r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"user_exit"}"#, - ] { - assert!( - run_copilot_hook(payload).is_none(), - "copilot session-only hook should ignore lifecycle payload {payload}" - ); - } -} - -#[test] -fn devin_hook_ignores_prompt_session_list_fallback() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"UserPromptSubmit","prompt":"run tests"}"#, - &[ - ("DEVIN_PROJECT_DIR", "/tmp/project"), - ( - "HERDR_DEVIN_LIST_JSON", - r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#, - ), - ], - ); - - assert!(request.is_none()); -} - -#[test] -fn devin_hook_reports_session_id_from_stdin_without_state() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"SessionStart","session_id":"devin-session","source":"startup"}"#, - &[("HERDR_DEVIN_LIST_JSON", r#"[{"id":"older-session"}]"#)], - ) - .expect("devin session start should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent"], "devin"); - assert_eq!(request["params"]["agent_session_id"], "devin-session"); - assert!(request["params"].get("state").is_none()); -} - -#[test] -fn devin_hook_prefers_hook_session_id_over_list() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"PreToolUse","sessionId":"fresh-session","tool_name":"exec"}"#, - &[ - ("DEVIN_PROJECT_DIR", "/tmp/project"), - ( - "HERDR_DEVIN_LIST_JSON", - r#"[{"id":"older-session","working_directory":"/tmp/project"}]"#, - ), - ], - ) - .expect("devin tool hook should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent_session_id"], "fresh-session"); - assert!(request["params"].get("state").is_none()); -} - -#[test] -fn devin_hook_reports_tool_session_from_list_without_state() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#, - &[ - ("DEVIN_PROJECT_DIR", "/tmp/project"), - ( - "HERDR_DEVIN_LIST_JSON", - r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#, - ), - ], - ) - .expect("devin tool hook should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent"], "devin"); - assert_eq!(request["params"]["agent_session_id"], "devin-session"); - assert!(request["params"].get("state").is_none()); -} - -#[test] -fn devin_hook_ignores_startup_session_list_fallback() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"SessionStart","source":"startup"}"#, - &[ - ("DEVIN_PROJECT_DIR", "/tmp/project"), - ( - "HERDR_DEVIN_LIST_JSON", - r#"[{"id":"stale-session","working_directory":"/tmp/project"}]"#, - ), - ], - ); - - assert!(request.is_none()); -} - -#[test] -fn devin_hook_ignores_non_matching_session_list_entries() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#, - &[ - ("DEVIN_PROJECT_DIR", "/tmp/project"), - ( - "HERDR_DEVIN_LIST_JSON", - r#"[{"id":"other-session","working_directory":"/tmp/other"}]"#, - ), - ], - ); - - assert!(request.is_none()); -} - -#[test] -fn pane_run_sends_one_send_input_request_with_enter_key() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - let (mut first_stream, first_line) = accept_fake_cli_operation(&listener); - 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 workspace_report_metadata_sends_token_patch() { - 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, line) = accept_fake_cli_operation(&listener); - 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, - &[ - "workspace", - "report-metadata", - "2", - "--source", - "user:jj", - "--token", - "jj_status=2 changes", - "--token", - "summary=clean", - "--clear-token", - "old", - "--ttl-ms", - "5000", - ], - ); - assert!( - run.status.success(), - "stderr: {}", - String::from_utf8_lossy(&run.stderr) - ); - - let request: serde_json::Value = serde_json::from_str(&server.join().unwrap()).unwrap(); - assert_eq!(request["method"], "workspace.report_metadata"); - assert_eq!(request["params"]["workspace_id"], "2"); - assert_eq!(request["params"]["tokens"]["jj_status"], "2 changes"); - assert_eq!(request["params"]["tokens"]["summary"], "clean"); - assert!(request["params"]["tokens"]["old"].is_null()); - assert_eq!(request["params"]["ttl_ms"], 5000); - - 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, line) = accept_fake_cli_operation(&listener); - 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", - "--state-label", - "working=deep in the mines", - "--token", - "summary=reviewing auth", - "--token", - "model=opus", - "--clear-token", - "old", - "--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"]["state_labels"]["working"], - "deep in the mines" - ); - assert_eq!(request["params"]["tokens"]["summary"], "reviewing auth"); - assert_eq!(request["params"]["tokens"]["model"], "opus"); - assert!(request["params"]["tokens"]["old"].is_null()); - 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", - " ", - "--token", - "summary=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", - " ", - "--token", - "summary=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"], - &["api", "-h"], - &["api", "schema", "-h"], - &["completion", "-h"], - &["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 completion_command_prints_zsh_script_without_session_startup() { - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["completion", "zsh"]) - .env_remove("HERDR_SOCKET_PATH") - .env_remove("HERDR_CLIENT_SOCKET_PATH") - .env_remove("HERDR_ENV") - .output() - .unwrap(); - - assert!( - output.status.success(), - "status={:?} stderr={}", - output.status.code(), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("#compdef herdr"), "stdout: {stdout}"); - assert!( - stdout.contains("bash elvish fish powershell zsh"), - "stdout: {stdout}" - ); - assert!( - stdout.contains("'--direction[]:DIRECTION:(right down)'"), - "pane split/plugin pane open should complete --direction right|down without requiring equals: {stdout}" - ); - assert!( - !stdout.contains("--cwd=[]"), - "zsh completions should not suggest equals-style values unsupported by most manual parsers: {stdout}" - ); - assert!( - !stdout.contains("--direction=[]"), - "zsh completions should not suggest equals-style direction values: {stdout}" - ); - assert!( - !stdout.contains("live-handoff"), - "internal server handoff command should not be completed: {stdout}" - ); -} - -#[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 root_help_advertises_api_schema_command_group() { - 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 api <subcommand>"), - "root help should advertise the api command group: {stdout}" - ); -} - -#[test] -fn api_schema_default_output_is_a_short_summary() { - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["api", "schema"]) - .output() - .unwrap(); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Herdr API schema"), "stdout: {stdout}"); - assert!( - stdout.contains("Use `herdr api schema --json`"), - "stdout: {stdout}" - ); - assert!( - stdout.len() < 400, - "summary should stay small enough for terminal output: {stdout}" - ); -} - -#[test] -fn api_schema_json_prints_bundled_schema() { - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["api", "schema", "--json"]) - .output() - .unwrap(); - - assert!(output.status.success()); - let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert!(schema - .get("protocol") - .and_then(serde_json::Value::as_u64) - .is_some_and(|protocol| protocol > 0)); - assert_eq!( - schema - .get("schemas") - .and_then(serde_json::Value::as_object) - .map(serde_json::Map::len), - Some(5) - ); -} - -#[test] -fn api_snapshot_prints_live_session_snapshot() { - 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({ - let socket_path = socket_path.clone(); - move || { - let (mut stream, line) = accept_fake_cli_operation(&listener); - let request: serde_json::Value = serde_json::from_str(&line).unwrap(); - assert_eq!(request["method"], "session.snapshot"); - assert_eq!(request["id"], "cli:api:snapshot"); - - let response = serde_json::json!({ - "id": "cli:api:snapshot", - "result": { - "type": "ok", - "marker": "snapshot-passthrough" - } - }); - writeln!(stream, "{response}").unwrap(); - stream.flush().unwrap(); - let _ = fs::remove_file(socket_path); - } - }); - - let value = run_cli_json(&socket_path, &["api", "snapshot"]); - - assert_eq!(value["result"]["marker"], "snapshot-passthrough"); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn api_schema_output_writes_bundled_schema_to_file() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let schema_path = base.join("herdr-api.schema.json"); - - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["api", "schema", "--output"]) - .arg(&schema_path) - .output() - .unwrap(); - - assert!(output.status.success()); - assert!( - String::from_utf8_lossy(&output.stdout).contains("wrote API schema"), - "stdout: {}", - String::from_utf8_lossy(&output.stdout) - ); - let schema: serde_json::Value = - serde_json::from_slice(&fs::read(&schema_path).unwrap()).unwrap(); - assert!(schema - .get("protocol") - .and_then(serde_json::Value::as_u64) - .is_some_and(|protocol| protocol > 0)); - - cleanup_test_base(&base); -} - -#[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 (v6)")); - 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: 17"), - "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: 17"), - "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: 17"), - "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"], 17); - 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"], 17); - 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"], 17); - 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) - ); - assert!( - !socket_path.exists() || UnixStream::connect(&socket_path).is_err(), - "api socket should be removed or stale before server stop returns" - ); - assert!( - !client_socket.exists() || UnixStream::connect(&client_socket).is_err(), - "client socket should be removed or stale before server stop returns" - ); - - 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"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn server_stop_then_restart_restores_pane_history() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - let marker = "PERSISTED_HISTORY_AFTER_STOP"; - - let mut herdr = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - wait_for_socket(&client_socket, Duration::from_secs(5)); - - let created = run_cli_json( - &socket_path, - &[ - "workspace", - "create", - "--cwd", - base.to_str().expect("test path should be utf-8"), - "--label", - "history-restart", - ], - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .expect("workspace create should return root pane id") - .to_string(); - let sent = run_cli( - &socket_path, - &["pane", "send-text", &pane_id, &format!("echo {marker}\n")], - ); - assert!( - sent.status.success(), - "stderr: {}", - String::from_utf8_lossy(&sent.stderr) - ); - assert!( - wait_until(Duration::from_secs(3), Duration::from_millis(25), || { - pane_read_recent_contains(&socket_path, &pane_id, marker) - }), - "pane should contain marker before server stop" - ); - - let stopped = run_cli(&socket_path, &["server", "stop"]); - assert!( - stopped.status.success(), - "stderr: {}", - String::from_utf8_lossy(&stopped.stderr) - ); - - let pid = herdr.child.process_id(); - let exit_status = herdr.child.wait().unwrap(); - unregister_spawned_herdr_pid(pid); - assert!(exit_status.success(), "server stop should exit cleanly"); - drop(herdr); - - let restarted = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - wait_for_socket(&client_socket, Duration::from_secs(5)); - - let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); - let workspace_id = workspaces["result"]["workspaces"] - .as_array() - .expect("workspace.list should return workspaces") - .iter() - .find(|workspace| workspace["label"] == "history-restart") - .and_then(|workspace| workspace["workspace_id"].as_str()) - .expect("restored workspace should exist") - .to_string(); - let panes = run_cli_json( - &socket_path, - &["pane", "list", "--workspace", &workspace_id], - ); - let restored_pane_id = panes["result"]["panes"] - .as_array() - .expect("pane.list should return panes") - .first() - .and_then(|pane| pane["pane_id"].as_str()) - .expect("restored pane should exist") - .to_string(); - - assert!( - wait_until(Duration::from_secs(3), Duration::from_millis(25), || { - pane_read_recent_contains(&socket_path, &restored_pane_id, marker) - }), - "restarted server should restore saved pane history" - ); - - cleanup_spawned_herdr(restarted, base); -} - -#[test] -fn server_start_restores_legacy_session_through_api_identity() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - let data_dir = config_home.join(app_dir_name()); - let pion_cwd = base.join("legacy-pion"); - let herdr_cwd = base.join("legacy-herdr"); - - fs::create_dir_all(&pion_cwd).unwrap(); - fs::create_dir_all(&herdr_cwd).unwrap(); - fs::create_dir_all(&data_dir).unwrap(); - let pion_cwd = pion_cwd.to_str().expect("test cwd should be UTF-8"); - let herdr_cwd = herdr_cwd.to_str().expect("test cwd should be UTF-8"); - let legacy_session = include_str!("fixtures/session/legacy-pre-tabs-v2.json") - .replace("/tmp/pion", pion_cwd) - .replace("/tmp/herdr", herdr_cwd); - fs::write(data_dir.join("session.json"), legacy_session).unwrap(); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - wait_for_socket(&client_socket, Duration::from_secs(5)); - - let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); - let restored_workspace = workspaces["result"]["workspaces"] - .as_array() - .expect("workspace.list should return workspaces") - .iter() - .find(|workspace| workspace["label"] == "legacy") - .expect("legacy workspace should restore"); - let workspace_id = restored_workspace["workspace_id"] - .as_str() - .expect("restored workspace should have public id") - .to_string(); - assert_eq!(restored_workspace["pane_count"], 2); - assert_eq!(restored_workspace["tab_count"], 1); - assert_eq!( - restored_workspace["active_tab_id"], - format!("{workspace_id}:t1") - ); - - let panes = run_cli_json( - &socket_path, - &["pane", "list", "--workspace", &workspace_id], - ); - let panes = panes["result"]["panes"] - .as_array() - .expect("pane.list should return panes"); - assert_eq!(panes.len(), 2); - let root_pane_id = format!("{workspace_id}:p1"); - let focused_pane_id = format!("{workspace_id}:p2"); - assert!(panes.iter().any(|pane| { - pane["pane_id"] == root_pane_id - && pane["tab_id"] == format!("{workspace_id}:t1") - && pane["cwd"] == pion_cwd - && pane["focused"] == false - })); - assert!(panes.iter().any(|pane| { - pane["pane_id"] == focused_pane_id - && pane["tab_id"] == format!("{workspace_id}:t1") - && pane["cwd"] == herdr_cwd - && pane["focused"] == true - })); - - let reported = run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &focused_pane_id, - "--source", - "test", - "--agent", - "pi", - "--state", - "working", - ], - ); - assert!( - reported.status.success(), - "stderr: {}", - String::from_utf8_lossy(&reported.stderr) - ); - - let agents = run_cli_json(&socket_path, &["agent", "list"]); - let agents = agents["result"]["agents"] - .as_array() - .expect("agent.list should return agents"); - assert_eq!(agents.len(), 1); - assert_eq!(agents[0]["pane_id"], focused_pane_id); - assert_eq!(agents[0]["workspace_id"], workspace_id); - assert_eq!(agents[0]["agent"], "pi"); - assert_eq!(agents[0]["agent_status"], "working"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn workspace_and_pane_management_commands_work() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let reloaded = run_cli(&socket_path, &["server", "reload-config"]); - assert!( - reloaded.status.success(), - "stderr: {}", - String::from_utf8_lossy(&reloaded.stderr) - ); - let reload_json: serde_json::Value = serde_json::from_slice(&reloaded.stdout).unwrap(); - assert_eq!(reload_json["result"]["type"], "config_reload"); - assert_eq!(reload_json["result"]["status"], "applied"); - - let listed = run_cli(&socket_path, &["workspace", "list"]); - assert!(listed.status.success()); - let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); - assert_eq!(listed_json["result"]["type"], "workspace_list"); - assert_eq!( - listed_json["result"]["workspaces"] - .as_array() - .unwrap() - .len(), - 0 - ); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); - let workspace_id = created_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - - let panes = run_cli(&socket_path, &["pane", "list", "--workspace", "1"]); - assert!(panes.status.success()); - let panes_json: serde_json::Value = serde_json::from_slice(&panes.stdout).unwrap(); - assert_eq!(panes_json["result"]["panes"].as_array().unwrap().len(), 1); - - let split = run_cli( - &socket_path, - &["pane", "split", "1-1", "--direction", "right"], - ); - assert!( - split.status.success(), - "stderr: {}", - String::from_utf8_lossy(&split.stderr) - ); - let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap(); - let split_pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap(); - - let fetched = run_cli(&socket_path, &["pane", "get", split_pane_id]); - assert!(fetched.status.success()); - let fetched_json: serde_json::Value = serde_json::from_slice(&fetched.stdout).unwrap(); - assert_eq!(fetched_json["result"]["pane"]["pane_id"], split_pane_id); - - let closed = run_cli(&socket_path, &["pane", "close", split_pane_id]); - assert!(closed.status.success()); - let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); - assert_eq!(closed_json["result"]["type"], "ok"); - - let renamed = run_cli( - &socket_path, - &["workspace", "rename", &workspace_id, "demo"], - ); - assert!(renamed.status.success()); - let renamed_json: serde_json::Value = serde_json::from_slice(&renamed.stdout).unwrap(); - assert_eq!(renamed_json["result"]["workspace"]["label"], "demo"); - - let focused = run_cli(&socket_path, &["workspace", "focus", &workspace_id]); - assert!(focused.status.success()); - - let closed_workspace = run_cli(&socket_path, &["workspace", "close", &workspace_id]); - assert!(closed_workspace.status.success()); - let closed_workspace_json: serde_json::Value = - serde_json::from_slice(&closed_workspace.stdout).unwrap(); - assert_eq!(closed_workspace_json["result"]["type"], "ok"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn worktree_management_commands_work() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let repo = base.join("repo"); - let checkout = base.join("checkout"); - create_committed_repo(&repo); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let branch = "worktree/cli-wrapper"; - let created = run_cli_json( - &socket_path, - &[ - "worktree", - "create", - "--cwd", - repo.to_str().unwrap(), - "--branch", - branch, - "--path", - checkout.to_str().unwrap(), - "--json", - ], - ); - assert_eq!(created["result"]["type"], "worktree_created"); - assert_eq!(created["result"]["worktree"]["branch"], branch); - let child_workspace_id = created["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - assert!(checkout.join("README.md").exists()); - - let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); - let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap(); - let parent_workspace_id = workspace_list - .iter() - .find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false)) - .and_then(|workspace| workspace["workspace_id"].as_str()) - .unwrap() - .to_string(); - assert!(workspace_list.iter().any(|workspace| { - workspace["workspace_id"].as_str() == Some(child_workspace_id.as_str()) - && workspace["worktree"]["is_linked_worktree"].as_bool() == Some(true) - })); - - let listed = run_cli_json( - &socket_path, - &[ - "worktree", - "list", - "--workspace", - &parent_workspace_id, - "--json", - ], - ); - let listed_entry = listed["result"]["worktrees"] - .as_array() - .unwrap() - .iter() - .find(|entry| entry["branch"].as_str() == Some(branch)) - .unwrap(); - assert_eq!( - listed_entry["open_workspace_id"].as_str(), - Some(child_workspace_id.as_str()) - ); - - let opened = run_cli_json( - &socket_path, - &[ - "worktree", - "open", - "--workspace", - &parent_workspace_id, - "--branch", - branch, - "--json", - ], - ); - assert_eq!(opened["result"]["type"], "worktree_opened"); - assert_eq!(opened["result"]["already_open"], true); - assert_eq!( - opened["result"]["workspace"]["workspace_id"].as_str(), - Some(child_workspace_id.as_str()) - ); - - fs::write(checkout.join("README.md"), "dirty\n").unwrap(); - let safe_remove = run_cli( - &socket_path, - &[ - "worktree", - "remove", - "--workspace", - &child_workspace_id, - "--json", - ], - ); - assert_eq!(safe_remove.status.code(), Some(1)); - let safe_remove_json: serde_json::Value = serde_json::from_slice(&safe_remove.stderr).unwrap(); - assert_eq!( - safe_remove_json["error"]["code"], - "dirty_worktree_requires_force" - ); - assert!(checkout.exists()); - - let force_removed = run_cli_json( - &socket_path, - &[ - "worktree", - "remove", - "--workspace", - &child_workspace_id, - "--force", - "--json", - ], - ); - assert_eq!(force_removed["result"]["type"], "worktree_removed"); - assert_eq!(force_removed["result"]["forced"], true); - assert!(!checkout.exists()); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn forced_worktree_remove_terminates_processes_inside_checkout() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let repo = base.join("repo"); - let checkout = base.join("checkout-with-process"); - create_committed_repo(&repo); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli_json( - &socket_path, - &[ - "worktree", - "create", - "--cwd", - repo.to_str().unwrap(), - "--branch", - "worktree/force-process", - "--path", - checkout.to_str().unwrap(), - "--json", - ], - ); - let child_workspace_id = created["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let pid_file = base.join("worktree-remove-force.pid"); - let command = format!( - "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'", - pid_file.display() - ); - let ran = run_cli(&socket_path, &["pane", "run", &pane_id, &command]); - assert!( - ran.status.success(), - "stderr: {}", - String::from_utf8_lossy(&ran.stderr) - ); - let pid = wait_for_pid_file(&pid_file, Duration::from_secs(5)).unwrap_or_else(|err| { - panic!("failed to read pane child pid: {err}"); - }); - assert!(process_exists(pid), "child process was not running"); - - let removed = run_cli_json( - &socket_path, - &[ - "worktree", - "remove", - "--workspace", - &child_workspace_id, - "--force", - "--json", - ], - ); - assert_eq!(removed["result"]["type"], "worktree_removed"); - assert!(wait_for_pid_exit(pid, Duration::from_secs(3))); - assert!(!checkout.exists()); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn worktree_open_existing_checkout_by_path_and_branch() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let repo = base.join("repo"); - let checkout = base.join("external-checkout"); - create_committed_repo(&repo); - let branch = "worktree/cli-open-existing"; - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - branch, - checkout.to_str().unwrap(), - "HEAD", - ], - ); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let opened = run_cli_json_in_dir( - &socket_path, - &[ - "worktree", - "open", - "--cwd", - "repo", - "--path", - "external-checkout", - "--json", - ], - &base, - ); - assert_eq!(opened["result"]["type"], "worktree_opened"); - assert_eq!(opened["result"]["already_open"], false); - assert_eq!(opened["result"]["worktree"]["branch"], branch); - assert_eq!( - opened["result"]["workspace"]["worktree"]["is_linked_worktree"], - true - ); - let child_workspace_id = opened["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - - let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); - let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap(); - let parent_workspace_id = workspace_list - .iter() - .find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false)) - .and_then(|workspace| workspace["workspace_id"].as_str()) - .unwrap() - .to_string(); - - let listed = run_cli_json( - &socket_path, - &[ - "worktree", - "list", - "--workspace", - &parent_workspace_id, - "--json", - ], - ); - let listed_entry = listed["result"]["worktrees"] - .as_array() - .unwrap() - .iter() - .find(|entry| entry["branch"].as_str() == Some(branch)) - .unwrap(); - assert_eq!( - listed_entry["open_workspace_id"].as_str(), - Some(child_workspace_id.as_str()) - ); - - let reopened = run_cli_json( - &socket_path, - &[ - "worktree", - "open", - "--workspace", - &parent_workspace_id, - "--branch", - branch, - "--json", - ], - ); - assert_eq!(reopened["result"]["type"], "worktree_opened"); - assert_eq!(reopened["result"]["already_open"], true); - assert_eq!( - reopened["result"]["workspace"]["workspace_id"].as_str(), - Some(child_workspace_id.as_str()) - ); - - let removed = run_cli_json( - &socket_path, - &[ - "worktree", - "remove", - "--workspace", - &child_workspace_id, - "--force", - "--json", - ], - ); - assert_eq!(removed["result"]["type"], "worktree_removed"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn config_check_reports_invalid_config_without_server() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let config_dir = config_home.join(app_dir_name()); - fs::create_dir_all(&config_dir).unwrap(); - fs::write( - config_dir.join("config.toml"), - "[keys\nnew_workspace = \"g\"\n", - ) - .unwrap(); - - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); - - assert_eq!(checked.status.code(), Some(1)); - assert!( - checked.stderr.is_empty(), - "stderr: {}", - String::from_utf8_lossy(&checked.stderr) - ); - let stdout = String::from_utf8_lossy(&checked.stdout); - assert!(stdout.contains("config: issues found"), "{stdout}"); - assert!(stdout.contains("TOML parse error"), "{stdout}"); - assert!(stdout.contains("line 1"), "{stdout}"); - - fs::write( - config_dir.join("config.toml"), - "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\n", - ) - .unwrap(); - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); - let stdout = String::from_utf8_lossy(&checked.stdout); - assert_eq!(checked.status.code(), Some(1)); - assert!(stdout.contains("sidebar_min_width (50)"), "{stdout}"); - assert!(stdout.contains("sidebar_max_width (30)"), "{stdout}"); - - cleanup_test_base(&base); -} - -#[test] -fn config_check_reports_unknown_keys_without_treating_comments_as_keys() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let config_dir = config_home.join(app_dir_name()); - fs::create_dir_all(&config_dir).unwrap(); - fs::write( - config_dir.join("config.toml"), - r#"made_up_toplevel = 1 -"made.up" = 2 -# commented_out_toplevel = true - -[ui] -mouse_capture = true -mouse_captur = false -# commented_out_ui_key = true - -[keys] -new_tabb = "ctrl+t" -"#, - ) - .unwrap(); - - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); - - assert_eq!(checked.status.code(), Some(1)); - assert!( - checked.stderr.is_empty(), - "stderr: {}", - String::from_utf8_lossy(&checked.stderr) - ); - let stdout = String::from_utf8_lossy(&checked.stdout); - assert!(stdout.contains("config: issues found"), "{stdout}"); - assert!( - stdout.contains("unknown config key made_up_toplevel; ignoring key"), - "{stdout}" - ); - assert!( - stdout.contains("unknown config key \"made.up\"; ignoring key"), - "{stdout}" - ); - assert!( - stdout.contains("unknown config key ui.mouse_captur; ignoring key"), - "{stdout}" - ); - assert!( - stdout.contains("unknown config key keys.new_tabb; ignoring key"), - "{stdout}" - ); - assert!(!stdout.contains("commented_out"), "{stdout}"); - - cleanup_test_base(&base); -} - -#[test] -fn config_check_reports_unreadable_config_path() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let config_path = config_home.join(app_dir_name()).join("config.toml"); - fs::create_dir_all(&config_path).unwrap(); - - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); - - assert_eq!(checked.status.code(), Some(1)); - let stdout = String::from_utf8_lossy(&checked.stdout); - assert!(stdout.contains("config: issues found"), "{stdout}"); - assert!(stdout.contains("config read error"), "{stdout}"); - assert!(stdout.contains("using defaults"), "{stdout}"); - - cleanup_test_base(&base); -} - -#[test] -fn config_check_reports_ok_when_config_is_missing() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); - - assert!(checked.status.success()); - let stdout = String::from_utf8_lossy(&checked.stdout); - assert!(stdout.contains("config: ok"), "{stdout}"); - - cleanup_test_base(&base); -} - -#[test] -fn config_check_rejects_json_output() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check", "--json"]); - - assert_eq!(checked.status.code(), Some(2)); - assert!(checked.stdout.is_empty()); - assert_eq!( - String::from_utf8_lossy(&checked.stderr), - "usage: herdr config check\n" - ); - - cleanup_test_base(&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() { - use std::os::unix::fs::PermissionsExt; - - 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 = base.join("bin"); - let captured_args = base.join("pi-args"); - fs::create_dir_all(&bin).unwrap(); - let fake_pi = bin.join("pi"); - fs::write( - &fake_pi, - format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nexport HERDR_AGENT=pi\n'{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state idle >/dev/null\nwhile IFS= read -r prompt; do\n [ \"$prompt\" = \"do not transition\" ] && continue\n '{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state working >/dev/null\n '{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state idle >/dev/null\ndone\n", - captured_args.display(), - env!("CARGO_BIN_EXE_herdr"), - env!("CARGO_BIN_EXE_herdr"), - env!("CARGO_BIN_EXE_herdr"), - ), - ) - .unwrap(); - fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); - - let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); - wait_for_socket(&socket_path, Duration::from_secs(5)); - let created = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let before = run_cli_json(&socket_path, &["pane", "list"]); - let before_topology = pane_topology_snapshot(&before); - - let missing = run_cli( - &socket_path, - &[ - "agent", "start", "missing", "--kind", "pi", "--pane", "w999:p1", - ], - ); - assert_eq!(missing.status.code(), Some(1)); - let missing: serde_json::Value = serde_json::from_slice(&missing.stderr).unwrap(); - assert_eq!(missing["error"]["code"], "agent_pane_not_found"); - assert_eq!( - pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])), - before_topology - ); - - for unsafe_arg in ["tab\tcompletion", "escape\x1b[201~"] { - let rejected = run_cli( - &socket_path, - &[ - "agent", - "start", - "invalid-argument", - "--kind", - "pi", - "--pane", - &pane_id, - "--", - unsafe_arg, - ], - ); - assert_eq!(rejected.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&rejected.stderr).unwrap(); - assert_eq!(error["error"]["code"], "invalid_agent_argument"); - } - - for invalid_timeout in ["3000", "300001"] { - let rejected = run_cli( - &socket_path, - &[ - "agent", - "start", - "invalid-timeout", - "--kind", - "pi", - "--pane", - &pane_id, - "--timeout", - invalid_timeout, - ], - ); - assert_eq!(rejected.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&rejected.stderr).unwrap(); - assert_eq!(error["error"]["code"], "invalid_agent_timeout"); - } - - let started = run_cli_json( - &socket_path, - &[ - "agent", - "start", - "main", - "--kind", - "pi", - "--pane", - &pane_id, - "--timeout", - "8000", - "--", - "--name", - "scratch", - "--no-session", - ], - ); - assert_eq!(started["result"]["type"], "agent_started"); - assert_eq!(started["result"]["agent"]["name"], "main"); - assert_eq!(started["result"]["agent"]["agent"], "pi"); - assert_eq!(started["result"]["agent"]["pane_id"], pane_id); - assert_eq!(started["result"]["argv"][0], "pi"); - assert_eq!(started["result"]["argv"][1], "--name"); - assert_eq!(started["result"]["argv"][2], "scratch"); - assert_eq!(started["result"]["argv"][3], "--no-session"); - assert_eq!( - fs::read_to_string(&captured_args).unwrap(), - "--name\nscratch\n--no-session\n" - ); - - let literal_flag_prompt = run_cli(&socket_path, &["agent", "prompt", "main", "--wait"]); - assert!( - literal_flag_prompt.status.success(), - "flag-shaped prompt was not treated literally: {}", - String::from_utf8_lossy(&literal_flag_prompt.stderr) - ); - - let after = run_cli_json(&socket_path, &["pane", "list"]); - assert_eq!(pane_topology_snapshot(&after), before_topology); - - let stale_idle = run_cli( - &socket_path, - &[ - "agent", - "prompt", - "main", - "do not transition", - "--wait", - "--timeout", - "200", - ], - ); - assert_eq!(stale_idle.status.code(), Some(1)); - let stale_idle: serde_json::Value = serde_json::from_slice(&stale_idle.stderr).unwrap(); - assert_eq!(stale_idle["error"]["code"], "timeout"); - - let prompted = run_cli( - &socket_path, - &[ - "agent", - "prompt", - "main", - "Review this diff", - "--wait", - "--timeout", - "2000", - ], - ); - assert!( - prompted.status.success(), - "prompt failed: {}", - String::from_utf8_lossy(&prompted.stderr) - ); - let prompted: serde_json::Value = serde_json::from_slice(&prompted.stdout).unwrap(); - assert_eq!(prompted["result"]["type"], "agent_prompted"); - - let duplicate = run_cli( - &socket_path, - &["agent", "start", "main", "--kind", "pi", "--pane", &pane_id], - ); - 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"); - - let busy = run_cli( - &socket_path, - &[ - "agent", "start", "second", "--kind", "pi", "--pane", &pane_id, - ], - ); - assert!(!busy.status.success()); - let busy_json: serde_json::Value = serde_json::from_slice(&busy.stderr).unwrap(); - assert_eq!(busy_json["error"]["code"], "agent_pane_busy"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_start_rejects_a_shell_replaced_by_a_foreground_program() { - 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_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let topology = pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])); - assert!( - run_cli(&socket_path, &["pane", "run", &pane_id, "exec sleep 5"]) - .status - .success() - ); - thread::sleep(Duration::from_millis(150)); - - let started = run_cli( - &socket_path, - &[ - "agent", - "start", - "worker", - "--kind", - "pi", - "--pane", - &pane_id, - "--timeout", - "1000", - ], - ); - assert_eq!(started.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); - assert_eq!(error["error"]["code"], "agent_pane_busy"); - assert_eq!( - pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])), - topology - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_start_timeout_releases_the_name_for_reuse() { - use std::os::unix::fs::PermissionsExt; - - 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 = base.join("bin"); - fs::create_dir_all(&bin).unwrap(); - let fake_pi = bin.join("pi"); - fs::write( - &fake_pi, - "#!/bin/sh\nunset HERDR_AGENT\nexec /bin/sleep 20\n", - ) - .unwrap(); - fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); - - let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); - wait_for_socket(&socket_path, Duration::from_secs(5)); - let created = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let started = run_cli( - &socket_path, - &[ - "agent", - "start", - "worker", - "--kind", - "pi", - "--pane", - &pane_id, - "--timeout", - "3100", - ], - ); - assert_eq!(started.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); - assert_eq!(error["error"]["code"], "timeout"); - - let reused = run_cli(&socket_path, &["agent", "rename", &pane_id, "worker"]); - assert!( - reused.status.success(), - "name was not released: {}", - String::from_utf8_lossy(&reused.stderr) - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_start_reports_detected_kind_mismatch_before_released_name() { - use std::os::unix::fs::PermissionsExt; - - 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 = base.join("bin"); - fs::create_dir_all(&bin).unwrap(); - let fake_pi = bin.join("pi"); - fs::write( - &fake_pi, - "#!/bin/sh\nHERDR_AGENT=codex exec /bin/sleep 10\n", - ) - .unwrap(); - fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); - - let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); - wait_for_socket(&socket_path, Duration::from_secs(5)); - let created = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let started = run_cli( - &socket_path, - &[ - "agent", - "start", - "worker", - "--kind", - "pi", - "--pane", - &pane_id, - "--timeout", - "5000", - ], - ); - assert_eq!(started.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); - assert_eq!(error["error"]["code"], "agent_kind_mismatch"); - - let reused = run_cli(&socket_path, &["agent", "rename", &pane_id, "worker"]); - assert!(reused.status.success()); - - 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 reported = run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &root_pane_id, - "--source", - "custom:test", - "--agent", - "pi", - "--state", - "idle", - ], - ); - assert!(reported.status.success()); - let waited = run_cli_json( - &socket_path, - &["agent", "wait", "worker", "--timeout", "100"], - ); - assert_eq!(waited["result"]["agent"]["pane_id"], root_pane_id); - - // A stale semantic report must not allow prompt text into the resumed shell. - let prompted = run_cli( - &socket_path, - &["agent", "prompt", "worker", "echo prompt-must-not-run"], - ); - assert_eq!(prompted.status.code(), Some(1)); - let prompted_json: serde_json::Value = serde_json::from_slice(&prompted.stderr).unwrap(); - assert_eq!(prompted_json["error"]["code"], "agent_not_ready"); - - let working = run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &root_pane_id, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "working", - ], - ); - assert!(working.status.success()); - let blocked_socket = socket_path.clone(); - let blocked_pane = root_pane_id.clone(); - let blocked_transition = thread::spawn(move || { - thread::sleep(Duration::from_millis(100)); - let blocked = run_cli( - &blocked_socket, - &[ - "pane", - "report-agent", - &blocked_pane, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "blocked", - ], - ); - assert!(blocked.status.success()); - }); - let waited = run_cli_json( - &socket_path, - &["agent", "wait", "worker", "--timeout", "2000"], - ); - blocked_transition.join().unwrap(); - assert_eq!(waited["result"]["agent"]["agent_status"], "blocked"); - let immediate_blocked = - run_cli_json(&socket_path, &["agent", "wait", "worker", "--timeout", "1"]); - assert_eq!( - immediate_blocked["result"]["agent"]["agent_status"], - "blocked" - ); - - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &root_pane_id, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "working", - ], - ) - .status - .success()); - let idle_socket = socket_path.clone(); - let idle_pane = root_pane_id.clone(); - let idle_transition = thread::spawn(move || { - thread::sleep(Duration::from_millis(100)); - assert!(run_cli( - &idle_socket, - &[ - "pane", - "report-agent", - &idle_pane, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "idle", - ], - ) - .status - .success()); - }); - let idle_wait = run_cli_json( - &socket_path, - &["agent", "wait", "worker", "--timeout", "2000"], - ); - idle_transition.join().unwrap(); - assert!(matches!( - idle_wait["result"]["agent"]["agent_status"].as_str(), - Some("idle" | "done") - )); - - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &root_pane_id, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "working", - ], - ) - .status - .success()); - let timed_out = run_cli( - &socket_path, - &["agent", "wait", "worker", "--timeout", "100"], - ); - assert_eq!(timed_out.status.code(), Some(1)); - let timeout: serde_json::Value = serde_json::from_slice(&timed_out.stderr).unwrap(); - assert_eq!(timeout["error"]["code"], "timeout"); - - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &root_pane_id, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "unknown", - ], - ) - .status - .success()); - let unknown = run_cli( - &socket_path, - &["agent", "wait", "worker", "--timeout", "1000"], - ); - assert_eq!(unknown.status.code(), Some(1)); - let unknown: serde_json::Value = serde_json::from_slice(&unknown.stderr).unwrap(); - assert_eq!(unknown["error"]["code"], "agent_not_running"); - - 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 agent_wait_returns_immediately_for_unseen_done_agent() { - 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_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let first = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let workspace_id = created["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap(); - let second_tab = run_cli_json( - &socket_path, - &["tab", "create", "--workspace", workspace_id], - ); - let second_tab_id = second_tab["result"]["tab"]["tab_id"].as_str().unwrap(); - assert_ne!(second_tab_id, "w1:t1"); - assert!(run_cli(&socket_path, &["tab", "focus", second_tab_id]) - .status - .success()); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &first, - "--source", - "custom:done", - "--agent", - "pi", - "--state", - "working", - ], - ) - .status - .success()); - assert!( - run_cli(&socket_path, &["agent", "rename", &first, "worker"]) - .status - .success() - ); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &first, - "--source", - "custom:done", - "--agent", - "pi", - "--state", - "idle", - ], - ) - .status - .success()); - - let waited = run_cli_json(&socket_path, &["agent", "wait", "worker", "--timeout", "1"]); - assert_eq!(waited["result"]["agent"]["agent_status"], "done"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_wait_pins_the_original_terminal_when_name_is_reused() { - 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_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let first = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let split = run_cli_json( - &socket_path, - &["pane", "split", &first, "--direction", "right"], - ); - let second = split["result"]["pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &first, - "--source", - "custom:race", - "--agent", - "pi", - "--state", - "working", - ], - ) - .status - .success()); - assert!( - run_cli(&socket_path, &["agent", "rename", &first, "worker"]) - .status - .success() - ); - - let wait_socket = socket_path.clone(); - let waiter = thread::spawn(move || { - run_cli( - &wait_socket, - &["agent", "wait", "worker", "--timeout", "2000"], - ) - }); - thread::sleep(Duration::from_millis(250)); - assert!( - run_cli(&socket_path, &["agent", "rename", "worker", "--clear"]) - .status - .success() - ); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &second, - "--source", - "custom:race", - "--agent", - "pi", - "--state", - "idle", - ], - ) - .status - .success()); - assert!( - run_cli(&socket_path, &["agent", "rename", &second, "worker"]) - .status - .success() - ); - - let waited = waiter.join().unwrap(); - assert_eq!(waited.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert_eq!(error["error"]["code"], "agent_name_not_found"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_wait_ignores_other_panes_and_errors_when_its_pane_closes() { - 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_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let first = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let split = run_cli_json( - &socket_path, - &["pane", "split", &first, "--direction", "right"], - ); - let second = split["result"]["pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &first, - "--source", - "custom:close", - "--agent", - "pi", - "--state", - "working", - ], - ) - .status - .success()); - assert!( - run_cli(&socket_path, &["agent", "rename", &first, "worker"]) - .status - .success() - ); - - let wait_socket = socket_path.clone(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - thread::spawn(move || { - let _ = done_tx.send(run_cli( - &wait_socket, - &["agent", "wait", "worker", "--timeout", "3000"], - )); - }); - thread::sleep(Duration::from_millis(150)); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &second, - "--source", - "custom:close", - "--agent", - "pi", - "--state", - "idle", - ], - ) - .status - .success()); - thread::sleep(Duration::from_millis(150)); - assert!(matches!( - done_rx.try_recv(), - Err(std::sync::mpsc::TryRecvError::Empty) - )); - - assert!(run_cli(&socket_path, &["pane", "close", &first]) - .status - .success()); - let waited = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert_eq!(waited.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert!(matches!( - error["error"]["code"].as_str(), - Some("agent_not_found" | "pane_not_found") - )); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn pane_close_only_removes_the_target_tab_when_other_tabs_exist() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); - let workspace_id = created_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - - let created_tab = run_cli( - &socket_path, - &["tab", "create", "--workspace", &workspace_id], - ); - assert!(created_tab.status.success()); - let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap(); - let second_root_pane_id = created_tab_json["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let closed = run_cli(&socket_path, &["pane", "close", &second_root_pane_id]); - assert!(closed.status.success()); - let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); - assert_eq!(closed_json["result"]["type"], "ok"); - - let workspaces = run_cli(&socket_path, &["workspace", "list"]); - assert!(workspaces.status.success()); - let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap(); - assert_eq!( - workspaces_json["result"]["workspaces"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert_eq!( - workspaces_json["result"]["workspaces"][0]["workspace_id"], - workspace_id - ); - - let tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]); - assert!(tabs.status.success()); - let tabs_json: serde_json::Value = serde_json::from_slice(&tabs.stdout).unwrap(); - assert_eq!(tabs_json["result"]["tabs"].as_array().unwrap().len(), 1); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn pane_close_removes_the_workspace_when_it_closes_the_last_pane() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); - let root_pane_id = created_json["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let closed = run_cli(&socket_path, &["pane", "close", &root_pane_id]); - assert!(closed.status.success()); - let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); - assert_eq!(closed_json["result"]["type"], "ok"); - - let workspaces = run_cli(&socket_path, &["workspace", "list"]); - assert!(workspaces.status.success()); - let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap(); - assert!(workspaces_json["result"]["workspaces"] - .as_array() - .unwrap() - .is_empty()); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn pane_run_read_and_wait_commands_work() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - send_request( - &socket_path, - &format!( - r#"{{"id":"req_cli_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, - base.display() - ), - ); - let create = run_cli( - &socket_path, - &[ - "pane", - "run", - "1-1", - "echo alpha && echo beta && printf 'ready\\n'", - ], - ); - assert!(create.status.success()); - - let started = Instant::now(); - let waited = run_cli( - &socket_path, - &[ - "wait", - "output", - "1-1", - "--match", - "ready", - "--source", - "recent", - "--lines", - "40", - "--timeout", - "5000", - ], - ); - let elapsed = started.elapsed(); - assert!( - waited.status.success(), - "stderr: {}", - String::from_utf8_lossy(&waited.stderr) - ); - assert!( - elapsed < Duration::from_millis(500), - "already-matching wait took {elapsed:?}" - ); - let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); - assert_eq!(waited_json["result"]["type"], "output_matched"); - - let read = run_cli( - &socket_path, - &["pane", "read", "1-1", "--source", "recent", "--lines", "40"], - ); - assert!(read.status.success()); - let text = String::from_utf8(read.stdout).unwrap(); - assert!(text.contains("alpha")); - assert!(text.contains("ready")); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn wait_output_matches_recent_unwrapped_text() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - - let token = "WRAP_WAIT_TEST_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; - let script = base.join("emit-long-token.sh"); - std::fs::write(&script, format!("#!/bin/sh\nprintf '%s\\n' '{token}'\n")).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&script).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&script, perms).unwrap(); - } - - let run = run_cli( - &socket_path, - &["pane", "run", "1-1", &format!("sh {}", script.display())], - ); - assert!(run.status.success()); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "output", - "1-1", - "--match", - token, - "--source", - "recent", - "--lines", - "80", - "--timeout", - "5000", - ], - ); - assert!( - waited.status.success(), - "stderr: {} stdout: {}", - String::from_utf8_lossy(&waited.stderr), - String::from_utf8_lossy(&waited.stdout) - ); - - let read = run_cli( - &socket_path, - &[ - "pane", - "read", - "1-1", - "--source", - "recent-unwrapped", - "--lines", - "80", - ], - ); - assert!(read.status.success()); - let text = String::from_utf8(read.stdout).unwrap(); - assert!(text.contains(token)); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn closing_pane_terminates_processes_inside_it() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - - let split = run_cli( - &socket_path, - &["pane", "split", "1-1", "--direction", "right"], - ); - assert!(split.status.success()); - let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap(); - let pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap(); - - let pid_file = base.join("pane-close.pid"); - let command = format!( - "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'", - pid_file.display() - ); - let ran = run_cli(&socket_path, &["pane", "run", pane_id, &command]); - assert!( - ran.status.success(), - "stderr: {}", - String::from_utf8_lossy(&ran.stderr) - ); - - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline && !pid_file.exists() { - thread::sleep(Duration::from_millis(25)); - } - assert!(pid_file.exists(), "pid file was not created"); - - let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| { - panic!("failed to read pane child pid: {err}"); - }); - assert!(process_exists(pid), "child process was not running"); - - let closed = run_cli(&socket_path, &["pane", "close", pane_id]); - assert!( - closed.status.success(), - "stderr: {}", - String::from_utf8_lossy(&closed.stderr) - ); - assert!( - wait_for_pid_exit(pid, Duration::from_secs(3)), - "process {pid} survived pane close" - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn closing_workspace_terminates_processes_inside_it() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - - let pid_file = base.join("workspace-close.pid"); - let command = format!( - "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'", - pid_file.display() - ); - let ran = run_cli(&socket_path, &["pane", "run", "1-1", &command]); - assert!( - ran.status.success(), - "stderr: {}", - String::from_utf8_lossy(&ran.stderr) - ); - - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline && !pid_file.exists() { - thread::sleep(Duration::from_millis(25)); - } - assert!(pid_file.exists(), "pid file was not created"); - - let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| { - panic!("failed to read pane child pid: {err}"); - }); - assert!(process_exists(pid), "child process was not running"); - - let closed = run_cli(&socket_path, &["workspace", "close", "1"]); - assert!( - closed.status.success(), - "stderr: {}", - String::from_utf8_lossy(&closed.stderr) - ); - assert!( - wait_for_pid_exit(pid, Duration::from_secs(3)), - "process {pid} survived workspace close" - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn workspace_ids_and_public_pane_ids_are_stable() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let ws1_json = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let ws1_id = ws1_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - - let split_12_json = run_cli_json( - &socket_path, - &["pane", "split", "1-1", "--direction", "right", "--no-focus"], - ); - assert_eq!( - split_12_json["result"]["pane"]["pane_id"], - format!("{ws1_id}:p2") - ); - - let split_13_json = run_cli_json( - &socket_path, - &["pane", "split", "1-1", "--direction", "down", "--no-focus"], - ); - assert_eq!( - split_13_json["result"]["pane"]["pane_id"], - format!("{ws1_id}:p3") - ); - - let ws2_json = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", "/tmp", "--no-focus"], - ); - let ws2_id = ws2_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - assert_ne!(ws2_id, ws1_id); - - let ws2_focus = run_cli(&socket_path, &["workspace", "focus", &ws2_id]); - assert!( - ws2_focus.status.success(), - "stderr: {}", - String::from_utf8_lossy(&ws2_focus.stderr) - ); - - let ws2_split_json = run_cli_json( - &socket_path, - &["pane", "split", "2-1", "--direction", "right", "--no-focus"], - ); - assert_eq!( - ws2_split_json["result"]["pane"]["pane_id"], - format!("{ws2_id}:p2") - ); - - let ws3_json = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", "/", "--no-focus"], - ); - let ws3_id = ws3_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - assert_ne!(ws3_id, ws1_id); - assert_ne!(ws3_id, ws2_id); - - let close_ws2 = run_cli(&socket_path, &["workspace", "close", &ws2_id]); - assert!( - close_ws2.status.success(), - "stderr: {}", - String::from_utf8_lossy(&close_ws2.stderr) - ); - - let workspaces_json = run_cli_json(&socket_path, &["workspace", "list"]); - let ids: Vec<String> = workspaces_json["result"]["workspaces"] - .as_array() - .unwrap() - .iter() - .map(|ws| ws["workspace_id"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(ids, vec![ws1_id.clone(), ws3_id.clone()]); - - let new_ws_json = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", "/var/tmp", "--no-focus"], - ); - let new_ws_id = new_ws_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - assert_ne!(new_ws_id, ws1_id); - assert_ne!(new_ws_id, ws2_id); - assert_ne!(new_ws_id, ws3_id); - - let ws3_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws3_id]); - assert_eq!( - ws3_panes_json["result"]["panes"][0]["pane_id"], - format!("{ws3_id}:p1") - ); - - let close_middle = run_cli(&socket_path, &["pane", "close", &format!("{ws1_id}-2")]); - assert!( - close_middle.status.success(), - "stderr: {}", - String::from_utf8_lossy(&close_middle.stderr) - ); - - let ws1_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws1_id]); - let pane_ids: Vec<String> = ws1_panes_json["result"]["panes"] - .as_array() - .unwrap() - .iter() - .map(|pane| pane["pane_id"].as_str().unwrap().to_string()) - .collect(); - assert_eq!( - pane_ids, - vec![format!("{ws1_id}:p1"), format!("{ws1_id}:p3")] - ); - - let closed_lookup = run_cli(&socket_path, &["pane", "get", &format!("{ws1_id}:p2")]); - assert!( - !closed_lookup.status.success(), - "closed pane id should not retarget: {}", - String::from_utf8_lossy(&closed_lookup.stdout) - ); - - let split_14_json = run_cli_json( - &socket_path, - &[ - "pane", - "split", - &format!("{ws1_id}:p1"), - "--direction", - "right", - "--no-focus", - ], - ); - assert_eq!( - split_14_json["result"]["pane"]["pane_id"], - format!("{ws1_id}:p4") - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn pane_shell_gets_herdr_socket_and_pane_env() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = send_request( - &socket_path, - &format!( - r#"{{"id":"req_env_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, - base.display() - ), - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let env_capture = base.join("pane-env.txt"); - let ran = run_cli( - &socket_path, - &[ - "pane", - "run", - "1-1", - &format!( - "printf '%s\\n%s\\n' \"$HERDR_SOCKET_PATH\" \"$HERDR_PANE_ID\" > {}", - env_capture.display() - ), - ], - ); - assert!(ran.status.success()); - - let deadline = Instant::now() + Duration::from_secs(3); - let mut text = String::new(); - while Instant::now() < deadline { - if env_capture.exists() { - text = fs::read_to_string(&env_capture).unwrap(); - if text.contains(&socket_path.display().to_string()) && text.contains(&pane_id) { - break; - } - } - thread::sleep(Duration::from_millis(25)); - } - assert!(env_capture.exists(), "env capture file was not created"); - assert!( - text.contains(&socket_path.display().to_string()), - "env file was: {text:?}" - ); - assert!(text.contains(&pane_id), "env file was: {text:?}"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn wait_agent_status_exits_when_idle_status_matches() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let bin_dir = base.join("bin"); - - fs::create_dir_all(&bin_dir).unwrap(); - let fake_pi = bin_dir.join("pi"); - fs::write( - &fake_pi, - "#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n", - ) - .unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&fake_pi).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&fake_pi, perms).unwrap(); - } - - let inherited_path = std::env::var("PATH").unwrap_or_default(); - let path_override = format!("{}:{}", bin_dir.display(), inherited_path); - let herdr = spawn_herdr_with_path( - &config_home, - &runtime_dir, - &socket_path, - Some(Path::new(&path_override)), - ); - - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = send_request( - &socket_path, - &format!( - r#"{{"id":"req_cli_2","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, - base.display() - ), - ); - assert!(created["result"]["workspace"]["workspace_id"].is_string()); - - let start_pi = run_cli(&socket_path, &["pane", "run", "1-1", "pi"]); - assert!(start_pi.status.success()); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "agent-status", - "1-1", - "--status", - "idle", - "--timeout", - "10000", - ], - ); - assert!( - waited.status.success(), - "stderr: {}", - String::from_utf8_lossy(&waited.stderr) - ); - let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); - assert_eq!(waited_json["event"], "pane.agent_status_changed"); - assert_eq!(waited_json["data"]["agent_status"], "idle"); - assert_eq!(waited_json["data"]["agent"], "pi"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn plugin_link_list_unlink_cli_smoke_test() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let plugin_dir = base.join("plugins").join("layout"); - fs::create_dir_all(&plugin_dir).unwrap(); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.layout" -name = "Layout" -version = "0.1.0" -min_herdr_version = "0.6.10" -description = "Apply a preferred Herdr layout" - -[[actions]] -id = "apply" -title = "Apply layout" -contexts = ["workspace"] -command = ["sh", "-c", "echo layout"] - -[[events]] -on = "worktree.created" -command = ["sh", "-c", "echo worktree"] - -[[panes]] -id = "board" -title = "Board" -placement = "tab" -command = ["sh", "-c", "sleep 5"] -"#, - ) - .unwrap(); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - let workspace = run_cli_json( - &socket_path, - &[ - "workspace", - "create", - "--cwd", - base.to_str().unwrap(), - "--focus", - ], - ); - assert_eq!(workspace["result"]["type"], "workspace_created"); - - let linked = run_cli_json_in_dir(&socket_path, &["plugin", "link", "plugins/layout"], &base); - assert_eq!(linked["result"]["type"], "plugin_linked"); - assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.layout"); - assert_eq!(linked["result"]["plugin"]["actions"][0]["id"], "apply"); - assert_eq!( - linked["result"]["plugin"]["events"][0]["on"], - "worktree.created" - ); - assert_eq!(linked["result"]["plugin"]["panes"][0]["id"], "board"); - - let listed_human = run_cli(&socket_path, &["plugin", "list"]); - assert!(listed_human.status.success()); - assert!(String::from_utf8_lossy(&listed_human.stdout).contains("example.layout")); - - let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]); - assert_eq!(listed["result"]["type"], "plugin_list"); - assert_eq!( - listed["result"]["plugins"][0]["plugin_id"], - "example.layout" - ); - - let invoked = run_cli_json( - &socket_path, - &[ - "plugin", - "action", - "invoke", - "apply", - "--plugin", - "example.layout", - ], - ); - assert_eq!(invoked["result"]["type"], "plugin_action_invoked"); - assert_eq!(invoked["result"]["action"]["action_id"], "apply"); - - let logs = run_cli_json( - &socket_path, - &[ - "plugin", - "log", - "list", - "--plugin", - "example.layout", - "--limit", - "5", - ], - ); - assert_eq!(logs["result"]["type"], "plugin_log_list"); - assert!(!logs["result"]["logs"].as_array().unwrap().is_empty()); - - let pane = run_cli_json( - &socket_path, - &[ - "plugin", - "pane", - "open", - "--plugin", - "example.layout", - "--entrypoint", - "board", - "--env", - "HERDR_ROLE=board", - "--no-focus", - ], - ); - assert_eq!(pane["result"]["type"], "plugin_pane_opened"); - assert_eq!(pane["result"]["plugin_pane"]["entrypoint"], "board"); - - let missing_plugin_value = run_cli(&socket_path, &["plugin", "list", "--plugin"]); - assert_eq!(missing_plugin_value.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&missing_plugin_value.stderr) - .contains("missing value for --plugin")); - - let invalid_limit = run_cli( - &socket_path, - &["plugin", "log", "list", "--limit", "not-a-number"], - ); - assert_eq!(invalid_limit.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&invalid_limit.stderr).contains("invalid --limit value")); - - let unlinked = run_cli_json(&socket_path, &["plugin", "unlink", "example.layout"]); - assert_eq!(unlinked["result"]["type"], "plugin_unlinked"); - assert_eq!(unlinked["result"]["removed"], true); - - let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]); - assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn plugin_install_list_uninstall_offline_cli_smoke_test() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[build]] -command = ["sh", "-c", "echo built > built.txt; if [ -n \"$HERDR_SESSION\" ]; then echo \"$HERDR_SESSION\" > leaked-session.txt; fi"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo bootstrap"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let install = run_named_cli_with_env( - &config_home, - &runtime_dir, - &[ - "--session", - "plugins", - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", - "--yes", - ], - &[ - ("GIT_CONFIG_GLOBAL", &git_config), - ("HERDR_SESSION", Path::new("leaked-session")), - ], - ); - assert!( - install.status.success(), - "install failed\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&install.stdout), - String::from_utf8_lossy(&install.stderr) - ); - - let listed = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], - ); - let plugin = &listed["result"]["plugins"][0]; - assert_eq!(plugin["plugin_id"], "example.worktree-bootstrap"); - assert_eq!(plugin["source"]["kind"], "github"); - assert_eq!(plugin["source"]["owner"], "ogulcancelik"); - assert_eq!(plugin["source"]["repo"], "herdr-plugin-examples"); - assert_eq!(plugin["source"]["subdir"], "worktree-bootstrap"); - assert!(plugin["source"]["resolved_commit"].as_str().is_some()); - let managed_path = PathBuf::from(plugin["source"]["managed_path"].as_str().unwrap()); - assert!(managed_path.exists(), "managed checkout should exist"); - assert!( - managed_path - .join("worktree-bootstrap") - .join("built.txt") - .exists(), - "build artifact should be preserved in managed checkout" - ); - assert!( - !managed_path - .join("worktree-bootstrap") - .join("leaked-session.txt") - .exists(), - "build command should not inherit HERDR_SESSION" - ); - - let uninstall = run_named_cli( - &config_home, - &runtime_dir, - &[ - "--session", - "plugins", - "plugin", - "uninstall", - "example.worktree-bootstrap", - ], - ); - assert!( - uninstall.status.success(), - "uninstall failed\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&uninstall.stdout), - String::from_utf8_lossy(&uninstall.stderr) - ); - assert!( - !managed_path.exists(), - "managed checkout should be deleted on uninstall" - ); - - let listed = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], - ); - assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_build_failure_does_not_register_or_create_checkout() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("build-fail"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.build-fail" -name = "Build Fail" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[build]] -command = ["sh", "-c", "echo before-fail && echo failed-build >&2 && exit 7"] - -[[actions]] -id = "run" -title = "Run" -command = ["sh", "-c", "echo should-not-install"] -"#, - ) - .unwrap(); - run_git(&source_repo, &["add", "build-fail/herdr-plugin.toml"]); - run_git( - &source_repo, - &["commit", "--quiet", "-m", "add failing plugin"], - ); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let install = run_named_cli_with_env( - &config_home, - &runtime_dir, - &[ - "--session", - "plugins", - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/build-fail", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - ); - assert!( - !install.status.success(), - "install should fail when build command fails" - ); - assert_eq!(install.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&install.stderr); - assert!(stderr.contains("error: plugin build failed"), "{stderr}"); - assert!(stderr.contains(" plugin: example.build-fail"), "{stderr}"); - assert!(stderr.contains(" build: 1/1"), "{stderr}"); - assert!(stderr.contains(" cwd: "), "{stderr}"); - assert!( - stderr.contains(" command: sh -c echo before-fail && echo failed-build >&2 && exit 7"), - "{stderr}" - ); - assert!(stderr.contains(" status: exit status: 7"), "{stderr}"); - assert!(stderr.contains("stdout:\nbefore-fail"), "{stderr}"); - assert!(stderr.contains("stderr:\nfailed-build"), "{stderr}"); - assert!(stderr.contains("Plugin was not installed."), "{stderr}"); - assert!(!stderr.contains("Error: Custom"), "{stderr}"); - - let listed = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], - ); - assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); - - assert!( - path_missing_or_empty(&managed_github_plugin_dir(&config_home)), - "failed build should not leave managed checkouts" - ); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_build_spawn_failure_prints_clean_error() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("missing-tool"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.missing-tool" -name = "Missing Tool" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[build]] -command = ["definitely-missing-herdr-build-tool-xyz"] - -[[actions]] -id = "run" -title = "Run" -command = ["sh", "-c", "echo should-not-install"] -"#, - ) - .unwrap(); - run_git(&source_repo, &["add", "missing-tool/herdr-plugin.toml"]); - run_git( - &source_repo, - &["commit", "--quiet", "-m", "add missing tool plugin"], - ); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let install = run_named_cli_with_env( - &config_home, - &runtime_dir, - &[ - "--session", - "plugins", - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/missing-tool", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - ); - assert!( - !install.status.success(), - "install should fail when build command cannot start" - ); - assert_eq!(install.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&install.stderr); - assert!(stderr.contains("error: plugin build failed"), "{stderr}"); - assert!( - stderr.contains(" plugin: example.missing-tool"), - "{stderr}" - ); - assert!(stderr.contains(" build: 1/1"), "{stderr}"); - assert!( - stderr.contains(" command: definitely-missing-herdr-build-tool-xyz"), - "{stderr}" - ); - assert!(stderr.contains(" error: failed to start:"), "{stderr}"); - assert!(stderr.contains("Plugin was not installed."), "{stderr}"); - assert!(!stderr.contains("Error: Custom"), "{stderr}"); - - let listed = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], - ); - assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_rejects_manifest_changed_by_build() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("manifest-mutator"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.manifest-mutator" -name = "Manifest Mutator" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[build]] -command = ["sh", "mutate.sh"] - -[[actions]] -id = "run" -title = "Run reviewed command" -command = ["sh", "-c", "echo reviewed"] -"#, - ) - .unwrap(); - fs::write( - plugin_dir.join("mutate.sh"), - r#"cat > herdr-plugin.toml <<'EOF' -id = "example.manifest-mutator" -name = "Manifest Mutator" -version = "0.1.0" -min_herdr_version = "0.0.1" -platforms = ["linux", "macos", "windows"] - -[[build]] -command = ["sh", "mutate.sh"] - -[[actions]] -id = "run" -title = "Run reviewed command" -command = ["sh", "-c", "echo reviewed"] -EOF -"#, - ) - .unwrap(); - run_git(&source_repo, &["add", "manifest-mutator"]); - run_git( - &source_repo, - &["commit", "--quiet", "-m", "add mutating plugin"], - ); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let install = run_named_cli_with_env( - &config_home, - &runtime_dir, - &[ - "--session", - "plugins", - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/manifest-mutator", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - ); - assert!( - !install.status.success(), - "install should fail when build changes reviewed manifest" - ); - let stderr = String::from_utf8_lossy(&install.stderr); - assert!( - stderr.contains("plugin build changed herdr-plugin.toml after install preview"), - "{stderr}" - ); - - let listed = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], - ); - assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); - - assert!( - path_missing_or_empty(&managed_github_plugin_dir(&config_home)), - "manifest mutation should not leave managed checkouts" - ); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_restores_previous_checkout_when_registration_fails() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("fake-herdr.sock"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.2.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo new"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let managed_checkout = config_home - .join("herdr-dev") - .join("plugins") - .join("github") - .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); - fs::create_dir_all(&managed_checkout).unwrap(); - fs::write(managed_checkout.join("old-marker"), "old checkout\n").unwrap(); - - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let listener = UnixListener::bind(&socket_path).unwrap(); - let managed_checkout_for_server = managed_checkout.clone(); - let server = thread::spawn(move || { - let (mut first, first_line) = accept_fake_cli_operation(&listener); - let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); - assert_eq!(first_request["method"], "plugin.list"); - writeln!( - first, - "{}", - serde_json::json!({ - "id": "cli:plugin", - "result": { - "type": "plugin_list", - "plugins": [{ - "plugin_id": "example.worktree-bootstrap", - "name": "Worktree Bootstrap", - "version": "0.1.0", - "min_herdr_version": "0.6.10", - "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), - "plugin_root": managed_checkout_for_server.display().to_string(), - "enabled": true, - "source": { - "kind": "github", - "owner": "ogulcancelik", - "repo": "herdr-plugin-examples", - "subdir": "worktree-bootstrap", - "resolved_commit": "old", - "managed_path": managed_checkout_for_server.display().to_string(), - "installed_unix_ms": 1 - } - }] - } - }) - ) - .unwrap(); - first.flush().unwrap(); - - let (mut second, second_line) = accept_fake_cli_operation(&listener); - let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap(); - assert_eq!(second_request["method"], "plugin.link"); - second - .write_all( - br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced failure"}}"#, - ) - .unwrap(); - second.write_all(b"\n").unwrap(); - second.flush().unwrap(); - }); - - let install = run_named_cli_with_env_and_socket_override( - &config_home, - &runtime_dir, - &[ - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - Some(&socket_path), - ); - assert!( - !install.status.success(), - "install should fail when plugin.link fails" - ); - server.join().unwrap(); - assert!( - managed_checkout.join("old-marker").exists(), - "old checkout should be restored after registration failure" - ); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_rejects_server_that_drops_source_metadata() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("fake-herdr.sock"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo install"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let managed_checkout = config_home - .join("herdr-dev") - .join("plugins") - .join("github") - .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let listener = UnixListener::bind(&socket_path).unwrap(); - let managed_checkout_for_server = managed_checkout.clone(); - let server = thread::spawn(move || { - let (mut first, first_line) = accept_fake_cli_operation(&listener); - 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, second_line) = accept_fake_cli_operation(&listener); - let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap(); - assert_eq!(second_request["method"], "plugin.link"); - writeln!( - second, - "{}", - serde_json::json!({ - "id": "cli:plugin", - "result": { - "type": "plugin_linked", - "plugin": { - "plugin_id": "example.worktree-bootstrap", - "name": "Worktree Bootstrap", - "version": "0.1.0", - "min_herdr_version": "0.6.10", - "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), - "plugin_root": managed_checkout_for_server.display().to_string(), - "enabled": true, - "source": {"kind": "local"} - } - } - }) - ) - .unwrap(); - second.flush().unwrap(); - - let (mut third, third_line) = accept_fake_cli_operation(&listener); - let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap(); - assert_eq!(third_request["method"], "plugin.unlink"); - assert_eq!( - third_request["params"]["plugin_id"], - "example.worktree-bootstrap" - ); - third - .write_all( - br#"{"id":"cli:plugin","result":{"type":"plugin_unlinked","plugin_id":"example.worktree-bootstrap","removed":true}}"#, - ) - .unwrap(); - third.write_all(b"\n").unwrap(); - third.flush().unwrap(); - }); - - let install = run_named_cli_with_env_and_socket_override( - &config_home, - &runtime_dir, - &[ - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - Some(&socket_path), - ); - assert!( - !install.status.success(), - "install should fail when server drops GitHub source metadata" - ); - server.join().unwrap(); - assert!( - !managed_checkout.exists(), - "new checkout should be removed after incompatible plugin.link response" - ); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_keeps_checkout_when_incompatible_server_cleanup_fails() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("fake-herdr.sock"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo install"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let managed_checkout = config_home - .join("herdr-dev") - .join("plugins") - .join("github") - .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let listener = UnixListener::bind(&socket_path).unwrap(); - let managed_checkout_for_server = managed_checkout.clone(); - let server = thread::spawn(move || { - let (mut first, _first_line) = accept_fake_cli_operation(&listener); - 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, _second_line) = accept_fake_cli_operation(&listener); - writeln!( - second, - "{}", - serde_json::json!({ - "id": "cli:plugin", - "result": { - "type": "plugin_linked", - "plugin": { - "plugin_id": "example.worktree-bootstrap", - "name": "Worktree Bootstrap", - "version": "0.1.0", - "min_herdr_version": "0.6.10", - "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), - "plugin_root": managed_checkout_for_server.display().to_string(), - "enabled": true, - "source": {"kind": "local"} - } - } - }) - ) - .unwrap(); - second.flush().unwrap(); - - let (mut third, third_line) = accept_fake_cli_operation(&listener); - 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 cli_rejects_protocol_mismatch_before_wait_agent_status_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 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(); - let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); - if first_request["method"] == "ping" { - write_fake_pong(&mut first_stream, &first_request, "0.7.1", 14); - } else { - first_stream - .write_all( - br#"{"id":"cli:wait:agent-status","error":{"code":"not_implemented","message":"method not implemented yet"}}"#, - ) - .unwrap(); - } - if first_request["method"] != "ping" { - 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((second_stream, _)) => { - let mut line = String::new(); - let mut reader = BufReader::new(second_stream); - reader.read_line(&mut line).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 waited = run_cli( - &socket_path, - &[ - "wait", - "agent-status", - "1-1", - "--status", - "idle", - "--timeout", - "5000", - ], - ); - - assert_eq!(waited.status.code(), Some(1)); - assert!(waited.stdout.is_empty()); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert_eq!(error["id"], "cli:wait:agent-status"); - assert_eq!(error["error"]["code"], "protocol_mismatch"); - let message = error["error"]["message"].as_str().unwrap(); - assert!( - message.contains(&format!("client protocol {CURRENT_PROTOCOL}")), - "message: {message}" - ); - assert!(message.contains("server protocol 14"), "message: {message}"); - assert!(message.contains("restart"), "message: {message}"); - - 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"], "ping"); - assert!( - second_line.is_none(), - "mismatched CLI sent an operational request: {second_line:?}" - ); - - cleanup_test_base(&base); -} - -#[test] -fn cli_allows_same_protocol_different_version_and_preserves_server_error() { - 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 ping_stream, _) = listener.accept().unwrap(); - let mut ping_line = String::new(); - BufReader::new(ping_stream.try_clone().unwrap()) - .read_line(&mut ping_line) - .unwrap(); - let ping: serde_json::Value = serde_json::from_str(&ping_line).unwrap(); - assert_eq!(ping["method"], "ping"); - write_fake_pong( - &mut ping_stream, - &ping, - "0.7.1-compatible-build", - CURRENT_PROTOCOL, - ); - - let (mut operation_stream, _) = listener.accept().unwrap(); - let mut operation_line = String::new(); - BufReader::new(operation_stream.try_clone().unwrap()) - .read_line(&mut operation_line) - .unwrap(); - let operation: serde_json::Value = serde_json::from_str(&operation_line).unwrap(); - assert_eq!(operation["method"], "events.wait"); - operation_stream - .write_all( - br#"{"id":"cli:wait:agent-status","error":{"code":"not_implemented","message":"compatible server error"}}"#, - ) - .unwrap(); - operation_stream.write_all(b"\n").unwrap(); - operation_stream.flush().unwrap(); - }); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "agent-status", - "1-1", - "--status", - "idle", - "--timeout", - "5000", - ], - ); - - assert_eq!(waited.status.code(), Some(1)); - assert!(waited.stdout.is_empty()); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert_eq!(error["id"], "cli:wait:agent-status"); - assert_eq!(error["error"]["code"], "not_implemented"); - assert_eq!(error["error"]["message"], "compatible server error"); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn agent_wait_reports_poll_transport_failure_as_structured_error() { - 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 || { - for request_number in 0..2 { - let (mut ping_stream, _) = listener.accept().unwrap(); - let mut ping_line = String::new(); - BufReader::new(ping_stream.try_clone().unwrap()) - .read_line(&mut ping_line) - .unwrap(); - let ping: serde_json::Value = serde_json::from_str(&ping_line).unwrap(); - assert_eq!(ping["method"], "ping"); - write_fake_pong(&mut ping_stream, &ping, "current", CURRENT_PROTOCOL); - - let (mut get_stream, _) = listener.accept().unwrap(); - let mut get_line = String::new(); - BufReader::new(get_stream.try_clone().unwrap()) - .read_line(&mut get_line) - .unwrap(); - let get: serde_json::Value = serde_json::from_str(&get_line).unwrap(); - assert_eq!(get["method"], "agent.get"); - if request_number == 0 { - writeln!( - get_stream, - "{}", - serde_json::json!({ - "id": get["id"], - "result": { - "type": "agent_info", - "agent": { - "name": "worker", - "pane_id": "w1:p1", - "terminal_id": "term_1", - "agent_status": "working" - } - } - }) - ) - .unwrap(); - get_stream.flush().unwrap(); - } - } - }); - - let waited = run_cli( - &socket_path, - &["agent", "wait", "worker", "--timeout", "5000"], - ); - assert_eq!(waited.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert_eq!(error["error"]["code"], "agent_wait_transport_failed"); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn agent_wait_rechecks_protocol_before_subscription() { - 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_ping_stream, _) = listener.accept().unwrap(); - let mut first_ping_line = String::new(); - BufReader::new(first_ping_stream.try_clone().unwrap()) - .read_line(&mut first_ping_line) - .unwrap(); - let first_ping: serde_json::Value = serde_json::from_str(&first_ping_line).unwrap(); - assert_eq!(first_ping["method"], "ping"); - write_fake_pong( - &mut first_ping_stream, - &first_ping, - "current", - CURRENT_PROTOCOL, - ); - - let (mut get_stream, _) = listener.accept().unwrap(); - let mut get_line = String::new(); - BufReader::new(get_stream.try_clone().unwrap()) - .read_line(&mut get_line) - .unwrap(); - let get_request: serde_json::Value = serde_json::from_str(&get_line).unwrap(); - assert_eq!(get_request["method"], "agent.get"); - get_stream - .write_all( - br#"{"id":"cli:agent:wait:resolve","result":{"type":"agent_info","agent":{"name":"worker","pane_id":"w1:p1","terminal_id":"t1","agent_status":"working"}}}"#, - ) - .unwrap(); - get_stream.write_all(b"\n").unwrap(); - get_stream.flush().unwrap(); - - let (mut second_ping_stream, _) = listener.accept().unwrap(); - let mut second_ping_line = String::new(); - BufReader::new(second_ping_stream.try_clone().unwrap()) - .read_line(&mut second_ping_line) - .unwrap(); - let second_ping: serde_json::Value = serde_json::from_str(&second_ping_line).unwrap(); - assert_eq!(second_ping["method"], "ping"); - write_fake_pong(&mut second_ping_stream, &second_ping, "0.7.1", 14); - - listener.set_nonblocking(true).unwrap(); - let deadline = Instant::now() + Duration::from_millis(250); - while Instant::now() < deadline { - match listener.accept() { - Ok(_) => panic!("agent wait polled agent state after protocol mismatch"), - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("accept failed: {err}"), - } - } - }); - - let waited = run_cli( - &socket_path, - &["agent", "wait", "worker", "--timeout", "5000"], - ); - - assert_eq!(waited.status.code(), Some(1)); - assert!(waited.stdout.is_empty()); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert_eq!(error["id"], "cli:agent:wait"); - assert_eq!(error["error"]["code"], "protocol_mismatch"); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn server_live_handoff_bypasses_protocol_guard() { - 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(); - BufReader::new(stream.try_clone().unwrap()) - .read_line(&mut line) - .unwrap(); - let request: serde_json::Value = serde_json::from_str(&line).unwrap(); - assert_eq!(request["method"], "server.live_handoff"); - stream - .write_all(br#"{"id":"cli:server:live-handoff","result":{"type":"ok"}}"#) - .unwrap(); - stream.write_all(b"\n").unwrap(); - stream.flush().unwrap(); - }); - - let handoff = run_cli(&socket_path, &["server", "live-handoff"]); - assert!( - handoff.status.success(), - "stderr: {}", - String::from_utf8_lossy(&handoff.stderr) - ); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn plugin_list_preserves_protocol_mismatch_envelope() { - 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(); - BufReader::new(stream.try_clone().unwrap()) - .read_line(&mut line) - .unwrap(); - let request: serde_json::Value = serde_json::from_str(&line).unwrap(); - assert_eq!(request["method"], "ping"); - write_fake_pong(&mut stream, &request, "0.7.1", 14); - }); - - let listed = run_cli(&socket_path, &["plugin", "list", "--json"]); - assert_eq!(listed.status.code(), Some(1)); - assert!(listed.stdout.is_empty()); - let error: serde_json::Value = serde_json::from_slice(&listed.stderr).unwrap(); - assert_eq!(error["id"], "cli:plugin"); - assert_eq!(error["error"]["code"], "protocol_mismatch"); - server.join().unwrap(); - 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_times_out_when_status_does_not_match() { - 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_timeout_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, - base.display() - ), - ); - assert_eq!(created["result"]["type"], "workspace_created"); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "agent-status", - "1-1", - "--status", - "blocked", - "--timeout", - "100", - ], - ); - assert!(!waited.status.success()); - assert!( - String::from_utf8_lossy(&waited.stderr) - .contains("timed out waiting for agent status change"), - "stderr: {}", - String::from_utf8_lossy(&waited.stderr) - ); - - 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); -} |
