diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/api_ping.rs | 52 | ||||
| -rw-r--r-- | tests/cli.rs | 6 | ||||
| -rw-r--r-- | tests/cli/agent_options.rs | 83 | ||||
| -rw-r--r-- | tests/cli/agent_transport.rs | 146 | ||||
| -rw-r--r-- | tests/cli/agent_wait.rs | 200 | ||||
| -rw-r--r-- | tests/cli/agents.rs | 1189 | ||||
| -rw-r--r-- | tests/cli/harness.rs | 608 | ||||
| -rw-r--r-- | tests/cli/hooks.rs | 308 | ||||
| -rw-r--r-- | tests/cli/mod.rs | 13 | ||||
| -rw-r--r-- | tests/cli/panes.rs | 568 | ||||
| -rw-r--r-- | tests/cli/plugins.rs | 932 | ||||
| -rw-r--r-- | tests/cli/protocol.rs | 122 | ||||
| -rw-r--r-- | tests/cli/protocol_guard.rs | 87 | ||||
| -rw-r--r-- | tests/cli/sessions.rs | 671 | ||||
| -rw-r--r-- | tests/cli/surface.rs | 603 | ||||
| -rw-r--r-- | tests/cli/workspace.rs | 663 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 5651 | ||||
| -rw-r--r-- | tests/live_handoff.rs | 88 |
18 files changed, 6322 insertions, 5668 deletions
diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 0e0b711e..a31a5866 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -509,6 +509,21 @@ fn workspace_list_and_create_round_trip() { let recent_text = recent["result"]["read"]["text"].as_str().unwrap(); assert!(recent_text.contains("beta") || recent_text.contains("gamma")); + for source in ["visible", "detection"] { + let limited = send_request( + &socket_path, + &format!( + r#"{{"id":"req_11_{source}","method":"pane.read","params":{{"pane_id":"{}","source":"{source}","lines":2}}}}"#, + pane_id + ), + ); + let text = limited["result"]["read"]["text"].as_str().unwrap(); + assert!( + text.lines().count() <= 2, + "{source} ignored its two-line limit: {text:?}" + ); + } + let waited = send_request( &socket_path, &format!( @@ -1176,29 +1191,32 @@ fn agent_methods_round_trip_over_socket() { assert_eq!(agents[0]["agent_status"], "working"); assert_eq!(agents[0]["pane_id"], pane_id); - let fetched_by_detected_agent = send_request( + let fetched_by_pane = send_request( &socket_path, - r#"{"id":"agent_get_detected","method":"agent.get","params":{"target":"pi"}}"#, + &format!( + r#"{{"id":"agent_get_pane","method":"agent.get","params":{{"target":"{}"}}}}"#, + pane_id + ), ); assert_eq!( - fetched_by_detected_agent["result"]["agent"]["terminal_id"], + fetched_by_pane["result"]["agent"]["terminal_id"], terminal_id ); let renamed_first_agent = send_request( &socket_path, - r#"{"id":"agent_rename_first","method":"agent.rename","params":{"target":"pi","name":"worker"}}"#, + &format!( + r#"{{"id":"agent_rename_first","method":"agent.rename","params":{{"target":"{}","name":"worker"}}}}"#, + pane_id + ), ); assert_eq!(renamed_first_agent["result"]["agent"]["name"], "worker"); - let fetched_by_terminal = send_request( + let fetched_by_name = send_request( &socket_path, - &format!( - r#"{{"id":"agent_get_terminal","method":"agent.get","params":{{"target":"{}"}}}}"#, - terminal_id - ), + r#"{"id":"agent_get_name","method":"agent.get","params":{"target":"worker"}}"#, ); - assert_eq!(fetched_by_terminal["result"]["agent"]["name"], "worker"); + assert_eq!(fetched_by_name["result"]["agent"]["name"], "worker"); let read = send_request( &socket_path, @@ -1209,12 +1227,9 @@ fn agent_methods_round_trip_over_socket() { let sent = send_request( &socket_path, - &format!( - r#"{{"id":"agent_send","method":"agent.send","params":{{"target":"{}","text":"echo agent-send-ok\n"}}}}"#, - terminal_id - ), + r#"{"id":"agent_send_keys","method":"agent.send_keys","params":{"target":"worker","keys":["enter"]}}"#, ); - assert_eq!(sent["result"]["type"], "ok"); + assert_eq!(sent["error"]["code"], "agent_not_ready"); let tab_created = send_request( &socket_path, @@ -1244,7 +1259,7 @@ fn agent_methods_round_trip_over_socket() { &socket_path, &format!( r#"{{"id":"agent_second_rename","method":"agent.rename","params":{{"target":"{}","name":"reviewer"}}}}"#, - second_terminal_id + second_pane_id ), ); assert_eq!(second_renamed["result"]["agent"]["name"], "reviewer"); @@ -1785,7 +1800,10 @@ fn pane_report_agent_updates_effective_state() { let agent = send_request( &socket_path, - r#"{"id":"req_hook_metadata_agent","method":"agent.get","params":{"target":"pi"}}"#, + &format!( + r#"{{"id":"req_hook_metadata_agent","method":"agent.get","params":{{"target":"{}"}}}}"#, + pane_id + ), ); assert_eq!(agent["result"]["agent"]["agent"], "pi"); assert_eq!( diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 00000000..c7eb2834 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,6 @@ +#![cfg(not(target_os = "macos"))] + +mod support; + +#[path = "cli/mod.rs"] +mod cases; diff --git a/tests/cli/agent_options.rs b/tests/cli/agent_options.rs new file mode 100644 index 00000000..f8b6b7af --- /dev/null +++ b/tests/cli/agent_options.rs @@ -0,0 +1,83 @@ +use super::harness::*; + +#[test] +fn agent_wait_accepts_repeated_until_and_exits_when_one_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() + ), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + + let start_pi = run_cli(&socket_path, &["pane", "run", &pane_id, "pi"]); + assert!(start_pi.status.success()); + assert!(wait_until( + Duration::from_secs(3), + Duration::from_millis(25), + || run_cli(&socket_path, &["agent", "get", &pane_id]) + .status + .success() + )); + + let waited = run_cli( + &socket_path, + &[ + "agent", + "wait", + &pane_id, + "--until", + "blocked", + "--until", + "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["result"]["agent"]["agent_status"], "idle"); + assert_eq!(waited_json["result"]["agent"]["agent"], "pi"); + + cleanup_spawned_herdr(herdr, base); +} diff --git a/tests/cli/agent_transport.rs b/tests/cli/agent_transport.rs new file mode 100644 index 00000000..42e67828 --- /dev/null +++ b/tests/cli/agent_transport.rs @@ -0,0 +1,146 @@ +use super::harness::*; + +#[test] +fn agent_start_accepts_durable_readiness_during_detection_gap() { + 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 start_stream, start_line) = accept_fake_cli_operation(&listener); + let start: serde_json::Value = serde_json::from_str(&start_line).unwrap(); + assert_eq!(start["method"], "agent.start"); + writeln!( + start_stream, + "{}", + serde_json::json!({ + "id": start["id"], + "result": { + "type": "agent_started", + "agent": { + "pane_id": "w1:p1", + "terminal_id": "term_1", + "name": "reviewer" + }, + "argv": ["opencode"] + } + }) + ) + .unwrap(); + start_stream.flush().unwrap(); + + let (mut get_stream, get_line) = accept_fake_cli_operation(&listener); + let get: serde_json::Value = serde_json::from_str(&get_line).unwrap(); + assert_eq!(get["method"], "agent.get"); + assert_eq!(get["params"]["target"], "reviewer"); + writeln!( + get_stream, + "{}", + serde_json::json!({ + "id": get["id"], + "result": { + "type": "agent_info", + "agent": { + "agent": null, + "agent_status": "unknown", + "interactive_ready": true, + "launch_pending": false, + "name": "reviewer", + "pane_id": "w1:p1", + "terminal_id": "term_1" + } + } + }) + ) + .unwrap(); + get_stream.flush().unwrap(); + }); + + let started = run_cli( + &socket_path, + &[ + "agent", "start", "reviewer", "--kind", "opencode", "--pane", "w1:p1", + ], + ); + assert!( + started.status.success(), + "stderr: {}", + String::from_utf8_lossy(&started.stderr) + ); + let started: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap(); + assert_eq!(started["result"]["type"], "agent_started"); + assert_eq!(started["result"]["agent"]["name"], "reviewer"); + assert!(started["result"]["agent"]["interactive_ready"] + .as_bool() + .unwrap()); + + server.join().unwrap(); + cleanup_test_base(&base); +} + +#[test] +fn prompt_wait_is_sent_as_one_agent_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 prompt_stream, prompt_line) = accept_fake_cli_operation(&listener); + let prompt: serde_json::Value = serde_json::from_str(&prompt_line).unwrap(); + assert_eq!(prompt["method"], "agent.prompt"); + assert_eq!(prompt["params"]["target"], "w1:p1"); + assert_eq!( + prompt["params"]["wait"]["until"], + serde_json::json!(["idle"]) + ); + assert!(prompt["params"]["wait"].get("timeout_ms").is_none()); + writeln!( + prompt_stream, + "{}", + serde_json::json!({ + "id": prompt["id"], + "result": { + "type": "agent_prompted", + "agent": { + "pane_id": "w1:p1", + "terminal_id": "term_1", + "name": "reviewer", + "agent": "pi", + "agent_status": "idle", + "workspace_id": "w1", + "tab_id": "w1:t1", + "focused": true, + "revision": 0 + } + } + }) + ) + .unwrap(); + prompt_stream.flush().unwrap(); + }); + + let prompted = run_cli( + &socket_path, + &[ + "agent", + "prompt", + "w1:p1", + "review this", + "--wait", + "--until", + "idle", + ], + ); + assert!( + prompted.status.success(), + "stderr: {}", + String::from_utf8_lossy(&prompted.stderr) + ); + let prompted: serde_json::Value = serde_json::from_slice(&prompted.stdout).unwrap(); + assert_eq!(prompted["result"]["agent"]["name"], "reviewer"); + + server.join().unwrap(); + cleanup_test_base(&base); +} diff --git a/tests/cli/agent_wait.rs b/tests/cli/agent_wait.rs new file mode 100644 index 00000000..11997d00 --- /dev/null +++ b/tests/cli/agent_wait.rs @@ -0,0 +1,200 @@ +use super::harness::*; + +#[test] +fn agent_wait_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, + &[ + "agent", + "wait", + &pane_id, + "--until", + "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["result"]["agent"]["agent_status"], "idle"); + assert_eq!(waited_json["result"]["agent"]["agent"], "pi"); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_wait_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 pane_id = created["result"]["root_pane"]["pane_id"].as_str().unwrap(); + let reported = send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_timeout_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#, + pane_id + ), + ); + assert_eq!(reported["result"]["type"], "ok"); + + let waited = run_cli( + &socket_path, + &[ + "agent", + "wait", + pane_id, + "--until", + "blocked", + "--timeout", + "100", + ], + ); + assert!(!waited.status.success()); + assert!( + String::from_utf8_lossy(&waited.stderr).contains("timed out waiting for agent status"), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_wait_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 pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let start_pi = run_cli(&socket_path, &["pane", "run", &pane_id, "pi"]); + assert!(start_pi.status.success()); + assert!(wait_until( + Duration::from_secs(3), + Duration::from_millis(25), + || run_cli(&socket_path, &["agent", "get", &pane_id]) + .status + .success() + )); + + let waited = run_cli( + &socket_path, + &[ + "agent", + "wait", + &pane_id, + "--until", + "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["result"]["agent"]["agent_status"], "done"); + assert_eq!(waited_json["result"]["agent"]["agent"], "pi"); + + cleanup_spawned_herdr(herdr, base); +} diff --git a/tests/cli/agents.rs b/tests/cli/agents.rs new file mode 100644 index 00000000..e06d0ee5 --- /dev/null +++ b/tests/cli/agents.rs @@ -0,0 +1,1189 @@ +use super::harness::*; + +#[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"); + let captured_prompts = base.join("pi-prompts"); + 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\n printf '%s\\n' \"$prompt\" >> '{}'\ndone\n", + captured_args.display(), + env!("CARGO_BIN_EXE_herdr"), + env!("CARGO_BIN_EXE_herdr"), + env!("CARGO_BIN_EXE_herdr"), + captured_prompts.display(), + ), + ) + .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(); + run_cli_json(&socket_path, &["pane", "rename", &pane_id, "shell-pane"]); + 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!( + run_cli_json(&socket_path, &["pane", "get", &pane_id])["result"]["pane"]["label"], + "shell-pane" + ); + 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 literal_flag_prompt: serde_json::Value = + serde_json::from_slice(&literal_flag_prompt.stdout).unwrap(); + assert_eq!(literal_flag_prompt["result"]["type"], "agent_prompted"); + assert!(wait_until( + Duration::from_secs(2), + Duration::from_millis(25), + || captured_prompts.exists() + )); + + 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 split = run_cli_json( + &socket_path, + &["pane", "split", &pane_id, "--direction", "right"], + ); + let reuse_pane_id = split["result"]["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"); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &reuse_pane_id, + "--source", + "custom:reuse", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + let reused = run_cli(&socket_path, &["agent", "rename", &reuse_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 split = run_cli_json( + &socket_path, + &["pane", "split", &pane_id, "--direction", "right"], + ); + let reuse_pane_id = split["result"]["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"); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &reuse_pane_id, + "--source", + "custom:reuse", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + let reused = run_cli(&socket_path, &["agent", "rename", &reuse_pane_id, "worker"]); + assert!(reused.status.success()); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_start_follows_its_named_terminal_when_the_pane_moves() { + 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=pi 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 first = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let start_socket = socket_path.clone(); + let start_pane = first.clone(); + let starter = thread::spawn(move || { + run_cli( + &start_socket, + &[ + "agent", + "start", + "worker", + "--kind", + "pi", + "--pane", + &start_pane, + "--timeout", + "8000", + ], + ) + }); + assert!(wait_until( + Duration::from_secs(2), + Duration::from_millis(25), + || run_cli(&socket_path, &["agent", "get", "worker"]) + .status + .success() + )); + + let moved = run_cli( + &socket_path, + &[ + "pane", + "move", + &first, + "--new-workspace", + "--label", + "moved", + "--no-focus", + ], + ); + assert!( + moved.status.success(), + "move failed: {}", + String::from_utf8_lossy(&moved.stderr) + ); + + let started = starter.join().unwrap(); + assert!( + started.status.success(), + "start failed after swap: {}", + String::from_utf8_lossy(&started.stderr) + ); + let started: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap(); + assert_ne!(started["result"]["agent"]["pane_id"], first); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_start_and_rename_reject_invalid_names() { + 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 expected_message = "agent name must start with a lowercase letter and contain only lowercase letters, digits, '-' or '_' (1-32 characters)"; + + let started = run_cli( + &socket_path, + &[ + "agent", + "start", + "reviewer one", + "--kind", + "pi", + "--pane", + &pane_id, + ], + ); + assert_eq!(started.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); + assert_eq!(error["error"]["code"], "invalid_agent_name"); + assert_eq!(error["error"]["message"], expected_message); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &pane_id, + "--source", + "custom:name", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + let renamed = run_cli(&socket_path, &["agent", "rename", &pane_id, "reviewer one"]); + assert_eq!(renamed.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&renamed.stderr).unwrap(); + assert_eq!(error["error"]["code"], "invalid_agent_name"); + assert_eq!(error["error"]["message"], expected_message); + + 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 reported = run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &root_pane_id, + "--source", + "custom:test", + "--agent", + "pi", + "--state", + "idle", + ], + ); + assert!(reported.status.success()); + let renamed = run_cli(&socket_path, &["agent", "rename", &root_pane_id, "worker"]); + assert!(renamed.status.success()); + + let listed = run_cli_json(&socket_path, &["agent", "list"]); + assert_eq!(listed["result"]["type"], "agent_list"); + assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id); + assert_eq!(listed["result"]["agents"][0]["name"], "worker"); + + let fetched = run_cli_json(&socket_path, &["agent", "get", "worker"]); + assert_eq!(fetched["result"]["agent"]["pane_id"], root_pane_id); + let waited = run_cli_json( + &socket_path, + &["agent", "wait", "worker", "--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_json( + &socket_path, + &[ + "agent", + "wait", + "worker", + "--until", + "unknown", + "--timeout", + "1000", + ], + ); + assert_eq!(unknown["result"]["agent"]["agent_status"], "unknown"); + + let pane_read = run_cli( + &socket_path, + &["pane", "read", &root_pane_id, "--source", "visible"], + ); + let agent_read = run_cli( + &socket_path, + &["agent", "read", &root_pane_id, "--source", "visible"], + ); + assert!(pane_read.status.success()); + assert!(agent_read.status.success()); + assert_eq!(agent_read.stdout, pane_read.stdout); + + let missing_read = run_cli(&socket_path, &["agent", "read", "missing"]); + assert_eq!(missing_read.status.code(), Some(1)); + let missing_read: serde_json::Value = serde_json::from_slice(&missing_read.stderr).unwrap(); + assert_eq!(missing_read["error"]["code"], "agent_not_found"); + + let sent = run_cli(&socket_path, &["agent", "send-keys", "worker", "enter"]); + assert_eq!(sent.status.code(), Some(1)); + let sent: serde_json::Value = serde_json::from_slice(&sent.stderr).unwrap(); + assert_eq!(sent["error"]["code"], "agent_not_ready"); + + 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_tolerates_detection_uncertainty_and_pane_target_rename() { + 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(); + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &pane_id, + "--source", + "custom:uncertain", + "--agent", + "pi", + "--state", + "working", + ], + ) + .status + .success()); + assert!( + run_cli(&socket_path, &["agent", "rename", &pane_id, "worker"]) + .status + .success() + ); + + let wait_socket = socket_path.clone(); + let waiter = thread::spawn(move || { + run_cli( + &wait_socket, + &[ + "agent", + "wait", + "worker", + "--until", + "unknown", + "--timeout", + "2000", + ], + ) + }); + thread::sleep(Duration::from_millis(150)); + let cleared = send_request( + &socket_path, + &format!( + r#"{{"id":"agent_wait_uncertain","method":"pane.clear_agent_authority","params":{{"pane_id":"{}","source":"custom:uncertain"}}}}"#, + pane_id + ), + ); + assert_eq!(cleared["result"]["type"], "ok"); + let waited = waiter.join().unwrap(); + assert!( + waited.status.success(), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + let waited: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); + assert_eq!(waited["result"]["agent"]["agent_status"], "unknown"); + assert_eq!(waited["result"]["agent"]["name"], "worker"); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &pane_id, + "--source", + "custom:return", + "--agent", + "pi", + "--state", + "working", + ], + ) + .status + .success()); + let wait_socket = socket_path.clone(); + let wait_pane = pane_id.clone(); + let waiter = thread::spawn(move || { + run_cli( + &wait_socket, + &[ + "agent", + "wait", + &wait_pane, + "--until", + "idle", + "--timeout", + "2000", + ], + ) + }); + thread::sleep(Duration::from_millis(150)); + assert!( + run_cli(&socket_path, &["agent", "rename", "worker", "reviewer"]) + .status + .success() + ); + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &pane_id, + "--source", + "custom:return", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + let waited = waiter.join().unwrap(); + assert!( + waited.status.success(), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + let waited: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); + assert_eq!(waited["result"]["agent"]["agent_status"], "idle"); + assert_eq!(waited["result"]["agent"]["name"], "reviewer"); + + 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_not_running"); + + 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_eq!(error["error"]["code"], "agent_not_running"); + + cleanup_spawned_herdr(herdr, base); +} diff --git a/tests/cli/harness.rs b/tests/cli/harness.rs new file mode 100644 index 00000000..91dab149 --- /dev/null +++ b/tests/cli/harness.rs @@ -0,0 +1,608 @@ +pub(super) use std::fs; +pub(super) use std::io::{BufRead, BufReader, Write}; +pub(super) use std::os::unix::net::{UnixListener, UnixStream}; +pub(super) use std::path::{Path, PathBuf}; +pub(super) use std::process::{Command, Stdio}; +pub(super) use std::thread; +pub(super) use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +pub(super) use crate::support::{ + cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, + unregister_spawned_herdr_pid, CURRENT_PROTOCOL, +}; +use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; + +pub(super) const WORKTREE_BOOTSTRAP_MANAGED_COMPONENT: &str = + "example.worktree-bootstrap-ef876653ffc3"; + +pub(super) 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())) +} + +pub(super) fn managed_github_plugin_dir(config_home: &Path) -> PathBuf { + config_home.join("herdr-dev").join("plugins").join("github") +} + +pub(super) 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()), + } +} + +pub(super) 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(" ") + ); +} + +pub(super) 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"]); +} + +pub(super) struct SpawnedHerdr { + _master: Box<dyn MasterPty + Send>, + pub(super) child: Box<dyn Child + Send + Sync>, +} + +pub(super) 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)); + } + } +} + +pub(super) fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) { + drop(spawned); + cleanup_test_base(&base); +} + +pub(super) 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()); +} + +pub(super) 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", + ) +} + +pub(super) 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", + ) +} + +pub(super) fn app_dir_name() -> &'static str { + if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + } +} + +pub(super) fn named_session_socket(config_home: &Path, session: &str) -> PathBuf { + config_home + .join(app_dir_name()) + .join("sessions") + .join(session) + .join("herdr.sock") +} + +pub(super) 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 } +} + +pub(super) 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) +} + +pub(super) 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) +} + +pub(super) 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) +} + +pub(super) 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() +} + +pub(super) 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() +} + +pub(super) 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", + ) +} + +pub(super) 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, + } +} + +pub(super) 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() +} + +pub(super) 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() +} + +pub(super) 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() +} + +pub(super) 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) +} + +pub(super) 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) +} + +pub(super) 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) + ) + }) +} + +pub(super) 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 +} + +pub(super) 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) +} + +pub(super) 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) + } +} + +pub(super) 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) +} + +pub(super) 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); +} + +pub(super) 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() +} + +pub(super) 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(); +} + +pub(super) 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, + ); + } +} diff --git a/tests/cli/hooks.rs b/tests/cli/hooks.rs new file mode 100644 index 00000000..b2edd3e7 --- /dev/null +++ b/tests/cli/hooks.rs @@ -0,0 +1,308 @@ +use super::harness::*; + +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()); +} diff --git a/tests/cli/mod.rs b/tests/cli/mod.rs new file mode 100644 index 00000000..0fdff6f1 --- /dev/null +++ b/tests/cli/mod.rs @@ -0,0 +1,13 @@ +mod agent_options; +mod agent_transport; +mod agent_wait; +mod agents; +mod harness; +mod hooks; +mod panes; +mod plugins; +mod protocol; +mod protocol_guard; +mod sessions; +mod surface; +mod workspace; diff --git a/tests/cli/panes.rs b/tests/cli/panes.rs new file mode 100644 index 00000000..32110b36 --- /dev/null +++ b/tests/cli/panes.rs @@ -0,0 +1,568 @@ +use super::harness::*; + +#[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, + &[ + "pane", + "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, + &[ + "pane", + "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); +} diff --git a/tests/cli/plugins.rs b/tests/cli/plugins.rs new file mode 100644 index 00000000..ab1dd21a --- /dev/null +++ b/tests/cli/plugins.rs @@ -0,0 +1,932 @@ +use super::harness::*; + +#[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); +} diff --git a/tests/cli/protocol.rs b/tests/cli/protocol.rs new file mode 100644 index 00000000..0c5571b8 --- /dev/null +++ b/tests/cli/protocol.rs @@ -0,0 +1,122 @@ +use super::harness::*; + +#[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"], "agent.wait"); + operation_stream + .write_all( + br#"{"id":"cli:agent:wait","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, + &[ + "agent", + "wait", + "1-1", + "--until", + "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:agent:wait"); + assert_eq!(error["error"]["code"], "not_implemented"); + assert_eq!(error["error"]["message"], "compatible server error"); + 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); +} diff --git a/tests/cli/protocol_guard.rs b/tests/cli/protocol_guard.rs new file mode 100644 index 00000000..4272ed28 --- /dev/null +++ b/tests/cli/protocol_guard.rs @@ -0,0 +1,87 @@ +use super::harness::*; + +#[test] +fn cli_rejects_protocol_mismatch_before_agent_wait_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:agent:wait","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, + &[ + "agent", + "wait", + "1-1", + "--until", + "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:agent:wait"); + 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); +} diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs new file mode 100644 index 00000000..4a4430e1 --- /dev/null +++ b/tests/cli/sessions.rs @@ -0,0 +1,671 @@ +use super::harness::*; + +#[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); +} diff --git a/tests/cli/surface.rs b/tests/cli/surface.rs new file mode 100644 index 00000000..2507804e --- /dev/null +++ b/tests/cli/surface.rs @@ -0,0 +1,603 @@ +use super::harness::*; + +#[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]] = &[ + &["--help"], + &["agent", "wait", "--help"], + &["terminal", "session", "control", "-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 subcommand_help_explains_automation_semantics_without_a_server() { + let cases: &[(&[&str], &str)] = &[ + (&["agent", "wait", "--help"], "Without --until"), + ( + &["agent", "prompt", "--help"], + "first matching state observed after submission", + ), + (&["agent", "start", "--help"], "ready for input"), + ( + &["agent", "send-keys", "--help"], + "canonical Escape key name", + ), + ( + &["pane", "wait-output", "--help"], + "including existing output", + ), + ( + &["pane", "send-keys", "--help"], + "canonical Escape key name", + ), + ]; + + for (args, expected) in cases { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(*args) + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_ENV") + .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) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(expected), + "herdr {} help did not contain {expected:?}: {stdout}", + args.join(" ") + ); + } +} + +#[test] +fn removed_wait_and_agent_send_commands_are_rejected() { + let wait = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["wait", "output", "w1:p1", "--match", "ready"]) + .output() + .unwrap(); + assert_eq!(wait.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&wait.stderr).contains("unknown command: wait")); + let help = Command::new(env!("CARGO_BIN_EXE_herdr")) + .arg("--help") + .output() + .unwrap(); + assert!(!String::from_utf8_lossy(&help.stdout).contains("herdr wait <subcommand>")); + + let send = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["agent", "send", "reviewer", "hello"]) + .output() + .unwrap(); + assert_eq!(send.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&send.stderr); + assert!(stderr.contains("herdr agent send-keys")); + assert!(!stderr.contains("herdr agent send <")); +} + +#[test] +fn agent_cli_rejects_invalid_wait_and_rename_grammar_locally() { + for args in [ + &["agent", "wait", "reviewer", "--until", "finished"][..], + &["agent", "wait", "reviewer", "--timeout", "later"][..], + &[ + "agent", "prompt", "reviewer", "work", "--wait", "--until", "finished", + ][..], + &[ + "agent", + "prompt", + "reviewer", + "work", + "--wait", + "--timeout", + "later", + ][..], + &[ + "agent", + "start", + "reviewer", + "--kind", + "pi", + "--pane", + "w1:p1", + "--timeout", + "later", + ][..], + &["agent", "rename", "reviewer"][..], + &["agent", "rename", "reviewer", "worker", "--clear"][..], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(args) + .env("HERDR_SOCKET_PATH", "/nonexistent/herdr.sock") + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(2), + "herdr {}: stdout={} stderr={}", + args.join(" "), + 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("--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(2)); + 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}" + ); +} diff --git a/tests/cli/workspace.rs b/tests/cli/workspace.rs new file mode 100644 index 00000000..8c435b2d --- /dev/null +++ b/tests/cli/workspace.rs @@ -0,0 +1,663 @@ +use super::harness::*; + +#[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); +} 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); -} diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index 9f607195..c93898b6 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -1212,6 +1212,94 @@ fn live_handoff_accepts_canonical_pane_id_from_child_env() { } #[test] +fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let old_session = base.join("old-session.jsonl"); + let new_session = base.join("new-session.jsonl"); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:agent:report", + "method": "pane.report_agent", + "params": { + "pane_id": pane_id, + "source": "herdr:pi", + "agent": "pi", + "state": "idle", + "seq": 1, + "agent_session_path": old_session + } + }), + )); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:agent:rename", + "method": "agent.rename", + "params": {"target": pane_id, "name": "reviewer"} + }), + )); + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:agent:new-session", + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": "herdr:pi", + "agent": "pi", + "seq": 2, + "agent_session_path": new_session, + "session_start_source": "new" + } + }), + )); + let old_name = request( + &api_socket, + serde_json::json!({ + "id": "test:agent:get-old-name", + "method": "agent.get", + "params": {"target": "reviewer"} + }), + ); + assert_eq!(old_name["error"]["code"], "agent_not_found", "{old_name}"); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] fn live_handoff_keeps_agent_started_pane_after_agent_exits() { use std::os::unix::fs::PermissionsExt; |
