Files
voculinux/tests/test_injection_ladder.py
T
soubarnak e100af7bcd fix(injection): align fallback ladder to portal>wtype>ydotool>xdotool>manual
Ticket #31 review found startup and demote paths preferring ydotool
over wtype, with a KDE ydotool-first exception, contradicting the
decided order in spec #16 (portal first where a backend implements
it, then direct keysym typing, then the virtual-input tool, then
XWayland-only injection, then manual paste).

- Auto-selection is now portal, wtype, ready ydotool, daemonless
  ydotool, xdotool on every desktop including KDE; the __init__ wtype
  probe validates wtype and demotes on compositor rejection.
- Portal-deny demotion, IBus-fallback switch, XWayland recovery, and
  a new wtype runtime-failure retry all follow the same rung order
  with partial-type accounting preserved.
- Ladder suite grows to 14 tests pinning KDE portal selection,
  wtype-before-ydotool selection, and each demotion trigger.
- USER_GUIDE states the full rung order and portal-first on KDE.
2026-11-06 09:21:13 +05:30

439 lines
17 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
_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 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()