diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-02 02:45:20 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-02 02:45:20 +0300 |
| commit | cc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (patch) | |
| tree | 4d37c760099070f1a1488b61c7b7271875aafef8 /src/client/shell/tests | |
| parent | 18e69891dca486d669a584facd80644bb51f54a2 (diff) | |
feat: add stable client endpoint compatibility (#3509)
* feat: add stable client endpoint compatibility
* fix: keep portable-pty patch checks composable
* ci: run maintenance checks in pull requests
* fix: reject endpoint errors from terminal clients
* feat: keep compatible servers running across updates
Diffstat (limited to 'src/client/shell/tests')
| -rw-r--r-- | src/client/shell/tests/agents_worktrees_notifications.rs | 30 | ||||
| -rw-r--r-- | src/client/shell/tests/keybindings_settings.rs | 147 | ||||
| -rw-r--r-- | src/client/shell/tests/startup_overlays.rs | 36 |
3 files changed, 207 insertions, 6 deletions
diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index 0ee5aa3f..055b594d 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -983,6 +983,36 @@ fn worktree_create_previews_the_endpoint_owned_checkout_path() { } #[test] +fn unavailable_worktree_create_does_not_wedge_the_overlay() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("new worktree should prepare through worktree.list"); + }; + state.handle_endpoint_result("boot-1", &request.id, Ok(worktree_list_result(None))); + state.set_endpoint_methods(Some(vec!["worktree.list".into()])); + state.handle_input_bytes(b"feature/unavailable"); + + let submit = state.handle_input_bytes(b"\r"); + + assert!(submit.actions.is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating + )); + assert!(state + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| notice.key.code == "worktree.create")); +} + +#[test] fn worktree_open_filters_and_clicks_a_stable_public_entry() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot())); diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index 5d2d8243..5b0fced7 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -437,6 +437,60 @@ fn plugin_command_carries_client_owned_selection_coordinates() { } #[test] +fn unavailable_endpoint_method_is_disabled_without_disconnect() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.set_endpoint_methods(Some(vec!["pane.focus".into()])); + let mut outcome = ClientShellInput::default(); + + state.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: "missing".into(), + }), + &mut outcome, + ); + + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + let notice = state + .visible_endpoint_notice + .as_ref() + .expect("unsupported action notice"); + assert_eq!(notice.key.kind, ClientEndpointNoticeKind::Unsupported); + assert_eq!(notice.key.code, "workspace.focus"); + assert!(notice.body.contains("This server")); + assert!(state.endpoint_error.is_none()); + + let mut repeated = ClientShellInput::default(); + state.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: "missing".into(), + }), + &mut repeated, + ); + assert!(repeated.actions.is_empty()); + assert!(!repeated.repaint); + + state.compose(106, 20).expect("endpoint notice frame"); + assert!(!state.hits.notification_toast.is_empty()); + + state.handle_input_bytes(b"x"); + assert!(state.visible_endpoint_notice.is_some()); + + let hit = state.hits.notification_toast; + let dismissed = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x, + row: hit.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(dismissed.repaint); + assert!(state.visible_endpoint_notice.is_none()); +} + +#[test] fn generic_endpoint_failures_and_control_errors_are_visible() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot())); @@ -461,17 +515,98 @@ fn generic_endpoint_failures_and_control_errors_are_visible() { ); assert!(repaint); assert!(actions.is_empty()); + let notice = state + .visible_endpoint_notice + .as_ref() + .expect("rejected action notice"); + assert_eq!(notice.key.kind, ClientEndpointNoticeKind::Rejected); + assert_eq!(notice.key.code, "workspace.focus:not_found"); + assert_eq!(notice.body, "workspace no longer exists"); + assert!(state.endpoint_error.is_none()); + + assert!(state.receive_endpoint_error("Paste rejected: too large".into())); + let notice = state + .visible_endpoint_notice + .as_ref() + .expect("paste rejection notice"); + assert_eq!(notice.key.kind, ClientEndpointNoticeKind::Rejected); + assert_eq!(notice.key.code, "paste_rejected"); + assert_eq!(notice.body, "Paste rejected: too large"); + assert!(state.receive_endpoint_error("Paste rejected again".into())); assert_eq!( - state.endpoint_error.as_deref(), - Some("workspace no longer exists") + state + .visible_endpoint_notice + .as_ref() + .map(|notice| notice.body.as_str()), + Some("Paste rejected again") ); +} - assert!(state.receive_endpoint_error("Paste rejected: too large".into())); +#[test] +fn endpoint_timeout_is_a_deduplicated_server_notice() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut outcome = ClientShellInput::default(); + state.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: "work".into(), + }), + &mut outcome, + ); + let request_id = match &outcome.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected endpoint request, got {other:?}"), + }; + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("endpoint_timeout".into()), + message: "transport timeout".into(), + }), + ); + + assert!(repaint); + assert!(actions.is_empty()); + let notice = state + .visible_endpoint_notice + .as_ref() + .expect("timeout notice"); + assert_eq!(notice.key.kind, ClientEndpointNoticeKind::Timeout); + assert_eq!(notice.key.code, "workspace.focus"); + assert!(notice.body.contains("workspace.focus")); + assert!(state.endpoint_error.is_none()); +} + +#[test] +fn endpoint_notice_dedupe_resets_for_a_new_server_boot() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_endpoint_methods(Some(Vec::new())); + let method = || { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: "work".into(), + }) + }; + let mut first = ClientShellInput::default(); + state.push_endpoint_method(method(), &mut first); + assert!(first.repaint); + + let mut next_snapshot = snapshot(); + next_snapshot.boot_id = "boot-2".into(); + state.set_snapshot(Box::new(next_snapshot)); + let mut second = ClientShellInput::default(); + state.push_endpoint_method(method(), &mut second); + + assert!(second.repaint); assert_eq!( - state.endpoint_error.as_deref(), - Some("Paste rejected: too large") + state + .visible_endpoint_notice + .as_ref() + .map(|notice| notice.key.boot_id.as_str()), + Some("boot-2") ); - assert!(!state.receive_endpoint_error("Paste rejected: too large".into())); } #[test] diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs index 40ba0bd6..2e2901e8 100644 --- a/src/client/shell/tests/startup_overlays.rs +++ b/src/client/shell/tests/startup_overlays.rs @@ -455,6 +455,41 @@ fn onboarding_completion_persists_and_opens_endpoint_integrations() { } #[test] +fn unavailable_integration_list_does_not_wedge_settings() { + let mut config = + ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); + config.local_config_path = std::env::temp_dir().join(format!( + "herdr-client-onboarding-unavailable-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.set_endpoint_methods(Some(Vec::new())); + + let outcome = state.handle_input_bytes(b"\r"); + + assert!(outcome.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + loading_integrations: false, + .. + })) + )); + assert!(state + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| notice.key.code == "integration.list")); + let _ = std::fs::remove_file(&state.config.local_config_path); +} + +#[test] fn startup_config_diagnostics_are_client_rendered_and_persist_until_replaced() { let config = ClientShellConfig::from_config(&Config::default()) .with_startup_config_diagnostic(Some("local config warning".into())); @@ -558,6 +593,7 @@ fn endpoint_reload_result_does_not_override_snapshot_diagnostic_authority() { "reload-1".into(), PendingEndpointRequest { boot_id: "boot-1".into(), + method_name: "server.reload_config".into(), confirmation_workspace_id: None, kind: PendingEndpointKind::ReloadConfig, }, |
