* fix(text-injection): route XWayland xdotool fallback through clipboard paste 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 (#657). The native ydotool path already avoids this by pasting via the clipboard instead of typing (_inject_with_wayland_tool); the XWayland xdotool fallback (_inject_with_xdotool) never got the same treatment. Prefer clipboard paste on that fallback too when ydotool is installed, falling back to raw xdotool type only when no paste tool is available or the paste itself fails. Fixes #657 * fix(text-injection): X11 clipboard paste for the XWayland fallback jatinkrmalik's review on #680 pointed out the AppImage build does not bundle ydotool, so the prior fix's ydotool clipboard paste never ran on the actual reported setup; it fell through to the same layout-dependent xdotool type as before. It also flagged a real paste-target mismatch: an XWayland window reads the X11 CLIPBOARD selection, not the Wayland clipboard wl-copy/ydotool write to. The XWayland fallback now copies via xclip/xsel and pastes with xdotool key --clearmodifiers ctrl+v when either is installed, waiting for held modifiers to release first (the same wait already used by the native ydotool path). ydotool clipboard paste stays as a second fallback when xclip/xsel are absent; xdotool type is the last resort. New regression test drives inject_text() end-to-end with no ydotool on PATH, reproducing the reported AppImage/GNOME log rather than calling the fallback method directly, so it also covers the recovery check that could otherwise skip this path. * fix(text-injection): thread x11_only through clipboard read/clear so restore matches the paste backend jatinkrmalik's rebase pulled in the layout-aware paste work from #788, and greptile's review on the merged branch caught a real gap in this PR's own XWayland path: _copy_to_clipboard already takes x11_only to write the X11 CLIPBOARD via xclip/xsel (since that is what an xdotool paste actually reads), but _read_clipboard and _clear_clipboard never gained the same parameter. On a Wayland host, _read_clipboard prefers wl-paste, so the "previous clipboard" saved before an XWayland paste could be read from an unrelated Wayland clipboard, and restoring it back through _copy_to_clipboard with no x11_only wrote to that same wrong backend, leaving the X11 CLIPBOARD stuck on the dictated text. Threads x11_only through both helpers and every restore call site (the immediate-failure path and the delayed _restore closure), so an XWayland paste captures, clears, and restores the same X11 CLIPBOARD selection it wrote to. * fix(text-injection): annotate clipboard helpers and XWayland paste tests Greptile P2 on #680: give `_get_clipboard_tools` a return annotation and mark the new XWayland clipboard-paste regression tests `-> None`. * fix(text-injection): derive XWayland paste from environment Drop the ydotool/wl-copy second fallback on WAYLAND_XDOTOOL (wrong clipboard, and rc=0 is not a paste) and the x11_only flag stack. Paste picks xclip/xsel + xdotool from environment; user-facing copy stays on the session clipboard. * fix(text-injection): always pass host_env() to the paste chord The AST guard only recognizes a direct host_env() call. The ternary around it looked like we were handing the AppImage library path to xdotool. * test(text-injection): give XWayland injector stubs clipboard restore state _inject_with_xdotool now pastes first, and that path reads _clipboard_restore_target. The __new__ helper in test_text_injector_ext never set it. * fix(text-injection): skip clipboard restore on a stale paste failure If a newer paste has already copied its text, the older paste's failure handler must not write its saved clipboard back. Success restores already honored the generation counter; the failure path did not. * fix(text-injection): keep original clipboard across a stale paste failure Publish the restore target as soon as copy succeeds (first writer wins) so an overlapping paste inherits it. If the older paste then fails, the newer restore still writes the pre-first clipboard, not intermediate dictated text. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
894 lines
41 KiB
Python
894 lines
41 KiB
Python
"""Tests for _read_clipboard() and clipboard restore in _inject_via_clipboard_paste.
|
|
|
|
Covers the behaviour introduced in PR #588: before injecting text via the
|
|
ydotool clipboard-paste path, the existing clipboard content is saved and
|
|
restored ~300 ms after the Ctrl+V keystroke is sent.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from typing import Any, cast
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
def _make_injector() -> Any:
|
|
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
|
|
|
|
obj = cast(Any, TextInjector.__new__(TextInjector))
|
|
obj._ibus_injector = None
|
|
obj.environment = DesktopEnvironment.WAYLAND
|
|
obj._session_environment = DesktopEnvironment.WAYLAND
|
|
obj._ibus_ready = False
|
|
obj._ibus_init_failed = False
|
|
obj._ibus_init_thread = None
|
|
obj._state_lock = threading.Lock()
|
|
obj._clipboard_tool_health = {}
|
|
obj._clipboard_timeout = 0.35
|
|
obj._clipboard_restore_generation = 0
|
|
obj._clipboard_restore_target = None
|
|
return obj
|
|
|
|
|
|
class TestReadClipboard(unittest.TestCase):
|
|
"""Unit tests for the _read_clipboard() helper.
|
|
|
|
subprocess.run is called with text=True so stdout is always a str.
|
|
"""
|
|
|
|
def test_wl_paste_used_first_on_wayland(self):
|
|
"""On Wayland, wl-paste is the first candidate and its output is returned."""
|
|
obj = _make_injector()
|
|
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
|
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: (
|
|
"/usr/bin/wl-paste" if cmd == "wl-paste" else None
|
|
)
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="copied text")
|
|
result = obj._read_clipboard()
|
|
self.assertEqual(result, "copied text")
|
|
mock_run.assert_called_once()
|
|
self.assertEqual(
|
|
mock_run.call_args.args[0],
|
|
["wl-paste", "--no-newline", "--type", "text"],
|
|
)
|
|
|
|
def test_skips_wl_paste_on_xwayland_fallback(self) -> None:
|
|
"""WAYLAND_XDOTOOL reads the X11 CLIPBOARD, not wl-paste."""
|
|
from vocalinux.text_injection.text_injector import DesktopEnvironment
|
|
|
|
obj = _make_injector()
|
|
obj.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
|
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: (
|
|
f"/usr/bin/{cmd}" if cmd in ("wl-paste", "xclip") else None
|
|
)
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="from-x11")
|
|
result = obj._read_clipboard()
|
|
self.assertEqual(result, "from-x11")
|
|
mock_run.assert_called_once()
|
|
self.assertEqual(
|
|
mock_run.call_args.args[0],
|
|
["xclip", "-selection", "clipboard", "-o", "-t", "UTF8_STRING"],
|
|
)
|
|
|
|
def test_xclip_requests_utf8_string_target(self):
|
|
"""xclip must request UTF8_STRING so image clipboards are not read as text."""
|
|
obj = _make_injector()
|
|
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: ("/usr/bin/xclip" if cmd == "xclip" else None)
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="xclip text")
|
|
result = obj._read_clipboard()
|
|
self.assertEqual(result, "xclip text")
|
|
self.assertEqual(
|
|
mock_run.call_args.args[0],
|
|
["xclip", "-selection", "clipboard", "-o", "-t", "UTF8_STRING"],
|
|
)
|
|
|
|
def test_xclip_fallback_when_wl_paste_unavailable(self):
|
|
"""Falls back to xclip when wl-paste is not installed."""
|
|
obj = _make_injector()
|
|
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: ("/usr/bin/xclip" if cmd == "xclip" else None)
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="xclip text")
|
|
result = obj._read_clipboard()
|
|
self.assertEqual(result, "xclip text")
|
|
|
|
def test_xsel_fallback_when_wl_paste_and_xclip_unavailable(self):
|
|
"""Falls back to xsel when wl-paste and xclip are both not installed."""
|
|
obj = _make_injector()
|
|
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: ("/usr/bin/xsel" if cmd == "xsel" else None)
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="xsel text")
|
|
result = obj._read_clipboard()
|
|
self.assertEqual(result, "xsel text")
|
|
|
|
def test_returns_none_when_no_tool_installed(self):
|
|
"""Returns None when no clipboard read tool is available."""
|
|
obj = _make_injector()
|
|
with patch("vocalinux.text_injection.text_injector.shutil.which", return_value=None):
|
|
result = obj._read_clipboard()
|
|
self.assertIsNone(result)
|
|
|
|
def test_returns_none_when_tool_exits_nonzero_with_unknown_stderr(self):
|
|
"""Returns None when exit code is non-zero and stderr has no recognised empty-clipboard message."""
|
|
obj = _make_injector()
|
|
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
return_value="/usr/bin/wl-paste",
|
|
):
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="some error")
|
|
result = obj._read_clipboard()
|
|
self.assertIsNone(result)
|
|
|
|
def test_returns_none_and_does_not_raise_on_timeout(self):
|
|
"""Returns None without raising when the clipboard tool times out."""
|
|
obj = _make_injector()
|
|
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
return_value="/usr/bin/wl-paste",
|
|
):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
side_effect=subprocess.TimeoutExpired("wl-paste", 1.0),
|
|
):
|
|
result = obj._read_clipboard()
|
|
self.assertIsNone(result)
|
|
|
|
def test_returns_none_and_does_not_raise_on_oserror(self):
|
|
"""Returns None without raising when subprocess raises OSError."""
|
|
obj = _make_injector()
|
|
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
return_value="/usr/bin/wl-paste",
|
|
):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
side_effect=OSError("not found"),
|
|
):
|
|
result = obj._read_clipboard()
|
|
self.assertIsNone(result)
|
|
|
|
def test_tries_next_tool_after_failure(self):
|
|
"""When the first tool fails, the next candidate is tried."""
|
|
obj = _make_injector()
|
|
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: (
|
|
"/usr/bin/" + cmd if cmd in ("wl-paste", "xclip") else None
|
|
)
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.side_effect = [
|
|
MagicMock(returncode=1, stdout=""), # wl-paste fails
|
|
MagicMock(returncode=0, stdout="via xclip"), # xclip succeeds
|
|
]
|
|
result = obj._read_clipboard()
|
|
self.assertEqual(result, "via xclip")
|
|
|
|
def test_returns_arabic_text(self):
|
|
"""Correctly returns multi-byte UTF-8 content such as Arabic text."""
|
|
obj = _make_injector()
|
|
arabic = "مرحبا بالعالم"
|
|
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
return_value="/usr/bin/wl-paste",
|
|
):
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout=arabic)
|
|
result = obj._read_clipboard()
|
|
self.assertEqual(result, arabic)
|
|
|
|
def test_wl_paste_used_as_fallback_on_x11(self):
|
|
"""On X11, wl-paste is added as a last-resort candidate (XWayland environments)."""
|
|
from vocalinux.text_injection.text_injector import DesktopEnvironment
|
|
|
|
obj = _make_injector()
|
|
obj._session_environment = DesktopEnvironment.X11
|
|
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": ""}):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
return_value="/usr/bin/wl-paste",
|
|
):
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="x11 clipboard")
|
|
result = obj._read_clipboard()
|
|
self.assertEqual(result, "x11 clipboard")
|
|
|
|
|
|
class TestClipboardRestoreAfterInjection(unittest.TestCase):
|
|
"""Behavioural tests for the save/restore logic in _inject_via_clipboard_paste."""
|
|
|
|
def test_clipboard_restored_to_previous_content(self):
|
|
"""Previous clipboard content is written back ~300ms after injection."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
|
|
# First read: saves previous content. Second read (inside _restore): clipboard
|
|
# still holds injected text, confirming the user hasn't copied anything else.
|
|
with patch.object(
|
|
obj, "_read_clipboard", side_effect=["original clipboard", "injected text"]
|
|
):
|
|
with patch.object(
|
|
obj, "_copy_to_clipboard", side_effect=lambda t, **kw: copy_calls.append(t) or True
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("injected text")
|
|
time.sleep(0.5) # wait for the 300ms restore thread
|
|
|
|
self.assertTrue(result)
|
|
self.assertEqual(copy_calls[0], "injected text")
|
|
self.assertEqual(copy_calls[-1], "original clipboard")
|
|
|
|
def test_read_clipboard_is_called_before_overwriting(self):
|
|
"""_read_clipboard must be called first, before _copy_to_clipboard."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
call_order: list[str] = []
|
|
|
|
with patch.object(
|
|
obj,
|
|
"_read_clipboard",
|
|
side_effect=lambda **kw: call_order.append("read") or "prev",
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_copy_to_clipboard",
|
|
side_effect=lambda t, **kw: call_order.append(f"copy:{t}") or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
obj._inject_via_clipboard_paste("text")
|
|
time.sleep(0.5)
|
|
|
|
self.assertEqual(call_order[0], "read", "read must happen before any copy")
|
|
self.assertIn("copy:text", call_order)
|
|
|
|
def test_no_restore_when_clipboard_was_empty(self):
|
|
"""No restore is attempted when _read_clipboard() returns None (clipboard empty)."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
|
|
with patch.object(obj, "_read_clipboard", return_value=None):
|
|
with patch.object(
|
|
obj,
|
|
"_copy_to_clipboard",
|
|
side_effect=lambda t, **kw: copy_calls.append(t) or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("text")
|
|
time.sleep(0.5)
|
|
|
|
self.assertTrue(result)
|
|
# Only one call: the injection. No second call for restore.
|
|
self.assertEqual(copy_calls, ["text"])
|
|
|
|
def test_no_restore_when_copy_to_clipboard_setting_enabled(self):
|
|
"""No restore when the user has opted-in to keeping dictated text in clipboard.
|
|
|
|
When copy_to_clipboard=true, the user explicitly wants the dictated text
|
|
to stay in the clipboard after injection. Restoring the old content would
|
|
silently undo that preference.
|
|
"""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
|
|
with patch.object(obj, "_read_clipboard", return_value="old clipboard"):
|
|
with patch.object(
|
|
obj,
|
|
"_copy_to_clipboard",
|
|
side_effect=lambda t, **kw: copy_calls.append(t) or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=True):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("dictated text")
|
|
time.sleep(0.5)
|
|
|
|
self.assertTrue(result)
|
|
# Only one copy call — the injection. The old content must NOT be restored.
|
|
self.assertEqual(copy_calls, ["dictated text"])
|
|
|
|
def test_clipboard_restored_when_ctrl_v_fails(self):
|
|
"""Clipboard is restored immediately when Ctrl+V fails, not silently lost."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
|
|
with patch.object(obj, "_read_clipboard", return_value="prev"):
|
|
with patch.object(
|
|
obj,
|
|
"_copy_to_clipboard",
|
|
side_effect=lambda t, **kw: copy_calls.append(t) or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
side_effect=subprocess.CalledProcessError(1, ["ydotool", "key"]),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("text")
|
|
time.sleep(0.5)
|
|
|
|
self.assertFalse(result)
|
|
# First copy: injection; second copy: immediate restore after paste failure
|
|
self.assertEqual(copy_calls, ["text", "prev"])
|
|
|
|
def test_restore_thread_is_a_daemon(self):
|
|
"""The restore thread is a daemon so it never blocks interpreter shutdown."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
started: list[threading.Thread] = []
|
|
original_start = threading.Thread.start
|
|
|
|
def capture_start(self_t: threading.Thread) -> None:
|
|
started.append(self_t)
|
|
original_start(self_t)
|
|
|
|
with patch.object(obj, "_read_clipboard", return_value="prev"):
|
|
with patch.object(obj, "_copy_to_clipboard", return_value=True):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
with patch.object(threading.Thread, "start", capture_start):
|
|
obj._inject_via_clipboard_paste("text")
|
|
|
|
self.assertEqual(len(started), 1, "exactly one restore thread should start")
|
|
self.assertTrue(started[0].daemon, "restore thread must be a daemon")
|
|
|
|
def test_arabic_content_is_preserved(self):
|
|
"""Clipboard content containing Arabic text is correctly restored."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
arabic_previous = "نص عربي سابق"
|
|
|
|
with patch.object(obj, "_read_clipboard", side_effect=[arabic_previous, "مرحبا"]):
|
|
with patch.object(
|
|
obj,
|
|
"_copy_to_clipboard",
|
|
side_effect=lambda t, **kw: copy_calls.append(t) or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
obj._inject_via_clipboard_paste("مرحبا")
|
|
time.sleep(0.5)
|
|
|
|
self.assertEqual(copy_calls[-1], arabic_previous)
|
|
|
|
def test_restore_failure_is_handled_gracefully(self):
|
|
"""No exception when _copy_to_clipboard returns False during restore."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
|
|
def fake_copy(t: str, **kwargs) -> bool:
|
|
copy_calls.append(t)
|
|
# injection copy succeeds; restore copy fails
|
|
return t != "previous_content"
|
|
|
|
with patch.object(
|
|
obj, "_read_clipboard", side_effect=["previous_content", "injected text"]
|
|
):
|
|
with patch.object(obj, "_copy_to_clipboard", side_effect=fake_copy):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("injected text")
|
|
time.sleep(0.5)
|
|
|
|
self.assertTrue(result)
|
|
# The restore attempt was made but failed — no exception raised
|
|
self.assertIn("previous_content", copy_calls)
|
|
|
|
def test_clipboard_restored_immediately_when_paste_fails(self):
|
|
"""Original clipboard is restored right away when Ctrl+V fails, not after 300ms."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
|
|
with patch.object(obj, "_read_clipboard", return_value="original content"):
|
|
with patch.object(
|
|
obj,
|
|
"_copy_to_clipboard",
|
|
side_effect=lambda t, **kw: copy_calls.append(t) or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
side_effect=subprocess.CalledProcessError(1, ["ydotool", "key"]),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("injected text")
|
|
|
|
self.assertFalse(result)
|
|
# First copy: injected text; second copy: immediate restore after failure
|
|
self.assertEqual(copy_calls, ["injected text", "original content"])
|
|
|
|
def test_no_restore_when_paste_fails_and_clipboard_was_unreadable(self):
|
|
"""No restore attempted when Ctrl+V fails and clipboard could not be read."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
|
|
with patch.object(obj, "_read_clipboard", return_value=None):
|
|
with patch.object(
|
|
obj,
|
|
"_copy_to_clipboard",
|
|
side_effect=lambda t, **kw: copy_calls.append(t) or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
side_effect=subprocess.CalledProcessError(1, ["ydotool", "key"]),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("text")
|
|
|
|
self.assertFalse(result)
|
|
# Only injection copy — no restore since clipboard was unreadable
|
|
self.assertEqual(copy_calls, ["text"])
|
|
|
|
def test_empty_clipboard_is_cleared_after_injection(self):
|
|
"""When clipboard was empty, _clear_clipboard() is called instead of copy("")."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
clear_called: list[bool] = []
|
|
|
|
with patch.object(obj, "_read_clipboard", side_effect=["", "new text"]):
|
|
with patch.object(obj, "_copy_to_clipboard", return_value=True):
|
|
with patch.object(
|
|
obj,
|
|
"_clear_clipboard",
|
|
side_effect=lambda **kw: clear_called.append(True) or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("new text")
|
|
time.sleep(0.5)
|
|
|
|
self.assertTrue(result)
|
|
self.assertTrue(
|
|
clear_called, "_clear_clipboard() must be called for empty previous content"
|
|
)
|
|
|
|
def test_restore_skipped_when_clipboard_changed_during_delay(self):
|
|
"""Restore is skipped if the user copied something else during the 300ms window."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
|
|
# First read: previous content. Second read (inside _restore): user copied "new thing"
|
|
with patch.object(obj, "_read_clipboard", side_effect=["original", "new thing"]):
|
|
with patch.object(
|
|
obj,
|
|
"_copy_to_clipboard",
|
|
side_effect=lambda t, **kw: copy_calls.append(t) or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("injected text")
|
|
time.sleep(0.5)
|
|
|
|
self.assertTrue(result)
|
|
# Only the injection copy — restore was skipped because clipboard changed
|
|
self.assertEqual(copy_calls, ["injected text"])
|
|
|
|
def test_restore_proceeds_when_clipboard_unchanged(self):
|
|
"""Restore fires when clipboard still holds the injected text after 300ms."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
|
|
# First read: previous content. Second read (inside _restore): still the injected text
|
|
with patch.object(obj, "_read_clipboard", side_effect=["original", "injected text"]):
|
|
with patch.object(
|
|
obj,
|
|
"_copy_to_clipboard",
|
|
side_effect=lambda t, **kw: copy_calls.append(t) or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("injected text")
|
|
time.sleep(0.5)
|
|
|
|
self.assertTrue(result)
|
|
self.assertEqual(copy_calls, ["injected text", "original"])
|
|
|
|
|
|
class TestClearClipboard(unittest.TestCase):
|
|
"""Unit tests for the _clear_clipboard() helper."""
|
|
|
|
def test_wl_copy_clear_flag_used(self):
|
|
"""Uses 'wl-copy --clear' on Wayland."""
|
|
obj = _make_injector()
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which", return_value="/usr/bin/wl-copy"
|
|
):
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0)
|
|
result = obj._clear_clipboard()
|
|
self.assertTrue(result)
|
|
cmd = mock_run.call_args[0][0]
|
|
self.assertEqual(cmd, ["wl-copy", "--clear"])
|
|
|
|
def test_xsel_clear_flag_used(self):
|
|
"""Uses 'xsel --clipboard --clear' when xsel is available."""
|
|
obj = _make_injector()
|
|
obj._session_environment = None
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
side_effect=lambda cmd: "/usr/bin/xsel" if cmd == "xsel" else None,
|
|
):
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0)
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": ""}):
|
|
result = obj._clear_clipboard()
|
|
self.assertTrue(result)
|
|
cmd = mock_run.call_args[0][0]
|
|
self.assertEqual(cmd, ["xsel", "--clipboard", "--clear"])
|
|
|
|
def test_xclip_empty_input_used(self):
|
|
"""Uses xclip with empty input when xclip is the only available tool."""
|
|
obj = _make_injector()
|
|
obj._session_environment = None
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
side_effect=lambda cmd: "/usr/bin/xclip" if cmd == "xclip" else None,
|
|
):
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0)
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": ""}):
|
|
result = obj._clear_clipboard()
|
|
self.assertTrue(result)
|
|
call_kwargs = mock_run.call_args
|
|
self.assertEqual(call_kwargs[0][0], ["xclip", "-selection", "clipboard"])
|
|
|
|
def test_skips_unhealthy_tool_and_tries_next(self):
|
|
"""Skips a tool marked unhealthy; returns False when it was the only tool."""
|
|
obj = _make_injector()
|
|
obj._clipboard_tool_health["wl-copy"] = False
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
side_effect=lambda cmd: "/usr/bin/wl-copy" if cmd == "wl-copy" else None,
|
|
):
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
result = obj._clear_clipboard()
|
|
self.assertFalse(result)
|
|
mock_run.assert_not_called()
|
|
|
|
def test_exception_falls_through_to_next_tool(self):
|
|
"""When one tool throws, the next candidate is tried."""
|
|
obj = _make_injector()
|
|
obj._session_environment = None
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
side_effect=lambda cmd: "/usr/bin/" + cmd if cmd in ("xclip", "xsel") else None,
|
|
):
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.side_effect = [
|
|
subprocess.CalledProcessError(1, ["xclip"]), # xclip fails
|
|
MagicMock(returncode=0), # xsel succeeds
|
|
]
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": ""}):
|
|
result = obj._clear_clipboard()
|
|
self.assertTrue(result)
|
|
|
|
def test_clear_called_when_paste_fails_and_clipboard_was_empty(self):
|
|
"""_clear_clipboard() is called immediately when paste fails and clipboard was empty."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
clear_called: list[bool] = []
|
|
|
|
with patch.object(obj, "_read_clipboard", return_value=""):
|
|
with patch.object(obj, "_copy_to_clipboard", return_value=True):
|
|
with patch.object(
|
|
obj,
|
|
"_clear_clipboard",
|
|
side_effect=lambda **kw: clear_called.append(True) or True,
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
side_effect=subprocess.CalledProcessError(1, ["ydotool", "key"]),
|
|
):
|
|
result = obj._inject_via_clipboard_paste("text")
|
|
|
|
self.assertFalse(result)
|
|
self.assertTrue(
|
|
clear_called,
|
|
"_clear_clipboard() must be called when paste fails with empty previous clipboard",
|
|
)
|
|
|
|
def test_returns_false_when_no_tool_available(self):
|
|
"""Returns False when no clipboard tool is installed."""
|
|
obj = _make_injector()
|
|
with patch("vocalinux.text_injection.text_injector.shutil.which", return_value=None):
|
|
result = obj._clear_clipboard()
|
|
self.assertFalse(result)
|
|
|
|
|
|
class TestReadClipboardEmptyDetection(unittest.TestCase):
|
|
"""Tests for _read_clipboard() detecting empty clipboard via stderr patterns."""
|
|
|
|
def test_wl_paste_nothing_is_copied_returns_empty_string(self):
|
|
"""Returns '' when wl-paste stderr says 'Nothing is copied'."""
|
|
obj = _make_injector()
|
|
with patch("shutil.which", return_value="/usr/bin/wl-paste"):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=1, stdout="", stderr="Nothing is copied"),
|
|
):
|
|
result = obj._read_clipboard()
|
|
self.assertEqual(result, "")
|
|
|
|
def test_xclip_target_not_available_returns_none(self):
|
|
"""xclip 'target not available' means no text — not an empty clipboard.
|
|
|
|
Image/file clipboards produce this message for UTF8_STRING. Returning
|
|
'' would cause a clear that destroys non-text data; skip restore instead.
|
|
"""
|
|
obj = _make_injector()
|
|
obj._session_environment = None # non-Wayland so xclip is tried
|
|
with patch(
|
|
"shutil.which", side_effect=lambda cmd: "/usr/bin/xclip" if cmd == "xclip" else None
|
|
):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(
|
|
returncode=1, stdout="", stderr="Error: target UTF8_STRING not available"
|
|
),
|
|
):
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": ""}):
|
|
result = obj._read_clipboard()
|
|
self.assertIsNone(result)
|
|
|
|
def test_wl_paste_empty_falls_through_to_xclip_text(self):
|
|
"""Empty Wayland clipboard must not skip an X11 backend that still has text."""
|
|
obj = _make_injector()
|
|
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
|
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: (
|
|
"/usr/bin/" + cmd if cmd in ("wl-paste", "xclip") else None
|
|
)
|
|
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
|
|
mock_run.side_effect = [
|
|
MagicMock(returncode=1, stdout="", stderr="Nothing is copied"),
|
|
MagicMock(returncode=0, stdout="x11 text"),
|
|
]
|
|
result = obj._read_clipboard()
|
|
self.assertEqual(result, "x11 text")
|
|
|
|
def test_non_empty_clipboard_error_still_returns_none(self):
|
|
"""A non-zero exit code with unknown stderr does not trigger empty detection."""
|
|
obj = _make_injector()
|
|
with patch("shutil.which", return_value=None):
|
|
result = obj._read_clipboard()
|
|
self.assertIsNone(result)
|
|
|
|
|
|
class TestOverlappingClipboardRestore(unittest.TestCase):
|
|
"""Overlapping pastes must restore the original pre-first-injection content."""
|
|
|
|
def test_overlapping_pastes_restore_original_clipboard(self):
|
|
"""Second paste within the restore window must not restore intermediate text."""
|
|
obj = _make_injector()
|
|
obj.wayland_tool = "ydotool"
|
|
copy_calls: list[str] = []
|
|
# First paste saves "URL". Second paste would naively save "hello" (still on
|
|
# clipboard); pending-target must keep restoring to "URL".
|
|
read_values = iter(["URL", "world"])
|
|
|
|
with patch.object(obj, "_read_clipboard", side_effect=lambda **kw: next(read_values)):
|
|
with patch.object(
|
|
obj, "_copy_to_clipboard", side_effect=lambda t, **kw: copy_calls.append(t) or True
|
|
):
|
|
with patch.object(
|
|
obj,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
self.assertTrue(obj._inject_via_clipboard_paste("hello"))
|
|
self.assertTrue(obj._inject_via_clipboard_paste("world"))
|
|
time.sleep(0.5)
|
|
|
|
self.assertEqual(copy_calls[0], "hello")
|
|
self.assertEqual(copy_calls[1], "world")
|
|
self.assertEqual(copy_calls[-1], "URL")
|
|
self.assertNotIn("hello", copy_calls[2:])
|
|
self.assertIsNone(obj._clipboard_restore_target)
|
|
|
|
def test_failed_older_paste_does_not_restore_over_newer(self) -> None:
|
|
"""A stale paste failure must not rewind the clipboard over a newer copy."""
|
|
obj = _make_injector()
|
|
copy_calls: list[str] = []
|
|
nested = {"done": False}
|
|
|
|
def run_side_effect(cmd, **kwargs):
|
|
if not nested["done"]:
|
|
nested["done"] = True
|
|
self.assertTrue(obj._inject_via_clipboard_paste("world"))
|
|
raise subprocess.CalledProcessError(1, cmd)
|
|
return MagicMock(returncode=0)
|
|
|
|
with patch.object(obj, "_read_clipboard", side_effect=["URL", "hello"]):
|
|
with patch.object(
|
|
obj, "_copy_to_clipboard", side_effect=lambda t, **kw: copy_calls.append(t) or True
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch.object(obj, "_should_use_terminal_paste", return_value=False):
|
|
with patch.object(
|
|
obj,
|
|
"_clipboard_paste_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch("threading.Thread"):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
side_effect=run_side_effect,
|
|
):
|
|
self.assertFalse(obj._inject_via_clipboard_paste("hello"))
|
|
# Stale failure must not rewind to "URL" over "world".
|
|
self.assertEqual(copy_calls, ["hello", "world"])
|
|
self.assertNotIn("URL", copy_calls)
|
|
|
|
def test_stale_failure_does_not_lose_original_clipboard(self) -> None:
|
|
"""Newer overlapping restore must still land on the pre-first clipboard.
|
|
|
|
If the older paste fails after generation moved on, skipping its
|
|
restore must not strand the newer thread on intermediate dictated text.
|
|
"""
|
|
obj = _make_injector()
|
|
copy_calls: list[str] = []
|
|
nested = {"done": False}
|
|
|
|
def run_side_effect(cmd, **kwargs):
|
|
if not nested["done"]:
|
|
nested["done"] = True
|
|
self.assertTrue(obj._inject_via_clipboard_paste("world"))
|
|
raise subprocess.CalledProcessError(1, cmd)
|
|
return MagicMock(returncode=0)
|
|
|
|
with patch.object(obj, "_read_clipboard", side_effect=["URL", "world"]):
|
|
with patch.object(
|
|
obj, "_copy_to_clipboard", side_effect=lambda t, **kw: copy_calls.append(t) or True
|
|
):
|
|
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
|
|
with patch.object(obj, "_should_use_terminal_paste", return_value=False):
|
|
with patch.object(
|
|
obj,
|
|
"_clipboard_paste_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
side_effect=run_side_effect,
|
|
):
|
|
self.assertFalse(obj._inject_via_clipboard_paste("hello"))
|
|
time.sleep(0.5)
|
|
|
|
self.assertEqual(copy_calls[0], "hello")
|
|
self.assertEqual(copy_calls[1], "world")
|
|
self.assertEqual(copy_calls[-1], "URL")
|
|
self.assertNotIn("hello", copy_calls[2:])
|
|
self.assertIsNone(obj._clipboard_restore_target)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|