aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tests
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-02 02:45:20 +0300
committerGitHub <noreply@github.com>2026-09-02 02:45:20 +0300
commitcc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (patch)
tree4d37c760099070f1a1488b61c7b7271875aafef8 /src/client/shell/tests
parent18e69891dca486d669a584facd80644bb51f54a2 (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.rs30
-rw-r--r--src/client/shell/tests/keybindings_settings.rs147
-rw-r--r--src/client/shell/tests/startup_overlays.rs36
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,
},