Files
voculinux/tests/conftest.py
T
f36c3f6ae5 feat(text_injection): add RemoteDesktop portal injection backend (#885)
* feat(text_injection): add RemoteDesktop portal injection backend

Wayland offers exactly one sanctioned key-injection path:
org.freedesktop.portal.RemoteDesktop's NotifyKeyboardKeysym. It reaches
native clients on compositors with no input-method-v2 bridge, works from
inside the Flatpak sandbox without /dev/uinput or a helper daemon, and
delivers keysyms so non-US layouts survive -- unlike ydotool's evdev
keycodes.

RemoteDesktopPortal is a synchronous client over a dedicated worker
thread holding a private bus connection and GLib context, which keeps
Request::Response delivery deterministic. Sessions are created lazily on
first use so startup probing only reads the interface version; v2 portal
sessions persist a restore token (mode 0600 under data_dir) so the
permission prompt appears once.

Backend selection now prefers the portal on Wayland: it is probed at
runtime ahead of ydotool/wtype/xdotool in autodetection, is pinnable via
VOCALINUX_FORCE_BACKEND=portal or text_injection.backend, and demotes to
the previous order on runtime failure. Flatpak still needs --device=all
for evdev hotkeys, but uinput is only a fallback path now.

Closes #750

* fix(text_injection): resolve Greptile findings on RemoteDesktop portal injection

- Move persist_mode/restore_token to SelectDevices options (portal v2
  sends them there, not CreateSession); a bad token is ignored rather
  than sinking the session, so drop the stale-token retry paths that
  could issue a second Start after a user cancel
- Track delivered units on RemoteDesktopPortalError so Wayland
  fallbacks retry only the undelivered tail instead of retyping the
  prefix (text chars, backspace taps, shortcut steps)
- Give queued portal jobs a single deadline budget across
  CreateSession/SelectDevices/Start waits; on submit timeout the job
  is cancelled via a Gio.Cancellable plus abort checkpoints so the
  worker cannot keep emitting keys after the caller gave up
- Close sessions whose Start never succeeded
- Release held shortcut modifiers on mid-step failure
- Skip the portal probe when an installed wtype/ydotool backend is
  explicitly pinned
- Type annotations on the new signatures and touched tests

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 23:55:27 -07:00

282 lines
10 KiB
Python

"""
Configuration file for pytest.
This file makes sure that the 'src' module can be imported in tests.
"""
import atexit
import os
import shutil
import subprocess
import sys
import tempfile
import threading
from unittest.mock import MagicMock, patch
import pytest
# Set PYTEST_RUNNING early so audio_feedback module can detect it
os.environ["PYTEST_RUNNING"] = "1"
# Isolate the whole test process from the developer's real desktop session
# and Vocalinux state, mirroring a fresh CI runner:
# * XDG base dirs point at a throwaway directory, so locally downloaded
# models (~/.local/share/vocalinux/models) and the real config file
# never leak into tests. This must happen at conftest import time:
# vocalinux modules freeze paths like MODELS_DIR at first import during
# collection, before any fixture could run. The directory is shared for
# the whole session on purpose — some tests rely on directories that
# earlier tests created, exactly as they do on CI with the runner's HOME.
# * Desktop session variables are cleared: a developer's GNOME/Wayland
# session flips IBus/keyboard code paths (issue #478) that CI never sees.
# Tests that need specific values set them via patch.dict/monkeypatch, which
# layer on top of this.
_XDG_ISOLATION_DIR = tempfile.mkdtemp(prefix="vocalinux-pytest-xdg-")
os.environ["XDG_DATA_HOME"] = os.path.join(_XDG_ISOLATION_DIR, "data")
os.environ["XDG_CONFIG_HOME"] = os.path.join(_XDG_ISOLATION_DIR, "config")
os.environ["XDG_CACHE_HOME"] = os.path.join(_XDG_ISOLATION_DIR, "cache")
for _var in (
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_TYPE",
"XDG_SESSION_DESKTOP",
"DESKTOP_SESSION",
"WAYLAND_DISPLAY",
):
os.environ.pop(_var, None)
atexit.register(shutil.rmtree, _XDG_ISOLATION_DIR, ignore_errors=True)
# Several legacy tests replace sys.modules["numpy"] with MagicMock at module
# import time. Preserve the real module once so newer tests can restore it
# without unloading/re-importing NumPy's compiled extensions.
try:
import numpy as _real_numpy
except ImportError:
_real_numpy = None
else:
sys._vocalinux_real_numpy = _real_numpy
# And the same for zipfile: test_recognition_manager.py and
# test_recognition_manager_download_buffer.py assign a MagicMock to
# sys.modules["zipfile"] at import time and never put it back, so any later test
# that extracts a real archive gets a silent no-op. Keep the genuine module
# reachable for the ones that need it.
import zipfile as _real_zipfile # noqa: E402
sys._vocalinux_real_zipfile = _real_zipfile
# Prevent specific known-blocking daemon threads from starting during tests.
# Source code in ibus_engine.py and evdev_backend.py spawns daemon threads
# (socket server, device monitor) that block on socket.accept() or
# select.select(). These threads cannot be interrupted by pytest-timeout
# and cause CI jobs to hang/timeout.
#
# We only block threads whose target function matches known blockers;
# test-created daemon threads (e.g. for mock socket servers) are allowed.
_real_thread_start = threading.Thread.start
_BLOCKED_THREAD_TARGETS = {"server_thread", "_monitor_devices"}
_real_thread_join = threading.Thread.join
def _safe_thread_start(self):
"""Skip known-blocking daemon threads to prevent CI hangs."""
if self.daemon and hasattr(self, "_target") and self._target is not None:
target_name = getattr(self._target, "__name__", "")
if target_name in _BLOCKED_THREAD_TARGETS:
# Mark this thread so join() can recognise it was never started.
self._skipped_by_conftest = True
return
_real_thread_start(self)
def _safe_thread_join(self, timeout=None):
"""Handle join() for threads that were skipped by _safe_thread_start."""
if getattr(self, "_skipped_by_conftest", False):
return # Thread was never started; nothing to join
_real_thread_join(self, timeout)
threading.Thread.start = _safe_thread_start
threading.Thread.join = _safe_thread_join
# Add the parent directory to sys.path so that 'src' can be imported
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
# Always mock GI/GTK modules before any test files are collected.
# On CI, real gi (PyGObject) is installed via "pip install PyGObject" and
# module-level imports in vocalinux source files (ibus_engine, tray_indicator,
# settings_dialog, etc.) will trigger GTK initialization or IBus daemon
# connections that hang indefinitely in headless environments.
# pytest-timeout cannot interrupt these C-level blocking calls.
#
# We unconditionally replace gi with a mock — even if the real package is
# already imported — because the real gi causes hangs on headless CI runners.
# The previous "if 'gi' not in sys.modules" guard was ineffective on CI
# precisely because PyGObject was installed and already loaded.
_mock_gi = MagicMock()
_mock_gi_repository = MagicMock()
_mock_gi_repository.Notify = MagicMock()
sys.modules["gi"] = _mock_gi
sys.modules["gi.repository"] = _mock_gi_repository
# Create and export the mock_audio_feedback module for tests that need it
# This mock is used by test_recognition_manager.py and test_speech_recognition.py
mock_audio_feedback = MagicMock()
mock_audio_feedback.play_start_sound = MagicMock()
mock_audio_feedback.play_stop_sound = MagicMock()
mock_audio_feedback.play_error_sound = MagicMock()
# Inject the mock into sys.modules so imports resolve correctly
sys.modules["vocalinux.ui.audio_feedback"] = mock_audio_feedback
@pytest.fixture(autouse=True)
def _suppress_desktop_notifications(request):
"""Patch _show_notification for all tests except those testing it directly."""
if "show_notification" in request.node.nodeid:
yield
return
with patch("vocalinux.speech_recognition.recognition_manager._show_notification"):
yield
@pytest.fixture(autouse=True)
def _no_remote_desktop_portal(monkeypatch):
"""Keep the RemoteDesktop portal probe off in tests.
``gi`` is a MagicMock here, so the module's import-time availability flag
is True and an unpatched probe would spawn its worker thread against a
mocked D-Bus. Tests that exercise the portal re-patch this themselves.
"""
monkeypatch.setattr("vocalinux.text_injection.remote_desktop_portal.PORTAL_AVAILABLE", False)
@pytest.fixture(autouse=True)
def _reset_shared_config_manager():
"""Drop the process-wide ConfigManager between tests.
In production one shared instance is the point (separate instances
overwrite each other's saves with stale caches). In tests it would be
created under whichever mocks the first caller had active and then leak
that mock into every later test, so each test starts without one.
"""
try:
from vocalinux.ui import config_manager as _cm
_cm._shared_instance = None
except Exception:
pass
yield
try:
from vocalinux.ui import config_manager as _cm
_cm._shared_instance = None
except Exception:
pass
@pytest.fixture(autouse=True)
def _clear_hardware_detection_cache():
"""Clear lru_cache on hardware-detection helpers between tests.
Hardware detection results are cached for the lifetime of the process
in production, but tests mock subprocess.run and call these helpers
multiple times with different mock return values; without clearing
the cache, later assertions would see the first call's cached result.
"""
try:
from vocalinux.utils import whispercpp_model_info as _wmi
for fn_name in (
"detect_vulkan_support",
"detect_cuda_support",
"detect_compute_backend",
"detect_cpu_info",
):
fn = getattr(_wmi, fn_name, None)
if fn is not None and hasattr(fn, "cache_clear"):
fn.cache_clear()
except Exception:
pass
yield
@pytest.fixture(autouse=True)
def _cleanup_ibus_server():
"""Stop any leftover IBus socket server threads after each test.
Some tests trigger VocalinuxEngine._start_socket_server() which spawns
a daemon thread blocking on socket.accept(). If the test doesn't call
stop_socket_server(), the thread keeps running and eventually causes
pytest-timeout to kill the whole process.
"""
yield
try:
from vocalinux.text_injection.ibus_engine import VocalinuxEngine
if getattr(VocalinuxEngine, "_server_running", False):
VocalinuxEngine._server_running = False
sock = getattr(VocalinuxEngine, "_server_socket", None)
if sock is not None:
try:
sock.close()
except Exception:
pass
VocalinuxEngine._server_socket = None
except Exception:
pass
def pytest_addoption(parser):
"""Add custom command line options for pytest."""
parser.addoption(
"--run-tray-tests",
action="store_true",
default=False,
help="Run tray indicator tests (may hang in headless environments)",
)
parser.addoption(
"--run-audio-tests",
action="store_true",
default=False,
help="Run audio feedback tests (may fail in CI environments without audio)",
)
def pytest_configure(config):
"""Configure pytest markers."""
config.addinivalue_line("markers", "tray: marks tests as tray indicator tests")
config.addinivalue_line("markers", "audio: marks tests as audio feedback tests")
@pytest.fixture
def mock_gi():
"""Fixture to mock GTK/GI modules for tests that need it."""
mock_gtk = MagicMock()
mock_glib = MagicMock()
mock_gobject = MagicMock()
mock_gdkpixbuf = MagicMock()
mock_appindicator = MagicMock()
# Make idle_add execute the function directly
mock_glib.idle_add.side_effect = lambda func, *args: func(*args) or False
return {
"Gtk": mock_gtk,
"GLib": mock_glib,
"GObject": mock_gobject,
"GdkPixbuf": mock_gdkpixbuf,
"AppIndicator3": mock_appindicator,
}
@pytest.fixture
def mock_audio_player():
"""Fixture to mock audio player detection."""
return MagicMock()
# This will help pytest discover all test files correctly
pytest_plugins = []