mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-05-10 17:22:44 +00:00
Compare commits
1 Commits
main
...
tests-wait
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b0788593d |
@@ -31,7 +31,7 @@ from supervisor.jobs import SupervisorJob
|
||||
from supervisor.mounts.mount import Mount
|
||||
from supervisor.supervisor import Supervisor
|
||||
|
||||
from tests.common import get_fixture_path
|
||||
from tests.common import get_fixture_path, wait_for_task_by_name
|
||||
from tests.const import TEST_ADDON_SLUG
|
||||
|
||||
|
||||
@@ -1444,8 +1444,8 @@ async def test_missing_file_removes_location_from_cache(
|
||||
resp = await api_client.request(method, url_path, json=body)
|
||||
assert resp.status == 404
|
||||
|
||||
# Wait for reload task to complete and confirm location is removed
|
||||
await asyncio.sleep(0.01)
|
||||
# Wait for the reload task spawned by the API to complete
|
||||
await wait_for_task_by_name(coresys, "BackupManager.reload")
|
||||
assert coresys.backups.get(slug).all_locations.keys() == {None}
|
||||
|
||||
|
||||
@@ -1500,8 +1500,8 @@ async def test_missing_file_removes_backup_from_cache(
|
||||
resp = await api_client.request(method, url_path, json=body)
|
||||
assert resp.status == 404
|
||||
|
||||
# Wait for reload task to complete and confirm backup is removed
|
||||
await asyncio.sleep(0.01)
|
||||
# Wait for the reload task spawned by the API to complete
|
||||
await wait_for_task_by_name(coresys, "BackupManager.reload")
|
||||
assert not coresys.backups.list_backups
|
||||
|
||||
|
||||
@@ -1548,7 +1548,7 @@ async def test_immediate_list_after_missing_file_restore(
|
||||
assert len(result["data"]["backups"]) == 2
|
||||
|
||||
event.set()
|
||||
await asyncio.sleep(0.1)
|
||||
await wait_for_task_by_name(coresys, "BackupManager.reload")
|
||||
resp = await api_client.get("/backups")
|
||||
assert resp.status == 200
|
||||
result = await resp.json()
|
||||
|
||||
@@ -19,6 +19,53 @@ from supervisor.utils.yaml import read_yaml_file
|
||||
from .dbus_service_mocks.base import DBusServiceMock
|
||||
|
||||
|
||||
async def wait_for_task_by_name(
|
||||
coresys, qualname_prefix: str, *, timeout: float = 10.0
|
||||
) -> None:
|
||||
"""Await any task whose coroutine qualname starts with prefix.
|
||||
|
||||
Looks at the per-test list of tasks captured by the ``coresys``
|
||||
fixture's ``create_task`` interceptor — that includes tasks that
|
||||
have already completed, which ``asyncio.all_tasks()`` would miss.
|
||||
If no matching task is recorded yet, blocks on
|
||||
``coresys.test_new_task_event`` until one is created (e.g. by a
|
||||
0-delay ``call_later`` callback that has yet to fire). Raises
|
||||
``LookupError`` if no matching task is recorded within ``timeout``
|
||||
seconds — that's a test bug, the call site expects the task to be
|
||||
scheduled.
|
||||
"""
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while True:
|
||||
matched = [
|
||||
t
|
||||
for t in coresys.test_created_tasks
|
||||
if t.get_coro().__qualname__.startswith(qualname_prefix)
|
||||
]
|
||||
if matched:
|
||||
# gather() is fine on already-done tasks (returns immediately)
|
||||
# and propagates the first exception so the test fails on a
|
||||
# real fire-and-forget error instead of silently passing.
|
||||
await asyncio.gather(*matched)
|
||||
return
|
||||
# Clear before waiting so a task created between the check above
|
||||
# and the wait below still wakes us.
|
||||
coresys.test_new_task_event.clear()
|
||||
remaining = deadline - asyncio.get_running_loop().time()
|
||||
if remaining <= 0:
|
||||
raise LookupError(
|
||||
f"No task with qualname prefix {qualname_prefix!r} was created "
|
||||
f"within {timeout}s"
|
||||
)
|
||||
try:
|
||||
async with asyncio.timeout(remaining):
|
||||
await coresys.test_new_task_event.wait()
|
||||
except TimeoutError as err:
|
||||
raise LookupError(
|
||||
f"No task with qualname prefix {qualname_prefix!r} was created "
|
||||
f"within {timeout}s"
|
||||
) from err
|
||||
|
||||
|
||||
def get_fixture_path(filename: str) -> Path:
|
||||
"""Get path for fixture."""
|
||||
return Path(Path(__file__).parent.joinpath("fixtures"), filename)
|
||||
|
||||
@@ -574,6 +574,27 @@ async def coresys(
|
||||
if not request.node.get_closest_marker("no_mock_init_websession"):
|
||||
coresys_obj.init_websession = AsyncMock()
|
||||
|
||||
# Capture every task spawned via coresys.create_task so tests can wait
|
||||
# on fire-and-forget background jobs (e.g. BackupManager.reload) by
|
||||
# name even if they have already finished. ``asyncio.all_tasks()`` only
|
||||
# returns pending tasks, so a fast task can race the test's lookup;
|
||||
# holding our own reference avoids that. The event lets a test
|
||||
# blocking in ``wait_for_task_by_name`` wake up as soon as a new task
|
||||
# is appended.
|
||||
created_tasks: list[asyncio.Task] = []
|
||||
new_task_event = asyncio.Event()
|
||||
_orig_create_task = coresys_obj.create_task
|
||||
|
||||
def _capturing_create_task(*args, **kwargs):
|
||||
task = _orig_create_task(*args, **kwargs)
|
||||
created_tasks.append(task)
|
||||
new_task_event.set()
|
||||
return task
|
||||
|
||||
coresys_obj.create_task = _capturing_create_task
|
||||
coresys_obj.test_created_tasks = created_tasks
|
||||
coresys_obj.test_new_task_event = new_task_event
|
||||
|
||||
# Don't remove files/folders related to apps and stores
|
||||
with patch("supervisor.store.git.GitRepo.remove"):
|
||||
yield coresys_obj
|
||||
|
||||
@@ -18,6 +18,8 @@ from supervisor.plugins.dns import HostEntry, PluginDns
|
||||
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
|
||||
from supervisor.resolution.data import Issue, Suggestion
|
||||
|
||||
from tests.common import wait_for_task_by_name
|
||||
|
||||
|
||||
@pytest.fixture(name="docker_interface")
|
||||
async def fixture_docker_interface() -> tuple[AsyncMock, AsyncMock]:
|
||||
@@ -267,11 +269,13 @@ async def test_notify_locals_changed_end_to_end_with_changes_and_running(
|
||||
patch.object(dns_plugin.instance, "is_running", return_value=True),
|
||||
patch.object(dns_plugin, "sys_call_later", new=mock_call_later),
|
||||
):
|
||||
# Call notify_locals_changed
|
||||
# Call notify_locals_changed; this schedules a 0-delay timer
|
||||
# whose callback creates the actual restart task. The helper
|
||||
# polls a few iterations so the timer-spawned task can show up.
|
||||
dns_plugin.notify_locals_changed()
|
||||
|
||||
# Wait for the async task to complete
|
||||
await asyncio.sleep(0.1)
|
||||
await wait_for_task_by_name(
|
||||
coresys, "PluginDns._restart_dns_after_locals_change"
|
||||
)
|
||||
|
||||
# Verify restart was called and cached locals were updated
|
||||
mock_restart.assert_called_once()
|
||||
@@ -297,11 +301,13 @@ async def test_notify_locals_changed_end_to_end_with_changes_but_not_running(
|
||||
patch.object(dns_plugin.instance, "is_running", return_value=False),
|
||||
patch.object(dns_plugin, "sys_call_later", new=mock_call_later),
|
||||
):
|
||||
# Call notify_locals_changed
|
||||
# Call notify_locals_changed; this schedules a 0-delay timer
|
||||
# whose callback creates the actual restart task. The helper
|
||||
# polls a few iterations so the timer-spawned task can show up.
|
||||
dns_plugin.notify_locals_changed()
|
||||
|
||||
# Wait for the async task to complete
|
||||
await asyncio.sleep(0.1)
|
||||
await wait_for_task_by_name(
|
||||
coresys, "PluginDns._restart_dns_after_locals_change"
|
||||
)
|
||||
|
||||
# Verify restart was NOT called but cached locals were still updated
|
||||
mock_restart.assert_not_called()
|
||||
@@ -322,11 +328,13 @@ async def test_notify_locals_changed_end_to_end_no_changes(
|
||||
patch.object(dns_plugin, "restart") as mock_restart,
|
||||
patch.object(dns_plugin, "sys_call_later", new=mock_call_later),
|
||||
):
|
||||
# Call notify_locals_changed
|
||||
# Call notify_locals_changed; this schedules a 0-delay timer
|
||||
# whose callback creates the actual restart task. The helper
|
||||
# polls a few iterations so the timer-spawned task can show up.
|
||||
dns_plugin.notify_locals_changed()
|
||||
|
||||
# Wait for the async task to complete
|
||||
await asyncio.sleep(0.1)
|
||||
await wait_for_task_by_name(
|
||||
coresys, "PluginDns._restart_dns_after_locals_change"
|
||||
)
|
||||
|
||||
# Verify restart was NOT called since no changes
|
||||
mock_restart.assert_not_called()
|
||||
@@ -376,8 +384,11 @@ async def test_notify_locals_changed_debouncing_cancels_previous_timer(
|
||||
assert second_handle is not None
|
||||
assert first_handle != second_handle
|
||||
|
||||
# Wait for the async task to complete
|
||||
await asyncio.sleep(0.1)
|
||||
# Let the 0-delay timer fire and spawn the restart task; the
|
||||
# helper polls a few iterations so it shows up.
|
||||
await wait_for_task_by_name(
|
||||
coresys, "PluginDns._restart_dns_after_locals_change"
|
||||
)
|
||||
|
||||
# Verify restart was called once for the final timer
|
||||
mock_restart.assert_called_once()
|
||||
|
||||
@@ -21,7 +21,7 @@ from supervisor.host.apparmor import AppArmorControl
|
||||
from supervisor.resolution.const import ContextType, IssueType
|
||||
from supervisor.resolution.data import Issue
|
||||
|
||||
from tests.common import MockResponse
|
||||
from tests.common import MockResponse, wait_for_task_by_name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -199,9 +199,8 @@ async def test_request_connectivity_check_is_fire_and_forget(
|
||||
result = coresys.supervisor.request_connectivity_check(force=True)
|
||||
assert result is None
|
||||
|
||||
# Yield until the scheduled task has had a chance to complete.
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
# Wait for the scheduled background check to finish.
|
||||
await wait_for_task_by_name(coresys, "Supervisor.check_and_update_connectivity")
|
||||
|
||||
assert websession.head.call_count == 1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user