diff --git a/src/korvid/ui/app.py b/src/korvid/ui/app.py index ea8ce12d..b408d573 100644 --- a/src/korvid/ui/app.py +++ b/src/korvid/ui/app.py @@ -31,7 +31,7 @@ import yaml from rich.text import Text -from textual.app import App, ComposeResult, SuspendNotSupported +from textual.app import App, ComposeResult, ScreenStackError, SuspendNotSupported from textual.binding import Binding from textual.containers import Horizontal from textual.coordinate import Coordinate @@ -2387,7 +2387,13 @@ def lookup(view: str, namespace: str) -> list[Summary] | None: def _refresh_hierarchy(self) -> None: """Rebuild an open hierarchy tree from the current store state.""" ctx = self._hierarchy_ctx - screen = self.screen + try: + screen = self.screen + except ScreenStackError: + # A ResourcesUpdated dispatched during app teardown can land + # after the screen stack is emptied (flaky-CI issue #147): + # no screen simply means no tree to refresh. + return if ctx is None or not isinstance(screen, HierarchyScreen): return title, refs, namespace, scope = ctx diff --git a/tests/ui/test_hierarchy_nav.py b/tests/ui/test_hierarchy_nav.py index 8ba5715b..e72ae05a 100644 --- a/tests/ui/test_hierarchy_nav.py +++ b/tests/ui/test_hierarchy_nav.py @@ -4,6 +4,9 @@ import asyncio from collections.abc import AsyncIterator from typing import Any +from unittest import mock + +from textual.app import ScreenStackError from korvid.core.config import KorvidConfig from korvid.core.store import ALL_NAMESPACES, ResourceStore, Summary @@ -424,6 +427,24 @@ async def navigate_then_switch(*args: object, **kwargs: object) -> None: app._ctx_switching = False +async def test_refresh_hierarchy_survives_an_empty_screen_stack() -> None: + """A ResourcesUpdated dispatched during app teardown reaches + _refresh_hierarchy after the screen stack is emptied: reading + App.screen then raises ScreenStackError and fails the whole run + (flaky-CI issue #147) - the refresh must treat 'no screen' as + 'no tree open'.""" + app, _ = make_app(_HELM_DATA, components=_WEB_COMPONENTS) + async with app.run_test() as pilot: + await pilot.pause(0.1) + # Simulate the teardown interleaving deterministically: the message + # handler runs while the screen stack is already empty. + with mock.patch.object( + type(app), "screen", property(mock.Mock(side_effect=ScreenStackError)) + ): + app._refresh_hierarchy() # must not raise + assert True # reaching here is the assertion: no ScreenStackError + + async def test_describe_from_tree_node() -> None: app, describe_calls = make_app(_HELM_DATA, components=_WEB_COMPONENTS) async with app.run_test() as pilot: