aboutsummaryrefslogtreecommitdiff
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
parentde0f43a061162a223c5946a4b50aa9f9ac7ab8d7 (diff)
test: harden pane identity regression coverage
-rw-r--r--src/api/schema/tests.rs119
-rw-r--r--src/app/actions.rs41
-rw-r--r--src/app/state.rs224
-rw-r--r--tests/cli_wrapper.rs99
-rw-r--r--tests/client_mode.rs364
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(
+ &notification.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);