diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-06 02:27:06 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-06 02:27:06 +0300 |
| commit | 9e9bc8a144667a6b7debfd4f0c7a31ff0e11493a (patch) | |
| tree | 03f1259f91fd9edeacfd4b904f2e84d4acf25232 /src/client/shell/tests/endpoints.rs | |
| parent | 35b0dff96579f099e0436290085024424ffb289c (diff) | |
feat: manage multiple ssh machines from one client (#3670)
Diffstat (limited to 'src/client/shell/tests/endpoints.rs')
| -rw-r--r-- | src/client/shell/tests/endpoints.rs | 1205 |
1 files changed, 1205 insertions, 0 deletions
diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs new file mode 100644 index 00000000..52c71923 --- /dev/null +++ b/src/client/shell/tests/endpoints.rs @@ -0,0 +1,1205 @@ +use super::*; +use crate::client::endpoint::{ + ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint, +}; +use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; + +fn remote_profile() -> SavedSshEndpoint { + SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Build".into(), + target: "dev@build.example".into(), + session: "agents".into(), + enabled: true, + } +} + +fn agent( + name: &str, + status: crate::api::schema::AgentStatus, + state_change_seq: u64, +) -> ClientShellAgent { + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some(name.into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: status, + state_change_seq, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + } +} + +fn state_with_remote() -> (ClientShellState, ClientEndpointId) { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.workspaces[0].label = "remote-workspace".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + (state, endpoint_id) +} + +#[test] +fn switching_machines_from_copy_mode_restores_terminal_input() { + let (mut state, remote) = state_with_remote(); + let mut local_surface = surface(); + local_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(local_surface); + state.compose(100, 28).unwrap(); + assert!(state.enter_copy_mode(&mut ClientShellInput::default())); + assert_eq!(state.mode, ClientShellMode::Copy); + + assert!(state.activate_endpoint_projection(&remote)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + state.compose(100, 28).unwrap(); + + assert!(state.copy_mode.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + let input = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))]); + assert!(matches!( + input.requests.as_slice(), + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" && events.len() == 1 + )); +} + +#[test] +fn live_catalog_rename_preserves_snapshot_and_disable_reenable_clears_it() { + let (mut state, remote) = state_with_remote(); + let mut profile = remote_profile(); + profile.label = "Renamed".into(); + state.set_endpoint_catalog(&[profile.clone()]); + assert_eq!(state.endpoint_label(&remote), "Renamed"); + assert!(state.endpoint_is_online(&remote)); + assert_eq!(state.endpoint_boot_id(&remote), Some("remote-boot")); + profile.enabled = false; + state.set_endpoint_catalog(&[profile.clone()]); + assert_eq!( + state.endpoint_status(&remote), + Some(ClientEndpointStatus::Disabled) + ); + assert!(!state.endpoint_has_snapshot(&remote)); + profile.enabled = true; + state.set_endpoint_catalog(&[profile]); + assert_eq!( + state.endpoint_status(&remote), + Some(ClientEndpointStatus::Connecting) + ); + assert!(!state.endpoint_has_snapshot(&remote)); +} + +#[test] +fn live_catalog_active_removal_does_not_retain_remote_projection_or_input() { + let (mut state, remote) = state_with_remote(); + assert!(state.activate_endpoint_projection(&remote)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Prefix; + state.overlay = Some(ClientShellOverlay::Onboarding); + state.select_unavailable_local(); + state.retire_endpoint(&remote); + state.set_endpoint_catalog(&[]); + assert!(state.endpoint_is_active(&ClientEndpointId::Local)); + assert!(state.snapshot.is_none()); + assert!(state.pane_surface.is_none()); + assert!(state.pending_pane_surface.is_none()); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.endpoint_has_snapshot(&ClientEndpointId::Local)); + let frame = state.compose(100, 30).unwrap(); + let buffer = frame.to_ratatui_buffer().unwrap(); + let text = buffer + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::<String>(); + assert!(!text.contains("remote-workspace")); +} + +#[test] +fn machine_navigation_does_not_require_a_local_snapshot_or_surface() { + for (cols, rows) in [(100, 28), (36, 18)] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let profile = remote_profile(); + let remote = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Reconnecting); + state.set_endpoint_status(&remote, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&remote, Box::new(snapshot())); + assert!(state.snapshot.is_none()); + assert!(state.pane_surface.is_none()); + let frame = state + .compose(cols, rows) + .expect("connection chrome without Local"); + let local = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .unwrap() + .rect; + let buffer = frame.to_ratatui_buffer().unwrap(); + let local_row = (local.x..local.right()) + .map(|x| buffer[(x, local.y)].symbol()) + .collect::<String>(); + assert!(!local_row.contains("reconnecting")); + assert!( + !local_row.contains('◐'), + "Local never gets a connection badge" + ); + let hit = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == remote) + .unwrap() + .rect; + let mut outcome = ClientShellInput::default(); + state.handle_mouse( + crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x + 5, + row: hit.y, + modifiers: KeyModifiers::NONE, + }, + &mut outcome, + ); + assert!( + matches!(outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint { endpoint_id, .. }] if endpoint_id == &remote) + ); + assert!( + state.snapshot.is_none(), + "selection is committed only by coherent activation" + ); + } +} + +#[test] +fn sidebar_renders_local_and_saved_ssh_endpoints_with_status() { + let (mut state, _) = state_with_remote(); + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("Local")); + assert!(text.contains("Build")); + assert!(text.contains("remote-workspace")); + let local = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("local machine row") + .rect; + let remote = state + .hits + .machines + .iter() + .find(|hit| !hit.endpoint_id.is_local()) + .expect("remote machine row") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●"); + assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●"); + assert_eq!( + buffer[(remote.right() - 1, remote.y)].fg, + state.config.palette.green + ); + + state.sidebar_collapsed = true; + let frame = state.compose(100, 28).expect("collapsed endpoint frame"); + let local = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("collapsed local machine row") + .rect; + let remote = state + .hits + .machines + .iter() + .find(|hit| !hit.endpoint_id.is_local()) + .expect("collapsed remote machine row") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●"); + assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●"); +} + +#[test] +fn active_workspace_is_the_only_highlight_when_machine_is_expanded() { + let (mut state, endpoint_id) = state_with_remote(); + assert!(state.activate_endpoint_projection(&endpoint_id)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let machine = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote machine hit") + .rect; + let workspace = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace hit") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_ne!( + buffer[(machine.x, machine.y)].bg, + state.config.palette.active_row_bg + ); + assert_eq!( + buffer[(workspace.x + 2, workspace.y)].bg, + state.config.palette.active_row_bg + ); + + state.collapsed_endpoints.insert(endpoint_id.clone()); + let frame = state.compose(100, 28).expect("collapsed endpoint frame"); + let machine = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote machine hit") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_eq!( + buffer[(machine.x, machine.y)].bg, + state.config.palette.active_row_bg + ); +} + +#[test] +fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() { + use crate::api::schema::AgentStatus; + use crate::config::{AgentSidebarToken, StatusIndicatorStyle}; + + let mut config = Config::default(); + config.ui.status_indicators = StatusIndicatorStyle::Symbols; + config.ui.sidebar.agents.rows = vec![vec![ + AgentSidebarToken::StateIcon, + AgentSidebarToken::Machine, + AgentSidebarToken::Agent, + ]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agents = vec![agent("local agent", AgentStatus::Idle, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![agent("remote agent", AgentStatus::Blocked, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("○ Local · local agent"), "frame: {text}"); + assert!(text.contains("× Build · remote agent"), "frame: {text}"); + assert!(text.contains("grouped"), "frame: {text}"); + let toggle = state.hits.agent_sort_toggle; + assert!(!toggle.is_empty()); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!( + state.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(click.actions.is_empty()); + + let buffer = frame + .to_ratatui_buffer() + .expect("aggregate frame should reconstruct"); + assert!(buffer + .content() + .iter() + .any(|cell| cell.symbol() == "×" && cell.fg == state.config.palette.red)); +} + +#[test] +fn aggregate_priority_uses_client_observed_recency_across_machines() { + use crate::api::schema::AgentStatus; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + config.ui.sidebar.agents.rows = + vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agents = vec![agent("local agent", AgentStatus::Idle, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone())); + + let mut local = snapshot(); + local.agents = vec![agent("local agent", AgentStatus::Idle, 2)]; + state.set_snapshot(Box::new(local)); + let frame_text = |state: &mut ClientShellState| { + let frame = state.compose(100, 28).expect("combined endpoint frame"); + frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n") + }; + let text = frame_text(&mut state); + assert!( + text.find("Local · local agent").expect("local agent") + < text.find("Build · remote agent").expect("remote agent") + ); + + remote.agents = vec![agent("remote agent", AgentStatus::Working, 2)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone())); + let text = frame_text(&mut state); + assert!( + text.find("Build · remote agent").expect("remote agent") + < text.find("Local · local agent").expect("local agent") + ); + + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 3)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + let text = frame_text(&mut state); + assert!( + text.find("Build · remote agent").expect("remote agent") + < text.find("Local · local agent").expect("local agent") + ); + let mut outcome = ClientShellInput::default(); + assert!( + state.handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(0), &mut outcome,) + ); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if activated == &endpoint_id && pane_id == "pane_1" + )); +} + +#[test] +fn unselected_endpoint_completion_projects_done_client_side() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![agent("background agent", AgentStatus::Working, 2)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone())); + remote.revision = 2; + remote.agents = vec![agent("background agent", AgentStatus::Idle, 3)]; + + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let status = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .and_then(|snapshot| snapshot.agents.first()) + .map(|agent| agent.agent_status); + assert_eq!(status, Some(AgentStatus::Done)); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); +} + +#[test] +fn clicking_remote_endpoint_requests_activation_without_mutating_projection() { + let (mut state, endpoint_id) = state_with_remote(); + state.compose(100, 28).expect("combined endpoint frame"); + let hit = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote endpoint hit") + .rect; + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x, + row: hit.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: None, + }] if activated == &endpoint_id + )); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); + assert_eq!( + state + .snapshot + .as_deref() + .map(|snapshot| snapshot.boot_id.as_str()), + Some("boot-1") + ); +} + +#[test] +fn context_menu_lookup_ignores_inactive_endpoint_workspaces() { + let (mut state, endpoint_id) = state_with_remote(); + state.compose(100, 28).expect("combined endpoint frame"); + let remote = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace") + .rect; + let local = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("local workspace") + .rect; + + assert_eq!( + state.active_endpoint_workspace_at((remote.x, remote.y)), + None + ); + assert_eq!( + state.active_endpoint_workspace_at((local.x, local.y)), + Some("ws_1".into()) + ); +} + +#[test] +fn future_surface_waits_for_its_exact_snapshot_revision() { + let (mut state, _) = state_with_remote(); + let mut future = surface(); + future.projection_revision = 2; + future.surface_revision = 2; + state.set_pane_surface(future); + assert_eq!( + state + .pane_surface + .as_ref() + .map(|surface| surface.projection_revision), + Some(1) + ); + assert_eq!( + state + .pending_pane_surface + .as_ref() + .map(|surface| surface.projection_revision), + Some(2) + ); + + let mut next = snapshot(); + next.revision = 2; + state.set_snapshot(Box::new(next)); + assert_eq!( + state + .pane_surface + .as_ref() + .map(|surface| surface.projection_revision), + Some(2) + ); + assert!(state.pending_pane_surface.is_none()); +} + +#[test] +fn inactive_endpoint_snapshot_cache_never_regresses_revision() { + let (mut state, endpoint_id) = state_with_remote(); + let mut newest = snapshot(); + newest.boot_id = "remote-boot".into(); + newest.revision = 3; + newest.workspaces[0].label = "newest".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(newest)); + let mut delayed = snapshot(); + delayed.boot_id = "remote-boot".into(); + delayed.revision = 2; + delayed.workspaces[0].label = "delayed".into(); + + state.set_endpoint_snapshot(&endpoint_id, Box::new(delayed)); + + let label = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .and_then(|snapshot| snapshot.workspaces.first()) + .map(|workspace| workspace.label.as_str()); + assert_eq!(label, Some("newest")); +} + +#[test] +fn new_connection_generation_accepts_a_lower_same_boot_projection_revision() { + let (mut state, endpoint_id) = state_with_remote(); + let mut previous = snapshot(); + previous.boot_id = "shared-server-boot".into(); + previous.revision = 9; + previous.workspaces[0].label = "old connection".into(); + state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 4, Box::new(previous)); + let mut reconnected = snapshot(); + reconnected.boot_id = "shared-server-boot".into(); + reconnected.revision = 1; + reconnected.workspaces[0].label = "new connection".into(); + + state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 5, Box::new(reconnected)); + + let endpoint = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint"); + assert_eq!(endpoint.snapshot_generation, Some(5)); + assert_eq!(endpoint.snapshot.as_ref().unwrap().revision, 1); + assert_eq!( + endpoint.snapshot.as_ref().unwrap().workspaces[0].label, + "new connection" + ); +} + +#[test] +fn reconnect_snapshot_waits_for_coherent_activation_before_replacing_projection() { + let (mut state, endpoint_id) = state_with_remote(); + assert!(state.activate_endpoint_projection(&endpoint_id)); + assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot"); + + state.mark_endpoint_disconnected(&endpoint_id); + let mut replacement = snapshot(); + replacement.boot_id = "replacement-boot".into(); + state.cache_endpoint_snapshot(&endpoint_id, Box::new(replacement)); + assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot"); + + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + assert!(state.activate_endpoint_projection(&endpoint_id)); + assert_eq!( + state.snapshot.as_deref().unwrap().boot_id, + "replacement-boot" + ); +} + +#[test] +fn disconnected_active_endpoint_freezes_surface_and_marks_cached_ui_stale() { + use crate::api::schema::AgentStatus; + use crate::config::{AgentSidebarToken, StatusIndicatorStyle}; + + let (mut state, endpoint_id) = state_with_remote(); + state.config.status_indicators = StatusIndicatorStyle::Symbols; + state.config.agents.rows = vec![vec![ + AgentSidebarToken::StateIcon, + AgentSidebarToken::Machine, + AgentSidebarToken::Agent, + ]]; + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint"); + endpoint.snapshot.as_mut().expect("remote snapshot").agents = + vec![agent("remote agent", AgentStatus::Blocked, 1)]; + assert!(state.activate_endpoint_projection(&endpoint_id)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + state.pending_integration_installs = 2; + + state.mark_endpoint_disconnected(&endpoint_id); + let frame = state.compose(100, 28).expect("frozen endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + + assert_eq!(state.pending_integration_installs, 0); + assert_eq!( + state.endpoint_status(&endpoint_id), + Some(ClientEndpointStatus::Reconnecting) + ); + assert!(text.contains("◐ reconnecting"), "frame: {text}"); + assert!(text.contains("Build · remote agent"), "frame: {text}"); + assert!( + text.contains("LIVE"), + "frozen surface should remain: {text}" + ); + assert!(state.hits.panes.is_empty()); + assert!(frame.cursor.is_none()); + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + let stale_icon = buffer + .content() + .iter() + .find(|cell| cell.symbol() == "×") + .expect("stale blocked icon"); + assert_eq!(stale_icon.fg, state.config.palette.overlay0); +} + +#[cfg(unix)] +#[test] +fn graphics_scope_qualifies_colliding_boot_ids_by_endpoint() { + let (mut state, endpoint_id) = state_with_remote(); + let local_scope = state.graphics_scope().to_owned(); + let mut remote = snapshot(); + remote.boot_id = "boot-1".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + assert!(state.activate_endpoint_projection(&endpoint_id)); + let remote_scope = state.graphics_scope(); + assert_ne!(local_scope, remote_scope); + assert!(remote_scope.starts_with("ssh:0123456789abcdef0123456789abcdef:")); +} + +#[test] +fn navigator_uses_machine_parents_only_for_federated_clients() { + let (mut state, _) = state_with_remote(); + state.open_navigator_overlay(); + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let machines = rows + .iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Machine { .. })) + .collect::<Vec<_>>(); + assert_eq!( + machines + .iter() + .map(|row| row.label.as_str()) + .collect::<Vec<_>>(), + vec!["Local", "Build"] + ); + assert!(rows.iter().all(|row| { + matches!(row.target, ClientNavigatorTarget::Machine { .. }) + || (!row.label.contains("Local ·") && !row.label.contains("Build ·")) + })); + assert!(rows.iter().all(|row| match row.target { + ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(), + ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(), + ClientNavigatorTarget::Tab { .. } => row.depth == 2 && row.status.is_none(), + ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(), + })); + assert_eq!(rows.iter().filter(|row| row.current).count(), 1); + + let mut local = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + local.set_snapshot(Box::new(snapshot())); + local.set_pane_surface(surface()); + let frame = local.compose(100, 28).expect("local-only sidebar"); + assert!(local.hits.machines.is_empty()); + assert!(!frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n") + .contains(" machines")); + local.open_navigator_overlay(); + let ClientShellOverlay::Navigator(navigator) = local.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + let rows = + render::client_navigator_rows(&local.endpoints, &local.active_endpoint_id, navigator); + assert!(rows + .iter() + .all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. }))); + assert!(rows.iter().all(|row| match row.target { + ClientNavigatorTarget::Workspace { .. } => row.depth == 0, + ClientNavigatorTarget::Tab { .. } => row.depth == 1, + ClientNavigatorTarget::Pane { .. } => row.depth == 2, + ClientNavigatorTarget::Machine { .. } => false, + })); +} + +#[test] +fn navigator_keeps_saved_machine_visible_before_metadata_arrives() { + let (mut state, endpoint_id) = state_with_remote(); + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("saved remote endpoint"); + endpoint.snapshot = None; + endpoint.status = ClientEndpointStatus::Connecting; + state.open_navigator_overlay(); + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + + assert!(rows.iter().any(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id + ) && row.label == "Build" + && row.stale + })); + assert!(!rows.iter().any(|row| match &row.target { + ClientNavigatorTarget::Machine { .. } => false, + ClientNavigatorTarget::Workspace { + endpoint_id: target, + .. + } + | ClientNavigatorTarget::Tab { + endpoint_id: target, + .. + } + | ClientNavigatorTarget::Pane { + endpoint_id: target, + .. + } => target == &endpoint_id, + })); +} + +#[test] +fn navigator_machine_selection_opens_its_remembered_view() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) + .into_iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id + ) + }) + .map(|row| row.target) + .expect("remote machine row") + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + + let mut outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut outcome); + + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: None, + }] if activated == &endpoint_id + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn navigator_foreign_pane_selection_activates_its_endpoint() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Pane { + endpoint_id: target_endpoint, + pane_id, + } if target_endpoint == &endpoint_id && pane_id == "pane_1" + ) + }) + .map(|row| row.target.clone()) + .expect("remote pane row") + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + let mut local = snapshot(); + let mut inserted = local.workspaces[0].clone(); + inserted.workspace_id = "ws_2".into(); + inserted.focused = false; + local.workspaces.push(inserted); + state.set_snapshot(Box::new(local)); + + let mut outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut outcome); + + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if activated == &endpoint_id && pane_id == "pane_1" + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn mobile_foreign_agent_and_workspace_targets_activate_their_endpoint() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .agents = vec![agent("remote agent", AgentStatus::Working, 2)]; + state.mode = ClientShellMode::Navigate; + state.compose(44, 30).expect("mobile switcher"); + let remote_agent = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!( + target, + ClientMobileTarget::Agent { + endpoint_id: target_endpoint, + pane_id, + } if target_endpoint == &endpoint_id && pane_id == "pane_1" + ) + .then_some(*rect) + }) + .expect("remote agent target"); + let click = |rect: Rect| { + RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + let outcome = state.handle_raw_events(vec![click(remote_agent)]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if activated == &endpoint_id && pane_id == "pane_1" + )); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 30).expect("mobile switcher"); + let remote_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!( + target, + ClientMobileTarget::Workspace { + endpoint_id: target_endpoint, + workspace_id, + } if target_endpoint == &endpoint_id && workspace_id == "ws_1" + ) + .then_some(*rect) + }) + .expect("remote workspace target"); + let outcome = state.handle_raw_events(vec![click(remote_workspace)]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)), + }] if activated == &endpoint_id && workspace_id == "ws_1" + )); +} + +#[test] +fn cached_offline_navigator_and_mobile_targets_are_dimmed_and_disabled() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .agents = vec![agent("remote agent", AgentStatus::Blocked, 2)]; + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting); + + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let machine = rows + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id + ) + }) + .expect("cached remote machine row"); + assert!(machine.stale); + let row = rows + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Pane { + endpoint_id: target_endpoint, + pane_id, + } if target_endpoint == &endpoint_id && pane_id == "pane_1" + ) + }) + .expect("cached remote pane row"); + assert!(row.stale); + assert!(!row.meta.contains("reconnecting")); + row.target.clone() + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + let mut navigator_outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut navigator_outcome); + assert!(navigator_outcome.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Navigator(_)) + )); + + state.overlay = None; + state.mode = ClientShellMode::Navigate; + let frame = state.compose(44, 30).expect("offline mobile switcher"); + let mobile_target = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!( + target, + ClientMobileTarget::Workspace { + endpoint_id: target_endpoint, + workspace_id, + } if target_endpoint == &endpoint_id && workspace_id == "ws_1" + ) + .then_some(*rect) + }) + .expect("cached remote workspace target"); + let buffer = frame.to_ratatui_buffer().expect("mobile frame buffer"); + assert_eq!( + buffer[(mobile_target.x, mobile_target.y)].fg, + state.config.palette.overlay0 + ); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: mobile_target.x, + row: mobile_target.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(outcome.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); +} + +#[test] +fn focus_agent_index_uses_online_aggregate_rows() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .agents = vec![agent("remote agent", AgentStatus::Working, 2)]; + let focus_agent = + |index| crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(index)); + + assert!(state.indexed_navigation_target_exists(&focus_agent(0))); + assert!(!state.indexed_navigation_target_exists(&focus_agent(1))); + + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting); + assert!(!state.indexed_navigation_target_exists(&focus_agent(0))); +} + +#[test] +fn workspace_drag_rejects_foreign_endpoint_slots() { + let (mut state, endpoint_id) = state_with_remote(); + state.compose(100, 28).expect("aggregate sidebar"); + let local = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("local workspace") + .rect; + let remote = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace") + .rect; + let mouse = |kind, rect: Rect| { + RawInputEvent::Mouse(MouseEvent { + kind, + column: rect.x.saturating_add(1), + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + + state.handle_raw_events(vec![mouse(MouseEventKind::Down(MouseButton::Left), local)]); + state.handle_raw_events(vec![mouse(MouseEventKind::Drag(MouseButton::Left), remote)]); + + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn collapsed_aggregate_workspace_status_uses_its_status_color() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .workspaces[0] + .agent_status = AgentStatus::Blocked; + state.sidebar_collapsed = true; + + let frame = state.compose(100, 28).expect("collapsed aggregate sidebar"); + let workspace = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame buffer"); + assert_eq!( + buffer[(workspace.x.saturating_add(2), workspace.y)].fg, + state.config.palette.red + ); +} + +#[test] +fn navigator_foreign_tab_selection_keeps_the_tab_target() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Tab { + endpoint_id: target_endpoint, + tab_id, + .. + } if target_endpoint == &endpoint_id && tab_id == "tab_1" + ) + }) + .map(|row| row.target.clone()) + .expect("remote tab row") + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + + let mut outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut outcome); + + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Tab(tab_id)), + }] if activated == &endpoint_id && tab_id == "tab_1" + )); +} |
