Files
voculinux/tests/test_injection_ladder.py
xavierk 180cd4d512
CI / security (push) Successful in 1m14s
CI / lint (push) Successful in 24s
CI / ai-review (push) Skipped
fix(spec16): close the review findings for the Void port (#34)
Model recommendation keyed to the installed build and free memory, fork-sourced Void templates, signed xbps release workflow on Gitea with the pinned repository key, niri injection guard and wtype-first order, tightened naming sweep, web copy fixes, and CI security and duplication fixes.
2026-11-06 14:13:49 +00:00

542 lines
22 KiB
Python

"""Ladder-level behaviour tests for the Wayland text-injection fallback.
Ticket #31: the decided injection behaviour becomes pinned, observable fact.
These tests lock the fallback order per desktop class without real
compositors — portal first only where a backend implements it, then direct
typing, then the virtual-input tool, then XWayland-only injection, then
manual paste — including IBus exclusion on niri, terminal paste-shortcut
selection, and waiting for held modifiers to release.
Seams under test (public boundaries, no internals):
1. Backend selection — constructing ``TextInjector()`` with mocked
``shutil.which``/``subprocess``/portal/IBus probes and a per-test desktop
environment. Catches: niri gaining an IBus or portal dependency, a
desktop class losing its portal-first rung.
2. Runtime demotion — ``_inject_with_wayland_tool`` with a failing portal.
Catches: a portal denial losing the dictation instead of demoting.
3. Paste delivery — ``_inject_via_clipboard_paste`` with the real terminal
probe and ``_inject_with_xdotool`` with held modifiers. Catches: a
terminal losing its Shift chord, a held push-to-talk modifier corrupting
the paste.
The decided rung order pinned here (spec #16, ticket #31) is: the
RemoteDesktop portal first where a backend implements it, then direct
keysym typing (wtype), then the virtual-input tool (ydotool, which needs
a ready ydotoold), then XWayland-only injection (xdotool), then manual
paste. KDE follows the same order: a working portal leads there too, and
a denial demotes to direct typing for the run.
"""
import subprocess
import threading
from typing import Any, Dict, Optional
from unittest.mock import MagicMock, patch
import pytest
from vocalinux.text_injection.remote_desktop_portal import RemoteDesktopPortalError
from vocalinux.text_injection.text_injector import (
DesktopEnvironment,
TextInjector,
_is_niri_session,
)
_TI = "vocalinux.text_injection.text_injector"
def _stub_portal(available: bool) -> MagicMock:
"""A RemoteDesktopPortal stand-in whose probe reports ``available``."""
instance = MagicMock()
instance.probe.return_value = available
cls = MagicMock()
cls.supported.return_value = True
cls.return_value = instance
return cls
def _selecting_injector(
monkeypatch: pytest.MonkeyPatch,
*,
desktop: str,
tools: Dict[str, Optional[str]],
portal_available: bool,
ibus_available: bool = False,
ibus_bridge_running: bool = False,
ydotool_ready: bool = True,
) -> TextInjector:
"""Construct a TextInjector on a mocked Wayland session for ``desktop``.
No compositor is contacted: tool presence, the portal probe, the IBus
daemon state and the uinput readiness are all stubbed.
"""
monkeypatch.setenv("XDG_SESSION_TYPE", "wayland")
monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0")
monkeypatch.setenv("XDG_CURRENT_DESKTOP", desktop)
monkeypatch.setenv("XDG_SESSION_DESKTOP", desktop)
monkeypatch.setenv("DESKTOP_SESSION", desktop)
monkeypatch.setenv("KDE_FULL_SESSION", "")
for var in (
"FLATPAK_ID",
"SNAP",
"VOCULINUX_FORCE_BACKEND",
"GTK_IM_MODULE",
"QT_IM_MODULE",
"XMODIFIERS",
):
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr("shutil.which", lambda cmd: tools.get(cmd))
monkeypatch.setattr(f"{_TI}.is_ibus_available", lambda: ibus_available)
monkeypatch.setattr(f"{_TI}.is_ibus_active_input_method", lambda: False)
monkeypatch.setattr(f"{_TI}.is_ibus_daemon_running", lambda: True)
monkeypatch.setattr(
TextInjector,
"_ibus_wayland_bridge_running",
staticmethod(lambda: ibus_bridge_running),
)
monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: ydotool_ready)
monkeypatch.setattr(f"{_TI}.RemoteDesktopPortal", _stub_portal(portal_available))
run = MagicMock()
run.return_value.returncode = 0
run.return_value.stderr = ""
monkeypatch.setattr("subprocess.run", run)
monkeypatch.setattr("time.sleep", lambda _seconds: None)
return TextInjector()
def _bare_wayland_injector(**attrs: Any) -> Any:
"""A TextInjector without __init__, carrying only what delivery needs."""
injector = TextInjector.__new__(TextInjector)
injector._state_lock = threading.Lock()
injector._abort_injections = threading.Event()
injector._ibus_injector = None
injector._ibus_ready = False
injector._ibus_init_failed = False
injector._ibus_init_thread = None
injector._portal = None
injector._clipboard_tool_health = {}
injector._clipboard_timeout = 0.35
injector._clipboard_restore_generation = 0
injector._clipboard_restore_target = None
injector._log_current_window_info = MagicMock()
injector._wait_for_modifiers_released = MagicMock()
for key, value in attrs.items():
setattr(injector, key, value)
return injector
class TestNiriLadder:
"""On niri there is no portal backend and IBus cannot reach apps."""
def test_niri_skips_ibus_even_with_daemon_running(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An IBus daemon on niri must not capture dictation (#31).
IBus reports the commit as delivered while native niri clients
receive nothing, so the injector must not construct its engine even
when IBus looks fully available.
"""
with patch(f"{_TI}.IBusTextInjector") as mock_ibus_class:
injector = _selecting_injector(
monkeypatch,
desktop="niri",
tools={"wtype": "/usr/bin/wtype"},
portal_available=False,
ibus_available=True,
)
mock_ibus_class.assert_not_called()
assert injector._ibus_injector is None
assert injector.environment == DesktopEnvironment.WAYLAND
assert injector.wayland_tool == "wtype"
def test_niri_never_selects_portal_without_backend(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No RemoteDesktop backend on niri means no portal and no prompt."""
injector = _selecting_injector(
monkeypatch,
desktop="niri",
tools={"wtype": "/usr/bin/wtype"},
portal_available=False,
ibus_available=True,
)
assert injector.wayland_tool == "wtype"
assert injector._portal is None
def test_niri_first_rung_is_direct_typing_without_clipboard(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The effective first rung on niri types directly, clipboard-free."""
injector = _bare_wayland_injector(
environment=DesktopEnvironment.WAYLAND, wayland_tool="wtype"
)
runs = []
def fake_run(cmd: Any, **kwargs: Any) -> MagicMock:
runs.append(cmd)
return MagicMock(returncode=0, stderr="")
monkeypatch.setattr("subprocess.run", fake_run)
with (
patch.object(injector, "_copy_to_clipboard", side_effect=AssertionError("no copy")),
patch.object(injector, "_read_clipboard", side_effect=AssertionError("no read")),
patch.object(
injector,
"_inject_via_clipboard_paste",
side_effect=AssertionError("no paste"),
),
):
injector._inject_with_wayland_tool("hi")
assert runs == [["wtype", "--", "hi"]]
class TestNiriGuard:
"""Autodetection on niri never selects the portal or IBus (spec #16, c4).
The guard is explicit, so it does not depend on the portal probe failing
or on the ibus-wayland bridge being absent. Explicit user pins stay
honoured as escape hatches and are covered elsewhere.
"""
def test_niri_never_selects_portal_even_when_a_backend_answers(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A RemoteDesktop backend that answers on niri must still be skipped."""
injector = _selecting_injector(
monkeypatch,
desktop="niri",
tools={"wtype": "/usr/bin/wtype"},
portal_available=True,
)
assert injector.wayland_tool == "wtype"
assert injector._portal is None
def test_niri_never_selects_ibus_even_with_ibus_wayland_bridge_running(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The ibus-wayland bridge exception does not reopen IBus on niri."""
with patch(f"{_TI}.IBusTextInjector") as mock_ibus_class:
injector = _selecting_injector(
monkeypatch,
desktop="niri",
tools={"wtype": "/usr/bin/wtype"},
portal_available=False,
ibus_available=True,
ibus_bridge_running=True,
)
mock_ibus_class.assert_not_called()
assert injector._ibus_injector is None
assert injector.wayland_tool == "wtype"
def test_bridge_check_is_false_on_niri_even_with_bridge_running(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The compositor-bridge predicate itself answers no on niri."""
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "niri")
monkeypatch.setattr(
TextInjector, "_ibus_wayland_bridge_running", staticmethod(lambda: True)
)
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._wayland_compositor_bridges_ibus() is False
def test_niri_is_recognised_from_its_session_socket(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""NIRI_SOCKET identifies niri even when the desktop variable is unset."""
for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("NIRI_SOCKET", "/run/user/1000/niri.wayland-1.1.sock")
assert _is_niri_session() is True
def test_other_desktops_are_not_niri(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A GNOME session keeps the portal and IBus eligible."""
monkeypatch.delenv("NIRI_SOCKET", raising=False)
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "GNOME")
assert _is_niri_session() is False
class TestKeyEventToolOrder:
"""Key events after startup follow the same rung order as autodetection.
Direct typing (wtype) outranks the virtual-input tool (ydotool) here too,
so BackSpace and IBus shortcut routing never silently switch tools.
"""
@staticmethod
def _tools(monkeypatch: pytest.MonkeyPatch, names: tuple) -> None:
monkeypatch.setattr("shutil.which", lambda cmd: f"/usr/bin/{cmd}" if cmd in names else None)
def test_wtype_outranks_a_ready_ydotool(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._tools(monkeypatch, ("wtype", "ydotool"))
ensure = MagicMock(return_value=True)
monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: ensure())
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._resolve_wayland_key_tool() == "wtype"
assert injector.wayland_tool == "wtype"
ensure.assert_not_called()
def test_ydotool_is_used_only_when_wtype_is_absent(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
self._tools(monkeypatch, ("ydotool",))
monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: True)
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._resolve_wayland_key_tool() == "ydotool"
def test_no_key_tool_when_neither_is_installed(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._tools(monkeypatch, ())
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._resolve_wayland_key_tool() is None
class TestPortalFirstWhereImplemented:
"""GNOME/KDE implement RemoteDesktop, so the portal leads there."""
def test_gnome_selects_portal_where_backend_exists(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
injector = _selecting_injector(
monkeypatch,
desktop="GNOME",
tools={"wtype": "/usr/bin/wtype", "ydotool": "/usr/bin/ydotool"},
portal_available=True,
)
assert injector.wayland_tool == "portal"
assert injector.environment == DesktopEnvironment.WAYLAND
def test_kde_selects_portal_where_backend_exists(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""KDE follows the same order: a working portal leads (#31).
A present ydotool must not outrank an available portal, even with
its daemon ready; the portal denial path below is what demotes.
"""
injector = _selecting_injector(
monkeypatch,
desktop="KDE",
tools={"wtype": "/usr/bin/wtype", "ydotool": "/usr/bin/ydotool"},
portal_available=True,
ydotool_ready=True,
)
assert injector.wayland_tool == "portal"
assert injector.environment == DesktopEnvironment.WAYLAND
def test_portal_deny_falls_back_without_losing_dictation(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Saying no to the portal costs a fallback, not the dictation.
The denied session is not retried within the run; the pending text
goes through the next rung whole.
"""
injector = _bare_wayland_injector(
environment=DesktopEnvironment.WAYLAND, wayland_tool="portal"
)
portal = MagicMock()
portal.inject_text.side_effect = RemoteDesktopPortalError("denied by user", delivered=0)
injector._portal = portal
monkeypatch.setattr(
"shutil.which", lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None
)
runs = []
def fake_run(cmd: Any, **kwargs: Any) -> MagicMock:
runs.append(cmd)
return MagicMock(returncode=0, stderr="")
monkeypatch.setattr("subprocess.run", fake_run)
injector._inject_with_wayland_tool("hello")
portal.inject_text.assert_called_once_with("hello")
assert injector.wayland_tool == "wtype"
assert runs == [["wtype", "--", "hello"]]
class TestDirectTypingBeforeVirtualInput:
"""Without a portal, direct keysym typing outranks the virtual-input tool."""
def test_no_portal_prefers_wtype_when_both_installed(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The decided second rung is wtype even with ydotool ready (#31)."""
injector = _selecting_injector(
monkeypatch,
desktop="GNOME",
tools={"wtype": "/usr/bin/wtype", "ydotool": "/usr/bin/ydotool"},
portal_available=False,
ydotool_ready=True,
)
assert injector.wayland_tool == "wtype"
assert injector.environment == DesktopEnvironment.WAYLAND
def test_no_portal_no_wtype_uses_ready_ydotool(self, monkeypatch: pytest.MonkeyPatch) -> None:
injector = _selecting_injector(
monkeypatch,
desktop="GNOME",
tools={"ydotool": "/usr/bin/ydotool"},
portal_available=False,
ydotool_ready=True,
)
assert injector.wayland_tool == "ydotool"
def test_no_portal_daemonless_ydotool_without_wtype_still_types(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A daemonless ydotool is picked with a warning when wtype is absent."""
injector = _selecting_injector(
monkeypatch,
desktop="GNOME",
tools={"ydotool": "/usr/bin/ydotool"},
portal_available=False,
ydotool_ready=False,
)
assert injector.wayland_tool == "ydotool"
def test_portal_deny_demotes_to_wtype_when_both_installed(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A denied portal demotes to direct typing, not the virtual tool."""
injector = _bare_wayland_injector(
environment=DesktopEnvironment.WAYLAND, wayland_tool="portal"
)
portal = MagicMock()
portal.inject_text.side_effect = RemoteDesktopPortalError("denied by user", delivered=0)
injector._portal = portal
monkeypatch.setattr(
"shutil.which",
lambda cmd: f"/usr/bin/{cmd}" if cmd in ("wtype", "ydotool") else None,
)
monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: True)
runs = []
def fake_run(cmd: Any, **kwargs: Any) -> MagicMock:
runs.append(cmd)
return MagicMock(returncode=0, stderr="")
monkeypatch.setattr("subprocess.run", fake_run)
injector._inject_with_wayland_tool("hello")
portal.inject_text.assert_called_once_with("hello")
assert injector.wayland_tool == "wtype"
assert runs == [["wtype", "--", "hello"]]
def test_wtype_runtime_failure_demotes_to_ydotool(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A wtype failure at injection time falls to ydotool whole (#31)."""
injector = _bare_wayland_injector(
environment=DesktopEnvironment.WAYLAND, wayland_tool="wtype"
)
injector._session_environment = DesktopEnvironment.WAYLAND
attempts = []
pastes = []
def fake_run(cmd: Any, **kwargs: Any) -> MagicMock:
attempts.append(cmd)
if cmd[0] == "wtype":
raise subprocess.CalledProcessError(1, cmd, stderr="boom")
return MagicMock(returncode=0, stderr="")
monkeypatch.setattr("subprocess.run", fake_run)
monkeypatch.setattr(
"shutil.which",
lambda cmd: f"/usr/bin/{cmd}" if cmd in ("wtype", "ydotool") else None,
)
monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: True)
with (
patch.object(
injector,
"_inject_via_clipboard_paste",
side_effect=lambda text, **kwargs: pastes.append(text) or True,
),
patch.object(injector, "_should_copy_to_clipboard", return_value=False),
):
assert injector.inject_text("hi") is True
assert injector.wayland_tool == "ydotool"
assert [cmd for cmd in attempts if cmd[0] == "wtype"] == [["wtype", "--", "hi"]]
assert pastes == ["hi"]
class TestTerminalAndModifierGating:
"""Terminals get the terminal chord; held modifiers wait first."""
def test_terminal_probe_selects_terminal_chord_end_to_end(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Auto-detect on a focused terminal must send Ctrl+Shift+V (#31)."""
injector = _bare_wayland_injector(
environment=DesktopEnvironment.WAYLAND, wayland_tool="wtype"
)
monkeypatch.setattr("threading.Thread", MagicMock())
paste_cmds = []
real_run = MagicMock(returncode=0, stdout="", stderr="")
def fake_run(cmd: Any, **kwargs: Any) -> MagicMock:
paste_cmds.append(cmd)
return real_run
monkeypatch.setattr("subprocess.run", fake_run)
with (
patch.object(injector, "_paste_shortcut_preference", return_value="auto"),
patch(
f"{_TI}.is_focused_window_terminal",
return_value=True,
),
patch.object(injector, "_copy_to_clipboard", return_value=True),
patch.object(injector, "_read_clipboard", return_value="old"),
):
assert injector._inject_via_clipboard_paste("ls") is True
assert ["wtype", "-M", "ctrl", "-M", "shift", "v"] in paste_cmds
def test_held_modifier_delays_xwayland_paste_until_release(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A held push-to-talk modifier must not rewrite the paste chord."""
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND_XDOTOOL)
injector._wait_for_modifiers_released = TextInjector._wait_for_modifiers_released.__get__(
injector, TextInjector
)
states = [{56}, {56}, set()]
polls = []
pastes = []
def fake_held() -> set:
held = states[min(len(polls), len(states) - 1)]
polls.append(set(held))
return set(held)
monkeypatch.setattr(injector, "_held_modifier_keycodes", fake_held)
monkeypatch.setattr("time.sleep", lambda _seconds: None)
with (
patch.object(injector, "_clipboard_holds_non_text", return_value=False),
patch.object(
injector,
"_inject_via_clipboard_paste",
side_effect=lambda text, **kwargs: pastes.append(text) or True,
),
):
injector._inject_with_xdotool("ls")
assert len(polls) == 3
assert pastes == ["ls"]
def test_all_tools_down_leaves_dictation_on_clipboard(self) -> None:
"""Manual paste is the last resort: text is kept, never dropped."""
injector = _bare_wayland_injector(environment=DesktopEnvironment.X11)
injector._session_environment = DesktopEnvironment.X11
with (
patch.object(injector, "_paste_long_text_enabled", return_value=False),
patch.object(injector, "_clipboard_holds_non_text", return_value=False),
patch.object(injector, "_should_copy_to_clipboard", return_value=False),
patch(
"vocalinux.text_injection.text_injector.subprocess.run",
side_effect=subprocess.CalledProcessError(1, "xdotool"),
),
patch(
"vocalinux.text_injection.text_injector.time.sleep",
return_value=None,
),
patch.object(injector, "_copy_to_clipboard", return_value=True) as mock_copy,
patch.object(injector, "_show_clipboard_fallback_notification") as mock_note,
):
assert injector.inject_text("hello") is False
mock_copy.assert_called_once_with("hello")
mock_note.assert_called_once_with()