diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 19:02:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 19:03:16 +0300 |
| commit | 7f702a7ad026f1503d33c442a50fb6aca84b0dc8 (patch) | |
| tree | 42457fa227d5e7cc0d450d14b2f277b48f7c696a /tests | |
| parent | de0f43a061162a223c5946a4b50aa9f9ac7ab8d7 (diff) | |
test: harden pane identity regression coverage
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/cli_wrapper.rs | 99 | ||||
| -rw-r--r-- | tests/client_mode.rs | 364 |
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); |
