Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/coldvox-text-injection/src/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
207 changes: 207 additions & 0 deletions crates/coldvox-text-injection/src/tests/test_ydotool_injector.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//! Unit tests for ydotool_injector.rs
use crate::ydotool_injector::{
candidate_socket_paths, locate_existing_socket, ydotool_daemon_socket, YdotoolInjector,

Check failure on line 3 in crates/coldvox-text-injection/src/tests/test_ydotool_injector.rs

View workflow job for this annotation

GitHub Actions / Build & Test (stable)

unused import: `ydotool_daemon_socket`
};
use crate::types::{InjectionConfig, InjectionContext};

Check failure on line 5 in crates/coldvox-text-injection/src/tests/test_ydotool_injector.rs

View workflow job for this annotation

GitHub Actions / Build & Test (stable)

unused import: `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<Self> {
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<PathBuf> {
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<String> {
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"));
}
12 changes: 9 additions & 3 deletions crates/coldvox-text-injection/src/ydotool_injector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ fn push_unique(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
}
}

fn candidate_socket_paths() -> Vec<PathBuf> {
pub(crate) fn candidate_socket_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();

if let Some(env_socket) = env::var_os("YDOTOOL_SOCKET") {
Expand Down Expand Up @@ -52,7 +52,7 @@ fn candidate_socket_paths() -> Vec<PathBuf> {
paths
}

fn locate_existing_socket() -> Option<PathBuf> {
pub(crate) fn locate_existing_socket() -> Option<PathBuf> {
#[allow(clippy::manual_find)]
for candidate in candidate_socket_paths() {
if Path::new(&candidate).exists() {
Expand Down Expand Up @@ -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
Expand Down
Loading