aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 19:02:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 19:03:16 +0300
commit7f702a7ad026f1503d33c442a50fb6aca84b0dc8 (patch)
tree42457fa227d5e7cc0d450d14b2f277b48f7c696a /tests
parentde0f43a061162a223c5946a4b50aa9f9ac7ab8d7 (diff)
test: harden pane identity regression coverage
Diffstat (limited to 'tests')
-rw-r--r--tests/cli_wrapper.rs99
-rw-r--r--tests/client_mode.rs364
2 files changed, 186 insertions, 277 deletions
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 367c3ece..0eb1b82d 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -1547,6 +1547,105 @@ fn server_stop_then_restart_restores_pane_history() {
}
#[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");
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index 99373e38..4f628faa 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -12,10 +12,11 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use serde::Deserialize;
+use serde_json::Value;
use support::{
- cleanup_test_base, client_handshake, encode_varint_u16, encode_varint_u32, frame_message,
- read_server_message, register_runtime_dir, register_spawned_herdr_pid,
- unregister_spawned_herdr_pid, wait_for_message_variant, wait_for_socket, wait_until,
+ cleanup_test_base, client_handshake, encode_varint_u32, frame_message, read_server_message,
+ register_runtime_dir, register_spawned_herdr_pid, unregister_spawned_herdr_pid,
+ wait_for_message_variant, wait_for_socket, wait_until,
};
fn unique_test_dir() -> PathBuf {
@@ -165,6 +166,43 @@ fn ping_socket(socket_path: &PathBuf) -> String {
response.trim().to_string()
}
+fn send_json_request(socket_path: &PathBuf, request: &str) -> Value {
+ let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+ writeln!(stream, "{}", request).unwrap();
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ serde_json::from_str(&response).expect("response should be valid JSON")
+}
+
+fn first_pane_id_in_workspace(socket_path: &PathBuf, workspace_id: &str) -> String {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ let request = format!(
+ r#"{{"id":"pane_list","method":"pane.list","params":{{"workspace_id":"{workspace_id}"}}}}"#
+ );
+ let panes = send_json_request(socket_path, &request);
+ if let Some(pane_id) = panes["result"]["panes"]
+ .as_array()
+ .and_then(|panes| panes.first())
+ .and_then(|pane| pane["pane_id"].as_str())
+ {
+ return pane_id.to_string();
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("pane.list did not return a pane for workspace {workspace_id} before timeout");
+}
+
+fn app_dir_name() -> &'static str {
+ if cfg!(debug_assertions) {
+ "herdr-dev"
+ } else {
+ "herdr"
+ }
+}
+
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct FrameWire {
@@ -373,184 +411,6 @@ fn client_sees_headless_startup_config_diagnostic() {
}
#[test]
-fn client_input_forwarded_to_pane() {
- // Stdin input is forwarded to server as ClientMessage::Input.
- // Server routes client input to the correct PTY.
- 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 client_socket = runtime_dir.join("herdr-client.sock");
-
- let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
- wait_for_socket(&api_socket, Duration::from_secs(10));
- wait_for_socket(&client_socket, Duration::from_secs(10));
-
- // Connect and handshake.
- let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
- assert!(error.is_none(), "{:?}", error);
-
- // Send an Input message containing "echo hello\n".
- // ClientMessage::Input is variant 1: { data: Vec<u8> }
- let input_data = b"echo hello\n".to_vec();
- let input_payload = {
- let mut buf = encode_varint_u32(1); // variant 1 = Input
- // Encode the data as a bincode Vec<u8>: length (varint) + bytes
- buf.extend_from_slice(&encode_varint_u32(input_data.len() as u32));
- buf.extend_from_slice(&input_data);
- buf
- };
- let framed = frame_message(&input_payload);
- stream
- .write_all(&framed)
- .expect("should send Input message");
- stream.flush().expect("should flush");
-
- assert!(
- wait_until(Duration::from_secs(2), Duration::from_millis(25), || {
- ping_socket(&api_socket).contains("pong")
- }),
- "server should still respond to ping after input"
- );
-
- // Verify the server is still alive and responsive via API.
- let response = ping_socket(&api_socket);
- assert!(
- response.contains("pong"),
- "server should still respond to ping after input: {response}"
- );
-
- cleanup_spawned_herdr(spawned, base);
-}
-
-#[test]
-fn client_resize_sends_message() {
- // Terminal resize triggers ClientMessage::Resize.
- 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 client_socket = runtime_dir.join("herdr-client.sock");
-
- let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
- wait_for_socket(&api_socket, Duration::from_secs(10));
- wait_for_socket(&client_socket, Duration::from_secs(10));
-
- // Connect and handshake.
- let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
- assert!(error.is_none(), "{:?}", error);
-
- // Drain the initial frame(s).
- stream
- .set_read_timeout(Some(Duration::from_secs(2)))
- .unwrap();
- while read_server_message(&mut stream).is_ok() {}
-
- // Send a Resize message: ClientMessage::Resize is variant 3.
- let resize_payload = {
- let mut buf = encode_varint_u32(3); // variant 3 = Resize
- buf.extend_from_slice(&encode_varint_u16(120)); // cols
- buf.extend_from_slice(&encode_varint_u16(40)); // rows
- buf.extend_from_slice(&encode_varint_u32(8)); // cell_width_px
- buf.extend_from_slice(&encode_varint_u32(16)); // cell_height_px
- buf
- };
- let framed = frame_message(&resize_payload);
- stream
- .write_all(&framed)
- .expect("should send Resize message");
- stream.flush().expect("should flush");
-
- assert!(
- wait_until(Duration::from_secs(2), Duration::from_millis(25), || {
- ping_socket(&api_socket).contains("pong")
- }),
- "server should respond after resize"
- );
-
- // Verify the server is still alive.
- let response = ping_socket(&api_socket);
- assert!(
- response.contains("pong"),
- "server should respond after resize: {response}"
- );
-
- cleanup_spawned_herdr(spawned, base);
-}
-
-#[test]
-fn server_shutdown_sends_message_to_client() {
- // ServerShutdown causes clean exit with informative message.
- 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 client_socket = runtime_dir.join("herdr-client.sock");
-
- let mut spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
- wait_for_socket(&api_socket, Duration::from_secs(10));
- wait_for_socket(&client_socket, Duration::from_secs(10));
-
- // Connect and handshake.
- let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
- assert!(error.is_none(), "{:?}", error);
-
- // Send SIGINT so the server takes the graceful shutdown path and
- // broadcasts ServerShutdown before exiting.
- if let Some(pid) = spawned.child.process_id() {
- unsafe {
- libc::kill(pid as libc::pid_t, libc::SIGINT);
- }
- }
-
- // The client should receive an explicit ServerShutdown message, or at
- // minimum observe clean connection close if shutdown races with send.
- stream
- .set_read_timeout(Some(Duration::from_secs(5)))
- .unwrap();
- let mut saw_shutdown = false;
- let mut saw_disconnect = false;
- let deadline = Instant::now() + Duration::from_secs(5);
- while Instant::now() < deadline {
- match read_server_message(&mut stream) {
- Ok((variant, _)) => {
- if variant == 3 {
- saw_shutdown = true;
- break;
- }
- }
- Err(_) => {
- saw_disconnect = true;
- break;
- }
- }
- }
- assert!(
- saw_shutdown || saw_disconnect,
- "client should observe ServerShutdown or disconnect during graceful shutdown"
- );
-
- // Wait for the server to exit after shutdown signal.
- spawned.close_master();
- let _ = spawned.child.wait();
-
- drop(spawned);
- cleanup_test_base(&base);
-}
-
-#[test]
fn server_unreachable_shows_clear_error() {
// when server is unreachable, the client exits quickly
// with an actionable connection-failed message.
@@ -764,83 +624,6 @@ fn client_receives_frame_after_pane_output() {
}
#[test]
-fn navigate_mode_keybind_dispatch_in_server() {
- // Navigate mode keybind dispatch in server.
- // This tests that the server can process a prefix key (Ctrl+B) to enter
- // navigate mode, and then a navigation key (like 'n' for new workspace).
- 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 client_socket = runtime_dir.join("herdr-client.sock");
-
- let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
- wait_for_socket(&api_socket, Duration::from_secs(10));
- wait_for_socket(&client_socket, Duration::from_secs(10));
-
- // Connect and handshake.
- let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
- assert!(error.is_none(), "{:?}", error);
-
- // Drain initial frames.
- stream
- .set_read_timeout(Some(Duration::from_secs(2)))
- .unwrap();
- while read_server_message(&mut stream).is_ok() {}
-
- // Send Ctrl+B (prefix key) as raw bytes. In kitty mode, Ctrl+B is 0x02.
- // In legacy mode, it's also 0x02 (control character).
- let prefix_input = vec![0x02]; // Ctrl+B
- let input_payload = {
- let mut buf = encode_varint_u32(1); // Input variant
- buf.extend_from_slice(&encode_varint_u32(prefix_input.len() as u32));
- buf.extend_from_slice(&prefix_input);
- buf
- };
- let framed = frame_message(&input_payload);
- stream.write_all(&framed).expect("send prefix key");
- stream.flush().expect("flush");
-
- stream
- .set_read_timeout(Some(Duration::from_millis(200)))
- .unwrap();
- while read_server_message(&mut stream).is_ok() {}
- stream.set_read_timeout(None).unwrap();
-
- // Send 'n' (new workspace in navigate mode).
- let n_input = b"n".to_vec();
- let n_payload = {
- let mut buf = encode_varint_u32(1); // Input variant
- buf.extend_from_slice(&encode_varint_u32(n_input.len() as u32));
- buf.extend_from_slice(&n_input);
- buf
- };
- let framed = frame_message(&n_payload);
- stream.write_all(&framed).expect("send n key");
- stream.flush().expect("flush");
-
- assert!(
- wait_until(Duration::from_secs(2), Duration::from_millis(25), || {
- ping_socket(&api_socket).contains("pong")
- }),
- "server should still respond after navigate mode input"
- );
-
- // Verify the server is still alive and the API still works.
- let response = ping_socket(&api_socket);
- assert!(
- response.contains("pong"),
- "server should still respond after navigate mode input: {response}"
- );
-
- cleanup_spawned_herdr(spawned, base);
-}
-
-#[test]
fn pane_spawn_cwd_fallback_in_server() {
// Pane spawn failure cwd fallback in server context.
// This test verifies that the server can start even with invalid
@@ -851,32 +634,59 @@ fn pane_spawn_cwd_fallback_in_server() {
let runtime_dir = base.join("runtime");
let api_socket = runtime_dir.join("herdr.sock");
let client_socket = runtime_dir.join("herdr-client.sock");
+ let data_dir = config_home.join(app_dir_name());
+ let missing_cwd = base.join("missing-cwd-for-test");
+ let missing_cwd = missing_cwd.to_str().expect("test cwd should be UTF-8");
+ fs::create_dir_all(&data_dir).unwrap();
+ let session = serde_json::json!({
+ "version": 2,
+ "workspaces": [{
+ "custom_name": "missing-cwd",
+ "layout": { "Pane": 0 },
+ "panes": { "0": { "cwd": missing_cwd } },
+ "zoomed": false,
+ "focused": 0,
+ "root_pane": 0
+ }],
+ "active": 0,
+ "selected": 0
+ });
+ fs::write(
+ data_dir.join("session.json"),
+ serde_json::to_vec_pretty(&session).unwrap(),
+ )
+ .unwrap();
let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
wait_for_socket(&api_socket, Duration::from_secs(10));
wait_for_socket(&client_socket, Duration::from_secs(10));
- // The server should have started successfully even though there are
- // no existing sessions (fresh state). The test verifies that the
- // server doesn't crash during initial pane creation.
- let response = ping_socket(&api_socket);
- assert!(
- response.contains("pong"),
- "server should respond to ping after startup: {response}"
+ let workspaces = send_json_request(
+ &api_socket,
+ r#"{"id":"workspace_list","method":"workspace.list","params":{}}"#,
);
-
- // Create a workspace via the API — this tests pane creation in the server.
- let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API");
- let request = r#"{"id":"2","method":"workspace.create","params":{"label":"cwd-test"}}"#;
- writeln!(ws_stream, "{}", request).unwrap();
-
- let mut reader = BufReader::new(ws_stream);
- let mut response = String::new();
- reader.read_line(&mut response).unwrap();
-
+ let restored_workspace = workspaces["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|workspace| workspace["label"] == "missing-cwd")
+ .expect("server should restore workspace with missing pane cwd");
+ let workspace_id = restored_workspace["workspace_id"]
+ .as_str()
+ .expect("restored workspace should have public id");
+ let pane_id = first_pane_id_in_workspace(&api_socket, workspace_id);
+ let pane = send_json_request(
+ &api_socket,
+ &format!(r#"{{"id":"pane_get","method":"pane.get","params":{{"pane_id":"{pane_id}"}}}}"#),
+ );
+ assert_eq!(pane["result"]["pane"]["workspace_id"], workspace_id);
+ let cwd = pane["result"]["pane"]["cwd"]
+ .as_str()
+ .expect("restored pane should report fallback cwd");
+ assert_ne!(cwd, missing_cwd);
assert!(
- response.contains("workspace_created") || response.contains("ok"),
- "workspace creation should succeed: {response}"
+ std::path::Path::new(cwd).exists(),
+ "fallback cwd should exist: {cwd}"
);
cleanup_spawned_herdr(spawned, base);