diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a0cb30a51..ecbc5dd4b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed a crash in `TextArea` when undoing an edit to a selection the selection was made backwards https://github.com/Textualize/textual/issues/4301 - Fixed issue with flickering scrollbars https://github.com/Textualize/textual/pull/4315 - Fix progress bar ETA not updating when setting `total` reactive https://github.com/Textualize/textual/pull/4316 +- Exceptions inside `Widget.compose` or workers weren't bubbling up in tests https://github.com/Textualize/textual/issues/4282 ### Changed diff --git a/src/textual/widget.py b/src/textual/widget.py index aac76e8778..4bfaabdee0 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3618,10 +3618,8 @@ async def _compose(self) -> None: raise TypeError( f"{self!r} compose() method returned an invalid result; {error}" ) from error - except Exception: - from rich.traceback import Traceback - - self.app.panic(Traceback()) + except Exception as error: + self.app._handle_exception(error) else: self._extend_compose(widgets) await self.mount_composed_widgets(widgets) diff --git a/src/textual/worker.py b/src/textual/worker.py index 7719cd4dca..f7f10b60ae 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -375,7 +375,8 @@ async def _run(self, app: App) -> None: app.log.worker(Traceback()) if self.exit_on_error: - app._fatal_error() + worker_failed = WorkerFailed(self._error) + app._handle_exception(worker_failed) else: self.state = WorkerState.SUCCESS app.log.worker(self) diff --git a/tests/test_pilot.py b/tests/test_pilot.py index d436f8b5fc..9451237fab 100644 --- a/tests/test_pilot.py +++ b/tests/test_pilot.py @@ -2,12 +2,14 @@ import pytest -from textual import events +from textual import events, work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Center, Middle from textual.pilot import OutOfBounds +from textual.screen import Screen from textual.widgets import Button, Label +from textual.worker import WorkerFailed KEY_CHARACTERS_TO_TEST = "akTW03" + punctuation """Test some "simple" characters (letters + digits) and all punctuation.""" @@ -56,7 +58,7 @@ def on_key(self, event: events.Key) -> None: assert keys_pressed[-1] == char -async def test_pilot_exception_catching_compose(): +async def test_pilot_exception_catching_app_compose(): """Ensuring that test frameworks are aware of exceptions inside compose methods when running via Pilot run_test().""" @@ -70,6 +72,21 @@ def compose(self) -> ComposeResult: pass +async def test_pilot_exception_catching_widget_compose(): + class SomeScreen(Screen[None]): + def compose(self) -> ComposeResult: + 1 / 0 + yield Label("Beep") + + class FailingApp(App[None]): + def on_mount(self) -> None: + self.push_screen(SomeScreen()) + + with pytest.raises(ZeroDivisionError): + async with FailingApp().run_test(): + pass + + async def test_pilot_exception_catching_action(): """Ensure that exceptions inside action handlers are presented to the test framework when running via Pilot run_test().""" @@ -85,6 +102,21 @@ def action_beep(self) -> None: await pilot.press("b") +async def test_pilot_exception_catching_worker(): + class SimpleAppThatCrashes(App[None]): + def on_mount(self) -> None: + self.crash() + + @work(name="crash") + async def crash(self) -> None: + 1 / 0 + + with pytest.raises(WorkerFailed) as exc: + async with SimpleAppThatCrashes().run_test(): + pass + assert exc.type is ZeroDivisionError + + async def test_pilot_click_screen(): """Regression test for https://github.com/Textualize/textual/issues/3395. diff --git a/tests/workers/test_worker.py b/tests/workers/test_worker.py index 7b553d122f..61af91f638 100644 --- a/tests/workers/test_worker.py +++ b/tests/workers/test_worker.py @@ -113,10 +113,10 @@ class ErrorApp(App): pass app = ErrorApp() - async with app.run_test(): - worker: Worker[str] = Worker(app, run_error) - worker._start(app) - with pytest.raises(WorkerFailed): + with pytest.raises(WorkerFailed): + async with app.run_test(): + worker: Worker[str] = Worker(app, run_error) + worker._start(app) await worker.wait() @@ -218,12 +218,12 @@ async def self_referential_work(): await get_current_worker().wait() app = App() - async with app.run_test(): - worker = Worker(app, self_referential_work) - worker._start(app) - with pytest.raises(WorkerFailed) as exc: + with pytest.raises(WorkerFailed) as exc: + async with app.run_test(): + worker = Worker(app, self_referential_work) + worker._start(app) await worker.wait() - assert exc.type is DeadlockError + assert exc.type is DeadlockError async def test_wait_without_start():