fix(ibus): raise exception on setup failure to enable fallback (#240)
* fix(ibus): raise exception on setup failure to enable fallback - Add IBusSetupError exception class for detailed error context - Modify _setup_engine() to raise exception instead of silent return - Ensures text_injector.py fallback mechanism works when IBus fails - Add unit tests for IBusSetupError and setup failure scenarios Fixes silent IBus text injection failures on Wayland/Fedora reported in #34 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * test(text-injector): add tests for IBus setup failure fallback - Test that TextInjector falls back to xdotool when IBus fails - Test fallback on both X11 and Wayland environments - Test that text injection still works after IBus failure Related to #34 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
co-authored by
Sisyphus
parent
be66bdad18
commit
59455098d3
@@ -4,6 +4,7 @@ Text injection module for Vocalinux
|
||||
|
||||
from . import text_injector # noqa: F401
|
||||
from .ibus_engine import ( # noqa: F401
|
||||
IBusSetupError,
|
||||
IBusTextInjector,
|
||||
install_ibus_component,
|
||||
is_engine_active,
|
||||
|
||||
@@ -27,6 +27,19 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IBusSetupError(RuntimeError):
|
||||
"""
|
||||
Exception raised when IBus engine setup fails.
|
||||
|
||||
This exception is raised when the IBus text injector cannot be properly
|
||||
initialized, allowing the caller to fall back to alternative text
|
||||
injection methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Check if IBus is available
|
||||
try:
|
||||
import gi
|
||||
@@ -576,13 +589,14 @@ class IBusTextInjector:
|
||||
|
||||
# Check again after installation
|
||||
if not is_engine_registered():
|
||||
logger.error("Failed to register IBus engine")
|
||||
return
|
||||
raise IBusSetupError(
|
||||
"Failed to register IBus engine. "
|
||||
"Try running 'ibus list-engine' to verify installation."
|
||||
)
|
||||
|
||||
# Start the engine process (Vocalinux starts it, not IBus)
|
||||
if not start_engine_process():
|
||||
logger.error("Failed to start IBus engine process")
|
||||
return
|
||||
raise IBusSetupError("Failed to start IBus engine process. Check logs for details.")
|
||||
|
||||
# Save current engine and switch to Vocalinux
|
||||
if not is_engine_active():
|
||||
@@ -594,7 +608,10 @@ class IBusTextInjector:
|
||||
if switch_engine(ENGINE_NAME):
|
||||
logger.info("Vocalinux IBus engine activated")
|
||||
else:
|
||||
logger.error("Failed to activate Vocalinux IBus engine")
|
||||
raise IBusSetupError(
|
||||
"Failed to activate Vocalinux IBus engine. "
|
||||
"Try manually: ibus engine vocalinux"
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -31,6 +31,102 @@ sys.modules["gi.repository"].GLib = mock_glib
|
||||
sys.modules["gi.repository"].GObject = mock_gobject
|
||||
|
||||
|
||||
class TestIBusSetupError(unittest.TestCase):
|
||||
"""Tests for IBusSetupError exception."""
|
||||
|
||||
def test_is_runtime_error_subclass(self):
|
||||
"""Test that IBusSetupError inherits from RuntimeError."""
|
||||
from vocalinux.text_injection.ibus_engine import IBusSetupError
|
||||
|
||||
self.assertTrue(issubclass(IBusSetupError, RuntimeError))
|
||||
|
||||
def test_accepts_message(self):
|
||||
"""Test that IBusSetupError accepts a message."""
|
||||
from vocalinux.text_injection.ibus_engine import IBusSetupError
|
||||
|
||||
error = IBusSetupError("Test message")
|
||||
self.assertEqual(str(error), "Test message")
|
||||
|
||||
|
||||
class TestIBusTextInjectorSetupFailures(unittest.TestCase):
|
||||
"""Tests for IBusTextInjector raising IBusSetupError on setup failures."""
|
||||
|
||||
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
|
||||
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
|
||||
@patch("vocalinux.text_injection.ibus_engine.install_ibus_component")
|
||||
@patch("vocalinux.text_injection.ibus_engine.is_engine_registered")
|
||||
def test_raises_on_registration_failure(
|
||||
self, mock_is_registered, mock_install, mock_ensure_dir
|
||||
):
|
||||
"""Test that IBusSetupError is raised when engine registration fails."""
|
||||
mock_is_registered.return_value = False # Registration fails
|
||||
mock_install.return_value = True
|
||||
|
||||
from vocalinux.text_injection.ibus_engine import (
|
||||
IBusSetupError,
|
||||
IBusTextInjector,
|
||||
)
|
||||
|
||||
with self.assertRaises(IBusSetupError) as context:
|
||||
IBusTextInjector(auto_activate=True)
|
||||
|
||||
self.assertIn("Failed to register IBus engine", str(context.exception))
|
||||
|
||||
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
|
||||
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
|
||||
@patch("vocalinux.text_injection.ibus_engine.is_engine_registered")
|
||||
@patch("vocalinux.text_injection.ibus_engine.start_engine_process")
|
||||
def test_raises_on_engine_process_failure(
|
||||
self, mock_start_engine, mock_is_registered, mock_ensure_dir
|
||||
):
|
||||
"""Test that IBusSetupError is raised when engine process fails to start."""
|
||||
mock_is_registered.return_value = True
|
||||
mock_start_engine.return_value = False # Process start fails
|
||||
|
||||
from vocalinux.text_injection.ibus_engine import (
|
||||
IBusSetupError,
|
||||
IBusTextInjector,
|
||||
)
|
||||
|
||||
with self.assertRaises(IBusSetupError) as context:
|
||||
IBusTextInjector(auto_activate=True)
|
||||
|
||||
self.assertIn("Failed to start IBus engine process", str(context.exception))
|
||||
|
||||
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
|
||||
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
|
||||
@patch("vocalinux.text_injection.ibus_engine.is_engine_registered")
|
||||
@patch("vocalinux.text_injection.ibus_engine.start_engine_process")
|
||||
@patch("vocalinux.text_injection.ibus_engine.is_engine_active")
|
||||
@patch("vocalinux.text_injection.ibus_engine.get_current_engine")
|
||||
@patch("vocalinux.text_injection.ibus_engine.switch_engine")
|
||||
def test_raises_on_activation_failure(
|
||||
self,
|
||||
mock_switch,
|
||||
mock_get_current,
|
||||
mock_is_active,
|
||||
mock_start_engine,
|
||||
mock_is_registered,
|
||||
mock_ensure_dir,
|
||||
):
|
||||
"""Test that IBusSetupError is raised when engine activation fails."""
|
||||
mock_is_registered.return_value = True
|
||||
mock_start_engine.return_value = True
|
||||
mock_is_active.return_value = False
|
||||
mock_get_current.return_value = "xkb:us::eng"
|
||||
mock_switch.return_value = False # Activation fails
|
||||
|
||||
from vocalinux.text_injection.ibus_engine import (
|
||||
IBusSetupError,
|
||||
IBusTextInjector,
|
||||
)
|
||||
|
||||
with self.assertRaises(IBusSetupError) as context:
|
||||
IBusTextInjector(auto_activate=True)
|
||||
|
||||
self.assertIn("Failed to activate Vocalinux IBus engine", str(context.exception))
|
||||
|
||||
|
||||
class TestIBusEngineHelpers(unittest.TestCase):
|
||||
"""Tests for IBus engine helper functions."""
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ class TestTextInjector(unittest.TestCase):
|
||||
|
||||
# Disable IBus for these tests (testing fallback paths)
|
||||
self.patch_ibus_available = patch(
|
||||
"vocalinux.text_injection.text_injector.is_ibus_available", return_value=False
|
||||
"vocalinux.text_injection.text_injector.is_ibus_available",
|
||||
return_value=False,
|
||||
)
|
||||
self.patch_ibus_available.start()
|
||||
|
||||
@@ -515,7 +516,8 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
|
||||
|
||||
# Disable IBus for these tests (testing fallback paths)
|
||||
self.patch_ibus_available = patch(
|
||||
"vocalinux.text_injection.text_injector.is_ibus_available", return_value=False
|
||||
"vocalinux.text_injection.text_injector.is_ibus_available",
|
||||
return_value=False,
|
||||
)
|
||||
self.patch_ibus_available.start()
|
||||
|
||||
@@ -1083,9 +1085,90 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
|
||||
|
||||
# Patch _test_xdotool_fallback at the class level before instantiation
|
||||
with patch.object(
|
||||
TextInjector, "_test_xdotool_fallback", side_effect=Exception("Test error")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user