diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index e7cfd49..f791fb3 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -924,11 +924,33 @@ class IBusTextInjector: logger.debug("Vocalinux engine not active, re-activating...") switch_engine(ENGINE_NAME) - # Try injection, retry once if engine instance was destroyed - # (e.g. user switched keyboard layout and IBus called do_destroy) - for attempt in range(2): + # Try injection with bounded retries for transient socket/engine races. + # This can happen if IBus re-created the engine instance or if the + # engine process/socket is still coming up when dictation ends. + max_attempts = 3 + + def restart_engine_process() -> bool: + logger.warning("IBus engine process is not running, restarting...") + if not start_engine_process(): + logger.error("Failed to restart IBus engine process") + return False + time.sleep(0.3) + return True + + for attempt in range(max_attempts): try: if not SOCKET_PATH.exists(): + if not is_engine_process_running() and not restart_engine_process(): + return False + + if attempt < max_attempts - 1: + logger.warning( + "IBus engine socket not found on attempt " + f"{attempt + 1}/{max_attempts}; retrying..." + ) + time.sleep(0.2 * (attempt + 1)) + continue + logger.error( "IBus engine socket not found. " "Make sure Vocalinux IBus engine is running." @@ -946,7 +968,7 @@ class IBusTextInjector: if response == "OK": logger.debug("Text injection successful") return True - elif response == "NO_ENGINE" and attempt == 0: + elif response == "NO_ENGINE" and attempt < max_attempts - 1: # Engine instance was destroyed (layout switch). # Re-activate to create a new instance and retry. logger.info("Engine instance not active, re-activating and retrying...") @@ -958,9 +980,37 @@ class IBusTextInjector: return False except socket.timeout: + if attempt < max_attempts - 1: + logger.warning( + "Timeout connecting to IBus engine on attempt " + f"{attempt + 1}/{max_attempts}; retrying..." + ) + time.sleep(0.2 * (attempt + 1)) + continue logger.error("Timeout connecting to IBus engine") return False + except ConnectionRefusedError as e: + if attempt < max_attempts - 1: + logger.warning( + "IBus engine refused connection on attempt " + f"{attempt + 1}/{max_attempts}: {e}. Retrying..." + ) + if not is_engine_process_running() and not restart_engine_process(): + return False + time.sleep(0.2 * (attempt + 1)) + continue + logger.error(f"Failed to inject text via IBus: {e}") + return False except FileNotFoundError: + if attempt < max_attempts - 1: + logger.warning( + "IBus engine socket disappeared on attempt " + f"{attempt + 1}/{max_attempts}; retrying..." + ) + if not is_engine_process_running() and not restart_engine_process(): + return False + time.sleep(0.2 * (attempt + 1)) + continue logger.error("IBus engine socket not found") return False except Exception as e: @@ -1022,6 +1072,4 @@ def main(): if __name__ == "__main__": - import sys - sys.exit(main()) diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 5d3b50b..72b7d73 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -160,7 +160,8 @@ class TextInjector: else: self.environment = DesktopEnvironment.WAYLAND_IBUS logger.info( - f"Using IBus for {self.environment.value} text injection (best compatibility)" + "Using IBus for " + f"{self.environment.value} text injection (best compatibility)" ) return except Exception as e: @@ -195,7 +196,9 @@ class TextInjector: if wtype_available: self.wayland_tool = "wtype" logger.info( - f"Using {self.wayland_tool} for Wayland text injection (ydotoold not running)" + "Using " + f"{self.wayland_tool} for Wayland text injection " + "(ydotoold not running)" ) else: logger.warning("ydotool found but ydotoold daemon not running") @@ -224,6 +227,60 @@ class TextInjector: ) raise RuntimeError("Missing required dependencies for text injection") + def _switch_to_non_ibus_backend(self) -> bool: + """Switch from IBus mode to a non-IBus backend for runtime fallback.""" + if self.environment == DesktopEnvironment.X11_IBUS: + if shutil.which("xdotool"): + self.environment = DesktopEnvironment.X11 + logger.warning("IBus injection failed, switching to X11 xdotool fallback") + return True + + logger.error("IBus fallback failed: xdotool is not available on X11") + return False + + if self.environment == DesktopEnvironment.WAYLAND_IBUS: + ydotool_available = shutil.which("ydotool") is not None + wtype_available = shutil.which("wtype") is not None + xdotool_available = shutil.which("xdotool") is not None + + if ydotool_available: + try: + subprocess.run( + ["ydotool", "type", ""], + check=True, + stderr=subprocess.PIPE, + timeout=2, + ) + self.wayland_tool = "ydotool" + self.environment = DesktopEnvironment.WAYLAND + logger.warning("IBus injection failed, switching to Wayland ydotool fallback") + return True + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + FileNotFoundError, + ): + logger.debug("ydotool fallback unavailable (daemon not running)") + + if wtype_available: + self.wayland_tool = "wtype" + self.environment = DesktopEnvironment.WAYLAND + logger.warning("IBus injection failed, switching to Wayland wtype fallback") + return True + + if xdotool_available: + self.environment = DesktopEnvironment.WAYLAND_XDOTOOL + logger.warning("IBus injection failed, switching to XWayland xdotool fallback") + return True + + logger.error( + "IBus fallback failed: no Wayland text injection tools available " + "(ydotool, wtype, xdotool)" + ) + return False + + return True + def _test_xdotool_fallback(self): """Test if xdotool is working correctly with XWayland.""" try: @@ -452,11 +509,23 @@ class TextInjector: args=(text,), daemon=True, ).start() - return result + return True + + logger.warning( + "IBus runtime injection failed. Falling back to non-IBus backend." + ) + if not self._switch_to_non_ibus_backend(): + raise RuntimeError( + "IBus injection failed and no non-IBus fallback is available" + ) else: - logger.error("IBus injector not initialized") - return False - elif ( + logger.error("IBus injector not initialized, trying non-IBus fallback") + if not self._switch_to_non_ibus_backend(): + raise RuntimeError( + "IBus injector not initialized and no non-IBus fallback is available" + ) + + if ( self.environment == DesktopEnvironment.X11 or self.environment == DesktopEnvironment.WAYLAND_XDOTOOL ): diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index 4939612..dffb328 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -582,6 +582,217 @@ class TestIBusTextInjector(unittest.TestCase): self.assertFalse(result) + @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True) + @patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir") + @patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH") + @patch("vocalinux.text_injection.ibus_engine.start_engine_process", return_value=True) + @patch("vocalinux.text_injection.ibus_engine.is_engine_process_running", return_value=True) + @patch("vocalinux.text_injection.ibus_engine.time") + def test_inject_text_connection_refused_then_success( + self, + mock_time, + mock_process_running, + mock_start_engine, + mock_socket_path, + mock_ensure_dir, + ): + """Test retry behavior when first socket connect is refused.""" + mock_socket_path.exists.return_value = True + + from vocalinux.text_injection.ibus_engine import IBusTextInjector + + with patch("socket.socket") as mock_socket_class: + socket_context = MagicMock() + mock_socket_class.return_value = socket_context + + first_sock = MagicMock() + second_sock = MagicMock() + first_sock.connect.side_effect = ConnectionRefusedError("refused") + second_sock.connect.return_value = None + second_sock.recv.return_value = b"OK" + socket_context.__enter__.side_effect = [first_sock, second_sock] + socket_context.__exit__.return_value = False + + injector = IBusTextInjector(auto_activate=False) + result = injector.inject_text("Hello") + + self.assertTrue(result) + + @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True) + @patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir") + @patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH") + @patch("vocalinux.text_injection.ibus_engine.start_engine_process", return_value=True) + @patch( + "vocalinux.text_injection.ibus_engine.is_engine_process_running", + side_effect=[False, True, True], + ) + @patch("vocalinux.text_injection.ibus_engine.time") + def test_inject_text_restarts_engine_when_not_running( + self, + mock_time, + mock_process_running, + mock_start_engine, + mock_socket_path, + mock_ensure_dir, + ): + """Test injector restarts the engine process when its socket is not ready.""" + mock_socket_path.exists.side_effect = [False, True, True] + + from vocalinux.text_injection.ibus_engine import IBusTextInjector + + with patch("socket.socket") as mock_socket_class: + socket_context = MagicMock() + mock_socket_class.return_value = socket_context + sock = MagicMock() + sock.recv.return_value = b"OK" + socket_context.__enter__.return_value = sock + socket_context.__exit__.return_value = False + + injector = IBusTextInjector(auto_activate=False) + result = injector.inject_text("Hello") + + self.assertTrue(result) + mock_start_engine.assert_called_once() + + @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True) + @patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir") + @patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH") + @patch("vocalinux.text_injection.ibus_engine.start_engine_process", return_value=False) + @patch("vocalinux.text_injection.ibus_engine.is_engine_process_running", return_value=False) + def test_inject_text_returns_false_when_engine_restart_fails( + self, + mock_process_running, + mock_start_engine, + mock_socket_path, + mock_ensure_dir, + ): + """Missing socket plus failed engine restart should abort cleanly.""" + mock_socket_path.exists.return_value = False + + from vocalinux.text_injection.ibus_engine import IBusTextInjector + + injector = IBusTextInjector(auto_activate=False) + result = injector.inject_text("Hello") + + self.assertFalse(result) + mock_start_engine.assert_called_once() + + @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True) + @patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir") + @patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH") + @patch("vocalinux.text_injection.ibus_engine.is_engine_process_running", return_value=True) + @patch("vocalinux.text_injection.ibus_engine.time") + def test_inject_text_socket_missing_until_final_attempt( + self, + mock_time, + mock_process_running, + mock_socket_path, + mock_ensure_dir, + ): + """Persistent missing socket should reach the final error branch.""" + mock_socket_path.exists.return_value = False + + from vocalinux.text_injection.ibus_engine import IBusTextInjector + + injector = IBusTextInjector(auto_activate=False) + result = injector.inject_text("Hello") + + self.assertFalse(result) + self.assertEqual(mock_socket_path.exists.call_count, 3) + + @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True) + @patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir") + @patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH") + @patch("vocalinux.text_injection.ibus_engine.is_engine_process_running", return_value=True) + @patch("vocalinux.text_injection.ibus_engine.time") + def test_inject_text_times_out_until_final_attempt( + self, + mock_time, + mock_process_running, + mock_socket_path, + mock_ensure_dir, + ): + """Repeated socket timeouts should exercise retry and terminal timeout branches.""" + mock_socket_path.exists.return_value = True + + from vocalinux.text_injection.ibus_engine import IBusTextInjector + + with patch("socket.socket") as mock_socket_class: + socket_context = MagicMock() + sock = MagicMock() + sock.connect.side_effect = socket.timeout("slow") + socket_context.__enter__.return_value = sock + socket_context.__exit__.return_value = False + mock_socket_class.return_value = socket_context + + injector = IBusTextInjector(auto_activate=False) + result = injector.inject_text("Hello") + + self.assertFalse(result) + self.assertEqual(sock.connect.call_count, 3) + + @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True) + @patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir") + @patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH") + @patch("vocalinux.text_injection.ibus_engine.start_engine_process", return_value=False) + @patch("vocalinux.text_injection.ibus_engine.is_engine_process_running", return_value=False) + def test_inject_text_connection_refused_aborts_when_restart_fails( + self, + mock_process_running, + mock_start_engine, + mock_socket_path, + mock_ensure_dir, + ): + """Connection refusal with a dead process should fail if restart fails.""" + mock_socket_path.exists.return_value = True + + from vocalinux.text_injection.ibus_engine import IBusTextInjector + + with patch("socket.socket") as mock_socket_class: + socket_context = MagicMock() + sock = MagicMock() + sock.connect.side_effect = ConnectionRefusedError("refused") + socket_context.__enter__.return_value = sock + socket_context.__exit__.return_value = False + mock_socket_class.return_value = socket_context + + injector = IBusTextInjector(auto_activate=False) + result = injector.inject_text("Hello") + + self.assertFalse(result) + mock_start_engine.assert_called_once() + + @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True) + @patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir") + @patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH") + @patch("vocalinux.text_injection.ibus_engine.start_engine_process", return_value=False) + @patch("vocalinux.text_injection.ibus_engine.is_engine_process_running", return_value=False) + def test_inject_text_socket_disappeared_aborts_when_restart_fails( + self, + mock_process_running, + mock_start_engine, + mock_socket_path, + mock_ensure_dir, + ): + """Socket disappearance with a dead process should fail if restart fails.""" + mock_socket_path.exists.return_value = True + + from vocalinux.text_injection.ibus_engine import IBusTextInjector + + with patch("socket.socket") as mock_socket_class: + socket_context = MagicMock() + sock = MagicMock() + sock.connect.side_effect = FileNotFoundError("gone") + socket_context.__enter__.return_value = sock + socket_context.__exit__.return_value = False + mock_socket_class.return_value = socket_context + + injector = IBusTextInjector(auto_activate=False) + result = injector.inject_text("Hello") + + self.assertFalse(result) + mock_start_engine.assert_called_once() + class TestTextInjectorWithIBus(unittest.TestCase): """Tests for TextInjector integration with IBus.""" diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index 213258e..304bd97 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -1415,3 +1415,204 @@ class TestIBusSetupErrorFallback(unittest.TestCase): # Should be able to inject text via xdotool result = injector.inject_text("Hello world") self.assertTrue(result) + + +class TestIBusRuntimeFallback(unittest.TestCase): + """Tests for runtime fallback when IBus injection fails.""" + + def setUp(self): + 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() + mock_process = MagicMock() + mock_process.returncode = 0 + mock_process.stdout = "" + mock_process.stderr = "" + self.mock_subprocess.return_value = mock_process + + def tearDown(self): + self.patch_which.stop() + self.patch_subprocess.stop() + + @patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True) + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True) + @patch("vocalinux.text_injection.text_injector.IBusTextInjector") + def test_runtime_fallback_from_x11_ibus_to_xdotool( + self, + mock_ibus_class, + mock_ibus_available, + mock_is_active, + mock_daemon, + ): + """If IBus runtime injection fails on X11, fallback to xdotool should work.""" + mock_ibus_instance = MagicMock() + mock_ibus_instance.inject_text.return_value = False + mock_ibus_class.return_value = mock_ibus_instance + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}): + injector = TextInjector() + self.assertEqual(injector.environment, DesktopEnvironment.X11_IBUS) + + result = injector.inject_text("Hello from fallback") + + self.assertTrue(result) + self.assertEqual(injector.environment, DesktopEnvironment.X11) + + @patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True) + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True) + @patch("vocalinux.text_injection.text_injector.IBusTextInjector") + def test_runtime_fallback_from_wayland_ibus_to_wtype( + self, + mock_ibus_class, + mock_ibus_available, + mock_is_active, + mock_daemon, + ): + """If IBus runtime injection fails on Wayland, fallback to wtype should work.""" + mock_ibus_instance = MagicMock() + mock_ibus_instance.inject_text.return_value = False + mock_ibus_class.return_value = mock_ibus_instance + + self.mock_which.side_effect = lambda cmd: { + "wtype": "/usr/bin/wtype", + "xdotool": "/usr/bin/xdotool", + }.get(cmd) + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}): + injector = TextInjector() + self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS) + + result = injector.inject_text("Hello via wayland fallback") + + self.assertTrue(result) + self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND) + self.assertEqual(injector.wayland_tool, "wtype") + + def test_switch_from_x11_ibus_fails_without_xdotool(self): + """X11 IBus fallback should fail cleanly when xdotool is unavailable.""" + self.mock_which.return_value = None + injector = TextInjector.__new__(TextInjector) + injector.environment = DesktopEnvironment.X11_IBUS + + result = injector._switch_to_non_ibus_backend() + + self.assertFalse(result) + self.assertEqual(injector.environment, DesktopEnvironment.X11_IBUS) + + def test_switch_from_wayland_ibus_prefers_running_ydotool(self): + """Wayland IBus fallback should prefer ydotool when its daemon responds.""" + self.mock_which.side_effect = lambda cmd: {"ydotool": "/usr/bin/ydotool"}.get(cmd) + injector = TextInjector.__new__(TextInjector) + injector.environment = DesktopEnvironment.WAYLAND_IBUS + injector.wayland_tool = None + + result = injector._switch_to_non_ibus_backend() + + self.assertTrue(result) + self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND) + self.assertEqual(injector.wayland_tool, "ydotool") + self.mock_subprocess.assert_any_call( + ["ydotool", "type", ""], check=True, stderr=subprocess.PIPE, timeout=2 + ) + + def test_switch_from_wayland_ibus_falls_back_when_ydotool_daemon_down(self): + """If ydotool exists but daemon fails, wtype should be selected next.""" + self.mock_which.side_effect = lambda cmd: { + "ydotool": "/usr/bin/ydotool", + "wtype": "/usr/bin/wtype", + }.get(cmd) + self.mock_subprocess.side_effect = subprocess.CalledProcessError(1, ["ydotool"]) + injector = TextInjector.__new__(TextInjector) + injector.environment = DesktopEnvironment.WAYLAND_IBUS + injector.wayland_tool = None + + result = injector._switch_to_non_ibus_backend() + + self.assertTrue(result) + self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND) + self.assertEqual(injector.wayland_tool, "wtype") + + def test_switch_from_wayland_ibus_uses_xwayland_as_last_tool(self): + """Wayland IBus fallback should use xdotool/XWayland when native tools are absent.""" + self.mock_which.side_effect = lambda cmd: {"xdotool": "/usr/bin/xdotool"}.get(cmd) + injector = TextInjector.__new__(TextInjector) + injector.environment = DesktopEnvironment.WAYLAND_IBUS + injector.wayland_tool = None + + result = injector._switch_to_non_ibus_backend() + + self.assertTrue(result) + self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL) + + def test_switch_from_wayland_ibus_fails_without_tools(self): + """Wayland IBus fallback should fail when no non-IBus tools are available.""" + self.mock_which.return_value = None + injector = TextInjector.__new__(TextInjector) + injector.environment = DesktopEnvironment.WAYLAND_IBUS + injector.wayland_tool = None + + result = injector._switch_to_non_ibus_backend() + + self.assertFalse(result) + self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS) + + def test_switch_to_non_ibus_backend_noop_outside_ibus_mode(self): + """Non-IBus modes are already on fallback backends.""" + injector = TextInjector.__new__(TextInjector) + injector.environment = DesktopEnvironment.X11 + + self.assertTrue(injector._switch_to_non_ibus_backend()) + self.assertEqual(injector.environment, DesktopEnvironment.X11) + + @patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True) + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True) + @patch("vocalinux.text_injection.text_injector.IBusTextInjector") + def test_successful_ibus_injection_still_copies_to_clipboard_when_enabled( + self, + mock_ibus_class, + mock_ibus_available, + mock_is_active, + mock_daemon, + ): + """Successful IBus injection should keep the existing optional clipboard copy path.""" + mock_ibus_instance = MagicMock() + mock_ibus_instance.inject_text.return_value = True + mock_ibus_class.return_value = mock_ibus_instance + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}): + injector = TextInjector() + with patch.object(injector, "_should_copy_to_clipboard", return_value=True): + with patch("threading.Thread") as mock_thread: + result = injector.inject_text("copy me") + + self.assertTrue(result) + mock_thread.assert_called_once() + mock_thread.return_value.start.assert_called_once() + + @patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True) + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True) + @patch("vocalinux.text_injection.text_injector.IBusTextInjector") + def test_uninitialized_ibus_injector_uses_non_ibus_fallback( + self, + mock_ibus_class, + mock_ibus_available, + mock_is_active, + mock_daemon, + ): + """If the IBus injector disappears at runtime, fallback backend should still run.""" + mock_ibus_class.return_value = MagicMock() + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}): + injector = TextInjector() + injector._ibus_injector = None + result = injector.inject_text("fallback without ibus instance") + + self.assertTrue(result) + self.assertEqual(injector.environment, DesktopEnvironment.X11) diff --git a/tests/test_xkb_layout.py b/tests/test_xkb_layout.py index 78639d1..d45ec07 100644 --- a/tests/test_xkb_layout.py +++ b/tests/test_xkb_layout.py @@ -7,6 +7,13 @@ from unittest.mock import MagicMock, patch class TestXkbLayoutFunctions(unittest.TestCase): + def setUp(self): + self.env_patcher = patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}) + self.env_patcher.start() + + def tearDown(self): + self.env_patcher.stop() + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") def test_get_current_xkb_layout_success(self, mock_run): mock_run.return_value = MagicMock(