//! Integration coverage for client keyboard aggregator internals. //! //! Scope: include keyboard input source and validate report shaping, magic //! chords, paste handling, and event processing against synthetic keyboards. //! Targets: `client/src/input/keyboard.rs`. //! Why: keyboard chord and paste logic is stateful and needs direct branch //! coverage to avoid regressions. mod keymap { pub use lesavka_client::input::keymap::*; } #[allow(warnings)] mod keyboard_contract { include!(env!("LESAVKA_CLIENT_KEYBOARD_SRC")); use serial_test::serial; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::thread; use temp_env::with_var; use tempfile::tempdir; fn write_executable(dir: &Path, name: &str, body: &str) { let path = dir.join(name); fs::write(&path, body).expect("write script"); let mut perms = fs::metadata(&path).expect("metadata").permissions(); perms.set_mode(0o755); fs::set_permissions(path, perms).expect("chmod"); } fn with_fake_path_command(name: &str, script_body: &str, f: impl FnOnce()) { let dir = tempdir().expect("tempdir"); write_executable(dir.path(), name, script_body); let prior = std::env::var("PATH").unwrap_or_default(); let merged = if prior.is_empty() { dir.path().display().to_string() } else { format!("{}:{prior}", dir.path().display()) }; with_var("PATH", Some(merged), f); } fn open_virtual_device(vdev: &mut evdev::uinput::VirtualDevice) -> Option { for _ in 0..40 { if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() { if let Some(Ok(path)) = nodes.next() { let dev = evdev::Device::open(path).ok()?; dev.set_nonblocking(true).ok()?; return Some(dev); } } thread::sleep(std::time::Duration::from_millis(10)); } None } fn open_any_keyboard_device() -> Option { let entries = std::fs::read_dir("/dev/input").ok()?; for entry in entries.flatten() { let path = entry.path(); let name = path.file_name()?.to_string_lossy(); if !name.starts_with("event") { continue; } let dev = evdev::Device::open(path).ok()?; let _ = dev.set_nonblocking(true); let looks_like_keyboard = dev .supported_keys() .map(|keys| { keys.contains(evdev::KeyCode::KEY_A) && keys.contains(evdev::KeyCode::KEY_ENTER) && keys.contains(evdev::KeyCode::KEY_LEFTCTRL) }) .unwrap_or(false); if looks_like_keyboard { return Some(dev); } } None } fn build_keyboard(name: &str) -> Option<(evdev::uinput::VirtualDevice, evdev::Device)> { let mut keys = evdev::AttributeSet::::new(); keys.insert(evdev::KeyCode::KEY_A); keys.insert(evdev::KeyCode::KEY_B); keys.insert(evdev::KeyCode::KEY_C); keys.insert(evdev::KeyCode::KEY_D); keys.insert(evdev::KeyCode::KEY_E); keys.insert(evdev::KeyCode::KEY_F); keys.insert(evdev::KeyCode::KEY_G); keys.insert(evdev::KeyCode::KEY_V); keys.insert(evdev::KeyCode::KEY_LEFTCTRL); keys.insert(evdev::KeyCode::KEY_LEFTALT); keys.insert(evdev::KeyCode::KEY_LEFTSHIFT); keys.insert(evdev::KeyCode::KEY_ESC); keys.insert(evdev::KeyCode::KEY_LEFT); keys.insert(evdev::KeyCode::KEY_RIGHT); let mut vdev = evdev::uinput::VirtualDevice::builder() .ok()? .name(name) .with_keys(&keys) .ok()? .build() .ok()?; let dev = open_virtual_device(&mut vdev)?; Some((vdev, dev)) } fn new_aggregator( dev: evdev::Device, ) -> ( KeyboardAggregator, tokio::sync::broadcast::Receiver, ) { let (tx, rx) = tokio::sync::broadcast::channel(128); (KeyboardAggregator::new(dev, true, tx, None), rx) } #[test] #[serial] fn process_events_emits_press_and_release_reports() { let Some((mut vdev, dev)) = build_keyboard("lesavka-include-kbd-events") else { return; }; let (mut agg, mut rx) = new_aggregator(dev); vdev.emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1, )]) .expect("emit key press"); thread::sleep(std::time::Duration::from_millis(20)); agg.process_events(); let press = rx.try_recv().expect("press report"); assert_ne!(press.data[2], 0); vdev.emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 0, )]) .expect("emit key release"); thread::sleep(std::time::Duration::from_millis(20)); agg.process_events(); let release = rx.try_recv().expect("release report"); assert_eq!(release.data[2], 0); } #[test] #[serial] fn build_report_sets_modifiers_and_limits_to_six_keys() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-report").map(|(_, dev)| dev)) else { return; }; let (mut agg, _) = new_aggregator(dev); agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); for key in [ evdev::KeyCode::KEY_A, evdev::KeyCode::KEY_B, evdev::KeyCode::KEY_C, evdev::KeyCode::KEY_D, evdev::KeyCode::KEY_E, evdev::KeyCode::KEY_F, evdev::KeyCode::KEY_G, ] { agg.pressed_keys.insert(key); } let report = agg.build_report(); assert_ne!(report[0], 0); assert!(report[2..].iter().filter(|value| **value != 0).count() <= 6); } #[test] #[serial] fn magic_chords_track_expected_combinations() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-magic").map(|(_, dev)| dev)) else { return; }; let (mut agg, _) = new_aggregator(dev); agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTSHIFT); agg.pressed_keys.insert(evdev::KeyCode::KEY_G); assert!(agg.magic_grab()); agg.pressed_keys.remove(&evdev::KeyCode::KEY_G); agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFT); assert!(agg.magic_left()); agg.pressed_keys.remove(&evdev::KeyCode::KEY_LEFT); agg.pressed_keys.insert(evdev::KeyCode::KEY_RIGHT); assert!(agg.magic_right()); agg.pressed_keys.clear(); agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); agg.pressed_keys.insert(evdev::KeyCode::KEY_ESC); assert!(agg.magic_kill()); } #[test] fn paste_rpc_enabled_contract_requires_flag_and_key() { assert!(!paste_rpc_enabled(false, true)); assert!(!paste_rpc_enabled(true, false)); assert!(paste_rpc_enabled(true, true)); assert!(is_paste_modifier(evdev::KeyCode::KEY_LEFTCTRL)); assert!(is_paste_modifier(evdev::KeyCode::KEY_RIGHTALT)); assert!(!is_paste_modifier(evdev::KeyCode::KEY_A)); } #[test] #[serial] fn try_handle_paste_event_consumes_chord_and_sends_rpc_payload() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-paste-rpc").map(|(_, dev)| dev)) else { return; }; let (paste_tx, mut rx_rpc) = tokio::sync::mpsc::unbounded_channel::(); let (kbd_tx, _rx) = tokio::sync::broadcast::channel(128); let mut agg = KeyboardAggregator::new(dev, true, kbd_tx, Some(paste_tx)); agg.paste_enabled = true; agg.paste_rpc_enabled = true; agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT); agg.pressed_keys.insert(evdev::KeyCode::KEY_V); with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'hello-from-clipboard'"), || { with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || { with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || { assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1)); }); }); }); let payload: String = rx_rpc.try_recv().expect("rpc payload"); assert!(payload.contains("hello-from-clipboard")); assert!(agg.paste_chord_consumed); assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 0)); assert!(!agg.paste_chord_consumed); } #[test] #[serial] fn paste_clipboard_emits_hid_reports_for_supported_chars() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-paste-hid").map(|(_, dev)| dev)) else { return; }; let (mut agg, mut rx) = new_aggregator(dev); agg.paste_enabled = true; with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'ab'"), || { with_var("LESAVKA_CLIPBOARD_MAX", Some("8"), || { with_var("LESAVKA_CLIPBOARD_DELAY_MS", Some("0"), || { agg.paste_clipboard(); }); }); }); let mut seen = 0usize; while rx.try_recv().is_ok() { seen += 1; } assert!(seen >= 2, "expected multiple key reports for pasted characters"); } #[test] #[serial] fn reset_state_clears_pressed_keys_and_emits_empty_report() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-reset").map(|(_, dev)| dev)) else { return; }; let (mut agg, mut rx) = new_aggregator(dev); agg.pressed_keys.insert(evdev::KeyCode::KEY_A); agg.reset_state(); assert!(agg.pressed_keys.is_empty()); let pkt = rx.try_recv().expect("empty report after reset"); assert_eq!(pkt.data, vec![0; 8]); } #[test] #[serial] fn set_send_false_blocks_manual_empty_report() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-nosend").map(|(_, dev)| dev)) else { return; }; let (mut agg, mut rx) = new_aggregator(dev); agg.set_send(false); agg.send_empty_report(); assert!(rx.try_recv().is_err()); } #[test] #[serial] fn process_events_respects_send_toggle() { let Some((mut vdev, dev)) = build_keyboard("lesavka-include-kbd-send-toggle") else { return; }; let (mut agg, mut rx) = new_aggregator(dev); agg.set_send(false); vdev.emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_B.0, 1, )]) .expect("emit key"); thread::sleep(std::time::Duration::from_millis(20)); agg.process_events(); assert!(rx.try_recv().is_err(), "send-disabled aggregator should not publish reports"); } #[test] #[serial] fn paste_chord_active_supports_ctrl_v_variant() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-ctrl-v").map(|(_, dev)| dev)) else { return; }; let (mut agg, _) = new_aggregator(dev); agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); agg.pressed_keys.insert(evdev::KeyCode::KEY_V); with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+v"), || { assert!(agg.paste_chord_active()); }); } #[test] #[serial] fn paste_debounced_rejects_rapid_repeat_with_positive_window() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-debounce").map(|(_, dev)| dev)) else { return; }; let (agg, _) = new_aggregator(dev); let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("time") .as_millis() as u64; LAST_PASTE_MS.store(now_ms, Ordering::Relaxed); with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("9999"), || { assert!(!agg.paste_debounced()); }); } #[test] #[serial] fn paste_via_rpc_returns_false_without_sender() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-rpc-none").map(|(_, dev)| dev)) else { return; }; let (tx, _rx) = tokio::sync::broadcast::channel(8); let agg = KeyboardAggregator::new(dev, false, tx, None); assert!(!agg.paste_via_rpc()); } #[test] #[serial] fn read_clipboard_text_returns_none_when_custom_and_fallback_tools_fail() { with_var("LESAVKA_CLIPBOARD_CMD", Some("nonexistent-clipboard-command"), || { with_var("PATH", Some("/tmp/definitely-missing-path"), || { assert!(read_clipboard_text().is_none()); }); }); } #[test] #[serial] fn read_clipboard_text_handles_empty_custom_command_output() { with_var("LESAVKA_CLIPBOARD_CMD", Some("printf ''"), || { with_var("PATH", Some("/tmp/definitely-missing-path"), || { assert!(read_clipboard_text().is_none()); }); }); } #[test] #[serial] fn read_clipboard_text_handles_failing_custom_command() { with_var("LESAVKA_CLIPBOARD_CMD", Some("echo boom >&2; exit 1"), || { with_var("PATH", Some("/tmp/definitely-missing-path"), || { assert!(read_clipboard_text().is_none()); }); }); } #[test] #[serial] fn read_clipboard_text_uses_fallback_tool_when_available() { let wl_paste = r#"#!/usr/bin/env sh printf 'fallback-clipboard' "#; with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || { with_fake_path_command("wl-paste", wl_paste, || { let text = read_clipboard_text().expect("fallback clipboard text"); assert_eq!(text, "fallback-clipboard"); }); }); } #[test] #[serial] fn paste_via_rpc_returns_true_for_empty_clipboard_payload() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-rpc-empty").map(|(_, dev)| dev)) else { return; }; let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::(); let (kbd_tx, _rx) = tokio::sync::broadcast::channel(8); let agg = KeyboardAggregator::new(dev, false, kbd_tx, Some(paste_tx)); let wl_paste_empty = r#"#!/usr/bin/env sh exit 0 "#; with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || { with_fake_path_command("wl-paste", wl_paste_empty, || { assert!(agg.paste_via_rpc(), "empty clipboard should still consume the chord"); assert!(paste_rx.try_recv().is_err(), "empty clipboard should not enqueue payload"); }); }); } #[test] #[serial] fn set_grab_path_is_non_panicking() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-grab").map(|(_, dev)| dev)) else { return; }; let (mut agg, _) = new_aggregator(dev); agg.set_grab(false); agg.set_grab(true); } #[test] #[serial] fn try_handle_paste_event_swallows_incomplete_chord_sequences() { let Some(dev) = open_any_keyboard_device() .or_else(|| build_keyboard("lesavka-include-kbd-incomplete").map(|(_, dev)| dev)) else { return; }; let (mut agg, mut rx) = new_aggregator(dev); agg.paste_enabled = true; agg.paste_chord_armed = true; agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_LEFTCTRL, 1)); let pkt = rx.try_recv().expect("swallow report"); assert_eq!(pkt.data, vec![0; 8]); } }