* feat(post-processing): pipe transcriptions through user-defined script Add a configurable post-processing step that passes each transcription result to an external executable via stdin and uses its stdout as the text to type. Enables custom transforms (formatting, LLM rewriting, etc.) without modifying Vocalinux. Falls back to original text on script failure or timeout. * test(post-processor): add unit tests and extract apply_post_processing Move config-reading logic from main.py closure into apply_post_processing() in post_processor.py so it can be tested independently. Add 11 unit tests covering PostProcessor.process() and apply_post_processing(). * docs(post-processing): document post-processing script feature * fix formatting of one file * fix(post-processing): run the user script with a stripped host environment Rebase compat: the host_process AST guard requires every subprocess spawned for a host binary to pass env=host_env() so an AppImage's LD_LIBRARY_PATH/GI_TYPELIB_PATH/PYTHONHOME do not leak in. Also give apply_post_processing() a typed ConfigManager parameter and keep text_callback_wrapper's local variable typed as str for mypy. * fix(post-processing): preserve paragraph breaks and tighten script handling Take script stdout verbatim instead of rstripping every trailing newline, so the "new paragraph" voice command's break survives a passthrough script. A single trailing newline is still dropped, but only when the transcription had none — it is almost always just the line terminator echo/print-style tools append. Also stop writing transformed output to the log (it can carry sensitive dictation into retained logs), catch only the expected process/OS/encoding failures instead of every Exception, and add the missing signature annotations. * fix(main): run the post-processing script off the recognition thread A user script may take seconds per segment, and text callbacks run on the recognition thread that drains the bounded audio-segment queue; stalling it drops queued dictation once the queue fills. Submit post-processing and injection to a dedicated single-worker executor so the backlog waits instead of losing speech, while keeping injection order. On timeout or failure the worker falls back to the original text, and an unexpected error is logged with a traceback and still injects the original rather than silently dropping the segment. Also pin the callback tests' get_str mock to no-script so they exercise the synchronous path deterministically, and add a callback test proving transformed text (including preserved paragraph breaks) reaches the injector. * docs(post-processing): annotate new callbacks and clarify stdout contract Add the missing type annotations on the new Settings page callbacks per the repository guide, and document that script stdout is used verbatim (a lone trailing newline added by line-oriented tools is dropped only when the transcription had none) and that scripts run on a dedicated worker. * test(main): annotate new post-processing callback tests * fix(main): bind queued dictation to its target app and drain worker on quit Route every transcription segment through the single post-processing worker (a pass-through when no script is configured) so clearing the script path mid-queue can no longer let a later segment overtake an earlier one still running. A segment that can sit in the backlog — a script is configured or a job is still running — is bound to the application focused when it was dictated; the worker drops it if focus has verifiably moved to another application, so delayed dictation cannot land in the wrong app. Wire the worker into the tray quit path via an on_quit hook: it clears the acceptance flag, cancels pending jobs, and waits — bounded by the script's own timeout — so nothing injects after the injector stops. * fix(main): keep voice actions ordered and probe focus off the recognition thread - Queue voice-command actions on the post-processing worker so an action can no longer overtake dictated text still waiting to be injected. - Move the submit-time focus probe onto a dedicated executor; the recognition thread only submits the probe, and the job joins the result at inject time, so compositor tools can no longer stall consumption of the bounded audio-segment queue. - Re-check the quit flag after the inject-time focus re-probe, then shut both workers down without waiting on a running script so quitting does not freeze on the script timeout. * fix(main): capture submit-time focus on dedicated threads and serialize quit - Replace the shared focus-probe executor with a fresh daemon thread per submission so a slow earlier probe cannot delay a later one past the focus change it is meant to capture; jobs read the result off a one-slot queue at inject time. - Give queued voice actions the same submit-time focus binding as text segments so a queued shortcut is dropped instead of fired into a newly focused application. - Hold injection_lock across the accept-check plus inject/action call so quit waits only for an in-flight injection instead of racing it. - Queue the IDLE reset of last_injected_text behind pending worker jobs so a queued 'delete that' still sees the text it targets. * fix(main): keep actions instant on a free worker and bound quit wait - An action now starts a submit-time focus probe only when it actually queues behind other jobs; with a free worker there is no delay window to bind against, so the probe's compositor calls are skipped and the action stays immediate even when a script is configured. - Probe and executor submission now happen inside pending_jobs_lock so queue order matches callback order — a job that saw an empty queue can no longer slip behind one that arrived while it was submitting. - Every inject-path subprocess call (xdotool window/key/type, wtype and ydotool type) is bounded by a timeout, and the quit hook's injection_lock wait is capped, so quitting can no longer freeze on a stalled injector subprocess. * fix(main): bind every action to submit-time focus without slowing it down - Every voice action now starts a submit-time focus probe: submission never guarantees immediate execution, so skipping the binding left a focus-change window in which a shortcut could fire into another app. The worker waits on the probe for at most one second — milliseconds on a healthy desktop — so immediate actions stay immediate while a genuinely stalled probe falls back to the injector's own targeting. - Quit waits for an in-flight injection without a cap again: every inject-path subprocess is individually bounded, so the wait always ends with the injection and a long transcription is never cut off. - The wtype/ydotool typing timeout is now derived from the configured per-key delay (a generous multiple), so a legitimately slow type can never be cut mid-text; a timed-out type no longer falls through to the clipboard fallback, which would duplicate the partial fragment. * fix(main): drop unverified actions and make quit-time injection abortable - An action whose submit-time focus probe cannot answer inside its one-second bound is now dropped instead of running unverified: a stalled probe can no longer leave a shortcut pointing at whatever application happens to be focused. - Quit asks the injector to abort in-flight injections, and injection paths honour it: the xdotool chunk loop stops between chunks, the wtype/ydotool type call runs as a polling Popen that can be killed, and paste/shortcut/backspace entry points exit early. The quit hook still waits for an in-flight injection, but the wait is bounded by at most one already-running subprocess call — never the injection's full length — and no subprocess is ever stopped mid-call by a timeout cap on the wait itself. - Annotate the remaining untyped test helpers. * fix(main): wait out slow focus probes; restore run() in wayland typing - An action no longer races its submit-time focus probe on a one-second bound: the job waits for the probe's own answer, whose compositor calls already carry per-call timeouts and short-circuit absent tools. A slow but living probe delays the command instead of discarding it, so a valid "undo" can no longer be lost to timing alone, and focus verification still decides every drop. - Wayland typing goes back to subprocess.run so tests keep intercepting the same entry point. Abortability is preserved by typing in 200-character chunks with the shutdown check between calls — a whole-string call could not be interrupted, but no single chunk holds the injector for more than its own scaled budget. - The abort flag gains a class-level default so injectors built without __init__ in tests still answer the checks; each real instance gets its own event in __init__. - Update call-shape assertions for the typed-call timeout and finish annotating the test methods touched by the callback refactor. * fix(inject): never retype text a failed chunked call already sent - _PartiallyTyped carries the confirmed-delivered prefix length out of the chunked type loops, so every downstream fallback continues from the untyped remainder: the Wayland-to-xdotool switch types only the tail, and the clipboard fallback copies only the tail, never the whole string a second time. - The xdotool retry loop gains the same accounting — a retry now resumes at the first untyped chunk instead of restarting at the top and duplicating everything the failed attempt already delivered. - Annotate the remaining test signature the repository guide requires. * fix(inject): report partial typing as failure even when clipboard lands A chunked type call that dies mid-text leaves only a prefix on screen, and the remainder-fallback keeps it that way: it copies the tail for a manual paste but now returns False, so the caller no longer records the full transcription as injected and a following "delete that" cannot erase text that was never typed. * fix(inject): keep deletion state aligned with delivered text inject_text now records how many characters a call confirmed on screen as last_typed_count: the whole length on success, the typed prefix after a partial failure, -1 when the delivered amount is unknowable. The transcription caller uses it to keep last_injected_text honest — a following "delete that" erases the typed prefix or nothing, never the previous segment or text that never arrived. * fix(inject): never replay input a timed-out portal job may have delivered - RemoteDesktopPortalError.delivered=None marks an unreported job's count as unknowable; all three portal fallbacks demote and skip instead of replaying, and text injection surfaces as TimeoutExpired so no full-text clipboard copy is offered - _PartiallyTyped counts raised after a portal demote are rebased onto the original text (typed_prefix) so the caller clips the real prefix - evdev: a consumed pure-modifier press is withheld and replayed ahead of the first non-target press, so holding RightAlt for AltGr composition still reaches the application - history: extend_latest gains an expected_latest CAS guard so a late segment from an older worker merges only into its own session's snippet, never into a newer session's entry - quit path aborts in-flight injections before waiting on the lock so quitting cannot freeze behind a long transcription's typing budget - tests: stop _ensure_ydotoold leaking a real Popen in Wayland tests * fix(shortcut): replay withheld modifiers across devices and after SYN_DROPPED - Replay withheld modifier presses from every device, not only the fd the character arrived on: split keyboards deliver AltGr and the character on separate devices, and a per-fd check forwarded a plain character. - Let the post-SYN_DROPPED resync run when only withheld modifiers exist for the fd, so a press whose release the dropped burst ate is pruned instead of replaying a stale modifier on the next ordinary key. * fix(history,pads): keep late segments pad-bound deliveries and pad deletions correct - Track the worker behind the ended session's snippet so late segments from an older worker cannot merge into a newer session's entry - Skip the focus-change drop for dictation-pad segments and pad-bound editing commands, which never target the focused application - Refuse 'delete that' when a manual pad edit blurred the segment boundaries instead of deleting the stale recorded length * style: drop unneeded nonlocal for mutated worker set * fix(shortcut): share one evdev device layer across shortcut listeners Each KeyboardShortcutManager built its own EvdevKeyboardBackend, and on evdev every backend opened and grabbed the same physical keyboards: the first grabber received all events and the rest received none, so per-language shortcuts could never fire on Wayland. All backends now bind to a process-wide EvdevDeviceHub that opens each keyboard once, owns the grabs and the paired uinput clones, and fans every event out to each registered engine. An event is re-emitted to applications only when no engine consumed it, so suppression semantics stay the same while per-engine matching state (combo progress, withheld modifiers, swallowed presses) remains independent. * fix(history): own late segments per worker and decide pad routing once - ended_worker_snippets maps each ended session's worker to its own (snippet, commit epoch): late deliveries merge into their session's entry however many sessions have committed since, so one session's stragglers never split across Recent Snippets entries. - The leftover-segment commit reads the live history epoch inside the session lock: a mid-session clear() no longer makes the stale ended epoch refuse valid post-clear dictation. - The pad-bound routing decision is captured once per job and passed to the injector/action handler, so a toggle flip between the focus check and delivery cannot reroute text or shortcuts into the focused app. - Dead recognition workers are pruned from the tracking set and map so long runs don't accumulate finished threads. - The custom-terms switch now suppresses GTK's own state-set on save failure so it shows the value that actually persisted. * fix(shortcut): serialize evdev device layer teardown against restart The last listener's unregister released the engine lock before joining the monitor thread and closing devices, so a new listener registering in that gap opened keyboards the stale teardown then closed — it stayed active but dead, and briefly grabbed keyboards stopped forwarding input. A lifecycle lock now covers the whole close/open transition in register, unregister, and the monitor's unexpected-exit teardown, so a cold start can only open devices after the previous generation finished closing (also making the new generation's grabs succeed). A generation stamp tells a monitor thread that outlived its join() its incarnation is over even when running has gone True again. * fix(injection): log focus-probe failures and narrow clipboard fallback catch A crashed focus probe turned into an unknown result with no logged error, indistinguishable from unavailable focus information; the partial-typing clipboard fallback caught every exception. Log the probe failure with context and narrow the clipboard catch to the exceptions the fallback path can actually raise. * fix(shortcut): join the evdev monitor outside the lifecycle lock If the monitor exited unexpectedly while the last listener unregistered, its cleanup queued on the lifecycle lock that unregister() held for the join — stalling shutdown for the join's full 2s timeout. The close now finishes inside the lock and the join waits after it is released, so the monitor's own cleanup can run and exit instead of queueing behind it. Closing before the join also dies the monitor's read/select on the closed fds right away. A _closed gate on _open_keyboard_device keeps a rescan from opening a device between detach and close that nothing would ever close. * fix(injection): narrow focus-probe catch to the raisable exception set get_focused_window() reports Optional and never raises; the catch now covers only what can realistically escape the queue write and logs it with context, keeping the waiting job released. * fix(shortcut): tag evdev device fds with the generation that opened them A monitor that outlived join() could still touch a newer generation's devices: the loop re-checked the generation only at the top, so mid-iteration it could dispatch from, forward to, or remove devices a restarted listener had just opened. Every fd is now tagged with the generation that opened it, and the tag is verified at each point a monitor could mutate shared state — fd lookup for dispatch, clone forwarding, forwarder release, device removal, and rescan opens — while dispatch re-checks the generation between events and the per-fd loop breaks on it. Cold starts bump the generation before opening so fresh devices are tagged for the new generation. Devices an older generation opened stay tagged stale, so its reader leaves them alone. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
3143 lines
134 KiB
Python
3143 lines
134 KiB
Python
"""
|
|
Tests for text injection functionality.
|
|
"""
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import unittest
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
from unittest import mock
|
|
from unittest.mock import MagicMock, mock_open, patch
|
|
|
|
# Update import path to use the new package structure
|
|
from vocalinux.text_injection.text_injector import (
|
|
DesktopEnvironment,
|
|
TextInjector,
|
|
_warn_if_ydotool_globally_enabled,
|
|
_ydotool_install_guidance,
|
|
)
|
|
|
|
# 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 probe 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_wtype_startup_probe_is_non_destructive(self):
|
|
"""Startup probing must not type visible text into the focused window."""
|
|
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
|
|
|
|
TextInjector()
|
|
|
|
self.mock_subprocess.assert_any_call(
|
|
["wtype", ""],
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
timeout=2,
|
|
env=mock.ANY,
|
|
)
|
|
wtype_calls = [
|
|
call.args[0]
|
|
for call in self.mock_subprocess.call_args_list
|
|
if call.args and call.args[0][0] == "wtype"
|
|
]
|
|
self.assertTrue(all(len(cmd) < 2 or cmd[1] == "" for cmd in wtype_calls))
|
|
|
|
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 probe 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_wayland_wtype_fail_prefers_ydotool_over_xdotool(self) -> None:
|
|
"""wtype rejection must not skip ydotool; xdotool is XWayland-only."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
self.mock_which.side_effect = lambda cmd: {
|
|
"wtype": "/usr/bin/wtype",
|
|
"ydotool": "/usr/bin/ydotool",
|
|
"xdotool": "/usr/bin/xdotool",
|
|
}.get(cmd)
|
|
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 1
|
|
mock_process.stderr = "compositor does not support virtual keyboard protocol"
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
with (
|
|
patch.object(TextInjector, "_is_ydotoold_running", return_value=False),
|
|
patch.object(TextInjector, "_uinput_usable", return_value=True),
|
|
):
|
|
injector = TextInjector()
|
|
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
self.assertEqual(injector.wayland_tool, "ydotool")
|
|
|
|
def test_wayland_wtype_fail_xdotool_when_uinput_blocked(self) -> None:
|
|
"""ydotool in PATH without /dev/uinput still cannot rescue a wtype miss."""
|
|
with patch.dict(
|
|
"os.environ", {"XDG_SESSION_TYPE": "wayland", "SNAP": "/snap/vocalinux/x1"}
|
|
):
|
|
self.mock_which.side_effect = lambda cmd: {
|
|
"wtype": "/usr/bin/wtype",
|
|
"ydotool": "/usr/bin/ydotool",
|
|
"xdotool": "/usr/bin/xdotool",
|
|
}.get(cmd)
|
|
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 1
|
|
mock_process.stderr = "compositor does not support virtual keyboard protocol"
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
with (
|
|
patch.object(TextInjector, "_is_ydotoold_running", return_value=False),
|
|
patch.object(TextInjector, "_uinput_usable", return_value=False),
|
|
):
|
|
injector = TextInjector()
|
|
|
|
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 probe
|
|
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,
|
|
timeout=mock.ANY,
|
|
env=mock.ANY,
|
|
)
|
|
|
|
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")
|
|
# _ensure_ydotoold spawns a real Popen, which patch("subprocess.run") misses.
|
|
injector._ensure_ydotoold = MagicMock(return_value=True)
|
|
|
|
# Inject text
|
|
injector.inject_text("Hello world")
|
|
|
|
# Clipboard paste fails (no wl-copy/xclip/xsel in this mock); type fallback.
|
|
# Default key-delay is 2 (overridable via VOCALINUX_YDOTOOL_KEY_DELAY).
|
|
self.mock_subprocess.assert_any_call(
|
|
["ydotool", "type", "--key-delay", "2", "Hello world"],
|
|
check=True,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
timeout=mock.ANY,
|
|
env=mock.ANY,
|
|
)
|
|
|
|
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)
|
|
injector._state_lock = threading.Lock()
|
|
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)
|
|
injector._state_lock = threading.Lock()
|
|
env = injector._detect_environment()
|
|
self.assertEqual(env, DesktopEnvironment.WAYLAND)
|
|
|
|
def test_detect_environment_wayland_socket_beats_x11_session_type(self):
|
|
"""Plasma Wayland GTK apps often keep XDG_SESSION_TYPE=x11 (#752)."""
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_SESSION_TYPE": "x11",
|
|
"WAYLAND_DISPLAY": "wayland-0",
|
|
"DISPLAY": ":0",
|
|
},
|
|
clear=True,
|
|
):
|
|
with patch.object(TextInjector, "_check_dependencies"):
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
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)
|
|
injector._state_lock = threading.Lock()
|
|
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):
|
|
"""wtype chords a shortcut with -M/-k/-m rather than refusing it."""
|
|
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
|
|
injector._wait_for_modifiers_released = MagicMock()
|
|
|
|
result = injector._inject_shortcut_with_wayland_tool("ctrl+z")
|
|
self.assertTrue(result)
|
|
self.assertEqual(
|
|
self.mock_subprocess.call_args[0][0],
|
|
["wtype", "-M", "ctrl", "-k", "z", "-m", "ctrl"],
|
|
)
|
|
|
|
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
|
|
# Pin the dialect: unpinned, the probe reads a mocked `key --help`
|
|
# and falls through to its legacy default, so this would silently
|
|
# stop covering the 1.x keycode form.
|
|
injector._ydotool_legacy_named_keys = False
|
|
injector._ensure_ydotoold = MagicMock(return_value=True)
|
|
injector._wait_for_modifiers_released = MagicMock()
|
|
|
|
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)
|
|
# ctrl=29, z=44 -- press in order, release in reverse.
|
|
self.assertEqual(
|
|
self.mock_subprocess.call_args[0][0],
|
|
["ydotool", "key", "29:1", "44:1", "44:0", "29:0"],
|
|
)
|
|
|
|
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 = subprocess.CalledProcessError(1, "xdotool")
|
|
|
|
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 probe, then fail for actual injection
|
|
call_count = [0]
|
|
|
|
def mock_subprocess_call(*args, **kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] <= 1: # wtype probe 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
|
|
injector._ensure_ydotoold = MagicMock(return_value=True)
|
|
|
|
# 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_also_uses_clipboard_paste(
|
|
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
|
|
):
|
|
"""ydotool always pastes via the clipboard, even for plain ASCII.
|
|
|
|
`ydotool type` emits positional evdev keycodes that get re-interpreted
|
|
through the active keyboard layout (assumed US QWERTY), so typing ASCII
|
|
is scrambled on non-US layouts (e.g. AZERTY). Clipboard paste is used
|
|
unconditionally for ydotool so the payload is not typed key-by-key.
|
|
"""
|
|
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
|
|
injector._ensure_ydotoold = MagicMock(return_value=True)
|
|
|
|
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[0] == "wl-copy" for c in calls),
|
|
"Should copy ASCII text to the clipboard",
|
|
)
|
|
self.assertTrue(
|
|
any(c[:2] == ["ydotool", "key"] for c in calls),
|
|
"Should paste with ydotool key (Ctrl+V)",
|
|
)
|
|
self.assertFalse(
|
|
any(c[:2] == ["ydotool", "type"] for c in calls),
|
|
"Should NOT use layout-dependent ydotool type",
|
|
)
|
|
|
|
@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.subprocess.run")
|
|
def test_clipboard_command_does_not_capture_pipe(self, mock_run):
|
|
"""_run_clipboard_command must not capture the tool's stderr via a pipe.
|
|
|
|
wl-copy/xclip/xsel fork a background process that keeps owning the
|
|
selection; if stderr is captured with subprocess.PIPE the call blocks on
|
|
the surviving child and times out even though the copy succeeded.
|
|
Redirecting to DEVNULL avoids that hang (regression guard).
|
|
"""
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._clipboard_timeout = 0.35
|
|
|
|
for tool in ("wl-copy", "xclip", "xsel"):
|
|
mock_run.reset_mock()
|
|
self.assertTrue(injector._run_clipboard_command(tool, "café"))
|
|
_, kwargs = mock_run.call_args
|
|
self.assertEqual(kwargs.get("stdout"), subprocess.DEVNULL)
|
|
self.assertEqual(kwargs.get("stderr"), subprocess.DEVNULL)
|
|
self.assertNotEqual(kwargs.get("stderr"), subprocess.PIPE)
|
|
|
|
@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.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
|
return_value=None,
|
|
)
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_ydotool_ctrl_v_command_legacy_uses_named_sequence(
|
|
self, mock_which, mock_run, _mock_map
|
|
):
|
|
"""Distro ydotool 0.1.x expects ctrl+v, not keycode:value."""
|
|
mock_which.return_value = "/usr/bin/ydotool"
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0,
|
|
stdout="",
|
|
stderr=(
|
|
"Each key sequence can be any number of modifiers and keys, "
|
|
"separated by plus (+)\n"
|
|
),
|
|
)
|
|
injector = TextInjector.__new__(TextInjector)
|
|
with patch.dict("os.environ", {}, clear=False):
|
|
# Ensure host path (not Flatpak)
|
|
os.environ.pop("FLATPAK_ID", None)
|
|
cmd = injector._ydotool_ctrl_v_command()
|
|
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
|
|
self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+v"])
|
|
|
|
@patch(
|
|
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
|
return_value=None,
|
|
)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_ydotool_ctrl_v_command_flatpak_uses_keycodes(self, mock_which, _mock_map):
|
|
"""Flatpak pins ydotool 1.0.4; always use keycode:value form."""
|
|
mock_which.return_value = "/app/bin/ydotool"
|
|
injector = TextInjector.__new__(TextInjector)
|
|
with patch.dict("os.environ", {"FLATPAK_ID": "com.vocalinux.Vocalinux"}):
|
|
cmd = injector._ydotool_ctrl_v_command()
|
|
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
|
|
|
|
@patch(
|
|
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
|
return_value=None,
|
|
)
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_ydotool_ctrl_v_command_v1_help_uses_keycodes(self, mock_which, mock_run, _mock_map):
|
|
"""Host ydotool 1.x (no plus-sequence help) uses press/release keycodes."""
|
|
mock_which.return_value = "/usr/local/bin/ydotool"
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="Usage: key N:1 N:0 ...", stderr="")
|
|
injector = TextInjector.__new__(TextInjector)
|
|
os.environ.pop("FLATPAK_ID", None)
|
|
cmd = injector._ydotool_ctrl_v_command()
|
|
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
|
|
|
|
@patch(
|
|
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
|
return_value=None,
|
|
)
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_ydotool_ctrl_v_command_unknown_help_defaults_legacy(
|
|
self, mock_which, mock_run, _mock_map
|
|
):
|
|
"""Unrecognized help text prefers named ctrl+v (safe on 0.1.x)."""
|
|
mock_which.return_value = "/usr/bin/ydotool"
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="mystery help", stderr="")
|
|
injector = TextInjector.__new__(TextInjector)
|
|
os.environ.pop("FLATPAK_ID", None)
|
|
cmd = injector._ydotool_ctrl_v_command()
|
|
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
|
|
|
|
@patch(
|
|
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
|
return_value=None,
|
|
)
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_ydotool_ctrl_v_command_probe_error_defaults_legacy(
|
|
self, mock_which, mock_run, _mock_map
|
|
):
|
|
"""If key --help fails, default to legacy named sequence."""
|
|
mock_which.return_value = "/usr/bin/ydotool"
|
|
mock_run.side_effect = OSError("no ydotool")
|
|
injector = TextInjector.__new__(TextInjector)
|
|
os.environ.pop("FLATPAK_ID", None)
|
|
cmd = injector._ydotool_ctrl_v_command()
|
|
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
|
|
|
|
@patch(
|
|
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
|
return_value=None,
|
|
)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_ydotool_ctrl_v_command_app_prefix_uses_keycodes(self, mock_which, _mock_map):
|
|
"""/app/bin/ydotool (Flatpak path) always uses 1.x keycodes without FLATPAK_ID."""
|
|
mock_which.return_value = "/app/bin/ydotool"
|
|
injector = TextInjector.__new__(TextInjector)
|
|
os.environ.pop("FLATPAK_ID", None)
|
|
cmd = injector._ydotool_ctrl_v_command()
|
|
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
|
|
|
|
def _minimal_paste_injector(self) -> TextInjector:
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
injector._clipboard_restore_generation = 0
|
|
injector._clipboard_restore_target = None
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
injector._session_environment = DesktopEnvironment.WAYLAND
|
|
injector.wayland_tool = "ydotool"
|
|
injector._wtype_paste_usable = False
|
|
return injector
|
|
|
|
def _inject_failing_paste(
|
|
self,
|
|
injector: TextInjector,
|
|
mock_run: MagicMock,
|
|
paste_cmd: list,
|
|
error: BaseException,
|
|
) -> bool:
|
|
def run_side_effect(cmd, **kwargs):
|
|
if cmd == paste_cmd:
|
|
raise error
|
|
return MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
mock_run.side_effect = run_side_effect
|
|
with patch.object(injector, "_copy_to_clipboard", return_value=True):
|
|
with patch.object(injector, "_read_clipboard", return_value=None):
|
|
with patch.object(injector, "_should_copy_to_clipboard", return_value=False):
|
|
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
|
|
with patch.object(
|
|
injector, "_clipboard_paste_command", return_value=paste_cmd
|
|
):
|
|
return injector._inject_via_clipboard_paste("hello")
|
|
|
|
def test_ydotool_paste_release_command_lifts_held_keys(self):
|
|
"""Release argv follows the actual paste chord, including Shift and layout 'v'."""
|
|
injector = TextInjector.__new__(TextInjector)
|
|
self.assertEqual(
|
|
injector._ydotool_paste_release_command(
|
|
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]
|
|
),
|
|
["ydotool", "key", "47:0", "29:0"],
|
|
)
|
|
self.assertEqual(
|
|
injector._ydotool_paste_release_command(
|
|
["ydotool", "key", "29:1", "42:1", "47:1", "47:0", "42:0", "29:0"]
|
|
),
|
|
["ydotool", "key", "47:0", "42:0", "29:0"],
|
|
)
|
|
self.assertEqual(
|
|
injector._ydotool_paste_release_command(
|
|
["ydotool", "key", "29:1", "17:1", "17:0", "29:0"]
|
|
),
|
|
["ydotool", "key", "17:0", "29:0"],
|
|
)
|
|
self.assertEqual(
|
|
injector._ydotool_paste_release_command(["ydotool", "key", "ctrl+v"]),
|
|
["ydotool", "key", "ctrl"],
|
|
)
|
|
self.assertEqual(
|
|
injector._ydotool_paste_release_command(["ydotool", "key", "ctrl+shift+v"]),
|
|
["ydotool", "key", "ctrl+shift"],
|
|
)
|
|
self.assertEqual(
|
|
injector._ydotool_paste_release_command(["wtype", "-M", "ctrl", "v"]),
|
|
[],
|
|
)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_ydotool_paste_timeout_releases_v1_modifiers(self, mock_run):
|
|
"""Timeout mid Ctrl+V must send V-up and Ctrl-up (#658)."""
|
|
injector = self._minimal_paste_injector()
|
|
paste_cmd = ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]
|
|
result = self._inject_failing_paste(
|
|
injector, mock_run, paste_cmd, subprocess.TimeoutExpired(paste_cmd, 3)
|
|
)
|
|
|
|
self.assertFalse(result)
|
|
cmds = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
release_cmd = ["ydotool", "key", "47:0", "29:0"]
|
|
self.assertIn(release_cmd, cmds)
|
|
release_kwargs = next(
|
|
c.kwargs for c in mock_run.call_args_list if c.args and c.args[0] == release_cmd
|
|
)
|
|
self.assertIn("env", release_kwargs)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_ydotool_paste_timeout_releases_terminal_shift(self, mock_run):
|
|
"""A timed-out Ctrl+Shift+V chord must also lift Shift (#658)."""
|
|
injector = self._minimal_paste_injector()
|
|
paste_cmd = ["ydotool", "key", "29:1", "42:1", "47:1", "47:0", "42:0", "29:0"]
|
|
result = self._inject_failing_paste(
|
|
injector, mock_run, paste_cmd, subprocess.TimeoutExpired(paste_cmd, 3)
|
|
)
|
|
|
|
self.assertFalse(result)
|
|
cmds = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
self.assertIn(["ydotool", "key", "47:0", "42:0", "29:0"], cmds)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_ydotool_paste_error_releases_legacy_ctrl(self, mock_run):
|
|
"""A failed 0.1.x ctrl+v paste must tap ctrl so a stuck modifier can lift (#658)."""
|
|
injector = self._minimal_paste_injector()
|
|
paste_cmd = list(TextInjector._YDOTOOL_LEGACY_CTRL_V)
|
|
result = self._inject_failing_paste(
|
|
injector, mock_run, paste_cmd, subprocess.CalledProcessError(1, paste_cmd)
|
|
)
|
|
|
|
self.assertFalse(result)
|
|
cmds = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
self.assertIn(["ydotool", "key", "ctrl"], cmds)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_ydotool_paste_error_releases_legacy_ctrl_shift(self, mock_run):
|
|
"""A failed 0.1.x ctrl+shift+v paste must tap ctrl+shift (#658)."""
|
|
injector = self._minimal_paste_injector()
|
|
paste_cmd = list(TextInjector._YDOTOOL_LEGACY_CTRL_SHIFT_V)
|
|
result = self._inject_failing_paste(
|
|
injector, mock_run, paste_cmd, subprocess.CalledProcessError(1, paste_cmd)
|
|
)
|
|
|
|
self.assertFalse(result)
|
|
cmds = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
self.assertIn(["ydotool", "key", "ctrl+shift"], cmds)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_ydotool_paste_release_timeout_still_returns_false(self, mock_run):
|
|
"""A wedged daemon on the release path must not raise out of paste (#658)."""
|
|
injector = self._minimal_paste_injector()
|
|
paste_cmd = ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]
|
|
mock_run.side_effect = subprocess.TimeoutExpired("ydotool", 3)
|
|
with patch.object(injector, "_copy_to_clipboard", return_value=True):
|
|
with patch.object(injector, "_read_clipboard", return_value=None):
|
|
with patch.object(injector, "_should_copy_to_clipboard", return_value=False):
|
|
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
|
|
with patch.object(
|
|
injector, "_clipboard_paste_command", return_value=paste_cmd
|
|
):
|
|
result = injector._inject_via_clipboard_paste("hello")
|
|
self.assertFalse(result)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_wtype_paste_timeout_does_not_release_ydotool_keys(self, mock_run):
|
|
"""wtype paste failure must not send a ydotool key-up (#658)."""
|
|
injector = self._minimal_paste_injector()
|
|
paste_cmd = ["wtype", "-M", "ctrl", "v"]
|
|
result = self._inject_failing_paste(
|
|
injector, mock_run, paste_cmd, subprocess.TimeoutExpired(paste_cmd, 3)
|
|
)
|
|
|
|
self.assertFalse(result)
|
|
cmds = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
self.assertFalse(any(isinstance(c, list) and c and c[0] == "ydotool" for c in cmds))
|
|
|
|
@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_probe_exception(self):
|
|
"""Test wtype probe 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 probe 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_ydotool_package_guidance_uses_user_service(self):
|
|
"""Package and source routes must clearly use their distinct unit scopes."""
|
|
guidance = _ydotool_install_guidance()
|
|
|
|
self.assertIn("sudo apt install ydotool", guidance)
|
|
self.assertIn("systemctl --user enable --now ydotool.service", guidance)
|
|
self.assertIn("sudo usermod -aG input $USER", guidance)
|
|
self.assertIn("sudo systemctl enable --now ydotoold.service", guidance)
|
|
self.assertIn("Do NOT use 'systemctl --global enable ydotool.service'", guidance)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.logger.warning")
|
|
@patch("vocalinux.text_injection.text_injector.os.path.lexists", return_value=True)
|
|
def test_globally_enabled_ydotool_warns_with_remediation(self, mock_lexists, mock_warning):
|
|
"""A persistent global unit should produce an actionable greeter warning."""
|
|
_warn_if_ydotool_globally_enabled()
|
|
|
|
mock_lexists.assert_called_once_with(
|
|
"/etc/systemd/user/default.target.wants/ydotool.service"
|
|
)
|
|
warning = mock_warning.call_args.args[0]
|
|
self.assertIn("display-manager greeter sessions", warning)
|
|
self.assertIn("systemctl --global disable ydotool.service", warning)
|
|
self.assertIn("systemctl --user enable --now ydotool.service", warning)
|
|
|
|
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)
|
|
injector._state_lock = threading.Lock()
|
|
# 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)
|
|
injector._state_lock = threading.Lock()
|
|
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)
|
|
injector._state_lock = threading.Lock()
|
|
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 probe 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 probe 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 = subprocess.CalledProcessError(1, "xdotool")
|
|
|
|
# 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):
|
|
env_backup = os.environ.get("DISPLAY")
|
|
os.environ.pop("DISPLAY", None)
|
|
|
|
try:
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd == "xdotool":
|
|
return "/usr/bin/xdotool"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
self.mock_subprocess.return_value = MagicMock(
|
|
returncode=0, stdout="12345", stderr=""
|
|
)
|
|
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
injector._inject_with_xdotool("test")
|
|
|
|
typed = [
|
|
c
|
|
for c in self.mock_subprocess.call_args_list
|
|
if c.args and c.args[0][:2] == ["xdotool", "type"]
|
|
]
|
|
self.assertTrue(typed, "should fall back to xdotool type without xclip")
|
|
self.assertEqual(typed[0].kwargs.get("env", {}).get("DISPLAY"), ":0")
|
|
finally:
|
|
if env_backup is not None:
|
|
os.environ["DISPLAY"] = env_backup
|
|
elif "DISPLAY" in os.environ:
|
|
del os.environ["DISPLAY"]
|
|
|
|
def test_inject_with_xdotool_xwayland_prefers_x11_clipboard_paste(self) -> None:
|
|
"""XWayland fallback must paste via xclip + xdotool ctrl+v, not `xdotool
|
|
type` (#657, and the 2026-08-17 review on PR #680).
|
|
|
|
`xdotool type` simulates keypresses against the active X keyboard
|
|
layout, so a character the layout doesn't map comes out as the wrong
|
|
glyph or garbled. The paste keystroke lands on an XWayland window,
|
|
which reads the X11 CLIPBOARD selection, so the copy step must use
|
|
xclip/xsel (not wl-copy) and the paste itself uses xdotool's own
|
|
`key ctrl+v` rather than ydotool: ydotool simulates Wayland-native
|
|
input, is not what an XWayland paste reads from, and per the reported
|
|
AppImage build is not even always installed.
|
|
"""
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd in ("xdotool", "xclip"):
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
self.mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
|
|
injector._inject_with_xdotool("привет")
|
|
|
|
calls = [c.args[0] for c in self.mock_subprocess.call_args_list if c.args]
|
|
self.assertTrue(
|
|
any(c[:2] == ["xclip", "-selection"] for c in calls),
|
|
"should copy text to the X11 CLIPBOARD selection via xclip",
|
|
)
|
|
xclip_copy_calls = [
|
|
c
|
|
for c in self.mock_subprocess.call_args_list
|
|
if c.args and c.args[0] == ["xclip", "-selection", "clipboard"]
|
|
]
|
|
self.assertTrue(
|
|
xclip_copy_calls and xclip_copy_calls[0].kwargs.get("input") == "привет",
|
|
"the dictated text itself must be what xclip receives on stdin",
|
|
)
|
|
self.assertIn(
|
|
["xdotool", "key", "--clearmodifiers", "ctrl+v"],
|
|
calls,
|
|
"should paste with xdotool key --clearmodifiers ctrl+v",
|
|
)
|
|
self.assertFalse(
|
|
any(c[0] == "wl-copy" for c in calls),
|
|
"must not write to the Wayland-native clipboard; an XWayland paste cannot read it",
|
|
)
|
|
self.assertFalse(
|
|
any(c[0] == "ydotool" for c in calls),
|
|
"must not require ydotool when xclip/xsel are available",
|
|
)
|
|
self.assertFalse(
|
|
any(c[:2] == ["xdotool", "type"] for c in calls),
|
|
"must not fall back to layout-dependent xdotool type when paste succeeds",
|
|
)
|
|
|
|
def test_inject_with_xdotool_xwayland_types_when_x11_clipboard_missing(
|
|
self,
|
|
) -> None:
|
|
"""No xclip/xsel: type, even if ydotool/wl-copy exist.
|
|
|
|
ydotool+wl-copy writes the Wayland clipboard; an XWayland window
|
|
pastes the X11 CLIPBOARD. Treating ydotool rc=0 as success skips
|
|
type and leaves the wrong (or empty) selection in the field.
|
|
"""
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd in ("xdotool", "ydotool", "wl-copy"):
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
self.mock_subprocess.return_value = MagicMock(returncode=0, stdout="12345", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
injector._inject_with_xdotool("привет")
|
|
|
|
calls = [c.args[0] for c in self.mock_subprocess.call_args_list if c.args]
|
|
self.assertTrue(
|
|
any(c[:2] == ["xdotool", "type"] for c in calls),
|
|
"should fall back to xdotool type when xclip/xsel are missing",
|
|
)
|
|
self.assertFalse(
|
|
any(c[0] == "wl-copy" for c in calls),
|
|
"must not write the Wayland clipboard for an XWayland paste",
|
|
)
|
|
self.assertFalse(
|
|
any(c[:2] == ["ydotool", "key"] for c in calls),
|
|
"must not treat ydotool ctrl+v as an XWayland paste",
|
|
)
|
|
|
|
def test_inject_with_xdotool_xwayland_types_when_paste_fails(self) -> None:
|
|
"""xclip copy works but xdotool key fails: fall through to type."""
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd in ("xdotool", "xclip"):
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
def run_side_effect(cmd, **kwargs):
|
|
if cmd[:2] == ["xdotool", "key"]:
|
|
raise subprocess.CalledProcessError(1, cmd, stderr="paste failed")
|
|
return MagicMock(returncode=0, stdout="12345", stderr="")
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
self.mock_subprocess.side_effect = run_side_effect
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
|
|
injector._inject_with_xdotool("привет")
|
|
|
|
calls = [c.args[0] for c in self.mock_subprocess.call_args_list if c.args]
|
|
self.assertTrue(
|
|
any(c[:2] == ["xdotool", "type"] for c in calls),
|
|
"should fall back to xdotool type when the paste chord fails",
|
|
)
|
|
|
|
def test_inject_with_xdotool_xwayland_paste_sets_display(self) -> None:
|
|
"""xdotool key on the paste path gets DISPLAY=:0 when it was unset."""
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd in ("xdotool", "xclip"):
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
self.mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
env_backup = os.environ.get("DISPLAY")
|
|
os.environ.pop("DISPLAY", None)
|
|
try:
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
|
|
injector._inject_with_xdotool("привет")
|
|
key_calls = [
|
|
c
|
|
for c in self.mock_subprocess.call_args_list
|
|
if c.args and c.args[0][:2] == ["xdotool", "key"]
|
|
]
|
|
self.assertTrue(key_calls)
|
|
self.assertEqual(key_calls[0].kwargs.get("env", {}).get("DISPLAY"), ":0")
|
|
finally:
|
|
if env_backup is not None:
|
|
os.environ["DISPLAY"] = env_backup
|
|
elif "DISPLAY" in os.environ:
|
|
del os.environ["DISPLAY"]
|
|
|
|
def test_inject_with_xdotool_xwayland_falls_back_without_any_paste_tool(
|
|
self,
|
|
) -> None:
|
|
"""Neither xclip/xsel nor ydotool installed: keeps typing via xdotool."""
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd == "xdotool":
|
|
return "/usr/bin/xdotool"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
self.mock_subprocess.return_value = MagicMock(returncode=0, stdout="12345", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
|
|
injector._inject_with_xdotool("hello")
|
|
|
|
calls = [c.args[0] for c in self.mock_subprocess.call_args_list if c.args]
|
|
self.assertTrue(
|
|
any(c[:2] == ["xdotool", "type"] for c in calls),
|
|
"should fall back to xdotool type when no clipboard-paste tool exists",
|
|
)
|
|
|
|
def test_inject_text_appimage_scenario_recovers_through_xclip_paste(self) -> None:
|
|
"""Reproduces the #657 report through inject_text(), not the fallback
|
|
method directly (2026-08-17 review on PR #680): AppImage packaging
|
|
does not bundle ydotool, so `_try_recover_from_fallback()` must find
|
|
`which("ydotool")` false and leave `environment` at WAYLAND_XDOTOOL,
|
|
and the xdotool fallback must then paste via xclip rather than type.
|
|
"""
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd in ("xdotool", "xclip"):
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
self.mock_subprocess.return_value = MagicMock(returncode=0, stdout="12345", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
|
|
result = injector.inject_text("привет")
|
|
|
|
self.assertTrue(result)
|
|
self.assertEqual(
|
|
injector.environment,
|
|
DesktopEnvironment.WAYLAND_XDOTOOL,
|
|
"recovery has no ydotool to find, so it must not claim a switch to WAYLAND",
|
|
)
|
|
calls = [c.args[0] for c in self.mock_subprocess.call_args_list if c.args]
|
|
self.assertIn(
|
|
["xdotool", "key", "--clearmodifiers", "ctrl+v"],
|
|
calls,
|
|
"should paste via xdotool ctrl+v against the X11 clipboard xclip wrote",
|
|
)
|
|
self.assertFalse(
|
|
any(c[:2] == ["xdotool", "type"] for c in calls),
|
|
"must not fall through to the layout-dependent type path when the paste succeeds",
|
|
)
|
|
|
|
def test_inject_with_xdotool_xwayland_uses_terminal_paste_chord(self) -> None:
|
|
"""XWayland xclip paste must still use Ctrl+Shift+V in terminals (#734)."""
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd in ("xdotool", "xclip"):
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
self.mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
with patch.object(injector, "_should_use_terminal_paste", return_value=True):
|
|
injector._inject_with_xdotool("ls")
|
|
|
|
calls = [c.args[0] for c in self.mock_subprocess.call_args_list if c.args]
|
|
self.assertIn(
|
|
["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"],
|
|
calls,
|
|
"terminals must get Ctrl+Shift+V from the XWayland xdotool paste path",
|
|
)
|
|
self.assertNotIn(
|
|
["xdotool", "key", "--clearmodifiers", "ctrl+v"],
|
|
calls,
|
|
)
|
|
self.assertFalse(
|
|
any(c[:2] == ["xdotool", "type"] for c in calls),
|
|
"must not fall back to xdotool type when the terminal paste succeeds",
|
|
)
|
|
|
|
def test_copy_to_clipboard_setting_still_prefers_wl_copy_on_xwayland(self) -> None:
|
|
"""User-facing copy keeps the session clipboard; only paste is X11-only."""
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd in ("xdotool", "xclip", "wl-copy"):
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
self.mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
injector._copy_to_clipboard("hello")
|
|
|
|
calls = [c.args[0] for c in self.mock_subprocess.call_args_list if c.args]
|
|
self.assertTrue(
|
|
any(c[0] == "wl-copy" for c in calls),
|
|
"copy_to_clipboard setting should still prefer wl-copy on a Wayland host",
|
|
)
|
|
|
|
def test_inject_with_xdotool_releases_modifiers_without_escape(self):
|
|
"""The xdotool path must keep the target input focused after injection."""
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector.environment = DesktopEnvironment.X11
|
|
|
|
injector._inject_with_xdotool("test")
|
|
|
|
commands = [call.args[0] for call in self.mock_subprocess.call_args_list]
|
|
self.assertIn(
|
|
[
|
|
"xdotool",
|
|
"keyup",
|
|
"--clearmodifiers",
|
|
"Control_L",
|
|
"Control_R",
|
|
"Shift_L",
|
|
"Shift_R",
|
|
"Alt_L",
|
|
"Alt_R",
|
|
"Super_L",
|
|
"Super_R",
|
|
],
|
|
commands,
|
|
)
|
|
self.assertFalse(any("Escape" in command for command in commands))
|
|
|
|
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 probe 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 probe
|
|
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._state_lock = threading.Lock()
|
|
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._state_lock = threading.Lock()
|
|
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._state_lock = threading.Lock()
|
|
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,
|
|
env=mock.ANY,
|
|
)
|
|
|
|
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._state_lock = threading.Lock()
|
|
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._state_lock = threading.Lock()
|
|
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._state_lock = threading.Lock()
|
|
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._state_lock = threading.Lock()
|
|
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)
|
|
|
|
|
|
class TestCompositorIBusBridging(unittest.TestCase):
|
|
"""Tests for skipping IBus on compositors that don't bridge it to native apps."""
|
|
|
|
def _bare_injector(self, environment=DesktopEnvironment.WAYLAND):
|
|
"""Build a TextInjector without running __init__ side effects."""
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector.environment = environment
|
|
return injector
|
|
|
|
def test_bridged_desktops_prefer_ibus(self):
|
|
"""GNOME/Cinnamon and unknown desktops should keep using IBus.
|
|
|
|
KDE is covered separately: bridging also requires KWin VirtualKeyboard.
|
|
"""
|
|
injector = self._bare_injector()
|
|
for desktop in ("GNOME", "ubuntu:GNOME", "X-Cinnamon", ""):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_CURRENT_DESKTOP": desktop,
|
|
"XDG_SESSION_DESKTOP": desktop,
|
|
"DESKTOP_SESSION": desktop,
|
|
"KDE_FULL_SESSION": "",
|
|
},
|
|
clear=False,
|
|
):
|
|
self.assertTrue(injector._wayland_compositor_bridges_ibus(), desktop)
|
|
|
|
def test_unbridged_compositors_skip_ibus(self):
|
|
"""COSMIC and wlroots compositors do not deliver IBus commits to native apps.
|
|
|
|
The ibus-wayland bridge is explicitly absent here; see
|
|
``test_unbridged_compositors_use_ibus_when_bridge_running`` for the
|
|
opposite case. Patching it keeps the result independent of whether the
|
|
machine running the suite happens to have the bridge up.
|
|
"""
|
|
injector = self._bare_injector()
|
|
for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "niri", "river"):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_CURRENT_DESKTOP": desktop,
|
|
"XDG_SESSION_DESKTOP": desktop,
|
|
"DESKTOP_SESSION": desktop,
|
|
},
|
|
):
|
|
with patch.object(TextInjector, "_ibus_wayland_bridge_running", return_value=False):
|
|
self.assertFalse(injector._wayland_compositor_bridges_ibus(), desktop)
|
|
|
|
def test_unbridged_compositors_use_ibus_when_bridge_running(self):
|
|
"""ibus-wayland supplies the input-method-v2 relay these compositors lack (#607)."""
|
|
injector = self._bare_injector()
|
|
for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "niri", "river"):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_CURRENT_DESKTOP": desktop,
|
|
"XDG_SESSION_DESKTOP": desktop,
|
|
"DESKTOP_SESSION": desktop,
|
|
},
|
|
):
|
|
with patch.object(TextInjector, "_ibus_wayland_bridge_running", return_value=True):
|
|
self.assertTrue(injector._wayland_compositor_bridges_ibus(), desktop)
|
|
|
|
def test_bridge_probe_not_consulted_for_bridged_desktops(self):
|
|
"""GNOME and friends never reach the probe; behaviour there is unchanged."""
|
|
injector = self._bare_injector()
|
|
with (
|
|
patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_CURRENT_DESKTOP": "GNOME",
|
|
"XDG_SESSION_DESKTOP": "GNOME",
|
|
"DESKTOP_SESSION": "GNOME",
|
|
"KDE_FULL_SESSION": "",
|
|
},
|
|
clear=False,
|
|
),
|
|
patch.object(TextInjector, "_ibus_wayland_bridge_running") as probe,
|
|
):
|
|
self.assertTrue(injector._wayland_compositor_bridges_ibus())
|
|
probe.assert_not_called()
|
|
|
|
def test_bridge_probe_detects_running_process(self):
|
|
"""The probe shells out to pgrep -x ibus-wayland."""
|
|
with patch("subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
|
self.assertTrue(TextInjector._ibus_wayland_bridge_running())
|
|
self.assertEqual(run.call_args[0][0], ["pgrep", "-x", "ibus-wayland"])
|
|
|
|
with patch("subprocess.run", return_value=MagicMock(returncode=1)):
|
|
self.assertFalse(TextInjector._ibus_wayland_bridge_running())
|
|
|
|
def test_bridge_probe_survives_missing_pgrep(self):
|
|
"""No pgrep (or a hung one) must not raise -- just report 'no bridge'."""
|
|
for boom in (FileNotFoundError(), subprocess.TimeoutExpired("pgrep", 2)):
|
|
with patch("subprocess.run", side_effect=boom):
|
|
self.assertFalse(TextInjector._ibus_wayland_bridge_running())
|
|
|
|
def test_non_wayland_always_bridges(self):
|
|
"""On X11/XWayland IBus works via XIM regardless of desktop."""
|
|
injector = self._bare_injector(DesktopEnvironment.X11)
|
|
with patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "COSMIC"}):
|
|
self.assertTrue(injector._wayland_compositor_bridges_ibus())
|
|
|
|
def test_kde_wayland_virtual_keyboard_disabled_skips_ibus(self):
|
|
"""KDE Wayland with KWin VirtualKeyboard disabled must not use IBus (#574)."""
|
|
injector = self._bare_injector()
|
|
with (
|
|
patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_SESSION_TYPE": "wayland",
|
|
"XDG_CURRENT_DESKTOP": "KDE",
|
|
"XDG_SESSION_DESKTOP": "KDE",
|
|
"DESKTOP_SESSION": "plasma",
|
|
"KDE_FULL_SESSION": "true",
|
|
},
|
|
),
|
|
patch.object(injector, "_kde_virtual_keyboard_enabled", return_value=False),
|
|
):
|
|
self.assertFalse(injector._wayland_compositor_bridges_ibus())
|
|
|
|
def test_kde_wayland_virtual_keyboard_enabled_allows_ibus(self):
|
|
"""KDE Wayland with KWin VirtualKeyboard enabled may still use IBus (#574)."""
|
|
injector = self._bare_injector()
|
|
with (
|
|
patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_SESSION_TYPE": "wayland",
|
|
"XDG_CURRENT_DESKTOP": "KDE",
|
|
"XDG_SESSION_DESKTOP": "KDE",
|
|
"DESKTOP_SESSION": "plasma",
|
|
"KDE_FULL_SESSION": "true",
|
|
},
|
|
),
|
|
patch.object(injector, "_kde_virtual_keyboard_enabled", return_value=True),
|
|
):
|
|
self.assertTrue(injector._wayland_compositor_bridges_ibus())
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_kde_virtual_keyboard_enabled_parses_gdbus_true(self, mock_run):
|
|
"""gdbus (<<true>>,) means VirtualKeyboard is enabled."""
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="(<<true>>,)\n", stderr="")
|
|
injector = self._bare_injector()
|
|
self.assertTrue(injector._kde_virtual_keyboard_enabled())
|
|
mock_run.assert_called_once()
|
|
args = mock_run.call_args[0][0]
|
|
self.assertEqual(args[0], "gdbus")
|
|
self.assertIn("org.kde.KWin", args)
|
|
self.assertIn("/VirtualKeyboard", args)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_kde_virtual_keyboard_enabled_parses_gdbus_false(self, mock_run):
|
|
"""gdbus (<<false>>,) means VirtualKeyboard is disabled."""
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="(<<false>>,)\n", stderr="")
|
|
injector = self._bare_injector()
|
|
self.assertFalse(injector._kde_virtual_keyboard_enabled())
|
|
|
|
@patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
side_effect=FileNotFoundError("gdbus"),
|
|
)
|
|
def test_kde_virtual_keyboard_enabled_false_when_gdbus_missing(self, _mock_run):
|
|
"""Missing gdbus → treat as unbridged (conservative)."""
|
|
injector = self._bare_injector()
|
|
self.assertFalse(injector._kde_virtual_keyboard_enabled())
|
|
|
|
@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")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_kde_wayland_vk_disabled_picks_ydotool_not_ibus(
|
|
self,
|
|
mock_which,
|
|
mock_run,
|
|
mock_ibus_class,
|
|
mock_ibus_available,
|
|
mock_is_active,
|
|
mock_daemon,
|
|
):
|
|
"""Full path: KDE Wayland + VK disabled → ydotool, not IBus (#574)."""
|
|
mock_which.side_effect = lambda cmd: {
|
|
"ydotool": "/usr/bin/ydotool",
|
|
"ydotoold": "/usr/bin/ydotoold",
|
|
}.get(cmd)
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="(<<false>>,)\n", stderr="")
|
|
|
|
with (
|
|
patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_SESSION_TYPE": "wayland",
|
|
"WAYLAND_DISPLAY": "wayland-0",
|
|
"XDG_CURRENT_DESKTOP": "KDE",
|
|
"XDG_SESSION_DESKTOP": "KDE",
|
|
"DESKTOP_SESSION": "plasma",
|
|
"KDE_FULL_SESSION": "true",
|
|
},
|
|
),
|
|
patch.object(TextInjector, "_is_ydotoold_running", return_value=True),
|
|
):
|
|
injector = TextInjector()
|
|
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
self.assertEqual(injector.wayland_tool, "ydotool")
|
|
mock_ibus_class.assert_not_called()
|
|
|
|
@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")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_kde_wayland_vk_enabled_allows_ibus(
|
|
self,
|
|
mock_which,
|
|
mock_run,
|
|
mock_ibus_class,
|
|
mock_ibus_available,
|
|
mock_is_active,
|
|
mock_daemon,
|
|
):
|
|
"""Full path: KDE Wayland + VK enabled → IBus still selected when ready (#574)."""
|
|
mock_which.side_effect = lambda cmd: {
|
|
"ydotool": "/usr/bin/ydotool",
|
|
"ydotoold": "/usr/bin/ydotoold",
|
|
}.get(cmd)
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="(<<true>>,)\n", stderr="")
|
|
mock_ibus_class.return_value = MagicMock()
|
|
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_SESSION_TYPE": "wayland",
|
|
"WAYLAND_DISPLAY": "wayland-0",
|
|
"XDG_CURRENT_DESKTOP": "KDE",
|
|
"XDG_SESSION_DESKTOP": "KDE",
|
|
"DESKTOP_SESSION": "plasma",
|
|
"KDE_FULL_SESSION": "true",
|
|
},
|
|
):
|
|
with patch.object(TextInjector, "_is_ydotoold_running", return_value=True):
|
|
injector = TextInjector()
|
|
if injector._ibus_init_thread is not None:
|
|
injector._ibus_init_thread.join(timeout=5)
|
|
|
|
mock_ibus_class.assert_called_once()
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.socket.socket")
|
|
@patch("os.path.exists", return_value=True)
|
|
def test_is_ydotoold_running_true_when_socket_connects(self, _mock_exists, mock_socket_cls):
|
|
"""Live ydotoold socket must accept a connect() probe."""
|
|
mock_sock = MagicMock()
|
|
mock_socket_cls.return_value = mock_sock
|
|
injector = self._bare_injector()
|
|
self.assertTrue(injector._is_ydotoold_running())
|
|
mock_sock.connect.assert_called()
|
|
|
|
@patch("os.unlink")
|
|
@patch("vocalinux.text_injection.text_injector.socket.socket")
|
|
@patch("os.path.exists", return_value=True)
|
|
def test_is_ydotoold_running_false_and_unlinks_stale_socket(
|
|
self, _mock_exists, mock_socket_cls, mock_unlink
|
|
):
|
|
"""Stale socket files must not count as a running daemon."""
|
|
mock_sock = MagicMock()
|
|
mock_sock.connect.side_effect = ConnectionRefusedError("stale")
|
|
mock_socket_cls.return_value = mock_sock
|
|
injector = self._bare_injector()
|
|
self.assertFalse(injector._is_ydotoold_running())
|
|
self.assertTrue(mock_unlink.called)
|
|
|
|
@patch("os.unlink")
|
|
@patch("vocalinux.text_injection.text_injector.socket.socket")
|
|
@patch("os.path.exists", return_value=True)
|
|
def test_is_ydotoold_running_prefers_dgram_connect(
|
|
self, _mock_exists, mock_socket_cls, mock_unlink
|
|
):
|
|
"""ydotool 1.x listens on SOCK_DGRAM; probe connects with dgram first."""
|
|
import socket as socket_mod
|
|
|
|
dgram_sock = MagicMock()
|
|
stream_sock = MagicMock()
|
|
err = OSError(91, "Protocol wrong type for socket")
|
|
err.errno = 91
|
|
stream_sock.connect.side_effect = err
|
|
|
|
def socket_factory(family, sock_type, *args, **kwargs):
|
|
if sock_type == socket_mod.SOCK_DGRAM:
|
|
return dgram_sock
|
|
return stream_sock
|
|
|
|
mock_socket_cls.side_effect = socket_factory
|
|
injector = self._bare_injector()
|
|
self.assertTrue(injector._is_ydotoold_running())
|
|
dgram_sock.connect.assert_called()
|
|
stream_sock.connect.assert_not_called()
|
|
mock_unlink.assert_not_called()
|
|
|
|
@patch("os.unlink")
|
|
@patch("vocalinux.text_injection.text_injector.socket.socket")
|
|
@patch("os.path.exists", return_value=True)
|
|
def test_is_ydotoold_running_eprototype_only_does_not_unlink(
|
|
self, _mock_exists, mock_socket_cls, mock_unlink
|
|
):
|
|
"""If only EPROTOTYPE is seen, do not delete a possibly live socket."""
|
|
err = OSError(91, "Protocol wrong type for socket")
|
|
err.errno = 91
|
|
mock_sock = MagicMock()
|
|
mock_sock.connect.side_effect = err
|
|
mock_socket_cls.return_value = mock_sock
|
|
injector = self._bare_injector()
|
|
self.assertFalse(injector._is_ydotoold_running())
|
|
mock_unlink.assert_not_called()
|
|
|
|
@patch("os.path.exists", return_value=False)
|
|
def test_is_ydotoold_running_false_when_absent(self, _mock_exists):
|
|
injector = self._bare_injector()
|
|
self.assertFalse(injector._is_ydotoold_running())
|
|
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_ensure_ydotoold_ready_when_only_ydotool_cli(self, mock_which: MagicMock) -> None:
|
|
"""Host ydotool 0.1.x without ydotoold is still considered ready."""
|
|
mock_which.side_effect = lambda cmd: ("/usr/bin/ydotool" if cmd == "ydotool" else None)
|
|
injector = self._bare_injector()
|
|
with patch.object(injector, "_is_ydotoold_running", return_value=False):
|
|
with patch.object(injector, "_uinput_usable", return_value=True):
|
|
self.assertTrue(injector._ensure_ydotoold())
|
|
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_ensure_ydotoold_false_when_uinput_not_writable(self, mock_which: MagicMock) -> None:
|
|
"""0.1.x ydotool without /dev/uinput write access is not ready (Snap)."""
|
|
mock_which.side_effect = lambda cmd: ("/usr/bin/ydotool" if cmd == "ydotool" else None)
|
|
injector = self._bare_injector()
|
|
with patch.object(injector, "_is_ydotoold_running", return_value=False):
|
|
with patch.object(injector, "_uinput_usable", return_value=False):
|
|
self.assertFalse(injector._ensure_ydotoold())
|
|
|
|
@patch.object(TextInjector, "_uinput_usable", return_value=False)
|
|
@patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
return_value="/app/bin/ydotoold",
|
|
)
|
|
def test_ensure_ydotoold_false_without_uinput(
|
|
self, _mock_which: MagicMock, _mock_uinput: MagicMock
|
|
) -> None:
|
|
injector = self._bare_injector()
|
|
with patch.object(injector, "_is_ydotoold_running", return_value=False):
|
|
self.assertFalse(injector._ensure_ydotoold())
|
|
|
|
@patch.object(TextInjector, "_uinput_usable", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.Popen")
|
|
@patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
return_value="/app/bin/ydotoold",
|
|
)
|
|
def test_ensure_ydotoold_starts_daemon(
|
|
self, _mock_which: MagicMock, mock_popen: MagicMock, _mock_uinput: MagicMock
|
|
) -> None:
|
|
injector = self._bare_injector()
|
|
# First probe: not running; after start: running
|
|
with patch.object(injector, "_is_ydotoold_running", side_effect=[False, False, True]):
|
|
with patch("time.sleep"):
|
|
self.assertTrue(injector._ensure_ydotoold())
|
|
mock_popen.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")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_cosmic_skips_ibus_and_uses_wtype(
|
|
self,
|
|
mock_which,
|
|
mock_run,
|
|
mock_ibus_class,
|
|
mock_ibus_available,
|
|
mock_is_active,
|
|
mock_daemon,
|
|
):
|
|
"""On COSMIC, even with IBus active, injection must use wtype, not IBus."""
|
|
mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
|
|
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
|
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_SESSION_TYPE": "wayland",
|
|
"WAYLAND_DISPLAY": "w-1",
|
|
"XDG_CURRENT_DESKTOP": "COSMIC",
|
|
"XDG_SESSION_DESKTOP": "cosmic",
|
|
"DESKTOP_SESSION": "cosmic",
|
|
},
|
|
):
|
|
# No ibus-wayland relay here, so COSMIC stays unbridged. Stated
|
|
# explicitly because mock_run returns returncode=0 for every
|
|
# subprocess, which the bridge probe would otherwise read as a hit.
|
|
with patch.object(TextInjector, "_ibus_wayland_bridge_running", return_value=False):
|
|
injector = TextInjector()
|
|
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
self.assertEqual(injector.wayland_tool, "wtype")
|
|
mock_ibus_class.assert_not_called()
|
|
|
|
def test_is_ydotoold_running_true_when_socket_env_set(self):
|
|
"""An explicit YDOTOOL_SOCKET that accepts connect() counts as running."""
|
|
injector = self._bare_injector()
|
|
with patch.dict("os.environ", {"YDOTOOL_SOCKET": "/run/user/1000/.ydotool_socket"}):
|
|
with patch("os.path.exists", return_value=True):
|
|
with patch("vocalinux.text_injection.text_injector.socket.socket") as mock_cls:
|
|
mock_cls.return_value = MagicMock()
|
|
self.assertTrue(injector._is_ydotoold_running())
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_ydotool_direct_mode_when_no_daemon_and_no_wtype(self, mock_which, _mock_ibus):
|
|
"""ydotool present without a daemon and no wtype falls back to ydotool direct mode."""
|
|
mock_which.side_effect = lambda cmd: ("/usr/bin/ydotool" if cmd == "ydotool" else None)
|
|
with patch.object(TextInjector, "_is_ydotoold_running", return_value=False):
|
|
with patch.dict(
|
|
"os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}
|
|
):
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.wayland_tool, "ydotool")
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _fake_config(config: Any) -> Iterator[None]:
|
|
"""Pretend config.json holds ``config``; ``None`` means no file at all.
|
|
|
|
Kept off the filesystem on purpose: another suite patches
|
|
``tempfile.mkdtemp`` globally, so a real temp dir makes these tests
|
|
order-dependent.
|
|
"""
|
|
with patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"):
|
|
if config is None:
|
|
with patch("os.path.exists", return_value=False):
|
|
yield
|
|
return
|
|
payload = config if isinstance(config, str) else json.dumps(config)
|
|
with patch("os.path.exists", return_value=True):
|
|
with patch("builtins.open", mock_open(read_data=payload)):
|
|
yield
|
|
|
|
|
|
class TestConfiguredBackend(unittest.TestCase):
|
|
"""text_injection.backend in config.json pins the injection backend (#476)."""
|
|
|
|
def test_recognised_backends(self):
|
|
for value in ("ibus", "wtype", "ydotool", "xdotool"):
|
|
with _fake_config({"text_injection": {"backend": value}}):
|
|
self.assertEqual(TextInjector._configured_backend(), value)
|
|
|
|
def test_value_is_normalised(self):
|
|
with _fake_config({"text_injection": {"backend": " WType "}}):
|
|
self.assertEqual(TextInjector._configured_backend(), "wtype")
|
|
|
|
def test_auto_or_missing_means_auto(self):
|
|
for config in (
|
|
{"text_injection": {"backend": "auto"}},
|
|
{"text_injection": {"backend": ""}},
|
|
{"text_injection": {}},
|
|
{},
|
|
None, # no config.json at all
|
|
):
|
|
with _fake_config(config):
|
|
self.assertEqual(TextInjector._configured_backend(), "auto")
|
|
|
|
def test_unknown_value_falls_back_to_auto(self):
|
|
"""A typo must not silently pin the wrong backend."""
|
|
with _fake_config({"text_injection": {"backend": "wtpye"}}):
|
|
self.assertEqual(TextInjector._configured_backend(), "auto")
|
|
|
|
def test_corrupt_config_does_not_raise(self):
|
|
"""An unreadable config must not stop text injection from starting.
|
|
|
|
Only that it survives; that it also says so is
|
|
test_corrupt_config_warns_that_the_pin_is_ignored.
|
|
"""
|
|
with _fake_config("{not valid json"):
|
|
self.assertEqual(TextInjector._configured_backend(), "auto")
|
|
|
|
def _warnings(self, logs: Any) -> list[str]:
|
|
return [line for line in logs.output if line.startswith("WARNING")]
|
|
|
|
def test_corrupt_config_warns_that_the_pin_is_ignored(self):
|
|
"""A stray comma is the likely way to break a file people hand-edit.
|
|
|
|
Staying quiet leaves the user with autodetection and the silent IBus
|
|
miss they set the pin to avoid, with nothing to tell the two apart.
|
|
"""
|
|
with _fake_config("{not valid json"):
|
|
with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs:
|
|
self.assertEqual(TextInjector._configured_backend(), "auto")
|
|
warned = self._warnings(logs)
|
|
self.assertTrue(warned, f"corrupt config was not reported: {logs.output}")
|
|
self.assertIn("text_injection.backend", warned[0])
|
|
|
|
def test_config_that_is_not_an_object_warns(self):
|
|
"""Valid JSON that is not an object reaches the lookup as a non-mapping.
|
|
|
|
Guarded rather than caught: the shape check is what lets the handler
|
|
stay narrow instead of swallowing every AttributeError raised below it.
|
|
"""
|
|
with _fake_config("[1, 2, 3]"):
|
|
with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs:
|
|
self.assertEqual(TextInjector._configured_backend(), "auto")
|
|
warned = self._warnings(logs)
|
|
self.assertTrue(warned, f"non-object config was not reported: {logs.output}")
|
|
self.assertIn("not a JSON object", warned[0])
|
|
|
|
def test_text_injection_section_that_is_not_an_object_warns(self):
|
|
"""The top level being a mapping is not enough; the section can still not be.
|
|
|
|
Contrast with test_config_that_is_not_an_object_warns: a single
|
|
top-level check passes this input and then fails on the lookup.
|
|
"""
|
|
with _fake_config({"text_injection": [1, 2]}):
|
|
with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs:
|
|
self.assertEqual(TextInjector._configured_backend(), "auto")
|
|
warned = self._warnings(logs)
|
|
self.assertTrue(warned, f"non-object section was not reported: {logs.output}")
|
|
self.assertIn("text_injection section", warned[0])
|
|
|
|
def test_absent_config_is_not_reported(self):
|
|
"""No config.json is the default install, not a problem to warn about."""
|
|
for config in (None, {}, {"text_injection": {}}):
|
|
with self.subTest(config=config):
|
|
# A mocked logger rather than assertLogs: the assertion is that
|
|
# nothing was logged, and assertLogs fails when nothing is.
|
|
with _fake_config(config):
|
|
with patch("vocalinux.text_injection.text_injector.logger") as mock_logger:
|
|
self.assertEqual(TextInjector._configured_backend(), "auto")
|
|
mock_logger.warning.assert_not_called()
|
|
|
|
|
|
class TestBackendPreference(unittest.TestCase):
|
|
"""The environment variable wins over the persisted setting."""
|
|
|
|
def test_environment_overrides_config(self):
|
|
"""A one-off experiment must not require editing the user's config."""
|
|
with _fake_config({"text_injection": {"backend": "ibus"}}):
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "wtype"}):
|
|
self.assertEqual(TextInjector._backend_preference(), "wtype")
|
|
|
|
def test_config_used_when_environment_unset(self):
|
|
with _fake_config({"text_injection": {"backend": "ydotool"}}):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
self.assertEqual(TextInjector._backend_preference(), "ydotool")
|
|
|
|
def test_auto_when_neither_is_set(self):
|
|
with _fake_config(None):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
self.assertEqual(TextInjector._backend_preference(), "auto")
|
|
|
|
def test_explicit_auto_overrides_a_saved_pin(self):
|
|
"""``VOCALINUX_FORCE_BACKEND=auto`` asks for autodetection *this run*.
|
|
|
|
It must not be read as "nothing was set" and fall through to the saved
|
|
pin, or the variable cannot undo a pin for a single run -- which is the
|
|
one-off A/B test it exists for.
|
|
"""
|
|
with _fake_config({"text_injection": {"backend": "wtype"}}):
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "auto"}):
|
|
self.assertEqual(TextInjector._backend_preference(), "auto")
|
|
|
|
def test_unset_environment_uses_the_saved_pin(self):
|
|
"""Regression guard for the case explicit ``auto`` must NOT behave like."""
|
|
with _fake_config({"text_injection": {"backend": "wtype"}}):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
self.assertEqual(TextInjector._backend_preference(), "wtype")
|
|
|
|
def test_typo_in_environment_is_treated_as_unset_not_as_auto(self):
|
|
"""A typo falls through to the saved pin rather than discarding it.
|
|
|
|
Deliberate: an unrecognised value means the user failed to override
|
|
their preference, not that they asked for autodetection. Contrast
|
|
``test_explicit_auto_overrides_a_saved_pin``, where they did ask.
|
|
"""
|
|
with _fake_config({"text_injection": {"backend": "wtype"}}):
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "wtpye"}):
|
|
self.assertEqual(TextInjector._backend_preference(), "wtype")
|
|
|
|
|
|
class TestSelectableBackends(unittest.TestCase):
|
|
"""xdotool is a pinnable value, and both readers accept the same set."""
|
|
|
|
def test_xdotool_is_accepted_from_the_environment(self):
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "xdotool"}):
|
|
self.assertEqual(TextInjector._forced_backend_setting(), "xdotool")
|
|
|
|
def test_xdotool_is_accepted_from_config(self):
|
|
with _fake_config({"text_injection": {"backend": "xdotool"}}):
|
|
self.assertEqual(TextInjector._configured_backend(), "xdotool")
|
|
|
|
def test_both_readers_accept_the_same_values(self):
|
|
"""A value valid in one source must be valid in the other."""
|
|
for value in TextInjector._SELECTABLE_BACKENDS:
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value}):
|
|
self.assertEqual(TextInjector._forced_backend_setting(), value)
|
|
with _fake_config({"text_injection": {"backend": value}}):
|
|
self.assertEqual(TextInjector._configured_backend(), value)
|
|
|
|
def test_help_text_lists_every_selectable_value(self):
|
|
"""Guards the 'expected ...' messages against drifting from the tuple."""
|
|
help_text = TextInjector._accepted_backends_help()
|
|
for value in TextInjector._SELECTABLE_BACKENDS:
|
|
self.assertIn(value, help_text)
|
|
self.assertIn("auto", help_text)
|
|
|
|
|
|
class TestForcedBackendSetting(unittest.TestCase):
|
|
"""The raw three-state reading of VOCALINUX_FORCE_BACKEND.
|
|
|
|
``_forced_backend()`` collapses unset and explicit ``auto`` together, which
|
|
is fine for its own callers but loses the distinction ``_backend_preference()``
|
|
needs to tell "no opinion" from "autodetect this run".
|
|
"""
|
|
|
|
def test_unset_is_none_and_is_distinct_from_explicit_auto(self):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
self.assertIsNone(TextInjector._forced_backend_setting())
|
|
|
|
def test_explicit_auto_is_the_string_not_none(self):
|
|
for value in ("auto", " AUTO "):
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value}):
|
|
self.assertEqual(TextInjector._forced_backend_setting(), "auto")
|
|
|
|
def test_empty_value_counts_as_unset(self):
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": " "}):
|
|
self.assertIsNone(TextInjector._forced_backend_setting())
|
|
|
|
def test_recognised_backends_are_returned(self):
|
|
for value in ("ibus", "wtype", "ydotool", "xdotool"):
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value.upper()}):
|
|
self.assertEqual(TextInjector._forced_backend_setting(), value)
|
|
|
|
def test_unknown_value_is_unset_rather_than_auto(self):
|
|
"""See ``test_typo_in_environment_is_treated_as_unset_not_as_auto``."""
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "ibsu"}):
|
|
self.assertIsNone(TextInjector._forced_backend_setting())
|
|
|
|
|
|
class TestForcedBackend(unittest.TestCase):
|
|
"""VOCALINUX_FORCE_BACKEND overrides backend autodetection."""
|
|
|
|
def test_unset_or_auto_means_auto(self):
|
|
for value in ("", "auto", " AUTO "):
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value}):
|
|
self.assertEqual(TextInjector._forced_backend(), "auto")
|
|
|
|
def test_recognised_backends(self):
|
|
for value in ("ibus", "wtype", "ydotool", "xdotool"):
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value.upper()}):
|
|
self.assertEqual(TextInjector._forced_backend(), value)
|
|
|
|
def test_unknown_value_falls_back_to_auto(self):
|
|
"""A typo must not silently pin the wrong backend."""
|
|
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "ibsu"}):
|
|
self.assertEqual(TextInjector._forced_backend(), "auto")
|
|
|
|
def test_missing_variable_means_auto(self):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
self.assertEqual(TextInjector._forced_backend(), "auto")
|
|
|
|
|
|
class TestKdeSkipsInactiveIbus(unittest.TestCase):
|
|
"""KDE without IBus as the session IM must not take the scoped IBus path (#752)."""
|
|
|
|
@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=False,
|
|
)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_kde_wayland_does_not_construct_ibus_injector(
|
|
self,
|
|
mock_which,
|
|
mock_ibus_class,
|
|
*_args,
|
|
):
|
|
mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_SESSION_TYPE": "wayland",
|
|
"WAYLAND_DISPLAY": "wayland-0",
|
|
"XDG_CURRENT_DESKTOP": "KDE",
|
|
"KDE_FULL_SESSION": "true",
|
|
},
|
|
clear=True,
|
|
):
|
|
injector = TextInjector()
|
|
mock_ibus_class.assert_not_called()
|
|
self.assertIsNone(injector._ibus_injector)
|