From 1efe36c1a2a66b5b9e974557751186fa6c8aebbb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:02:08 +0000 Subject: [PATCH] feat(ydotool): add comprehensive unit tests for ydotool injector This commit introduces a full suite of unit tests for the `ydotool_injector.rs` module. - A `TestHarness` is implemented to create an isolated test environment, mocking the filesystem, environment variables, and external binaries (`ydotool`, `which`). - The core `ydotool_injector.rs` logic was made testable by introducing a `UINPUT_PATH_OVERRIDE` environment variable under a `cfg(test)` flag to avoid permission issues with `/dev/uinput` during tests. - Tests cover: - Socket path discovery logic. - Binary permission and availability checks. - `inject_text` functionality, including the fallback from 'paste' to 'type' mode. These tests improve the reliability and maintainability of the ydotool injection backend. --- .../coldvox-text-injection/src/tests/mod.rs | 1 + .../src/tests/test_ydotool_injector.rs | 207 ++++++++++++++++++ .../src/ydotool_injector.rs | 12 +- 3 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 crates/coldvox-text-injection/src/tests/test_ydotool_injector.rs diff --git a/crates/coldvox-text-injection/src/tests/mod.rs b/crates/coldvox-text-injection/src/tests/mod.rs index b576f697..639ddaaa 100644 --- a/crates/coldvox-text-injection/src/tests/mod.rs +++ b/crates/coldvox-text-injection/src/tests/mod.rs @@ -1,6 +1,7 @@ //! Test modules for coldvox-text-injection // pub mod real_injection; +pub mod test_ydotool_injector; pub mod test_utils; pub mod wl_copy_basic_test; pub mod wl_copy_simple_test; diff --git a/crates/coldvox-text-injection/src/tests/test_ydotool_injector.rs b/crates/coldvox-text-injection/src/tests/test_ydotool_injector.rs new file mode 100644 index 00000000..83509d0c --- /dev/null +++ b/crates/coldvox-text-injection/src/tests/test_ydotool_injector.rs @@ -0,0 +1,207 @@ +//! Unit tests for ydotool_injector.rs +use crate::ydotool_injector::{ + candidate_socket_paths, locate_existing_socket, ydotool_daemon_socket, YdotoolInjector, +}; +use crate::types::{InjectionConfig, InjectionContext}; +use crate::TextInjector; +use anyhow::Result; +use serial_test::serial; +use std::env; +use std::fs::{self, File}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use tempfile::{tempdir, TempDir}; + +/// A test harness to create a controlled environment for ydotool tests. +struct TestHarness { + _temp_dir: TempDir, + bin_dir: PathBuf, + home_dir: PathBuf, + runtime_dir: PathBuf, + original_path: String, + /// Path to a file that mock binaries can use to report arguments. + output_file: PathBuf, +} + +impl TestHarness { + fn new() -> Result { + let temp_dir = tempdir()?; + let base_path = temp_dir.path(); + + let bin_dir = base_path.join("bin"); + let home_dir = base_path.join("home"); + let runtime_dir = base_path.join("run"); + let uinput_path = base_path.join("uinput"); + let output_file = base_path.join("output.log"); + + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&home_dir)?; + fs::create_dir_all(&runtime_dir)?; + File::create(&uinput_path)?; + File::create(&output_file)?; + + let original_path = env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", bin_dir.display(), original_path); + env::set_var("PATH", new_path); + env::set_var("HOME", &home_dir); + env::set_var("XDG_RUNTIME_DIR", &runtime_dir); + env::set_var("UINPUT_PATH_OVERRIDE", &uinput_path); + + env::remove_var("YDOTOOL_SOCKET"); + env::remove_var("UID"); + + Ok(Self { + _temp_dir: temp_dir, + bin_dir, + home_dir, + runtime_dir, + original_path, + output_file, + }) + } + + /// Creates a mock executable file that echoes a specific path. + fn create_which_mock(&self, target_binary: &Path) -> Result<()> { + let content = format!("#!/bin/sh\necho {}", target_binary.display()); + self.create_mock_binary("which", &content, true)?; + Ok(()) + } + + fn create_mock_binary(&self, name: &str, content: &str, executable: bool) -> Result { + let path = self.bin_dir.join(name); + fs::write(&path, content)?; + if executable { + fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?; + } + Ok(path) + } + + fn create_mock_socket(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + File::create(path)?; + Ok(()) + } + + /// Reads the content of the argument log file. + fn read_output(&self) -> Result { + Ok(fs::read_to_string(&self.output_file)?) + } +} + +impl Drop for TestHarness { + fn drop(&mut self) { + env::set_var("PATH", &self.original_path); + env::remove_var("HOME"); + env::remove_var("XDG_RUNTIME_DIR"); + env::remove_var("YDOTOOL_SOCKET"); + env::remove_var("UID"); + env::remove_var("UINPUT_PATH_OVERRIDE"); + } +} + +#[test] +#[serial] +fn test_candidate_socket_paths_priority() { + let harness = TestHarness::new().unwrap(); + env::set_var("YDOTOOL_SOCKET", "/custom/socket"); + env::set_var("UID", "1001"); + + let paths = candidate_socket_paths(); + assert_eq!(paths.len(), 4); + assert_eq!(paths[0], PathBuf::from("/custom/socket")); +} + +#[test] +#[serial] +fn test_locate_existing_socket_finds_first_available() { + let harness = TestHarness::new().unwrap(); + let _ = harness.runtime_dir.join(".ydotool_socket"); + let expected_socket = harness.home_dir.join(".ydotool").join("socket"); + harness.create_mock_socket(&expected_socket).unwrap(); + + let located = locate_existing_socket(); + assert_eq!(located, Some(expected_socket)); +} + +#[tokio::test] +#[serial] +async fn test_check_binary_permissions_success() { + let harness = TestHarness::new().unwrap(); + let ydotool_path = harness + .create_mock_binary("ydotool", "#!/bin/sh\nexit 0", true) + .unwrap(); + harness.create_which_mock(&ydotool_path).unwrap(); + + let result = YdotoolInjector::check_binary_permissions("ydotool"); + assert!(result.is_ok()); +} + +#[tokio::test] +#[serial] +async fn test_check_ydotool_available_when_binary_and_socket_present() { + let harness = TestHarness::new().unwrap(); + let ydotool_path = harness + .create_mock_binary("ydotool", "", true) + .unwrap(); + harness.create_which_mock(&ydotool_path).unwrap(); + let socket_path = harness.home_dir.join(".ydotool/socket"); + harness.create_mock_socket(&socket_path).unwrap(); + + let injector = YdotoolInjector::new(InjectionConfig::default()); + assert!(injector.is_available().await); +} + +#[tokio::test] +#[serial] +async fn test_inject_text_uses_paste_by_default() { + let harness = TestHarness::new().unwrap(); + let ydotool_script = format!( + "#!/bin/sh\necho \"$@\" > {}", + harness.output_file.display() + ); + let ydotool_path = harness + .create_mock_binary("ydotool", &ydotool_script, true) + .unwrap(); + harness.create_which_mock(&ydotool_path).unwrap(); + let socket_path = harness.home_dir.join(".ydotool/socket"); + harness.create_mock_socket(&socket_path).unwrap(); + + let injector = YdotoolInjector::new(InjectionConfig::default()); + let result = injector.inject_text("hello", None).await; + + assert!(result.is_ok()); + let output = harness.read_output().unwrap(); + assert!(output.contains("key ctrl+v")); +} + +#[tokio::test] +#[serial] +async fn test_inject_text_falls_back_to_type() { + let harness = TestHarness::new().unwrap(); + // This mock fails for 'key' command, but succeeds for 'type' + let ydotool_script = format!( + r#"#!/bin/sh +if [ "$1" = "key" ]; then + exit 1 +else + echo "$@" > {} +fi +"#, + harness.output_file.display() + ); + let ydotool_path = harness + .create_mock_binary("ydotool", &ydotool_script, true) + .unwrap(); + harness.create_which_mock(&ydotool_path).unwrap(); + let socket_path = harness.home_dir.join(".ydotool/socket"); + harness.create_mock_socket(&socket_path).unwrap(); + + let injector = YdotoolInjector::new(InjectionConfig::default()); + let result = injector.inject_text("world", None).await; + + assert!(result.is_ok()); + let output = harness.read_output().unwrap(); + assert!(output.contains("type --delay 10 world")); +} diff --git a/crates/coldvox-text-injection/src/ydotool_injector.rs b/crates/coldvox-text-injection/src/ydotool_injector.rs index 72a05784..9bfcd398 100644 --- a/crates/coldvox-text-injection/src/ydotool_injector.rs +++ b/crates/coldvox-text-injection/src/ydotool_injector.rs @@ -21,7 +21,7 @@ fn push_unique(paths: &mut Vec, candidate: PathBuf) { } } -fn candidate_socket_paths() -> Vec { +pub(crate) fn candidate_socket_paths() -> Vec { let mut paths = Vec::new(); if let Some(env_socket) = env::var_os("YDOTOOL_SOCKET") { @@ -52,7 +52,7 @@ fn candidate_socket_paths() -> Vec { paths } -fn locate_existing_socket() -> Option { +pub(crate) fn locate_existing_socket() -> Option { #[allow(clippy::manual_find)] for candidate in candidate_socket_paths() { if Path::new(&candidate).exists() { @@ -196,8 +196,14 @@ impl YdotoolInjector { fn check_uinput_access() -> Result<(), InjectionError> { use std::fs::OpenOptions; + let uinput_path = if cfg!(test) { + env::var("UINPUT_PATH_OVERRIDE").unwrap_or_else(|_| "/dev/uinput".to_string()) + } else { + "/dev/uinput".to_string() + }; + // Check if we can open /dev/uinput - match OpenOptions::new().write(true).open("/dev/uinput") { + match OpenOptions::new().write(true).open(uinput_path) { Ok(_) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { // Check if user is in input group