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:
Jatin K Malik
2026-02-21 00:39:19 -08:00
committed by GitHub
co-authored by Sisyphus
parent be66bdad18
commit 59455098d3
4 changed files with 205 additions and 8 deletions
+1
View File
@@ -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,
+22 -5
View File
@@ -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:
"""
+96
View File
@@ -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."""
+86 -3
View File
@@ -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)