Files
supervisor/tests/resolution/fixup/test_app_execute_restart.py
T
Stefan AgnerandClaude Fable 5 69c5a53864 Surface fixup failures to the caller applying a suggestion (#7145)
* Surface fixup failures to the caller applying a suggestion

Applying a suggestion whose fixup failed reported success: the fixup
base swallowed ResolutionFixupError, the API returned OK, and the
repair flow in Home Assistant completed and removed the repair from
the UI while the issue persisted in Supervisor.

Let the error propagate from the fixup instead. The autofix loop
already handles and logs per-fixup errors, bus-event triggered fixups
now get the same treatment in the event callback, and a user-applied
suggestion surfaces the failure as an API error so clients can show
that the fix did not apply. ResolutionFixupError becomes an APIError
with a translatable error key so it is reported as a client-visible
error instead of an unexpected one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop ResolutionFixupError, let fixup failures bubble

Per review: the generic wrapper existed for a time when suggestions
were only applied by autofix and the one job was separating fixup
failures from real bugs for Sentry. The suggestion API is in regular
use now and the wrapper actively hurts it — well-defined errors from
the underlying operations were caught and replaced with a generic
message.

Remove the exception type entirely and let the original errors reach
the caller. The autofix loop and the bus-event fixup path treat any
HassioError as an environmental/config failure: log and continue
without Sentry capture (the raise site reports to Sentry where
warranted); everything else is still captured. Direct raises in the
data disk fixups become HassOSDataDiskError, the could-not-start check
in the app start fixup becomes AppsError. ResolutionFixupJobError now
derives from ResolutionError and JobException.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Share unattended fixup error handling between autofix and bus events

Address review: the bus event listener only caught HassioError, so a
Supervisor bug in a bus-triggered fixup ended as an unretrieved task
exception without Sentry report, unlike the same bug during autofix.
Both unattended paths now use a common apply_fixup_safely() helper:
log HassioError (environmental, reported at the raise site if
warranted), capture everything else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 23:14:44 +02:00

123 lines
4.2 KiB
Python

"""Test fixup app execute restart."""
from unittest.mock import patch
import pytest
from supervisor.apps.app import App
from supervisor.const import AppState
from supervisor.coresys import CoreSys
from supervisor.docker.app import DockerApp
from supervisor.docker.interface import DockerInterface
from supervisor.exceptions import AppUnknownError, DockerError
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from supervisor.resolution.data import Issue, Suggestion
from supervisor.resolution.fixups.app_execute_restart import FixupAppExecuteRestart
from tests.common import force_app_state
from tests.const import TEST_ADDON_SLUG
DEVICE_ACCESS_MISSING_ISSUE = Issue(
IssueType.DEVICE_ACCESS_MISSING,
ContextType.ADDON,
reference=TEST_ADDON_SLUG,
)
EXECUTE_RESTART_SUGGESTION = Suggestion(
SuggestionType.EXECUTE_RESTART, ContextType.ADDON, reference="local_ssh"
)
@pytest.mark.usefixtures("path_extern")
async def test_fixup(coresys: CoreSys, install_app_ssh: App):
"""Test fixup restarts app."""
force_app_state(install_app_ssh, AppState.STARTED)
app_execute_restart = FixupAppExecuteRestart(coresys)
assert app_execute_restart.auto is False
async def mock_stop(*args, **kwargs):
force_app_state(install_app_ssh, AppState.STOPPED)
coresys.resolution.add_issue(
DEVICE_ACCESS_MISSING_ISSUE,
suggestions=[SuggestionType.EXECUTE_RESTART],
)
with (
patch.object(DockerInterface, "stop") as stop,
patch.object(DockerApp, "run") as run,
patch.object(App, "_wait_for_startup"),
patch.object(App, "write_options"),
):
await app_execute_restart()
stop.assert_called_once()
run.assert_called_once()
assert not coresys.resolution.issues
assert not coresys.resolution.suggestions
@pytest.mark.usefixtures("path_extern")
async def test_fixup_stop_error(
coresys: CoreSys, install_app_ssh: App, caplog: pytest.LogCaptureFixture
):
"""Test fixup fails on stop app failure."""
force_app_state(install_app_ssh, AppState.STARTED)
app_execute_start = FixupAppExecuteRestart(coresys)
coresys.resolution.add_issue(
DEVICE_ACCESS_MISSING_ISSUE,
suggestions=[SuggestionType.EXECUTE_RESTART],
)
with (
patch.object(DockerInterface, "stop", side_effect=DockerError),
patch.object(DockerApp, "run") as run,
):
with pytest.raises(AppUnknownError):
await app_execute_start()
run.assert_not_called()
assert DEVICE_ACCESS_MISSING_ISSUE in coresys.resolution.issues
assert EXECUTE_RESTART_SUGGESTION in coresys.resolution.suggestions
assert "Could not stop container for app local_ssh" in caplog.text
@pytest.mark.usefixtures("path_extern")
async def test_fixup_start_error(
coresys: CoreSys, install_app_ssh: App, caplog: pytest.LogCaptureFixture
):
"""Test fixup logs a start app failure."""
force_app_state(install_app_ssh, AppState.STARTED)
app_execute_start = FixupAppExecuteRestart(coresys)
coresys.resolution.add_issue(
DEVICE_ACCESS_MISSING_ISSUE,
suggestions=[SuggestionType.EXECUTE_RESTART],
)
with (
patch.object(DockerInterface, "stop") as stop,
patch.object(DockerApp, "run", side_effect=DockerError),
patch.object(App, "write_options"),
):
await app_execute_start()
stop.assert_called_once()
assert DEVICE_ACCESS_MISSING_ISSUE not in coresys.resolution.issues
assert EXECUTE_RESTART_SUGGESTION not in coresys.resolution.suggestions
assert "Could not restart local_ssh" in caplog.text
async def test_fixup_no_app(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
"""Test fixup dismisses if app is missing."""
app_execute_start = FixupAppExecuteRestart(coresys)
coresys.resolution.add_issue(
DEVICE_ACCESS_MISSING_ISSUE,
suggestions=[SuggestionType.EXECUTE_RESTART],
)
with patch.object(DockerApp, "stop") as stop:
await app_execute_start()
stop.assert_not_called()
assert not coresys.resolution.issues
assert not coresys.resolution.suggestions
assert "Cannot restart app local_ssh as it does not exist" in caplog.text