Repository: Textualize/textual
Pull request: Tests re-raise exceptions that happen inside `Widget.compose`
This will fix #4282.

The fix was to simply leverage the machinery introduced in #2754; for some reason, exceptions caught inside `Widget.compose` or workers were directly being sent to `App.panic` instead of using `App._handle_exception`.

Problem statement:
Exception not being raised in tests
Consider such an example:

```python
from __future__ import annotations

from textual import work
from textual.app import App


class SimpleAppThatCrashes(App[None]):
    def on_mount(self) -> None:
        self.crash()

    @work(name="crash")
    async def crash(self) -> None:
        raise ValueError("I'm crashing!")


async def test_exception_not_being_raised():
    async with SimpleAppThatCrashes().run_test() as pilot:
        await pilot.pause(1)

# SimpleAppThatCrashes().run()   # Uncomment and run as regular app
```

when running via pilot in pytest - everything is green.

But when running this app as regular :

```python
╭─────────────────────────────────────────────────────────────────────────────────────────── Traceback (most recent call last) ───────────────────────────────────────────────────────────────────────────────────────────╮
│ /home/mzebrak/.pyenv/versions/textual/lib/python3.10/site-packages/textual/worker.py:365 in _run                                                                                                                          │
│                                                                                                                                                                                                                         │
│   362 │   │   self.state = WorkerState.RUNNING                                                 ╭──────────────────────────────────────── locals ────────────────────────────────────────╮                               │
│   363 │   │   app.log.worker(self)                                                             │       app = SimpleAppThatCrashes(title='SimpleAppThatCrashes', classes={'-dark-mode'}) │                               │
│   364 │   │   try:                                                                             │     error = ValueError("I'm crashing!")                                                │                               │
│ ❱ 365 │   │   │   self._result = await self.run()                                              │      self = <Worker ERROR name='crash' description='crash()'>                          │                               │
│   366 │   │   except asyncio.CancelledError as error:                                          │ Traceback = <class 'rich.traceback.Traceback'>                                         │                               │
│   367 │   │   │   self.state = WorkerState.CANCELLED                                           ╰────────────────────────────────────────────────────────────────────────────────────────╯                               │
│   368 │   │   │   self._error = error                                                                                                                                                                                   │
│                                                                                                                                                                                                                         │
│ /home/mzebrak/.pyenv/versions/textual/lib/python3.10/site-packages/textual/worker.py:349 in run                                                                                                                           │
│                                                                                                                                                                                                                         │
│   346 │   │   Returns:                                                                         ╭───────────────────────── locals ─────────────────────────╮                                                             │
│   347 │   │   │   Return value of the work.                                                    │ self = <Worker ERROR name='crash' description='crash()'> │                                                             │
│   348 │   │   """                                                                              ╰──────────────────────────────────────────────────────────╯                                                             │
│ ❱ 349 │   │   return await (                                                                                                                                                                                            │
│   350 │   │   │   self._run_threaded() if self._thread_worker else self._run_async()                                                                                                                                    │
│   351 │   │   )                                                                                                                                                                                                         │
│   352                                                                                                                                                                                                                   │
│                                                                                                                                                                                                                         │
│ /home/mzebrak/.pyenv/versions/textual/lib/python3.10/site-packages/textual/worker.py:334 in _run_async                                                                                                                    │
│                                                                                                                                                                                                                         │
│   331 │   │   │   or hasattr(self._work, "func")                                               ╭───────────────────────── locals ─────────────────────────╮                                                             │
│   332 │   │   │   and inspect.iscoroutinefunction(self._work.func)                             │ self = <Worker ERROR name='crash' description='crash()'> │                                                             │
│   333 │   │   ):                                                                               ╰──────────────────────────────────────────────────────────╯                                                             │
│ ❱ 334 │   │   │   return await self._work()                                                                                                                                                                             │
│   335 │   │   elif inspect.isawaitable(self._work):                                                                                                                                                                     │
│   336 │   │   │   return await self._work                                                                                                                                                                               │
│   337 │   │   elif callable(self._work):                                                                                                                                                                                │
│                                                                                                                                                                                                                         │
│ /home/mzebrak/1workspace/textual/textual_pytest_exceptions.py:13 in crash                                                                                                                                                 │
│                                                                                                                                                                                                                         │
│   10 │                                                                                        ╭───────────────────────────────────── locals ──────────────────────────────────────╮                                     │
│   11 │   @work(name="crash")                                                                  │ self = SimpleAppThatCrashes(title='SimpleAppThatCrashes', classes={'-dark-mode'}) │                                     │
│   12 │   async def crash(self) -> None:                                                       ╰───────────────────────────────────────────────────────────────────────────────────╯                                     │
│ ❱ 13 │   │   raise ValueError("I'm crashing!")                                                                                                                                                                          │
│   14                                                                                                                                                                                                                    │
│   15                                                                                                                                                                                                                    │
│   16 async def test_exception_not_being_raised():                                                                                                                                                                       │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
ValueError: I'm crashing!
```

Hints from issue discussion:
Thank you for your issue. Give us a little time to review it.

PS. You might want to check the [FAQ](https://textual.textualize.io/FAQ/) if you haven't done so already.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
Another intresting (even simplier) case:

```python
from __future__ import annotations

from textual.app import App, ComposeResult
from textual.screen import Screen


class SomeScreen(Screen):
    def compose(self) -> ComposeResult:
        self.query_one("#not-existing")
        return []


class SimpleAppThatCrashes(App[None]):
    def on_mount(self) -> None:
        self.push_screen(SomeScreen())


async def test_exception_not_being_raised():
    async with SimpleAppThatCrashes().run_test() as pilot:
        await pilot.pause(1)
        assert isinstance(pilot.app.screen, SomeScreen)


# SimpleAppThatCrashes().run()  # Uncomment and run as regular app
```
I think we can make `run_test()` re-raise fatal errors, such as those raised from workers.

Commit message: Add test to ensure exception reraised in tests.

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a0cb30a51..3239310763 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` 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/tests/test_pilot.py b/tests/test_pilot.py
index d436f8b5fc..10ea623453 100644
--- a/tests/test_pilot.py
+++ b/tests/test_pilot.py
@@ -7,6 +7,7 @@
 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
 
 KEY_CHARACTERS_TO_TEST = "akTW03" + punctuation
@@ -56,7 +57,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 +71,21 @@ def compose(self) -> ComposeResult:
             pass
 
 
+async def test_pilot_exception_cathing_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()."""


Code review comments:
diff_hunk: @@ -70,6 +71,21 @@ def compose(self) -> ComposeResult:
             pass
 
 
+async def test_pilot_exception_cathing_widget_compose():
path: tests/test_pilot.py
text: @user1:
```suggestion
async def test_pilot_exception_catching_widget_compose():
```