From 59455098d3f4fb56e6e2fea630caa06a54a418c8 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sat, 21 Feb 2026 00:39:19 -0800 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Sisyphus --- src/vocalinux/text_injection/__init__.py | 1 + src/vocalinux/text_injection/ibus_engine.py | 27 ++++-- tests/test_ibus_engine.py | 96 +++++++++++++++++++++ tests/test_text_injector.py | 89 ++++++++++++++++++- 4 files changed, 205 insertions(+), 8 deletions(-) diff --git a/src/vocalinux/text_injection/__init__.py b/src/vocalinux/text_injection/__init__.py index 87c8766..3ebadab 100644 --- a/src/vocalinux/text_injection/__init__.py +++ b/src/vocalinux/text_injection/__init__.py @@ -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, diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 43d50d4..72f3cd8 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -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: """ diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index fc0d30c..468d11e 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -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.""" diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index f71a48c..92b4285 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -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)