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.
This commit is contained in:
Storm Dragon committed 2026-10-23 01:25:58 -04:00
1 parent 28dd2f02dc
commit dcc68e87e2
3 files changed
+102 -6

No files matched your search

+9
View File
@@ -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,
+87
View File
@@ -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()
+6 -6
View File
@@ -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()