1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-04-02 08:12:47 +01:00
Files
supervisor/tests/resolution/test_resolution_manager.py
Stefan Agner da800b8889 Simplify HomeAssistantWebSocket and raise on connection errors (#6553)
* Raise HomeAssistantWSError when Core WebSocket is unreachable

Previously, async_send_command silently returned None when Home Assistant
Core was not reachable, leading to misleading error messages downstream
(e.g. "returned invalid response of None instead of a list of users").

Refactor _can_send to _ensure_connected which now raises
HomeAssistantWSError on connection failures while still returning False
for silent-skip cases (shutdown, unsupported version). async_send_message
catches the exception to preserve fire-and-forget behavior.

Update callers that don't handle HomeAssistantWSError: _hardware_events
and addon auto-update in tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Simplify HomeAssistantWebSocket command/message distinction

The WebSocket layer had a confusing split between "messages" (fire-and-forget)
and "commands" (request/response) that didn't reflect Home Assistant Core's
architecture where everything is just a WS command.

- Remove dead WSClient.async_send_message (never called)
- Rename async_send_message → _async_send_command (private, fire-and-forget)
- Rename send_message → send_command (sync wrapper)
- Simplify _ensure_connected: drop message param, always raise on failure
- Simplify async_send_command: always raise on connection errors
- Remove MIN_VERSION gating (minimum supported Core is now 2024.2+)
- Remove begin_backup/end_backup version guards for Core < 2022.1.0
- Add debug logging for silently ignored connection errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Wait for Core to come up before backup

This is crucial since the WebSocket command to Core now fails with the
new error handling if Core is not running yet.

* Wait for Core install job instead

* Use CLI to fetch jobs instead of Supervisor API

The Supervisor API needs authentication token, which we have not
available at this point in the workflow. Instead of fetching the token,
we can use the CLI, which is available in the container.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 09:20:23 +01:00

452 lines
16 KiB
Python

"""Tests for resolution manager."""
import asyncio
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from supervisor.coresys import CoreSys
from supervisor.exceptions import ResolutionError
from supervisor.resolution.const import (
ContextType,
IssueType,
SuggestionType,
UnhealthyReason,
UnsupportedReason,
)
from supervisor.resolution.data import Issue, Suggestion
def test_properies_unsupported(coresys: CoreSys):
"""Test resolution manager properties unsupported."""
assert coresys.core.supported
coresys.resolution.add_unsupported_reason(UnsupportedReason.OS)
assert not coresys.core.supported
def test_properies_unhealthy(coresys: CoreSys):
"""Test resolution manager properties unhealthy."""
assert coresys.core.healthy
coresys.resolution.add_unhealthy_reason(UnhealthyReason.SUPERVISOR)
assert not coresys.core.healthy
@pytest.mark.asyncio
async def test_resolution_dismiss_suggestion(coresys: CoreSys):
"""Test resolution manager suggestion apply api."""
coresys.resolution.add_suggestion(
clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
)
assert coresys.resolution.suggestions[-1].type == SuggestionType.CLEAR_FULL_BACKUP
coresys.resolution.dismiss_suggestion(clear_backup)
assert clear_backup not in coresys.resolution.suggestions
with pytest.raises(ResolutionError):
coresys.resolution.dismiss_suggestion(clear_backup)
@pytest.mark.asyncio
async def test_resolution_apply_suggestion(coresys: CoreSys):
"""Test resolution manager suggestion apply api."""
coresys.resolution.add_suggestion(
clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
)
coresys.resolution.add_suggestion(
create_backup := Suggestion(
SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM
)
)
mock_backups = AsyncMock()
mock_health = AsyncMock()
coresys.backups.do_backup_full = mock_backups
coresys.resolution.healthcheck = mock_health
await coresys.resolution.apply_suggestion(clear_backup)
await coresys.resolution.apply_suggestion(create_backup)
assert mock_backups.called
assert mock_health.called
assert clear_backup not in coresys.resolution.suggestions
assert create_backup not in coresys.resolution.suggestions
with pytest.raises(ResolutionError):
await coresys.resolution.apply_suggestion(clear_backup)
@pytest.mark.asyncio
async def test_resolution_dismiss_issue(coresys: CoreSys):
"""Test resolution manager issue apply api."""
coresys.resolution.add_issue(
updated_failed := Issue(IssueType.UPDATE_FAILED, ContextType.SYSTEM)
)
assert coresys.resolution.issues[-1].type == IssueType.UPDATE_FAILED
coresys.resolution.dismiss_issue(updated_failed)
assert updated_failed not in coresys.resolution.issues
with pytest.raises(ResolutionError):
coresys.resolution.dismiss_issue(updated_failed)
@pytest.mark.asyncio
async def test_resolution_create_issue_suggestion(coresys: CoreSys):
"""Test resolution manager issue and suggestion."""
coresys.resolution.create_issue(
IssueType.UPDATE_ROLLBACK,
ContextType.CORE,
"slug",
[SuggestionType.EXECUTE_REPAIR],
)
assert coresys.resolution.issues[-1].type == IssueType.UPDATE_ROLLBACK
assert coresys.resolution.issues[-1].context == ContextType.CORE
assert coresys.resolution.issues[-1].reference == "slug"
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REPAIR
assert coresys.resolution.suggestions[-1].context == ContextType.CORE
@pytest.mark.asyncio
async def test_resolution_dismiss_unsupported(coresys: CoreSys):
"""Test resolution manager dismiss unsupported reason."""
coresys.resolution.add_unsupported_reason(UnsupportedReason.SOFTWARE)
coresys.resolution.dismiss_unsupported(UnsupportedReason.SOFTWARE)
assert UnsupportedReason.SOFTWARE not in coresys.resolution.unsupported
with pytest.raises(ResolutionError):
coresys.resolution.dismiss_unsupported(UnsupportedReason.SOFTWARE)
async def test_suggestions_for_issue(coresys: CoreSys):
"""Test getting suggestions that fix an issue."""
coresys.resolution.add_issue(
corrupt_repo := Issue(
IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test_repo"
)
)
# Unrelated suggestions don't appear
coresys.resolution.add_suggestion(
Suggestion(SuggestionType.EXECUTE_RESET, ContextType.SUPERVISOR)
)
coresys.resolution.add_suggestion(
Suggestion(SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "other_repo")
)
assert coresys.resolution.suggestions_for_issue(corrupt_repo) == set()
# Related suggestions do
coresys.resolution.add_suggestion(
execute_remove := Suggestion(
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo"
)
)
coresys.resolution.add_suggestion(
execute_reset := Suggestion(
SuggestionType.EXECUTE_RESET, ContextType.STORE, "test_repo"
)
)
assert coresys.resolution.suggestions_for_issue(corrupt_repo) == {
execute_reset,
execute_remove,
}
async def test_issues_for_suggestion(coresys: CoreSys):
"""Test getting issues fixed by a suggestion."""
coresys.resolution.add_suggestion(
execute_reset := Suggestion(
SuggestionType.EXECUTE_RESET, ContextType.STORE, "test_repo"
)
)
# Unrelated issues don't appear
coresys.resolution.add_issue(Issue(IssueType.FATAL_ERROR, ContextType.CORE))
coresys.resolution.add_issue(
Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "other_repo")
)
assert coresys.resolution.issues_for_suggestion(execute_reset) == set()
# Related issues do
coresys.resolution.add_issue(
fatal_error := Issue(IssueType.FATAL_ERROR, ContextType.STORE, "test_repo")
)
coresys.resolution.add_issue(
corrupt_repo := Issue(
IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test_repo"
)
)
assert coresys.resolution.issues_for_suggestion(execute_reset) == {
fatal_error,
corrupt_repo,
}
def _supervisor_event_message(event: str, data: dict[str, Any]) -> dict[str, Any]:
"""Make mock supervisor event message for ha websocket."""
return {
"type": "supervisor/event",
"data": {
"event": event,
"data": data,
},
}
async def test_events_on_issue_changes(
coresys: CoreSys, supervisor_internet, ha_ws_client: AsyncMock
):
"""Test events fired when an issue changes."""
# Creating an issue with a suggestion should fire exactly one issue changed event
assert coresys.resolution.issues == []
assert coresys.resolution.suggestions == []
coresys.resolution.create_issue(
IssueType.CORRUPT_REPOSITORY,
ContextType.STORE,
"test_repo",
[SuggestionType.EXECUTE_RESET],
)
await asyncio.sleep(0)
assert len(coresys.resolution.issues) == 1
assert len(coresys.resolution.suggestions) == 1
issue = coresys.resolution.issues[0]
suggestion = coresys.resolution.suggestions[0]
issue_expected = {
"type": "corrupt_repository",
"context": "store",
"reference": "test_repo",
"uuid": issue.uuid,
}
suggestion_expected = {
"type": "execute_reset",
"context": "store",
"reference": "test_repo",
"uuid": suggestion.uuid,
}
assert _supervisor_event_message(
"issue_changed", issue_expected | {"suggestions": [suggestion_expected]}
) in [call.args[0] for call in ha_ws_client.async_send_command.call_args_list]
# Adding a suggestion that fixes the issue changes it
ha_ws_client.async_send_command.reset_mock()
coresys.resolution.add_suggestion(
execute_remove := Suggestion(
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo"
)
)
await asyncio.sleep(0)
messages = [
call.args[0]
for call in ha_ws_client.async_send_command.call_args_list
if call.args[0].get("data", {}).get("event") == "issue_changed"
]
assert len(messages) == 1
sent_data = messages[0]
assert sent_data["type"] == "supervisor/event"
assert sent_data["data"]["event"] == "issue_changed"
assert sent_data["data"]["data"].items() >= issue_expected.items()
assert len(sent_data["data"]["data"]["suggestions"]) == 2
assert suggestion_expected in sent_data["data"]["data"]["suggestions"]
assert {
"type": "execute_remove",
"context": "store",
"reference": "test_repo",
"uuid": execute_remove.uuid,
} in sent_data["data"]["data"]["suggestions"]
# Removing a suggestion that fixes the issue changes it again
ha_ws_client.async_send_command.reset_mock()
coresys.resolution.dismiss_suggestion(execute_remove)
await asyncio.sleep(0)
assert _supervisor_event_message(
"issue_changed", issue_expected | {"suggestions": [suggestion_expected]}
) in [call.args[0] for call in ha_ws_client.async_send_command.call_args_list]
# Applying a suggestion should only fire an issue removed event
ha_ws_client.async_send_command.reset_mock()
with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))):
await coresys.resolution.apply_suggestion(suggestion)
await asyncio.sleep(0)
assert _supervisor_event_message("issue_removed", issue_expected) in [
call.args[0] for call in ha_ws_client.async_send_command.call_args_list
]
async def test_resolution_apply_suggestion_multiple_copies(coresys: CoreSys):
"""Test resolution manager applies correct suggestion when has multiple that differ by reference."""
coresys.resolution.add_suggestion(
remove_store_1 := Suggestion(
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_1"
)
)
coresys.resolution.add_suggestion(
remove_store_2 := Suggestion(
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_2"
)
)
coresys.resolution.add_suggestion(
remove_store_3 := Suggestion(
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_3"
)
)
await coresys.resolution.apply_suggestion(remove_store_2)
assert remove_store_1 in coresys.resolution.suggestions
assert remove_store_2 not in coresys.resolution.suggestions
assert remove_store_3 in coresys.resolution.suggestions
async def test_events_on_unsupported_changed(coresys: CoreSys):
"""Test events fired when unsupported changes."""
with patch.object(
type(coresys.homeassistant.websocket), "_async_send_command"
) as send_message:
# Marking system as unsupported tells HA
assert coresys.resolution.unsupported == []
coresys.resolution.add_unsupported_reason(UnsupportedReason.CONNECTIVITY_CHECK)
await asyncio.sleep(0)
assert coresys.resolution.unsupported == [UnsupportedReason.CONNECTIVITY_CHECK]
send_message.assert_called_once_with(
_supervisor_event_message(
"supported_changed",
{"supported": False, "unsupported_reasons": ["connectivity_check"]},
)
)
# Adding the same reason again does nothing
send_message.reset_mock()
coresys.resolution.add_unsupported_reason(UnsupportedReason.CONNECTIVITY_CHECK)
await asyncio.sleep(0)
assert coresys.resolution.unsupported == [UnsupportedReason.CONNECTIVITY_CHECK]
send_message.assert_not_called()
# Adding and removing additional reasons tells HA unsupported reasons changed
coresys.resolution.add_unsupported_reason(UnsupportedReason.JOB_CONDITIONS)
await asyncio.sleep(0)
assert coresys.resolution.unsupported == [
UnsupportedReason.CONNECTIVITY_CHECK,
UnsupportedReason.JOB_CONDITIONS,
]
send_message.assert_called_once_with(
_supervisor_event_message(
"supported_changed",
{
"supported": False,
"unsupported_reasons": ["connectivity_check", "job_conditions"],
},
)
)
send_message.reset_mock()
coresys.resolution.dismiss_unsupported(UnsupportedReason.CONNECTIVITY_CHECK)
await asyncio.sleep(0)
assert coresys.resolution.unsupported == [UnsupportedReason.JOB_CONDITIONS]
send_message.assert_called_once_with(
_supervisor_event_message(
"supported_changed",
{"supported": False, "unsupported_reasons": ["job_conditions"]},
)
)
# Dismissing all unsupported reasons tells HA its supported again
send_message.reset_mock()
coresys.resolution.dismiss_unsupported(UnsupportedReason.JOB_CONDITIONS)
await asyncio.sleep(0)
assert coresys.resolution.unsupported == []
send_message.assert_called_once_with(
_supervisor_event_message(
"supported_changed", {"supported": True, "unsupported_reasons": None}
)
)
async def test_events_on_unhealthy_changed(coresys: CoreSys):
"""Test events fired when unhealthy changes."""
with patch.object(
type(coresys.homeassistant.websocket), "_async_send_command"
) as send_message:
# Marking system as unhealthy tells HA
assert coresys.resolution.unhealthy == []
coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER)
await asyncio.sleep(0)
assert coresys.resolution.unhealthy == [UnhealthyReason.DOCKER]
send_message.assert_called_once_with(
_supervisor_event_message(
"health_changed",
{"healthy": False, "unhealthy_reasons": ["docker"]},
)
)
# Adding the same reason again does nothing
send_message.reset_mock()
coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER)
await asyncio.sleep(0)
assert coresys.resolution.unhealthy == [UnhealthyReason.DOCKER]
send_message.assert_not_called()
# Adding an additional reason tells HA unhealthy reasons changed
coresys.resolution.add_unhealthy_reason(UnhealthyReason.UNTRUSTED)
await asyncio.sleep(0)
assert coresys.resolution.unhealthy == [
UnhealthyReason.DOCKER,
UnhealthyReason.UNTRUSTED,
]
send_message.assert_called_once_with(
_supervisor_event_message(
"health_changed",
{"healthy": False, "unhealthy_reasons": ["docker", "untrusted"]},
)
)
async def test_dismiss_issue_removes_orphaned_suggestions(coresys: CoreSys):
"""Test dismissing an issue also removes any suggestions which have been orphaned."""
with patch.object(
type(coresys.homeassistant.websocket), "_async_send_command"
) as send_message:
coresys.resolution.create_issue(
IssueType.MOUNT_FAILED,
ContextType.MOUNT,
"test",
[SuggestionType.EXECUTE_RELOAD, SuggestionType.EXECUTE_REMOVE],
)
await asyncio.sleep(0)
assert len(coresys.resolution.issues) == 1
assert len(coresys.resolution.suggestions) == 2
send_message.assert_called_once()
send_message.reset_mock()
issue = coresys.resolution.issues[0]
coresys.resolution.dismiss_issue(issue)
await asyncio.sleep(0)
# The issue and both suggestions should be dismissed as they are now orphaned
assert coresys.resolution.issues == []
assert coresys.resolution.suggestions == []
# Only one message should fire to tell HA the issue was removed
send_message.assert_called_once_with(
_supervisor_event_message(
"issue_removed",
{
"type": "mount_failed",
"context": "mount",
"reference": "test",
"uuid": issue.uuid,
},
)
)