From dcc68e87e277a8fccfa08ab94651122d0e433b7d Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 23 Oct 2026 01:25:58 -0400 Subject: [PATCH] Fix Orca extension startup and disable lifecycle An approved and enabled Wine Access extension could remain completely inactive: no Wine D-Bus endpoint was published and no helper was launched, leaving even Wine Notepad silent. The extension implemented on_ready(), on_enabled(), and on_disabled(), but the installed Orca 51 extension loader does not invoke those callbacks. Start Wine Access from set_up_commands(), which Orca's loader actually calls, after preserving the base class setup and checking the disabled state. Override disable() to remove timers, stop managed helpers, and unpublish the endpoint before delegating to Orca's base implementation. Keep on_shutdown() cleanup, the existing delayed bus startup retry, and the guard against duplicate startup. Add five lifecycle regression tests using an installed Orca's actual extension loader, with helper launches, the bus, and timers mocked. Cover startup and repeated setup, disabled extensions, delayed bus availability, shutdown, and disabling both active and pending startup. Document how to run these checks without changing the running reader. The existing integration harness publishes its own endpoint and starts the helper directly, while the API check only instantiates the extension and checks APIs. Neither exercised normal extension startup, which let this failure escape their coverage. Validation: - Before the fix, four of the five lifecycle tests failed; all five pass with the corrected callbacks: GSETTINGS_BACKEND=memory python3 orca/tests/test_lifecycle.py - All 45 existing extension tests passed: python3 -m pytest orca/tests/test_presenter.py orca/tests/test_manager.py orca/tests/test_service.py orca/tests/test_review.py - The installed-Orca API check passed: GSETTINGS_BACKEND=memory python3 orca/tests/orca_api_check.py - A private D-Bus probe loaded the approved package through Orca's actual loader, verified delayed publication of the Wine interface and helper supervision timer, and verified loader shutdown cleanup. - Existing Wine integration checks passed on a private display, session bus, and disposable prefix: dbus-run-session -- xvfb-run -a python3 orca/tests/integration_test.py --build-dir build These covered Notepad editing feedback, menus, save dialog controls and message, NVDA Controller speech/cancellation/braille calls, and helper shutdown after the prefix became idle. Recorded output does not prove physical braille or audible speech. - After installing the fix, reapproving the extension, and restarting Orca, the user confirmed successful live use in three applications. - git diff --check passed. --- orca/README.md | 9 ++++ orca/tests/test_lifecycle.py | 87 ++++++++++++++++++++++++++++++++++++ orca/wine_access/__init__.py | 12 ++--- 3 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 orca/tests/test_lifecycle.py diff --git a/orca/README.md b/orca/README.md index fae74f2..572cb99 100644 --- a/orca/README.md +++ b/orca/README.md @@ -117,6 +117,15 @@ and bus, lets the manager start the helper, runs the Notepad and controller fixtures, and checks what Orca would present. To check the extension against an installed Orca, run `orca/tests/orca_api_check.py` with Orca's Python environment; it loads the extension and verifies the Orca APIs it calls. +Also run its lifecycle regression checks against the installed Orca: + +```sh +GSETTINGS_BACKEND=memory python3 orca/tests/test_lifecycle.py +``` + +These use Orca's actual extension loader to check startup, delayed D-Bus +availability, repeated setup, disabling, and shutdown. Helper launches and +timers are mocked; the checks do not change the running reader. Recorded presentations do not prove audible speech. For live acceptance, open Wine Notepad with Orca running and check the window title, line, diff --git a/orca/tests/test_lifecycle.py b/orca/tests/test_lifecycle.py new file mode 100644 index 0000000..6dc07aa --- /dev/null +++ b/orca/tests/test_lifecycle.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +"""Exercise startup through an installed Orca's actual extension loader.""" + +from pathlib import Path +import sys +import unittest +from unittest.mock import Mock, patch + +try: + from orca import extension_loader +except ImportError as error: + raise unittest.SkipTest("Lifecycle checks require an installed Orca 51") from error + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from wine_access import POLL_INTERVAL_MS, WineAccess # noqa: E402 +from wine_access.service import OBJECT_PATH # noqa: E402 + + +class LifecycleTests(unittest.TestCase): + def setUp(self): + self.manager = Mock(helper="/helper/wine-access.exe") + managerPatch = patch("wine_access.HelperManager", return_value=self.manager) + managerPatch.start() + self.addCleanup(managerPatch.stop) + self.extension = WineAccess() + self.bus = Mock() + busPatch = patch.object(self.extension.controller, "_bus", self.bus) + busPatch.start() + self.addCleanup(busPatch.stop) + self.loader = extension_loader.ExtensionLoader() + self.loader._user_extensions = [self.extension] + disabledPatch = patch.object(self.loader, "get_disabled_extensions", return_value=[]) + disabledPatch.start() + self.addCleanup(disabledPatch.stop) + for name in ("timeout_add", "timeout_add_seconds", "source_remove"): + timerPatch = patch(f"wine_access.GLib.{name}", return_value=42) + setattr(self, name, timerPatch.start()) + self.addCleanup(timerPatch.stop) + + def test_loader_starts_once_and_shutdown_cleans_up(self): + self.loader.set_up_user_commands() + self.loader.set_up_user_commands() + self.bus.publish_object.assert_called_once() + self.assertEqual(self.bus.publish_object.call_args.args[0], OBJECT_PATH) + self.manager.poll.assert_called_once_with() + self.timeout_add.assert_called_once_with(POLL_INTERVAL_MS, self.manager.poll) + self.extension.on_shutdown() + self.manager.shutdown.assert_called_once_with() + self.bus.unpublish_object.assert_called_once_with(OBJECT_PATH) + self.source_remove.assert_called_once_with(42) + + def test_disabled_extension_does_not_start(self): + self.loader.get_disabled_extensions.return_value = [self.extension.module_name] + self.loader.set_up_user_commands() + self.bus.publish_object.assert_not_called() + self.manager.poll.assert_not_called() + self.timeout_add.assert_not_called() + self.timeout_add_seconds.assert_not_called() + + def test_start_waits_for_controller_bus(self): + self.extension.controller._bus = None + self.loader.set_up_user_commands() + self.timeout_add_seconds.assert_called_once_with(2, self.extension._retry_publish) + self.manager.poll.assert_not_called() + self.extension.controller._bus = self.bus + self.assertFalse(self.extension._retry_publish()) + self.bus.publish_object.assert_called_once() + self.manager.poll.assert_called_once_with() + + def test_disable_stops_active_extension(self): + self.loader.set_up_user_commands() + self.extension.disable() + self.manager.shutdown.assert_called_once_with() + self.bus.unpublish_object.assert_called_once_with(OBJECT_PATH) + self.source_remove.assert_called_once_with(42) + + def test_disable_cancels_pending_start(self): + self.extension.controller._bus = None + self.loader.set_up_user_commands() + self.extension.disable() + self.source_remove.assert_called_once_with(42) + self.assertEqual(self.extension._retry_id, 0) + self.manager.poll.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/orca/wine_access/__init__.py b/orca/wine_access/__init__.py index abc5427..a16eafc 100644 --- a/orca/wine_access/__init__.py +++ b/orca/wine_access/__init__.py @@ -52,14 +52,14 @@ class WineAccess(Extension): # Extension lifecycle - def on_ready(self) -> None: - self._start() + def set_up_commands(self) -> None: + super().set_up_commands() + if not self._disabled: + self._start() - def on_enabled(self) -> None: - self._start() - - def on_disabled(self) -> None: + def disable(self) -> None: self._stop() + super().disable() def on_shutdown(self) -> None: self._stop()