Files
voculinux/tests/test_text_injector.py
T
Jatin K MalikandGitHub c65cc14a47 fix(whispercpp): reduce CPU threads and ensure GPU backend builds in dev mode (#439)
* fix(whispercpp): reduce CPU threads and ensure GPU backend builds in dev mode

- Reduce default n_threads from cpu_count() (16) to max 8 (CPU) / max 4 (GPU)
- Add runtime pywhispercpp GPU capability detection (_detect_pywhispercpp_gpu_backend)
- Make n_threads configurable via whispercpp_n_threads config option
- Fix install.sh regression: GPU source build was skipped in --dev mode
- Extract GPU install logic into install_whispercpp_with_gpu_support()
- Add is_pywhispercpp_gpu_capable() to detect GPU libs in installed pywhispercpp
- Force rebuild pywhispercpp when GPU is detected but lacks GPU support

Fixes CPU spiking to 100% on GPU systems due to excessive thread count
and ensures dev installs get GPU acceleration.

* fix(audio): skip output-only HDMI devices when selecting input device

Add _resolve_valid_input_device() that filters devices by
maxInputChannels > 0, falling back preferred → default → first valid.

Previously, HDMI output devices (0 input channels) could be selected
as the default audio input, causing 'Invalid number of channels' errors.
Now both _record_audio() and _attempt_audio_reconnection() validate the
device has input channels before probing or opening the stream.

* fix(whispercpp): treat n_threads=0 as auto-detect, not literal zero

Config default whispercpp_n_threads=0 was being passed directly
to whisper.cpp as n_threads=0, causing 'cannot create std::vector
larger than max_size()' transcription errors. Now 0 and negative
values are treated as auto-detect, falling through to the smart
thread count logic (max 4 with GPU, max 8 on CPU).

* test: add whispercpp_n_threads to mock assertions and config defaults

Update test_main.py mock to include the new whispercpp_n_threads=0 kwarg
and test_config_manager.py to assert the default config value.

* fix(audio): harden device enumeration against test mocks

The new HDMI-filter helper and GPU-backend detector crashed unit tests
that pass MagicMock() for the PyAudio/pathlib surfaces:

- _find_pywhispercpp_shared_library_dirs hit TypeError when tests
  monkey-patch os.stat (Path.is_dir internally calls S_ISDIR on a
  non-int st_mode). Widen the catch to OSError/TypeError/ValueError so
  unconfigured stat results just skip the candidate dir.

- _resolve_valid_input_device crashed with 'MagicMock > int' on
  audio.get_device_count() / info.get('maxInputChannels'). Coerce the
  count, treat zero/unknown as 'use system default', and include
  non-dict info objects rather than filtering everything out.

No production behavior changes: real PyAudio always returns int counts
and dict device info, so the HDMI filter still works as designed.

* test(recognition): cover new HDMI filter and GPU backend detector

- TestResolveValidInputDevice: 9 cases for the HDMI-filter resolver
  (preferred passthrough, HDMI fallback, no-default fallback, no-input
  None return, and three mock-tolerance escape hatches).
- TestDetectPywhispercppGpuBackend: 4 cases for runtime GPU library
  detection (cpu/vulkan/cuda/empty dirs).
- TestFindPywhispercppSharedLibraryDirsResilience: proves the widened
  exception catch survives a TypeError from pathlib.Path.is_dir.

* test(text-injection): wait for IBus init thread to avoid race on slow runners

The IBus environment promotion runs in a background daemon thread spawned
by TextInjector.__init__. The two TestIBusRuntimeFallback cases asserted
on injector.environment immediately after construction, which raced the
thread on Python 3.10 GHA runners and produced intermittent failures.

Join the init thread (with a generous timeout) before asserting, so the
test exercises the post-warmup state deterministically.
2026-05-30 16:44:53 -07:00

1626 lines
67 KiB
Python

"""
Tests for text injection functionality.
"""
import os
import subprocess
import sys
import unittest
from unittest.mock import MagicMock, patch
# Update import path to use the new package structure
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
# Create a mock for audio feedback module
mock_audio_feedback = MagicMock()
mock_audio_feedback.play_error_sound = MagicMock()
# Add the mock to sys.modules
sys.modules["vocalinux.ui.audio_feedback"] = mock_audio_feedback
class TestTextInjector(unittest.TestCase):
"""Test cases for the text injection functionality."""
def setUp(self):
"""Set up for tests."""
# Create patches for external functions
self.patch_which = patch("shutil.which")
self.mock_which = self.patch_which.start()
self.patch_subprocess = patch("subprocess.run")
self.mock_subprocess = self.patch_subprocess.start()
self.patch_sleep = patch("time.sleep")
self.mock_sleep = self.patch_sleep.start()
# Disable IBus for these tests (testing fallback paths)
self.patch_ibus_available = patch(
"vocalinux.text_injection.text_injector.is_ibus_available",
return_value=False,
)
self.patch_ibus_available.start()
# Setup environment variable patching
self.env_patcher = patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"})
self.env_patcher.start()
# Set default return values
self.mock_which.return_value = "/usr/bin/xdotool" # Default to having xdotool
# Setup subprocess mock
mock_process = MagicMock()
mock_process.returncode = 0
mock_process.stdout = "1234"
mock_process.stderr = ""
self.mock_subprocess.return_value = mock_process
# Reset mock for error sound
mock_audio_feedback.play_error_sound.reset_mock()
def tearDown(self):
"""Clean up after tests."""
self.patch_which.stop()
self.patch_subprocess.stop()
self.patch_sleep.stop()
self.patch_ibus_available.stop()
self.env_patcher.stop()
def test_detect_x11_environment(self):
"""Test detection of X11 environment."""
# Force our mock_which to be selective based on command
self.mock_which.side_effect = lambda cmd: ("/usr/bin/xdotool" if cmd == "xdotool" else None)
# Explicitly set X11 environment
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
# Create TextInjector and ensure it detects X11
injector = TextInjector()
# Force X11 detection by patching the _detect_environment method
with patch.object(injector, "_detect_environment", return_value=DesktopEnvironment.X11):
injector.environment = DesktopEnvironment.X11
# Verify environment is X11
self.assertEqual(injector.environment, DesktopEnvironment.X11)
def test_detect_wayland_environment(self):
"""Test detection of Wayland environment."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make wtype available for Wayland
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
# Mock wtype test call to return success
mock_process = MagicMock()
mock_process.returncode = 0
mock_process.stderr = ""
self.mock_subprocess.return_value = mock_process
injector = TextInjector()
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
self.assertEqual(injector.wayland_tool, "wtype")
def test_force_wayland_mode(self):
"""Test forcing Wayland mode."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
# Make wtype available
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
# Create injector with wayland_mode=True
injector = TextInjector(wayland_mode=True)
# Should be forced to Wayland
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
def test_wayland_fallback_to_xdotool(self):
"""Test fallback to XWayland with xdotool when wtype fails."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make both wtype and xdotool available
self.mock_which.side_effect = lambda cmd: {
"wtype": "/usr/bin/wtype",
"xdotool": "/usr/bin/xdotool",
}.get(cmd)
# Make wtype test fail with compositor error
mock_process = MagicMock()
mock_process.returncode = 1
mock_process.stderr = "compositor does not support virtual keyboard protocol"
self.mock_subprocess.return_value = mock_process
# Initialize injector
injector = TextInjector()
# Should fall back to XWayland
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
def test_x11_text_injection(self):
"""Test text injection in X11 environment."""
# Setup X11 environment
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
# Force X11 mode
injector = TextInjector()
injector.environment = DesktopEnvironment.X11
# Create a list to capture subprocess calls
calls = []
def capture_call(*args, **kwargs):
calls.append((args, kwargs))
process = MagicMock()
process.returncode = 0
return process
self.mock_subprocess.side_effect = capture_call
# Inject text
injector.inject_text("Hello world")
# Verify xdotool was called correctly
found_xdotool_call = False
for args, _ in calls:
if len(args) > 0 and isinstance(args[0], list):
cmd = args[0]
if "xdotool" in cmd and "type" in cmd:
found_xdotool_call = True
break
self.assertTrue(found_xdotool_call, "No xdotool type calls were made")
def test_wayland_text_injection(self):
"""Test text injection in Wayland environment using wtype."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make wtype available
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
# Successful wtype test
mock_process = MagicMock()
mock_process.returncode = 0
mock_process.stderr = ""
self.mock_subprocess.return_value = mock_process
# Initialize injector
injector = TextInjector()
self.assertEqual(injector.wayland_tool, "wtype")
# Inject text
injector.inject_text("Hello world")
# Verify wtype was called correctly
self.mock_subprocess.assert_any_call(
["wtype", "Hello world"], check=True, stderr=subprocess.PIPE, text=True
)
def test_wayland_with_ydotool(self):
"""Test text injection in Wayland environment using ydotool."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make only ydotool available
self.mock_which.side_effect = lambda cmd: (
"/usr/bin/ydotool" if cmd == "ydotool" else None
)
# Initialize injector
injector = TextInjector()
self.assertEqual(injector.wayland_tool, "ydotool")
# Inject text
injector.inject_text("Hello world")
# Verify ydotool was called correctly
self.mock_subprocess.assert_any_call(
["ydotool", "type", "Hello world"],
check=True,
stderr=subprocess.PIPE,
text=True,
)
def test_inject_special_characters(self):
"""Test injecting text with special characters that need escaping."""
# Setup a TextInjector using X11 environment
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
# Force X11 mode
injector = TextInjector()
injector.environment = DesktopEnvironment.X11
# Set up subprocess call to properly collect the escaped command
calls = []
def capture_call(*args, **kwargs):
calls.append((args, kwargs))
process = MagicMock()
process.returncode = 0
return process
self.mock_subprocess.side_effect = capture_call
# Text with special characters
special_text = "Special 'quotes' and \"double quotes\" and $dollar signs"
# Inject text
injector.inject_text(special_text)
# Verify xdotool was called with the original text (no escaping needed)
# Find calls that contain xdotool and check they contain the original text
found_unescaped = False
found_escaped = False
for args, _ in calls:
if len(args) > 0 and isinstance(args[0], list):
cmd = args[0]
if "xdotool" in cmd and "type" in cmd:
# Join the command to check for characters
cmd_str = " ".join(cmd)
# Check for unescaped special characters
if "'" in cmd_str:
found_unescaped = True
if "\\'" in cmd_str or '\\"' in cmd_str or "\\$" in cmd_str:
# Found escaped characters - this is bad!
found_escaped = True
break
# Should find unescaped characters and NOT find escaped ones
self.assertTrue(
found_unescaped,
"Text was not passed correctly to xdotool",
)
self.assertFalse(
found_escaped,
"Text should not be shell-escaped when passed to xdotool",
)
def test_empty_text_injection(self):
"""Test injecting empty text (should do nothing)."""
injector = TextInjector()
# Reset the subprocess mock to clear previous calls
self.mock_subprocess.reset_mock()
# Inject empty text
injector.inject_text("")
# No subprocess calls should have been made
self.mock_subprocess.assert_not_called()
# Try with just whitespace
injector.inject_text(" ")
# Still no subprocess calls
self.mock_subprocess.assert_not_called()
def test_missing_dependencies(self):
"""Test error when no text injection dependencies are available."""
# No tools available
self.mock_which.return_value = None
# Should raise RuntimeError
with self.assertRaises(RuntimeError):
TextInjector()
def test_xdotool_error_handling(self):
"""Test handling of xdotool errors."""
# Setup xdotool to fail
mock_error = subprocess.CalledProcessError(1, ["xdotool", "type"], stderr="Error")
self.mock_subprocess.side_effect = mock_error
injector = TextInjector()
# Get the audio feedback mock
audio_feedback = sys.modules["vocalinux.ui.audio_feedback"]
audio_feedback.play_error_sound.reset_mock()
# Inject text - this should call play_error_sound
injector.inject_text("Test text")
# Check that error sound was triggered
audio_feedback.play_error_sound.assert_called_once()
def test_detect_environment_unknown(self):
"""Test environment detection when no indicators are present."""
with patch.dict("os.environ", {}, clear=True):
# Clear all environment variables
with patch.object(TextInjector, "_check_dependencies"):
injector = TextInjector.__new__(TextInjector)
env = injector._detect_environment()
# Should default to X11 when unknown
self.assertEqual(env, DesktopEnvironment.X11)
def test_detect_environment_wayland_display(self):
"""Test environment detection via WAYLAND_DISPLAY."""
with patch.dict("os.environ", {"WAYLAND_DISPLAY": "wayland-0"}, clear=True):
with patch.object(TextInjector, "_check_dependencies"):
injector = TextInjector.__new__(TextInjector)
env = injector._detect_environment()
self.assertEqual(env, DesktopEnvironment.WAYLAND)
def test_detect_environment_display_only(self):
"""Test environment detection via DISPLAY only."""
with patch.dict("os.environ", {"DISPLAY": ":0"}, clear=True):
with patch.object(TextInjector, "_check_dependencies"):
injector = TextInjector.__new__(TextInjector)
env = injector._detect_environment()
self.assertEqual(env, DesktopEnvironment.X11)
def test_inject_keyboard_shortcut_x11(self):
"""Test keyboard shortcut injection in X11."""
injector = TextInjector()
injector.environment = DesktopEnvironment.X11
mock_process = MagicMock()
mock_process.returncode = 0
self.mock_subprocess.return_value = mock_process
result = injector._inject_keyboard_shortcut("ctrl+z")
self.assertTrue(result)
def test_inject_keyboard_shortcut_wayland_xdotool(self):
"""Test keyboard shortcut injection with XWayland fallback."""
injector = TextInjector()
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
mock_process = MagicMock()
mock_process.returncode = 0
self.mock_subprocess.return_value = mock_process
result = injector._inject_keyboard_shortcut("ctrl+c")
self.assertTrue(result)
def test_inject_keyboard_shortcut_wayland_wtype(self):
"""Test keyboard shortcut injection with wtype (not supported)."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
mock_process = MagicMock()
mock_process.returncode = 0
mock_process.stderr = ""
self.mock_subprocess.return_value = mock_process
injector = TextInjector()
injector.wayland_tool = "wtype"
injector.environment = DesktopEnvironment.WAYLAND
result = injector._inject_shortcut_with_wayland_tool("ctrl+z")
self.assertFalse(result) # wtype doesn't support shortcuts
def test_inject_keyboard_shortcut_wayland_ydotool(self):
"""Test keyboard shortcut injection with ydotool."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
self.mock_which.side_effect = lambda cmd: (
"/usr/bin/ydotool" if cmd == "ydotool" else None
)
injector = TextInjector()
injector.wayland_tool = "ydotool"
injector.environment = DesktopEnvironment.WAYLAND
mock_process = MagicMock()
mock_process.returncode = 0
self.mock_subprocess.return_value = mock_process
result = injector._inject_shortcut_with_wayland_tool("ctrl+z")
self.assertTrue(result)
def test_inject_keyboard_shortcut_failure(self):
"""Test keyboard shortcut injection failure handling."""
injector = TextInjector()
injector.environment = DesktopEnvironment.X11
self.mock_subprocess.side_effect = subprocess.CalledProcessError(
1, ["xdotool", "key"], stderr="Error"
)
result = injector._inject_keyboard_shortcut("ctrl+z")
self.assertFalse(result)
def test_log_current_window_info_wayland(self):
"""Test window info logging for pure Wayland."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
mock_process = MagicMock()
mock_process.returncode = 0
mock_process.stderr = ""
self.mock_subprocess.return_value = mock_process
injector = TextInjector()
injector.environment = DesktopEnvironment.WAYLAND
# Should not raise, just logs debug message
injector._log_current_window_info()
def test_inject_text_returns_true_on_success(self):
"""Test that inject_text returns True on success."""
injector = TextInjector()
mock_process = MagicMock()
mock_process.returncode = 0
self.mock_subprocess.return_value = mock_process
result = injector.inject_text("Test")
self.assertTrue(result)
def test_inject_text_returns_false_on_failure(self):
"""Test that inject_text returns False on failure."""
injector = TextInjector()
self.mock_subprocess.side_effect = Exception("Injection failed")
result = injector.inject_text("Test")
self.assertFalse(result)
def test_wayland_tool_fallback_on_injection_failure(self):
"""Test automatic fallback to xdotool when Wayland tool fails during injection."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make both wtype and xdotool available
self.mock_which.side_effect = lambda cmd: {
"wtype": "/usr/bin/wtype",
"xdotool": "/usr/bin/xdotool",
}.get(cmd)
# First return success for wtype test, then fail for actual injection
call_count = [0]
def mock_subprocess_call(*args, **kwargs):
call_count[0] += 1
if call_count[0] <= 1: # wtype test call
mock = MagicMock()
mock.returncode = 0
mock.stderr = ""
return mock
elif call_count[0] == 2: # First injection attempt with wtype
err = subprocess.CalledProcessError(
1, ["wtype"], stderr="compositor does not support"
)
err.stderr = "compositor does not support"
raise err
else: # xdotool fallback calls
mock = MagicMock()
mock.returncode = 0
mock.stdout = "12345"
return mock
self.mock_subprocess.side_effect = mock_subprocess_call
injector = TextInjector()
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
# Try injection, should fallback to xdotool
result = injector.inject_text("Test")
# After failure, should have switched to WAYLAND_XDOTOOL
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
def test_has_non_ascii_with_ascii_text(self):
"""Test _has_non_ascii returns False for pure ASCII text."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
injector = TextInjector()
self.assertFalse(injector._has_non_ascii("Hello world"))
self.assertFalse(injector._has_non_ascii("123 test!"))
def test_has_non_ascii_with_accented_text(self):
"""Test _has_non_ascii returns True for accented characters (#362)."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
injector = TextInjector()
self.assertTrue(injector._has_non_ascii("Esdrújula"))
self.assertTrue(injector._has_non_ascii("crème brûlée"))
self.assertTrue(injector._has_non_ascii("café"))
self.assertTrue(injector._has_non_ascii("niño"))
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_ydotool_non_ascii_uses_clipboard_paste(
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
):
"""Test that ydotool uses clipboard paste for non-ASCII text (#362)."""
mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy")
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.wayland_tool = "ydotool"
injector.environment = DesktopEnvironment.WAYLAND
# Reset call list so init calls don't interfere
mock_run.reset_mock()
injector._inject_with_wayland_tool("Esdrújula")
# Should have called wl-copy and ydotool key (Ctrl+V), not ydotool type
calls = [c.args[0] for c in mock_run.call_args_list]
has_wl_copy = any(c[0] == "wl-copy" for c in calls)
has_ydotool_key = any(c[:2] == ["ydotool", "key"] for c in calls)
has_ydotool_type = any(c[:2] == ["ydotool", "type"] for c in calls)
self.assertTrue(has_wl_copy, "Should use wl-copy for clipboard")
self.assertTrue(has_ydotool_key, "Should use ydotool key for Ctrl+V")
self.assertFalse(has_ydotool_type, "Should NOT use ydotool type for non-ASCII")
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_ydotool_ascii_uses_type_directly(
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
):
"""Test that ydotool still uses type for plain ASCII text."""
mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy")
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.wayland_tool = "ydotool"
injector.environment = DesktopEnvironment.WAYLAND
mock_run.reset_mock()
injector._inject_with_wayland_tool("Hello world")
calls = [c.args[0] for c in mock_run.call_args_list if c.args]
self.assertTrue(
any(c[:2] == ["ydotool", "type"] for c in calls),
"Should use ydotool type for ASCII text",
)
self.assertFalse(
any(c[0] == "wl-copy" for c in calls),
"Should NOT invoke clipboard for ASCII text",
)
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_ydotool_non_ascii_falls_back_to_type_when_clipboard_fails(
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
):
"""Test ydotool falls back to type when clipboard paste fails (#362)."""
mock_which.side_effect = lambda x: x == "ydotool" # no wl-copy
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.wayland_tool = "ydotool"
injector.environment = DesktopEnvironment.WAYLAND
injector._inject_with_wayland_tool("café")
# Should fall back to ydotool type since no clipboard tool available
calls = [str(c) for c in mock_run.call_args_list]
has_ydotool_type = any("'type'" in c for c in calls)
self.assertTrue(has_ydotool_type, "Should fall back to ydotool type")
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_clipboard_paste_uses_xclip_fallback(self, mock_run, mock_which):
"""Test clipboard paste falls back to xclip when wl-copy is unavailable (#362)."""
mock_which.side_effect = lambda x: x in ("xclip", "ydotool")
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.wayland_tool = "ydotool"
injector.environment = DesktopEnvironment.WAYLAND
result = injector._inject_via_clipboard_paste("café")
self.assertTrue(result)
calls = [c.args[0] for c in mock_run.call_args_list if c.args]
has_xclip = any(c[0] == "xclip" for c in calls)
self.assertTrue(has_xclip, "Should use xclip as fallback")
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_clipboard_paste_uses_xsel_fallback(self, mock_run, mock_which):
"""Test clipboard paste falls back to xsel when wl-copy/xclip unavailable (#362)."""
mock_which.side_effect = lambda x: x in ("xsel", "ydotool")
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.wayland_tool = "ydotool"
injector.environment = DesktopEnvironment.WAYLAND
result = injector._inject_via_clipboard_paste("café")
self.assertTrue(result)
calls = [c.args[0] for c in mock_run.call_args_list if c.args]
has_xsel = any(c[0] == "xsel" for c in calls)
self.assertTrue(has_xsel, "Should use xsel as fallback")
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_wtype_injects_unicode_directly(
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
):
"""wtype supports Unicode natively and must NOT use the clipboard-paste path."""
mock_which.side_effect = lambda x: x in ("wtype", "wl-copy")
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.wayland_tool = "wtype"
injector.environment = DesktopEnvironment.WAYLAND
mock_run.reset_mock()
injector._inject_with_wayland_tool("café")
calls = [c.args[0] for c in mock_run.call_args_list if c.args]
self.assertTrue(
any(c[0] == "wtype" and c[1] == "café" for c in calls),
"wtype should inject text directly",
)
self.assertFalse(
any(c[0] == "wl-copy" for c in calls),
"wtype must NOT use clipboard-paste workaround",
)
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_clipboard_paste_returns_false_on_paste_failure(self, mock_run, mock_which):
"""Test clipboard paste returns False when Ctrl+V simulation fails (#362)."""
mock_which.side_effect = lambda x: x in ("wl-copy", "ydotool")
def side_effect(*args, **kwargs):
cmd = args[0]
if cmd[0] == "wl-copy":
return MagicMock(returncode=0)
# ydotool key fails
raise subprocess.CalledProcessError(1, cmd)
mock_run.side_effect = side_effect
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.wayland_tool = "ydotool"
injector.environment = DesktopEnvironment.WAYLAND
result = injector._inject_via_clipboard_paste("café")
self.assertFalse(result)
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
@patch("vocalinux.text_injection.text_injector.shutil.which")
def test_clipboard_paste_returns_false_when_no_clipboard_tools(
self, mock_which, mock_ibus_avail, mock_ibus_active
):
"""Test clipboard paste returns False when no clipboard tools available."""
# ydotool available for init, but no clipboard tools (wl-copy/xclip/xsel)
mock_which.side_effect = lambda x: x if x == "ydotool" else None
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.wayland_tool = "ydotool"
injector.environment = DesktopEnvironment.WAYLAND
result = injector._inject_via_clipboard_paste("café")
self.assertFalse(result)
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_inject_text_ydotool_non_ascii_end_to_end(
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
):
"""inject_text routes accented text through clipboard-paste on Wayland+ydotool (#362)."""
mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy")
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.wayland_tool = "ydotool"
injector.environment = DesktopEnvironment.WAYLAND
mock_run.reset_mock()
result = injector.inject_text("Esdrújula")
self.assertTrue(result)
calls = [c.args[0] for c in mock_run.call_args_list if c.args]
self.assertTrue(
any(c[0] == "wl-copy" for c in calls),
"Should copy to clipboard via wl-copy",
)
self.assertTrue(
any(c[:2] == ["ydotool", "key"] for c in calls),
"Should simulate Ctrl+V via ydotool key",
)
self.assertFalse(
any(c[:2] == ["ydotool", "type"] for c in calls),
"Should NOT call ydotool type for non-ASCII text",
)
class TestDesktopEnvironmentEnum(unittest.TestCase):
"""Tests for DesktopEnvironment enum."""
def test_enum_values(self):
"""Test that enum values are as expected."""
self.assertEqual(DesktopEnvironment.X11.value, "x11")
self.assertEqual(DesktopEnvironment.WAYLAND.value, "wayland")
self.assertEqual(DesktopEnvironment.WAYLAND_XDOTOOL.value, "wayland-xdotool")
self.assertEqual(DesktopEnvironment.WAYLAND_IBUS.value, "wayland-ibus")
self.assertEqual(DesktopEnvironment.UNKNOWN.value, "unknown")
class TestTextInjectorEdgeCases(unittest.TestCase):
"""Tests for edge cases in TextInjector."""
def setUp(self):
"""Set up for tests."""
self.patch_which = patch("shutil.which")
self.mock_which = self.patch_which.start()
self.mock_which.return_value = "/usr/bin/xdotool"
self.patch_subprocess = patch("subprocess.run")
self.mock_subprocess = self.patch_subprocess.start()
self.patch_sleep = patch("time.sleep")
self.mock_sleep = self.patch_sleep.start()
# Disable IBus for these tests (testing fallback paths)
self.patch_ibus_available = patch(
"vocalinux.text_injection.text_injector.is_ibus_available",
return_value=False,
)
self.patch_ibus_available.start()
mock_process = MagicMock()
mock_process.returncode = 0
mock_process.stdout = "1234"
mock_process.stderr = ""
self.mock_subprocess.return_value = mock_process
def tearDown(self):
"""Clean up after tests."""
self.patch_which.stop()
self.patch_subprocess.stop()
self.patch_sleep.stop()
self.patch_ibus_available.stop()
def test_wtype_test_exception(self):
"""Test wtype test handling exceptions gracefully."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
def which_side_effect(cmd):
if cmd == "wtype":
return "/usr/bin/wtype"
return None
self.mock_which.side_effect = which_side_effect
# Make the wtype test raise an exception
self.mock_subprocess.side_effect = Exception("Test wtype error")
# Should still create the injector (will log warning)
injector = TextInjector()
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
def test_xwayland_fallback_test_exception(self):
"""Test XWayland fallback test handles exceptions."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make only xdotool available, not wtype
def which_side_effect(cmd):
if cmd == "xdotool":
return "/usr/bin/xdotool"
return None
self.mock_which.side_effect = which_side_effect
# Make subprocess raise for xdotool test
self.mock_subprocess.side_effect = Exception("xdotool test failed")
# Should create injector and log error
injector = TextInjector()
def test_check_dependencies_no_xdotool(self):
"""Test _check_dependencies when xdotool is missing on X11."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
self.mock_which.return_value = None # No tools available
with self.assertRaises(RuntimeError):
TextInjector()
def test_wayland_no_tools_available(self):
"""Test Wayland when no tools are available."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
self.mock_which.return_value = None # No tools available
with self.assertRaises(RuntimeError):
TextInjector()
def test_detect_environment_unknown(self):
"""Test environment detection when session type is unknown and no display vars set."""
# Use clear=True to start fresh, then only set XDG_SESSION_TYPE to empty
# This ensures WAYLAND_DISPLAY and DISPLAY are not in os.environ
clean_env = {"XDG_SESSION_TYPE": "", "PATH": os.environ.get("PATH", "")}
with patch.dict("os.environ", clean_env, clear=True):
injector = TextInjector.__new__(TextInjector)
# Don't call __init__, just test _detect_environment
result = injector._detect_environment()
# Should default to X11 when unknown (no WAYLAND_DISPLAY or DISPLAY)
self.assertEqual(result, DesktopEnvironment.X11)
def test_detect_environment_wayland_display(self):
"""Test environment detection via WAYLAND_DISPLAY."""
# Clear session type but set WAYLAND_DISPLAY
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "", "WAYLAND_DISPLAY": "wayland-0"}):
injector = TextInjector.__new__(TextInjector)
result = injector._detect_environment()
self.assertEqual(result, DesktopEnvironment.WAYLAND)
def test_detect_environment_display_only(self):
"""Test environment detection via DISPLAY only."""
# Clear session type and WAYLAND_DISPLAY, set only DISPLAY
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "", "DISPLAY": ":0"}, clear=False):
# Ensure WAYLAND_DISPLAY is not set
env_copy = dict(os.environ)
env_copy.pop("WAYLAND_DISPLAY", None)
env_copy["XDG_SESSION_TYPE"] = ""
env_copy["DISPLAY"] = ":0"
with patch.dict("os.environ", env_copy, clear=True):
injector = TextInjector.__new__(TextInjector)
result = injector._detect_environment()
self.assertEqual(result, DesktopEnvironment.X11)
def test_wtype_compositor_not_supported(self):
"""Test wtype fallback when compositor doesn't support virtual keyboard."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make both wtype and xdotool available
def which_side_effect(cmd):
if cmd in ["wtype", "xdotool"]:
return f"/usr/bin/{cmd}"
return None
self.mock_which.side_effect = which_side_effect
# Make wtype test return error about compositor
mock_process = MagicMock()
mock_process.returncode = 1
mock_process.stderr = "compositor does not support virtual keyboard protocol"
self.mock_subprocess.return_value = mock_process
injector = TextInjector()
# Should have fallen back to WAYLAND_XDOTOOL
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
def test_wtype_compositor_not_supported_no_fallback(self):
"""Test wtype error when no xdotool fallback available."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make only wtype available
def which_side_effect(cmd):
if cmd == "wtype":
return "/usr/bin/wtype"
return None
self.mock_which.side_effect = which_side_effect
# Make wtype test return error
mock_process = MagicMock()
mock_process.returncode = 1
mock_process.stderr = "compositor does not support virtual keyboard"
self.mock_subprocess.return_value = mock_process
# Should log error but still create injector
injector = TextInjector()
# Should stay as WAYLAND since no fallback
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
def test_xwayland_fallback_test_exception_with_both_tools(self):
"""Test XWayland fallback when test raises exception with both tools."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make both tools available
def which_side_effect(cmd):
if cmd in ["wtype", "xdotool"]:
return f"/usr/bin/{cmd}"
return None
self.mock_which.side_effect = which_side_effect
# Make wtype succeed
mock_wtype = MagicMock()
mock_wtype.returncode = 0
mock_wtype.stderr = ""
# Make xdotool test fail with exception
def run_side_effect(*args, **kwargs):
cmd = args[0] if args else kwargs.get("args", [])
if isinstance(cmd, list) and "xdotool" in cmd:
if "getactivewindow" in cmd:
raise Exception("XWayland test failed")
return mock_wtype
self.mock_subprocess.side_effect = run_side_effect
# Should still create injector
injector = TextInjector()
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
def test_inject_text_import_error_for_audio(self):
"""Test text injection when audio_feedback import fails."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
self.mock_which.return_value = "/usr/bin/xdotool"
injector = TextInjector()
# Make xdotool fail to trigger error path
self.mock_subprocess.side_effect = Exception("Test error")
# Mock the audio import to fail
with patch.dict("sys.modules", {"vocalinux.ui.audio_feedback": None}):
result = injector.inject_text("test")
self.assertFalse(result)
def test_inject_with_xdotool_retry_timeout(self):
"""Test xdotool injection with timeout on retry."""
import subprocess as real_subprocess
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
self.mock_which.return_value = "/usr/bin/xdotool"
injector = TextInjector()
# First call for getactivewindow, then timeouts on type
call_count = [0]
def run_side_effect(*args, **kwargs):
cmd = args[0] if args else kwargs.get("args", [])
call_count[0] += 1
if isinstance(cmd, list):
if "getactivewindow" in cmd:
result = MagicMock()
result.returncode = 0
result.stdout = "12345"
result.stderr = ""
return result
if "type" in cmd:
raise real_subprocess.TimeoutExpired(cmd, 5)
result = MagicMock()
result.returncode = 0
result.stderr = ""
return result
self.mock_subprocess.side_effect = run_side_effect
# Should fail after retries
with self.assertRaises(real_subprocess.TimeoutExpired):
injector._inject_with_xdotool("test")
def test_inject_with_xdotool_xwayland_no_display(self):
"""Test xdotool injection in XWayland mode without DISPLAY set."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}, clear=False):
# Temporarily remove DISPLAY
env_backup = os.environ.get("DISPLAY")
if "DISPLAY" in os.environ:
del os.environ["DISPLAY"]
try:
def which_side_effect(cmd):
if cmd in ["xdotool"]:
return f"/usr/bin/{cmd}"
return None
self.mock_which.side_effect = which_side_effect
# Create injector in WAYLAND_XDOTOOL mode
injector = TextInjector.__new__(TextInjector)
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
# Mock subprocess.run
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "12345"
mock_result.stderr = ""
self.mock_subprocess.return_value = mock_result
# Run injection - should set DISPLAY to :0
injector._inject_with_xdotool("test")
# Verify DISPLAY was set in the env passed to subprocess
calls = self.mock_subprocess.call_args_list
for call in calls:
if "env" in call.kwargs:
env = call.kwargs["env"]
if "type" in str(call):
self.assertEqual(env.get("DISPLAY"), ":0")
finally:
# Restore DISPLAY
if env_backup is not None:
os.environ["DISPLAY"] = env_backup
def test_inject_with_wayland_tool_ydotool(self):
"""Test text injection with ydotool."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make only ydotool available
def which_side_effect(cmd):
if cmd == "ydotool":
return "/usr/bin/ydotool"
return None
self.mock_which.side_effect = which_side_effect
# Create injector
injector = TextInjector()
self.assertEqual(injector.wayland_tool, "ydotool")
# Mock successful injection
mock_result = MagicMock()
mock_result.returncode = 0
self.mock_subprocess.return_value = mock_result
# Inject text
injector._inject_with_wayland_tool("test text")
# Verify ydotool was called correctly
self.mock_subprocess.assert_called()
call_args = self.mock_subprocess.call_args
self.assertIn("ydotool", call_args[0][0])
self.assertIn("type", call_args[0][0])
def test_inject_keyboard_shortcut_x11(self):
"""Test keyboard shortcut injection on X11."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
self.mock_which.return_value = "/usr/bin/xdotool"
injector = TextInjector()
# Mock subprocess.run for xdotool key command
mock_result = MagicMock()
mock_result.returncode = 0
self.mock_subprocess.return_value = mock_result
result = injector._inject_keyboard_shortcut("ctrl+c")
self.assertTrue(result)
# Verify xdotool key was called
self.mock_subprocess.assert_called()
def test_inject_keyboard_shortcut_wayland(self):
"""Test keyboard shortcut injection on Wayland with wtype."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
def which_side_effect(cmd):
if cmd == "wtype":
return "/usr/bin/wtype"
return None
self.mock_which.side_effect = which_side_effect
# Make wtype test succeed
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = ""
self.mock_subprocess.return_value = mock_result
injector = TextInjector()
result = injector._inject_keyboard_shortcut("ctrl+v")
# Verify wtype -k was called
self.mock_subprocess.assert_called()
def test_inject_keyboard_shortcut_exception(self):
"""Test keyboard shortcut injection handles exceptions."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
self.mock_which.return_value = "/usr/bin/xdotool"
injector = TextInjector()
# Make subprocess.run raise an exception
self.mock_subprocess.side_effect = Exception("Shortcut injection failed")
result = injector._inject_keyboard_shortcut("ctrl+z")
self.assertFalse(result)
def test_inject_shortcut_xdotool_error(self):
"""Test keyboard shortcut injection with xdotool error."""
import subprocess as real_subprocess
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
self.mock_which.return_value = "/usr/bin/xdotool"
injector = TextInjector()
# Make xdotool return error
error = real_subprocess.CalledProcessError(1, "xdotool")
error.stderr = "xdotool error message"
self.mock_subprocess.side_effect = error
result = injector._inject_shortcut_with_xdotool("ctrl+a")
self.assertFalse(result)
def test_inject_shortcut_with_ydotool(self):
"""Test keyboard shortcut injection with ydotool."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make only ydotool available
def which_side_effect(cmd):
if cmd == "ydotool":
return "/usr/bin/ydotool"
return None
self.mock_which.side_effect = which_side_effect
injector = TextInjector()
self.assertEqual(injector.wayland_tool, "ydotool")
# Mock subprocess.run for successful shortcut
mock_result = MagicMock()
mock_result.returncode = 0
self.mock_subprocess.return_value = mock_result
result = injector._inject_shortcut_with_wayland_tool("ctrl+a")
self.assertTrue(result)
# Verify ydotool key was called
self.mock_subprocess.assert_called()
call_args = self.mock_subprocess.call_args
self.assertIn("ydotool", call_args[0][0])
self.assertIn("key", call_args[0][0])
def test_inject_shortcut_with_ydotool_error(self):
"""Test keyboard shortcut injection with ydotool error."""
import subprocess as real_subprocess
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make only ydotool available
def which_side_effect(cmd):
if cmd == "ydotool":
return "/usr/bin/ydotool"
return None
self.mock_which.side_effect = which_side_effect
injector = TextInjector()
# Make ydotool return error
error = real_subprocess.CalledProcessError(1, "ydotool")
error.stderr = "ydotool error message"
self.mock_subprocess.side_effect = error
result = injector._inject_shortcut_with_wayland_tool("ctrl+a")
self.assertFalse(result)
def test_inject_shortcut_wayland_unsupported_tool(self):
"""Test keyboard shortcut warning with unsupported wayland tool."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make wtype available
def which_side_effect(cmd):
if cmd == "wtype":
return "/usr/bin/wtype"
return None
self.mock_which.side_effect = which_side_effect
# Succeed on wtype test
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = ""
self.mock_subprocess.return_value = mock_result
injector = TextInjector()
# Force wayland_tool to something unsupported
injector.wayland_tool = "unsupported_tool"
result = injector._inject_shortcut_with_wayland_tool("ctrl+a")
self.assertFalse(result)
def test_log_current_window_info_exception(self):
"""Test _log_current_window_info handles exceptions gracefully."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
self.mock_which.return_value = "/usr/bin/xdotool"
injector = TextInjector()
# Make subprocess.run raise an exception
self.mock_subprocess.side_effect = Exception("Window info error")
# Should not raise - just log debug message
injector._log_current_window_info()
def test_log_current_window_info_wayland(self):
"""Test _log_current_window_info on pure Wayland."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
def which_side_effect(cmd):
if cmd == "wtype":
return "/usr/bin/wtype"
return None
self.mock_which.side_effect = which_side_effect
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = ""
self.mock_subprocess.return_value = mock_result
injector = TextInjector()
# Should just log debug message for pure Wayland
injector._log_current_window_info()
def test_inject_shortcut_xdotool_wayland_no_display(self):
"""Test shortcut injection in WAYLAND_XDOTOOL mode without DISPLAY."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}, clear=False):
# Remove DISPLAY for this test
env_backup = os.environ.get("DISPLAY")
if "DISPLAY" in os.environ:
del os.environ["DISPLAY"]
try:
def which_side_effect(cmd):
if cmd in ["xdotool"]:
return f"/usr/bin/{cmd}"
return None
self.mock_which.side_effect = which_side_effect
# Create injector directly in WAYLAND_XDOTOOL mode
injector = TextInjector.__new__(TextInjector)
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
# Mock subprocess.run
mock_result = MagicMock()
mock_result.returncode = 0
self.mock_subprocess.return_value = mock_result
# Run shortcut injection - should set DISPLAY to :0
result = injector._inject_shortcut_with_xdotool("ctrl+a")
self.assertTrue(result)
# Verify xdotool key was called with env containing DISPLAY
self.mock_subprocess.assert_called()
call_args = self.mock_subprocess.call_args
if "env" in call_args.kwargs:
self.assertEqual(call_args.kwargs["env"].get("DISPLAY"), ":0")
finally:
# Restore DISPLAY
if env_backup is not None:
os.environ["DISPLAY"] = env_backup
def test_xwayland_fallback_test_successful(self):
"""Test XWayland fallback test when successful."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}):
def which_side_effect(cmd):
if cmd == "xdotool":
return "/usr/bin/xdotool"
return None
self.mock_which.side_effect = which_side_effect
# Make getactivewindow succeed
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "12345"
mock_result.stderr = ""
self.mock_subprocess.return_value = mock_result
# Create injector - should test XWayland fallback
injector = TextInjector()
# Verify it's in WAYLAND_XDOTOOL mode
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
def test_xwayland_fallback_test_error_logging(self):
"""Test XWayland fallback logs error when _test_xdotool_fallback raises."""
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}):
def which_side_effect(cmd):
if cmd == "xdotool":
return "/usr/bin/xdotool"
return None
self.mock_which.side_effect = which_side_effect
# Mock subprocess to not raise during dependency check
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
self.mock_subprocess.return_value = mock_result
# Patch _test_xdotool_fallback at the class level before instantiation
with patch.object(
TextInjector,
"_test_xdotool_fallback",
side_effect=Exception("Test error"),
):
with patch("time.sleep"): # Skip the sleep
# Should create injector and log error but not crash
injector = TextInjector()
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
class TestIBusSetupErrorFallback(unittest.TestCase):
"""Tests for fallback behavior when IBus setup fails."""
def setUp(self):
"""Set up for tests."""
self.patch_which = patch("shutil.which")
self.mock_which = self.patch_which.start()
self.mock_which.return_value = "/usr/bin/xdotool"
self.patch_subprocess = patch("subprocess.run")
self.mock_subprocess = self.patch_subprocess.start()
self.patch_sleep = patch("time.sleep")
self.mock_sleep = self.patch_sleep.start()
mock_process = MagicMock()
mock_process.returncode = 0
mock_process.stdout = "1234"
mock_process.stderr = ""
self.mock_subprocess.return_value = mock_process
def tearDown(self):
"""Clean up after tests."""
self.patch_which.stop()
self.patch_subprocess.stop()
self.patch_sleep.stop()
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
def test_fallback_when_ibus_setup_fails(self, mock_ibus_class, mock_ibus_available):
"""Test that TextInjector falls back when IBus setup raises IBusSetupError."""
from vocalinux.text_injection.ibus_engine import IBusSetupError
# Make IBusTextInjector raise IBusSetupError
mock_ibus_class.side_effect = IBusSetupError("Failed to register IBus engine")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
injector = TextInjector()
# Should have fallen back to X11 with xdotool
self.assertEqual(injector.environment, DesktopEnvironment.X11)
self.assertIsNone(injector._ibus_injector)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
def test_fallback_on_wayland_when_ibus_fails(self, mock_ibus_class, mock_ibus_available):
"""Test that TextInjector falls back on Wayland when IBus setup fails."""
from vocalinux.text_injection.ibus_engine import IBusSetupError
mock_ibus_class.side_effect = IBusSetupError("Failed to start IBus engine process")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
# Make xdotool available for fallback
self.mock_which.side_effect = lambda cmd: (
"/usr/bin/xdotool" if cmd == "xdotool" else None
)
injector = TextInjector()
# Should have fallen back to WAYLAND_XDOTOOL
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
self.assertIsNone(injector._ibus_injector)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
def test_text_injection_works_after_ibus_fallback(self, mock_ibus_class, mock_ibus_available):
"""Test that text injection still works after IBus setup failure."""
from vocalinux.text_injection.ibus_engine import IBusSetupError
mock_ibus_class.side_effect = IBusSetupError("Test failure")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
injector = TextInjector()
# Should be able to inject text via xdotool
result = injector.inject_text("Hello world")
self.assertTrue(result)
class TestIBusRuntimeFallback(unittest.TestCase):
"""Tests for runtime fallback when IBus injection fails."""
def setUp(self):
self.patch_which = patch("shutil.which")
self.mock_which = self.patch_which.start()
self.mock_which.return_value = "/usr/bin/xdotool"
self.patch_subprocess = patch("subprocess.run")
self.mock_subprocess = self.patch_subprocess.start()
mock_process = MagicMock()
mock_process.returncode = 0
mock_process.stdout = ""
mock_process.stderr = ""
self.mock_subprocess.return_value = mock_process
def tearDown(self):
self.patch_which.stop()
self.patch_subprocess.stop()
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
def test_runtime_fallback_from_x11_ibus_to_xdotool(
self,
mock_ibus_class,
mock_ibus_available,
mock_is_active,
mock_daemon,
):
"""If IBus runtime injection fails on X11, fallback to xdotool should work."""
mock_ibus_instance = MagicMock()
mock_ibus_instance.inject_text.return_value = False
mock_ibus_class.return_value = mock_ibus_instance
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}):
injector = TextInjector()
# IBus environment promotion happens in a background daemon thread
# (see TextInjector._initialize_ibus_in_background). Wait for it to
# finish so the assertion below is not subject to scheduler timing.
if injector._ibus_init_thread is not None:
injector._ibus_init_thread.join(timeout=5)
self.assertEqual(injector.environment, DesktopEnvironment.X11_IBUS)
result = injector.inject_text("Hello from fallback")
self.assertTrue(result)
self.assertEqual(injector.environment, DesktopEnvironment.X11)
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
def test_runtime_fallback_from_wayland_ibus_to_wtype(
self,
mock_ibus_class,
mock_ibus_available,
mock_is_active,
mock_daemon,
):
"""If IBus runtime injection fails on Wayland, fallback to wtype should work."""
mock_ibus_instance = MagicMock()
mock_ibus_instance.inject_text.return_value = False
mock_ibus_class.return_value = mock_ibus_instance
self.mock_which.side_effect = lambda cmd: {
"wtype": "/usr/bin/wtype",
"xdotool": "/usr/bin/xdotool",
}.get(cmd)
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
injector = TextInjector()
if injector._ibus_init_thread is not None:
injector._ibus_init_thread.join(timeout=5)
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
result = injector.inject_text("Hello via wayland fallback")
self.assertTrue(result)
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
self.assertEqual(injector.wayland_tool, "wtype")
def test_switch_from_x11_ibus_fails_without_xdotool(self):
"""X11 IBus fallback should fail cleanly when xdotool is unavailable."""
self.mock_which.return_value = None
injector = TextInjector.__new__(TextInjector)
injector.environment = DesktopEnvironment.X11_IBUS
result = injector._switch_to_non_ibus_backend()
self.assertFalse(result)
self.assertEqual(injector.environment, DesktopEnvironment.X11_IBUS)
def test_switch_from_wayland_ibus_prefers_running_ydotool(self):
"""Wayland IBus fallback should prefer ydotool when its daemon responds."""
self.mock_which.side_effect = lambda cmd: {"ydotool": "/usr/bin/ydotool"}.get(cmd)
injector = TextInjector.__new__(TextInjector)
injector.environment = DesktopEnvironment.WAYLAND_IBUS
injector.wayland_tool = None
result = injector._switch_to_non_ibus_backend()
self.assertTrue(result)
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
self.assertEqual(injector.wayland_tool, "ydotool")
self.mock_subprocess.assert_any_call(
["ydotool", "type", ""], check=True, stderr=subprocess.PIPE, timeout=2
)
def test_switch_from_wayland_ibus_falls_back_when_ydotool_daemon_down(self):
"""If ydotool exists but daemon fails, wtype should be selected next."""
self.mock_which.side_effect = lambda cmd: {
"ydotool": "/usr/bin/ydotool",
"wtype": "/usr/bin/wtype",
}.get(cmd)
self.mock_subprocess.side_effect = subprocess.CalledProcessError(1, ["ydotool"])
injector = TextInjector.__new__(TextInjector)
injector.environment = DesktopEnvironment.WAYLAND_IBUS
injector.wayland_tool = None
result = injector._switch_to_non_ibus_backend()
self.assertTrue(result)
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
self.assertEqual(injector.wayland_tool, "wtype")
def test_switch_from_wayland_ibus_uses_xwayland_as_last_tool(self):
"""Wayland IBus fallback should use xdotool/XWayland when native tools are absent."""
self.mock_which.side_effect = lambda cmd: {"xdotool": "/usr/bin/xdotool"}.get(cmd)
injector = TextInjector.__new__(TextInjector)
injector.environment = DesktopEnvironment.WAYLAND_IBUS
injector.wayland_tool = None
result = injector._switch_to_non_ibus_backend()
self.assertTrue(result)
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
def test_switch_from_wayland_ibus_fails_without_tools(self):
"""Wayland IBus fallback should fail when no non-IBus tools are available."""
self.mock_which.return_value = None
injector = TextInjector.__new__(TextInjector)
injector.environment = DesktopEnvironment.WAYLAND_IBUS
injector.wayland_tool = None
result = injector._switch_to_non_ibus_backend()
self.assertFalse(result)
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
def test_switch_to_non_ibus_backend_noop_outside_ibus_mode(self):
"""Non-IBus modes are already on fallback backends."""
injector = TextInjector.__new__(TextInjector)
injector.environment = DesktopEnvironment.X11
self.assertTrue(injector._switch_to_non_ibus_backend())
self.assertEqual(injector.environment, DesktopEnvironment.X11)
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
def test_successful_ibus_injection_still_copies_to_clipboard_when_enabled(
self,
mock_ibus_class,
mock_ibus_available,
mock_is_active,
mock_daemon,
):
"""Successful IBus injection should keep the existing optional clipboard copy path."""
mock_ibus_instance = MagicMock()
mock_ibus_instance.inject_text.return_value = True
mock_ibus_class.return_value = mock_ibus_instance
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}):
injector = TextInjector()
with patch.object(injector, "_should_copy_to_clipboard", return_value=True):
with patch("threading.Thread") as mock_thread:
result = injector.inject_text("copy me")
self.assertTrue(result)
mock_thread.assert_called_once()
mock_thread.return_value.start.assert_called_once()
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
def test_uninitialized_ibus_injector_uses_non_ibus_fallback(
self,
mock_ibus_class,
mock_ibus_available,
mock_is_active,
mock_daemon,
):
"""If the IBus injector disappears at runtime, fallback backend should still run."""
mock_ibus_class.return_value = MagicMock()
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}):
injector = TextInjector()
injector._ibus_injector = None
result = injector.inject_text("fallback without ibus instance")
self.assertTrue(result)
self.assertEqual(injector.environment, DesktopEnvironment.X11)