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 | |
| parent | de0f43a061162a223c5946a4b50aa9f9ac7ab8d7 (diff) | |
test: harden pane identity regression coverage
| -rw-r--r-- | src/api/schema/tests.rs | 119 | ||||
| -rw-r--r-- | src/app/actions.rs | 41 | ||||
| -rw-r--r-- | src/app/state.rs | 224 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 99 | ||||
| -rw-r--r-- | tests/client_mode.rs | 364 |
5 files changed, 430 insertions, 417 deletions
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index c1713972..05ac9291 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -3,125 +3,6 @@ use std::collections::HashMap; use super::*; #[test] -fn request_round_trips_for_pane_read() { - let request = Request { - id: "req_1".into(), - method: Method::PaneRead(PaneReadParams { - pane_id: "p_1".into(), - source: ReadSource::Recent, - lines: Some(80), - format: ReadFormat::Text, - strip_ansi: true, - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); -} - -#[test] -fn request_round_trips_for_pane_report_agent() { - let request = Request { - id: "req_hook".into(), - method: Method::PaneReportAgent(PaneReportAgentParams { - pane_id: "1-1".into(), - source: "herdr:pi".into(), - agent: "pi".into(), - state: PaneAgentState::Working, - message: Some("thinking".into()), - custom_status: Some("indexing".into()), - seq: Some(42), - agent_session_id: Some("pi-session".into()), - agent_session_path: Some("/tmp/pi-session.jsonl".into()), - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); -} - -#[test] -fn request_round_trips_for_pane_report_agent_session() { - let request = Request { - id: "req_session".into(), - method: Method::PaneReportAgentSession(PaneReportAgentSessionParams { - pane_id: "1-1".into(), - source: "herdr:claude".into(), - agent: "claude".into(), - seq: Some(42), - agent_session_id: Some("claude-session".into()), - agent_session_path: None, - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); -} - -#[test] -fn request_round_trips_for_pane_report_metadata() { - let request = Request { - id: "req_metadata".into(), - method: Method::PaneReportMetadata(PaneReportMetadataParams { - pane_id: "1-1".into(), - source: "user:claude-title".into(), - agent: Some("claude".into()), - applies_to_source: Some("herdr:claude".into()), - title: Some("Refactor auth".into()), - display_agent: Some("Claude auth".into()), - custom_status: Some("refactor auth".into()), - state_labels: HashMap::from([("working".into(), "deep in the mines".into())]), - clear_title: false, - clear_display_agent: false, - clear_custom_status: false, - clear_state_labels: false, - seq: Some(42), - ttl_ms: Some(3_600_000), - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); -} - -#[test] -fn request_round_trips_for_pane_clear_agent_authority() { - let request = Request { - id: "req_clear".into(), - method: Method::PaneClearAgentAuthority(PaneClearAgentAuthorityParams { - pane_id: "1-1".into(), - source: Some("herdr:pi".into()), - seq: Some(42), - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); -} - -#[test] -fn request_round_trips_for_pane_release_agent() { - let request = Request { - id: "req_release".into(), - method: Method::PaneReleaseAgent(PaneReleaseAgentParams { - pane_id: "1-1".into(), - source: "herdr:pi".into(), - agent: "pi".into(), - seq: Some(42), - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); -} - -#[test] fn request_uses_dot_method_names() { let request = Request { id: "req_1".into(), diff --git a/src/app/actions.rs b/src/app/actions.rs index 99bd0ff6..1538f12b 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -3531,27 +3531,6 @@ mod tests { assert_eq!(state.workspaces[0].worktree_space().cloned(), membership); } - #[test] - fn update_ready_sets_explicit_upgrade_toast() { - let mut state = AppState::test_new(); - state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - - let updates = state.handle_app_event(crate::events::AppEvent::UpdateReady { - version: "0.5.0".into(), - install_command: "herdr update".into(), - }); - - assert!(updates.is_empty()); - assert_eq!(state.update_available.as_deref(), Some("0.5.0")); - assert!(state.latest_release_notes_available); - let toast = state.toast.as_ref().expect("update toast"); - assert_eq!(toast.title, "v0.5.0 available"); - assert_eq!( - toast.context, - "detach, run `herdr update`, then follow its restart guidance" - ); - } - fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) { state.ensure_test_terminals(); let terminal_id = state.workspaces[ws_idx].tabs[tab_idx] @@ -3596,6 +3575,7 @@ mod tests { state.previous_agent(); assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second)); + state.assert_invariants_for_test(); } #[test] @@ -3621,6 +3601,7 @@ mod tests { assert_eq!(state.active, Some(1)); assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); + state.assert_invariants_for_test(); } #[test] @@ -3633,6 +3614,7 @@ mod tests { assert!(state.focus_agent_entry(0)); assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); + state.assert_invariants_for_test(); } #[test] @@ -3659,6 +3641,7 @@ mod tests { assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); + state.assert_invariants_for_test(); } #[test] @@ -3688,6 +3671,7 @@ mod tests { let last_idx = state.workspaces[0].tabs.len() - 1; assert_eq!(state.workspaces[0].active_tab, last_idx); assert!(state.agent_panel_scroll > 0); + state.assert_invariants_for_test(); } #[test] @@ -3958,6 +3942,7 @@ mod tests { assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].custom_name.as_deref(), Some("b")); + state.assert_invariants_for_test(); } #[test] @@ -3970,17 +3955,20 @@ mod tests { assert!(state.workspaces.is_empty()); assert_eq!(state.mode, Mode::Navigate); + state.assert_invariants_for_test(); } #[test] fn pane_died_multi_pane_keeps_workspace() { let mut state = app_with_workspaces(&["test"]); let second_id = state.workspaces[0].test_split(Direction::Horizontal); + state.ensure_test_terminals(); state.handle_pane_died(second_id); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].panes.len(), 1); + state.assert_invariants_for_test(); } #[test] @@ -3991,6 +3979,7 @@ mod tests { state.handle_pane_died(fake_id); assert_eq!(state.workspaces.len(), 1); + state.assert_invariants_for_test(); } #[test] @@ -4013,6 +4002,7 @@ mod tests { assert!(state.selection.is_some()); assert!(state.selection_autoscroll.is_some()); + state.assert_invariants_for_test(); } #[test] @@ -4020,6 +4010,7 @@ mod tests { let mut state = app_with_workspaces(&["test"]); let first_id = state.workspaces[0].tabs[0].root_pane; let second_id = state.workspaces[0].test_split(Direction::Horizontal); + state.ensure_test_terminals(); state.selection = Some(crate::selection::Selection::anchor(second_id, 0, 0, None)); state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { @@ -4036,6 +4027,7 @@ mod tests { assert!(state.selection_autoscroll.is_none()); assert_eq!(state.workspaces[0].panes.len(), 1); assert_eq!(state.workspaces[0].panes.keys().next().unwrap(), &first_id); + state.assert_invariants_for_test(); } #[test] @@ -4928,6 +4920,7 @@ mod tests { fn close_pane_removes_from_workspace() { let mut state = app_with_workspaces(&["test"]); let closed = state.workspaces[0].test_split(Direction::Horizontal); + state.ensure_test_terminals(); assert_eq!(state.workspaces[0].panes.len(), 2); state.plugin_panes.insert( closed, @@ -4940,6 +4933,7 @@ mod tests { state.close_pane(); assert_eq!(state.workspaces[0].panes.len(), 1); assert!(!state.plugin_panes.contains_key(&closed)); + state.assert_invariants_for_test(); } #[test] @@ -4985,6 +4979,7 @@ mod tests { state.close_pane(); assert!(!state.terminals.contains_key(&terminal_id)); + state.assert_invariants_for_test(); } #[test] @@ -5007,6 +5002,7 @@ mod tests { assert!(!state.terminals.contains_key(&terminal_id)); assert!(!state.plugin_panes.contains_key(&pane_id)); + state.assert_invariants_for_test(); } #[test] @@ -5026,6 +5022,7 @@ mod tests { assert!(!state.terminals.contains_key(&terminal_id)); assert!(!state.plugin_panes.contains_key(&pane_id)); + state.assert_invariants_for_test(); } #[test] @@ -5042,6 +5039,7 @@ mod tests { assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); assert!(!state.terminals.contains_key(&active_terminal_id)); + state.assert_invariants_for_test(); } #[test] @@ -5058,6 +5056,7 @@ mod tests { assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); assert!(!state.terminals.contains_key(&active_terminal_id)); + state.assert_invariants_for_test(); } #[test] diff --git a/src/app/state.rs b/src/app/state.rs index 2482a06d..089834d8 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1782,6 +1782,68 @@ impl AppState { self.selected, 0, "empty app state should keep selected workspace at 0" ); + assert!( + self.pane_id_aliases.is_empty(), + "empty app state must not keep raw pane aliases" + ); + assert!( + self.public_pane_id_aliases.is_empty(), + "empty app state must not keep public pane aliases" + ); + assert!( + self.previous_pane_focus.is_none(), + "empty app state must not keep previous pane focus" + ); + assert!( + self.plugin_panes.is_empty(), + "empty app state must not keep plugin pane records" + ); + assert!( + self.pending_agent_notifications.is_empty(), + "empty app state must not keep pending agent notifications" + ); + assert!( + self.copy_mode.is_none(), + "empty app state must not keep copy mode" + ); + assert!( + self.rename_pane_target.is_none(), + "empty app state must not keep rename pane target" + ); + assert!( + self.selection.is_none(), + "empty app state must not keep text selection" + ); + assert!( + self.selection_autoscroll.is_none(), + "empty app state must not keep selection autoscroll" + ); + if let Some(toast) = &self.toast { + assert!( + toast.target.is_none(), + "empty app state must not keep pane-targeted toast" + ); + } + assert!( + self.right_click_passthrough.is_none(), + "empty app state must not keep right-click passthrough gesture" + ); + assert!( + self.drag.is_none(), + "empty app state must not keep drag state" + ); + assert!( + self.workspace_press.is_none(), + "empty app state must not keep workspace press state" + ); + assert!( + self.tab_press.is_none(), + "empty app state must not keep tab press state" + ); + assert!( + self.context_menu.is_none(), + "empty app state must not keep context menu" + ); return; } @@ -1802,6 +1864,7 @@ impl AppState { ); let mut workspace_ids = std::collections::HashSet::new(); + let mut workspace_id_to_idx = std::collections::HashMap::new(); let mut pane_ids = std::collections::HashSet::new(); let mut attached_terminal_ids = std::collections::HashSet::new(); for (ws_idx, ws) in self.workspaces.iter().enumerate() { @@ -1811,6 +1874,7 @@ impl AppState { ws.id, ws_idx ); + workspace_id_to_idx.insert(ws.id.clone(), ws_idx); ws.assert_invariants_for_test(); for tab in &ws.tabs { @@ -1834,6 +1898,166 @@ impl AppState { } } } + + let assert_live_pane = |pane_id: PaneId, context: &str| { + assert!( + pane_ids.contains(&pane_id), + "{context} references missing pane {:?}", + pane_id + ); + }; + let assert_workspace_pane = |workspace_id: &str, pane_id: PaneId, context: &str| { + let ws_idx = workspace_id_to_idx + .get(workspace_id) + .copied() + .unwrap_or_else(|| panic!("{context} references missing workspace {workspace_id}")); + assert!( + self.workspaces[ws_idx].pane_state(pane_id).is_some(), + "{context} references pane {:?} outside workspace {}", + pane_id, + workspace_id + ); + }; + let assert_workspace_index = |ws_idx: usize, context: &str| { + assert!( + ws_idx < self.workspaces.len(), + "{context} references workspace index {} out of bounds for {} workspaces", + ws_idx, + self.workspaces.len() + ); + }; + let assert_tab_index = |ws_idx: usize, tab_idx: usize, context: &str| { + assert_workspace_index(ws_idx, context); + assert!( + tab_idx < self.workspaces[ws_idx].tabs.len(), + "{context} references tab index {} out of bounds for workspace {} with {} tabs", + tab_idx, + ws_idx, + self.workspaces[ws_idx].tabs.len() + ); + }; + + for (&raw, &pane_id) in &self.pane_id_aliases { + assert_live_pane(pane_id, &format!("raw pane alias {raw}")); + } + for (public_id, &pane_id) in &self.public_pane_id_aliases { + assert_live_pane(pane_id, &format!("public pane alias {public_id}")); + } + if let Some(focus) = &self.previous_pane_focus { + assert_workspace_pane(&focus.workspace_id, focus.pane_id, "previous pane focus"); + } + if let Some(toast) = &self.toast { + if let Some(target) = &toast.target { + assert_workspace_pane(&target.workspace_id, target.pane_id, "toast target"); + } + } + for (&pane_id, notification) in &self.pending_agent_notifications { + assert_eq!( + pane_id, notification.pane_id, + "pending agent notification map key must match payload pane id" + ); + assert_workspace_pane( + ¬ification.workspace_id, + notification.pane_id, + "pending agent notification", + ); + } + for &pane_id in self.plugin_panes.keys() { + assert_live_pane(pane_id, "plugin pane record"); + } + if let Some(copy_mode) = &self.copy_mode { + assert_live_pane(copy_mode.pane_id, "copy mode"); + } + if let Some(pane_id) = self.rename_pane_target { + assert_live_pane(pane_id, "rename pane target"); + } + if let Some(selection) = &self.selection { + assert_live_pane(selection.pane_id, "text selection"); + } else { + assert!( + self.selection_autoscroll.is_none(), + "selection autoscroll must not remain without an active text selection" + ); + } + if let Some(gesture) = &self.right_click_passthrough { + assert_live_pane(gesture.pane_info.id, "right-click passthrough gesture"); + } + if let Some(drag) = &self.drag { + match &drag.target { + DragTarget::WorkspaceReorder { + source_ws_idx, + insert_idx, + } => { + assert_workspace_index(*source_ws_idx, "workspace drag source"); + if let Some(insert_idx) = insert_idx { + assert!( + *insert_idx <= self.workspaces.len(), + "workspace drag insert index {} out of bounds for {} workspaces", + insert_idx, + self.workspaces.len() + ); + } + } + DragTarget::TabReorder { + ws_idx, + source_tab_idx, + insert_idx, + } => { + assert_tab_index(*ws_idx, *source_tab_idx, "tab drag source"); + if let Some(insert_idx) = insert_idx { + assert!( + *insert_idx <= self.workspaces[*ws_idx].tabs.len(), + "tab drag insert index {} out of bounds for workspace {} with {} tabs", + insert_idx, + ws_idx, + self.workspaces[*ws_idx].tabs.len() + ); + } + } + DragTarget::PaneScrollbar { pane_id, .. } => { + assert_live_pane(*pane_id, "pane scrollbar drag") + } + _ => {} + } + } + if let Some(press) = &self.workspace_press { + assert_workspace_index(press.ws_idx, "workspace press"); + } + if let Some(press) = &self.tab_press { + assert_tab_index(press.ws_idx, press.tab_idx, "tab press"); + } + if let Some(menu) = &self.context_menu { + match menu.kind { + ContextMenuKind::Workspace { ws_idx } + | ContextMenuKind::GitWorkspace { ws_idx, .. } => { + assert_workspace_index(ws_idx, "context menu workspace") + } + ContextMenuKind::Tab { ws_idx, tab_idx } => { + assert_tab_index(ws_idx, tab_idx, "context menu tab") + } + ContextMenuKind::Pane { + ws_idx, + tab_idx, + pane_id, + source_pane_id, + .. + } => { + assert_tab_index(ws_idx, tab_idx, "context menu pane tab"); + assert!( + self.workspaces[ws_idx].tabs[tab_idx] + .panes + .contains_key(&pane_id), + "context menu pane references pane {:?} outside workspace {} tab {}", + pane_id, + ws_idx, + tab_idx + ); + if let Some(source_pane_id) = source_pane_id { + assert_live_pane(source_pane_id, "context menu source pane"); + } + } + } + } } pub fn insert_test_runtime( 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); |
