aboutsummaryrefslogtreecommitdiff
path: root/src/client/tests/mod.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/tests/mod.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/tests/mod.rs')
-rw-r--r--src/client/tests/mod.rs823
1 files changed, 823 insertions, 0 deletions
diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs
new file mode 100644
index 00000000..d8f71a84
--- /dev/null
+++ b/src/client/tests/mod.rs
@@ -0,0 +1,823 @@
+use super::*;
+use std::ffi::OsString;
+use std::sync::{Mutex, OnceLock};
+
+fn env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+}
+
+#[test]
+fn resize_signal_reports_even_when_polled_size_is_unchanged() {
+ let size = (120, 40, 8, 16, true);
+ assert!(resize_report_required(true, size, size));
+ assert!(!resize_report_required(false, size, size));
+ assert!(resize_report_required(false, (120, 41, 8, 16, true), size));
+ assert!(resize_report_required(false, (120, 40, 9, 18, true), size));
+ assert!(resize_report_required(false, (120, 40, 8, 16, false), size));
+}
+
+#[test]
+fn direct_graphics_profile_is_narrow_and_transport_safe() {
+ for (program, term, kitty, expected) in [
+ ("ghostty", "", false, true),
+ ("WezTerm", "", false, true),
+ ("", "xterm-kitty", false, true),
+ ("", "xterm-256color", true, true),
+ ("", "xterm-256color", false, false),
+ ] {
+ assert_eq!(
+ direct_graphics_profile_values(program, term, kitty, false, true),
+ expected
+ );
+ }
+ assert!(!direct_graphics_profile_values(
+ "ghostty", "", false, true, true
+ ));
+ assert!(!direct_graphics_profile_values(
+ "ghostty", "", false, false, false
+ ));
+}
+
+fn restore_env_var(key: &str, value: Option<OsString>) {
+ if let Some(value) = value {
+ std::env::set_var(key, value);
+ } else {
+ std::env::remove_var(key);
+ }
+}
+
+struct EnvVarGuard {
+ key: &'static str,
+ previous: Option<OsString>,
+}
+
+impl EnvVarGuard {
+ fn set(key: &'static str, value: &str) -> Self {
+ let previous = std::env::var_os(key);
+ std::env::set_var(key, value);
+ Self { key, previous }
+ }
+}
+
+impl Drop for EnvVarGuard {
+ fn drop(&mut self) {
+ restore_env_var(self.key, self.previous.clone());
+ }
+}
+
+#[test]
+fn windows_virtual_terminal_input_mode_sets_only_vti_bit() {
+ assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0);
+ assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0);
+}
+
+struct EnvVarsRemovedGuard {
+ previous: Vec<(&'static str, Option<OsString>)>,
+}
+
+impl EnvVarsRemovedGuard {
+ fn new(keys: &[&'static str]) -> Self {
+ let previous: Vec<_> = keys
+ .iter()
+ .map(|key| (*key, std::env::var_os(key)))
+ .collect();
+ for key in keys {
+ std::env::remove_var(key);
+ }
+ Self { previous }
+ }
+}
+
+impl Drop for EnvVarsRemovedGuard {
+ fn drop(&mut self) {
+ for (key, value) in self.previous.clone() {
+ restore_env_var(key, value);
+ }
+ }
+}
+
+#[test]
+fn remote_client_uses_extended_handshake_timeout() {
+ let _guard = env_lock().lock().unwrap();
+ let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local");
+
+ assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT);
+}
+
+#[test]
+fn host_cursor_policy_auto_uses_platform_default() {
+ assert_eq!(
+ should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto),
+ crate::platform::should_draw_host_cursor_by_default()
+ );
+}
+
+#[test]
+fn host_cursor_policy_native_and_drawn_override_auto_detection() {
+ let _guard = env_lock().lock().unwrap();
+ let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm");
+
+ assert!(!should_draw_host_cursor(
+ crate::config::HostCursorModeConfig::Native
+ ));
+ assert!(should_draw_host_cursor(
+ crate::config::HostCursorModeConfig::Drawn
+ ));
+}
+
+#[cfg(unix)]
+#[test]
+fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() {
+ let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap();
+ assert!(should_bridge_clipboard_image_paste(
+ &[0x16],
+ true,
+ Some(ctrl_v)
+ ));
+ assert!(should_bridge_clipboard_image_paste(
+ b"\x1b[118;5u",
+ true,
+ Some(ctrl_v)
+ ));
+ assert!(should_bridge_clipboard_image_paste(
+ b"\x1b[200~\x1b[201~",
+ true,
+ None
+ ));
+ assert!(!should_bridge_clipboard_image_paste(
+ b"\x1b[200~\x1b[201~",
+ false,
+ Some(ctrl_v)
+ ));
+ assert!(!should_bridge_clipboard_image_paste(
+ b"\x1b[200~text\x1b[201~",
+ true,
+ Some(ctrl_v)
+ ));
+ assert!(!should_bridge_clipboard_image_paste(&[0x16], true, None));
+ assert!(!should_bridge_clipboard_image_paste(
+ b"v",
+ true,
+ Some(ctrl_v)
+ ));
+}
+
+struct TempImageFile {
+ path: std::path::PathBuf,
+}
+
+impl TempImageFile {
+ fn new(extension: &str, bytes: &[u8]) -> Self {
+ Self::with_name_fragment("test", extension, bytes)
+ }
+
+ fn with_name_fragment(name_fragment: &str, extension: &str, bytes: &[u8]) -> Self {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ let path = std::env::temp_dir().join(format!(
+ "herdr-client-drop-{name_fragment}-{}-{nanos}.{extension}",
+ std::process::id()
+ ));
+ std::fs::write(&path, bytes).unwrap();
+ Self { path }
+ }
+}
+
+impl Drop for TempImageFile {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_file(&self.path);
+ }
+}
+#[cfg(unix)]
+#[test]
+fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() {
+ let file = TempImageFile::new("PNG", b"image-bytes");
+ let input = format!("\x1b[200~{}\x1b[201~", file.path.display());
+
+ let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap();
+
+ assert_eq!(image.extension, "png");
+ assert_eq!(image.bytes, b"image-bytes");
+}
+
+#[cfg(unix)]
+#[test]
+fn remote_image_file_drop_bridge_reads_plain_quoted_path_with_newline() {
+ let file = TempImageFile::new("jpeg", b"jpeg-bytes");
+ let input = format!("'{}'\n", file.path.display());
+
+ let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap();
+
+ assert_eq!(image.extension, "jpg");
+ assert_eq!(image.bytes, b"jpeg-bytes");
+}
+
+#[cfg(unix)]
+#[test]
+fn remote_image_file_drop_bridge_unescapes_spaces_in_paths() {
+ let file = TempImageFile::with_name_fragment("space test", "png", b"image-bytes");
+ let escaped_path = file.path.display().to_string().replace(' ', "\\ ");
+
+ let image = read_image_file_from_terminal_drop(escaped_path.as_bytes(), true).unwrap();
+
+ assert_eq!(image.extension, "png");
+ assert_eq!(image.bytes, b"image-bytes");
+}
+
+#[cfg(unix)]
+#[test]
+fn remote_image_file_drop_bridge_ignores_non_remote_and_non_image_input() {
+ let file = TempImageFile::new("png", b"image-bytes");
+ let path = file.path.display().to_string();
+
+ assert!(read_image_file_from_terminal_drop(path.as_bytes(), false).is_none());
+ assert!(read_image_file_from_terminal_drop(b"relative.png\n", true).is_none());
+ assert!(read_image_file_from_terminal_drop(b"/tmp/file.txt\n", true).is_none());
+ assert!(read_image_file_from_terminal_drop(
+ format!("{}\nextra", file.path.display()).as_bytes(),
+ true
+ )
+ .is_none());
+}
+
+#[test]
+fn graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor() {
+ let mut output = Vec::new();
+ write_encoded_frame_with_graphics(
+ &mut output,
+ b"\x1b[?2026htext\x1b[?2026lcursor",
+ b"graphics",
+ )
+ .unwrap();
+
+ assert_eq!(
+ output,
+ b"\x1b[?2026htext\x1b7graphics\x1b8\x1b[?2026lcursor"
+ );
+}
+
+#[test]
+fn empty_graphics_writes_only_blit_frame() {
+ let mut output = Vec::new();
+ write_encoded_frame_with_graphics(&mut output, b"text", b"").unwrap();
+
+ assert_eq!(output, b"text");
+}
+
+#[test]
+fn terminal_frame_kitty_detection_matches_apc_prefix() {
+ assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\"));
+ assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h"));
+}
+
+#[test]
+fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() {
+ let ids = kitty_graphics_image_ids(
+ b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\",
+ );
+ assert_eq!(ids, vec![10023, 10023]);
+}
+
+#[test]
+fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() {
+ record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\");
+ let mut output = Vec::new();
+ clear_received_kitty_graphics(&mut output).unwrap();
+ let text = String::from_utf8(output).unwrap();
+ assert!(text.contains("a=d,d=I,i=123"));
+ assert!(!text.contains("d=A"));
+}
+
+#[test]
+fn write_host_terminal_appearance_query_emits_mode_2031_query() {
+ let mut output = Vec::new();
+ write_host_terminal_appearance_query(&mut output).unwrap();
+ assert_eq!(output, b"\x1b[?996n");
+}
+
+#[test]
+fn write_host_terminal_theme_query_emits_osc_queries() {
+ let mut output = Vec::new();
+ write_host_terminal_theme_query(&mut output).unwrap();
+ assert_eq!(
+ output,
+ crate::terminal_theme::host_terminal_theme_query_sequence(
+ crate::platform::should_query_host_terminal_palette(),
+ )
+ .as_bytes()
+ );
+ assert!(
+ !output
+ .windows(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.len())
+ .any(|window| window
+ == crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())
+ );
+}
+
+#[test]
+fn write_host_color_scheme_report_mode_emits_mode_sequences() {
+ let mut output = Vec::new();
+ write_host_color_scheme_report_mode(&mut output, true).unwrap();
+ write_host_color_scheme_report_mode(&mut output, false).unwrap();
+
+ let mut expected = Vec::new();
+ expected.extend_from_slice(
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(),
+ );
+ expected.extend_from_slice(
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
+ );
+ assert_eq!(output, expected);
+}
+
+#[test]
+fn color_scheme_change_event_requests_host_theme_query() {
+ let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n");
+
+ assert!(crate::raw_input::events_require_host_terminal_theme_query(
+ &events
+ ));
+}
+
+#[test]
+fn host_terminal_theme_query_is_disabled_on_windows() {
+ assert_eq!(should_query_host_terminal_theme(), !cfg!(windows));
+}
+
+#[test]
+fn write_host_cell_size_query_emits_xtwinops_request() {
+ let mut output = Vec::new();
+ write_host_cell_size_query(&mut output).unwrap();
+
+ assert_eq!(output, b"\x1b[16t");
+}
+
+#[test]
+fn host_cell_size_query_is_disabled_on_windows() {
+ assert_eq!(should_query_host_cell_size(), !cfg!(windows));
+}
+
+#[test]
+fn cell_size_fallback_prefers_reported_then_previous_size() {
+ assert_eq!(cell_size_fallback(0, None), (8, 16));
+ assert_eq!(cell_size_fallback(0, Some((11, 22))), (11, 22));
+ assert_eq!(
+ cell_size_fallback(pack_cell_size(10, 21), Some((11, 22))),
+ (10, 21)
+ );
+ assert_eq!(cell_size_fallback(pack_cell_size(10, 0), None), (8, 16));
+ assert_eq!(cell_size_fallback(pack_cell_size(0, 21), None), (8, 16));
+}
+
+#[test]
+fn reported_cell_size_is_taken_from_host_cell_size_events() {
+ let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n");
+ assert_eq!(
+ super::terminal_geometry::reported_cell_size_from_events(&events),
+ None
+ );
+
+ let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t");
+ assert_eq!(
+ super::terminal_geometry::reported_cell_size_from_events(&events),
+ Some((9, 18))
+ );
+}
+
+#[test]
+fn color_scheme_reports_are_enabled_only_for_full_clients() {
+ assert_eq!(
+ should_enable_host_color_scheme_reports(true),
+ !cfg!(windows)
+ );
+ assert!(!should_enable_host_color_scheme_reports(false));
+}
+
+#[test]
+fn terminal_restore_postlude_restores_visible_default_cursor() {
+ let mut output = Vec::new();
+ write_terminal_restore_postlude(&mut output, false).unwrap();
+ assert_eq!(output, b"\x1b[?25h\x1b[0 q");
+}
+
+#[test]
+fn direct_attach_mouse_capture_combines_local_preference_with_child_demand() {
+ assert!(effective_mouse_capture(false, true));
+ assert!(effective_mouse_capture(true, false));
+ assert!(!effective_mouse_capture(false, false));
+ assert!(effective_sgr_pixel_mouse(true, true, true));
+ assert!(!effective_sgr_pixel_mouse(true, true, false));
+}
+
+#[test]
+fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() {
+ let mut output = Vec::new();
+ write_terminal_restore_postlude(&mut output, true).unwrap();
+
+ let mut expected = Vec::new();
+ expected.extend_from_slice(
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
+ );
+ expected.extend_from_slice(b"\x1b[?25h\x1b[0 q");
+ assert_eq!(output, expected);
+}
+
+#[test]
+fn client_error_display_connection_failed() {
+ let err = ClientError::ConnectionFailed(io::Error::new(
+ io::ErrorKind::ConnectionRefused,
+ "connection refused",
+ ));
+ let msg = err.to_string();
+ assert!(
+ msg.contains("failed to connect to server"),
+ "should mention connection failure: {msg}"
+ );
+ assert!(
+ msg.contains("herdr server"),
+ "should suggest starting server: {msg}"
+ );
+}
+
+#[test]
+fn client_error_display_handshake_rejected() {
+ let err = ClientError::HandshakeRejected {
+ version: 1,
+ error: "incompatible".into(),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("rejected handshake"),
+ "should mention rejection: {msg}"
+ );
+ assert!(msg.contains("incompatible"), "should include error: {msg}");
+}
+
+#[test]
+fn client_error_display_server_shutdown() {
+ let err = ClientError::ServerShutdown {
+ reason: Some("maintenance".into()),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("server shut down"),
+ "should mention shutdown: {msg}"
+ );
+ assert!(msg.contains("maintenance"), "should include reason: {msg}");
+}
+
+#[test]
+fn client_error_display_server_shutdown_no_reason() {
+ let err = ClientError::ServerShutdown { reason: None };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("server shut down"),
+ "should mention shutdown: {msg}"
+ );
+}
+
+#[test]
+fn client_error_display_detached_default_session_reattach_hint() {
+ let _guard = env_lock().lock().unwrap();
+ let _env = EnvVarsRemovedGuard::new(&[
+ crate::remote::REATTACH_COMMAND_ENV_VAR,
+ crate::session::SESSION_ENV_VAR,
+ ]);
+ let err = ClientError::ServerShutdown {
+ reason: Some("detached".into()),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("Run `herdr` to reattach"),
+ "should suggest default reattach command: {msg}"
+ );
+}
+
+#[test]
+fn client_error_display_detached_named_session_reattach_hint() {
+ let _guard = env_lock().lock().unwrap();
+ let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]);
+ let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work");
+ let err = ClientError::ServerShutdown {
+ reason: Some("detached".into()),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("Run `herdr session attach work` to reattach"),
+ "should suggest named session reattach command: {msg}"
+ );
+}
+
+#[test]
+fn client_error_display_detached_remote_reattach_hint_takes_precedence() {
+ let _guard = env_lock().lock().unwrap();
+ let _remote_env = EnvVarGuard::set(
+ crate::remote::REATTACH_COMMAND_ENV_VAR,
+ "herdr --remote host --session work",
+ );
+ let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work");
+ let err = ClientError::ServerShutdown {
+ reason: Some("detached".into()),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("Run `herdr --remote host --session work` to reattach"),
+ "should prefer remote reattach command: {msg}"
+ );
+}
+
+#[test]
+fn client_error_display_connection_lost() {
+ let _guard = env_lock().lock().unwrap();
+ let _env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]);
+ let err = ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"));
+ let msg = err.to_string();
+ assert!(
+ msg.contains("lost connection to server"),
+ "should mention lost connection: {msg}"
+ );
+}
+
+#[test]
+fn client_error_display_remote_connection_lost_has_reattach_hint() {
+ let _guard = env_lock().lock().unwrap();
+ let _remote_env = EnvVarGuard::set(
+ crate::remote::REATTACH_COMMAND_ENV_VAR,
+ "herdr --remote host --session work",
+ );
+ let err = ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"));
+ let msg = err.to_string();
+ assert!(
+ msg.contains("lost connection to remote Herdr"),
+ "should mention remote connection loss: {msg}"
+ );
+ assert!(
+ msg.contains("panes may still be running"),
+ "should explain possible persistence: {msg}"
+ );
+ assert!(
+ msg.contains("Run `herdr --remote host --session work` to reattach"),
+ "should show remote reattach command: {msg}"
+ );
+}
+
+#[test]
+fn sound_from_notify_message_maps_done() {
+ assert_eq!(
+ sound_from_notify_message("agent done"),
+ Some(crate::sound::Sound::Done)
+ );
+}
+
+#[test]
+fn sound_from_notify_message_maps_attention() {
+ assert_eq!(
+ sound_from_notify_message("agent attention"),
+ Some(crate::sound::Sound::Request)
+ );
+}
+
+#[test]
+fn sound_from_notify_message_rejects_unknown_payloads() {
+ assert_eq!(sound_from_notify_message("toast"), None);
+}
+
+#[test]
+fn reload_local_client_config_refreshes_local_client_presentation_state() {
+ let _guard = crate::config::test_config_env_lock().lock().unwrap();
+ let path = std::env::temp_dir().join(format!(
+ "herdr-client-config-reload-{}-{}.toml",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ std::fs::write(
+ &path,
+ "[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\nmouse_capture = false\n",
+ )
+ .unwrap();
+ let path_string = path.to_string_lossy().to_string();
+ let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string);
+ let mut sound_config = crate::config::SoundConfig::default();
+ let mut redraw_on_focus_gained = true;
+ let mut draw_host_cursor = false;
+ let mut remote_image_paste_key = None;
+ let mut mouse_capture = true;
+
+ reload_local_client_config(
+ &mut sound_config,
+ &mut redraw_on_focus_gained,
+ &mut draw_host_cursor,
+ &mut remote_image_paste_key,
+ &mut mouse_capture,
+ );
+
+ assert!(!redraw_on_focus_gained);
+ assert!(draw_host_cursor);
+ assert!(!mouse_capture);
+ let _ = std::fs::remove_file(path);
+}
+
+#[test]
+fn reload_local_client_config_keeps_ui_preferences_when_ui_is_invalid() {
+ let _guard = crate::config::test_config_env_lock().lock().unwrap();
+ let path = std::env::temp_dir().join(format!(
+ "herdr-client-invalid-ui-reload-{}-{}.toml",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ std::fs::write(&path, "[ui]\nmouse_capture = \"invalid\"\n").unwrap();
+ let path_string = path.to_string_lossy().to_string();
+ let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string);
+ let mut sound_config = crate::config::SoundConfig::default();
+ let mut redraw_on_focus_gained = false;
+ let mut draw_host_cursor = true;
+ let mut remote_image_paste_key = None;
+ let mut mouse_capture = false;
+
+ reload_local_client_config(
+ &mut sound_config,
+ &mut redraw_on_focus_gained,
+ &mut draw_host_cursor,
+ &mut remote_image_paste_key,
+ &mut mouse_capture,
+ );
+
+ assert!(!mouse_capture);
+ assert!(!redraw_on_focus_gained);
+ assert!(draw_host_cursor);
+ let _ = std::fs::remove_file(path);
+}
+
+#[test]
+fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() {
+ let sound_config = crate::config::SoundConfig::default();
+ let mut emitted = None;
+
+ handle_notify_with_notifiers(
+ NotifyKind::Toast,
+ "pi finished",
+ Some("workspace 1"),
+ &sound_config,
+ |title, body| {
+ emitted = Some((title.to_string(), body.map(str::to_string)));
+ Ok(true)
+ },
+ |_, _| Ok(false),
+ );
+
+ assert_eq!(
+ emitted,
+ Some(("pi finished".to_string(), Some("workspace 1".to_string())))
+ );
+}
+
+#[test]
+fn system_toast_notify_from_server_uses_system_notifier() {
+ let sound_config = crate::config::SoundConfig::default();
+ let mut emitted = None;
+
+ handle_notify_with_notifiers(
+ NotifyKind::SystemToast,
+ "pi finished",
+ Some("workspace 1"),
+ &sound_config,
+ |_, _| Ok(false),
+ |title, body| {
+ emitted = Some((title.to_string(), body.map(str::to_string)));
+ Ok(true)
+ },
+ );
+
+ assert_eq!(
+ emitted,
+ Some(("pi finished".to_string(), Some("workspace 1".to_string())))
+ );
+}
+
+#[test]
+fn system_toast_notify_preserves_colon_in_title() {
+ let sound_config = crate::config::SoundConfig::default();
+ let mut emitted = None;
+
+ handle_notify_with_notifiers(
+ NotifyKind::SystemToast,
+ "build: failed",
+ Some("api workspace"),
+ &sound_config,
+ |_, _| Ok(false),
+ |title, body| {
+ emitted = Some((title.to_string(), body.map(str::to_string)));
+ Ok(true)
+ },
+ );
+
+ assert_eq!(
+ emitted,
+ Some((
+ "build: failed".to_string(),
+ Some("api workspace".to_string())
+ ))
+ );
+}
+
+#[test]
+fn decode_clipboard_payload_decodes_base64() {
+ assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec()));
+}
+
+#[test]
+fn ioctl_cell_size_accepts_fractional_terminal_geometry() {
+ assert_eq!(ioctl_cell_size(80, 24, 800, 480), Some((10, 20)));
+ assert_eq!(ioctl_cell_size(80, 24, 805, 480), Some((10, 20)));
+ assert_eq!(ioctl_cell_size(80, 24, 800, 485), Some((10, 20)));
+ assert_eq!(ioctl_cell_size(80, 24, 0, 485), None);
+}
+
+#[test]
+fn decode_clipboard_payload_rejects_invalid_base64() {
+ assert_eq!(decode_clipboard_payload("not-base64!!!"), None);
+}
+
+#[test]
+fn terminal_control_input_command_accepts_text() {
+ let action =
+ terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#).unwrap();
+ let ClientMessage::Input { data } = action else {
+ panic!("expected input command");
+ };
+ assert_eq!(data, b"hello");
+}
+
+#[test]
+fn terminal_control_input_command_accepts_base64_bytes() {
+ let action =
+ terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#).unwrap();
+ let ClientMessage::Input { data } = action else {
+ panic!("expected input command");
+ };
+ assert_eq!(data, b"\x1b[A");
+}
+
+#[test]
+fn terminal_control_resize_command_maps_to_client_resize() {
+ let action = terminal_control_command_from_json(
+ r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#,
+ )
+ .unwrap();
+ let ClientMessage::Resize {
+ cols,
+ rows,
+ cell_width_px,
+ cell_height_px,
+ pixel_mouse,
+ } = action
+ else {
+ panic!("expected resize command");
+ };
+ assert_eq!(
+ (cols, rows, cell_width_px, cell_height_px),
+ (100, 30, 8, 16)
+ );
+ assert!(!pixel_mouse);
+}
+
+#[test]
+fn terminal_control_scroll_command_maps_to_attach_scroll() {
+ let action = terminal_control_command_from_json(
+ r#"{"type":"terminal.scroll","direction":"up","lines":3}"#,
+ )
+ .unwrap();
+ let ClientMessage::AttachScroll {
+ source,
+ direction,
+ lines,
+ ..
+ } = action
+ else {
+ panic!("expected scroll command");
+ };
+ assert_eq!(source, AttachScrollSource::Wheel);
+ assert_eq!(direction, AttachScrollDirection::Up);
+ assert_eq!(lines, 3);
+}
+
+#[test]
+fn forward_clipboard_uses_local_clipboard_path() {
+ unsafe {
+ std::env::set_var("SSH_CONNECTION", "1 2 3 4");
+ }
+ assert!(forward_clipboard("dGVzdA=="));
+ assert!(!forward_clipboard("not base64"));
+ unsafe {
+ std::env::remove_var("SSH_CONNECTION");
+ }
+}