diff --git a/tests/addons/test_addon.py b/tests/addons/test_addon.py index 1e1b03e3b83..52ccb319aa4 100644 --- a/tests/addons/test_addon.py +++ b/tests/addons/test_addon.py @@ -6,7 +6,7 @@ from http import HTTPStatus from pathlib import Path, PurePath from typing import Any -from unittest.mock import MagicMock, PropertyMock, call, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch import aiodocker from aiodocker.containers import DockerContainer @@ -51,16 +51,18 @@ from tests.const import TEST_ADDON_SLUG -def _fire_test_event(coresys: CoreSys, name: str, state: ContainerState): - """Fire a test event.""" - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name=name, - state=state, - id="abc123", - time=1, - ), +async def _fire_test_event(coresys: CoreSys, name: str, state: ContainerState) -> None: + """Fire a test event and await the listener tasks the bus spawned.""" + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name=name, + state=state, + id="abc123", + time=1, + ), + ) ) @@ -125,25 +127,30 @@ async def test_app_state_listener(coresys: CoreSys, install_app_ssh: App) -> Non assert install_app_ssh.state == AppState.UNKNOWN with patch.object(App, "watchdog_container"): - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING + ) assert install_app_ssh.state == AppState.STARTED - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED + ) assert install_app_ssh.state == AppState.STOPPED - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.HEALTHY) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.HEALTHY + ) assert install_app_ssh.state == AppState.STARTED - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.FAILED) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.FAILED + ) assert install_app_ssh.state == AppState.ERROR # Test other apps are ignored - _fire_test_event(coresys, "addon_local_non_installed", ContainerState.RUNNING) - await asyncio.sleep(0) + await _fire_test_event( + coresys, "addon_local_non_installed", ContainerState.RUNNING + ) assert install_app_ssh.state == AppState.ERROR @@ -155,15 +162,20 @@ async def test_app_watchdog(coresys: CoreSys, install_app_ssh: App) -> None: install_app_ssh.watchdog = True install_app_ssh._manual_stop = False # pylint: disable=protected-access + # Watchdog does ``await (await self.start())`` because App.start returns + # an asyncio.Task. The mock must mirror that shape. + done_task = asyncio.get_running_loop().create_future() + done_task.set_result(None) with ( - patch.object(App, "restart") as restart, - patch.object(App, "start") as start, + patch.object(App, "restart", AsyncMock(return_value=done_task)) as restart, + patch.object(App, "start", AsyncMock(return_value=done_task)) as start, patch.object(DockerApp, "current_state") as current_state, ): # Restart if it becomes unhealthy current_state.return_value = ContainerState.UNHEALTHY - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.UNHEALTHY) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.UNHEALTHY + ) restart.assert_called_once() start.assert_not_called() @@ -172,8 +184,9 @@ async def test_app_watchdog(coresys: CoreSys, install_app_ssh: App) -> None: # Rebuild if it failed current_state.return_value = ContainerState.FAILED with patch.object(DockerApp, "stop") as stop: - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.FAILED) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.FAILED + ) stop.assert_called_once_with(remove_container=True) restart.assert_not_called() start.assert_called_once() @@ -182,15 +195,17 @@ async def test_app_watchdog(coresys: CoreSys, install_app_ssh: App) -> None: # Do not process event if container state has changed since fired current_state.return_value = ContainerState.HEALTHY - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.FAILED) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.FAILED + ) restart.assert_not_called() start.assert_not_called() # Other apps ignored current_state.return_value = ContainerState.UNHEALTHY - _fire_test_event(coresys, "addon_local_non_installed", ContainerState.UNHEALTHY) - await asyncio.sleep(0) + await _fire_test_event( + coresys, "addon_local_non_installed", ContainerState.UNHEALTHY + ) restart.assert_not_called() start.assert_not_called() @@ -216,8 +231,9 @@ async def test_watchdog_port_conflict_does_not_retry( patch("supervisor.addons.addon.async_capture_exception") as capture_exception, ): caplog.clear() - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.FAILED) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.FAILED + ) start.assert_called_once() capture_exception.assert_not_called() @@ -231,8 +247,12 @@ async def test_watchdog_on_stop(coresys: CoreSys, install_app_ssh: App) -> None: install_app_ssh.watchdog = True + # Watchdog does ``await (await self.restart())`` because App.restart + # returns an asyncio.Task; the mock must mirror that shape. + done_task = asyncio.get_running_loop().create_future() + done_task.set_result(None) with ( - patch.object(App, "restart") as restart, + patch.object(App, "restart", AsyncMock(return_value=done_task)) as restart, patch.object( DockerApp, "current_state", @@ -241,18 +261,22 @@ async def test_watchdog_on_stop(coresys: CoreSys, install_app_ssh: App) -> None: patch.object(DockerApp, "stop"), ): # Do not restart when app stopped by user - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING + ) await install_app_ssh.stop() - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED + ) restart.assert_not_called() # Do restart app if it stops and user didn't do it - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) - await asyncio.sleep(0) - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING + ) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED + ) restart.assert_called_once() @@ -279,8 +303,7 @@ async def test_listener_attached_on_install(coresys: CoreSys): # Normally this would be defaulted to False on start of the app but test skips that coresys.apps.get_local_only(TEST_ADDON_SLUG).watchdog = False - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) - await asyncio.sleep(0) + await _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) assert coresys.apps.get(TEST_ADDON_SLUG).state == AppState.STARTED @@ -297,8 +320,12 @@ async def test_watchdog_during_attach( store = coresys.apps.store[TEST_ADDON_SLUG] await coresys.apps.data.install(store) + # Watchdog does ``await (await self.restart())`` because App.restart + # returns an asyncio.Task; the mock must mirror that shape. + done_task = asyncio.get_running_loop().create_future() + done_task.set_result(None) with ( - patch.object(App, "restart") as restart, + patch.object(App, "restart", AsyncMock(return_value=done_task)) as restart, patch.object(HwHelper, "last_boot", return_value=utcnow()), patch.object(DockerApp, "attach"), patch.object( @@ -315,8 +342,9 @@ async def test_watchdog_during_attach( app.watchdog = True await app.load() - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED) - await asyncio.sleep(0) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED + ) assert restart.call_count == restart_count @@ -386,7 +414,7 @@ async def test_start(coresys: CoreSys, install_app_ssh: App) -> None: start_task = await install_app_ssh.start() assert start_task - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) + await _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) await start_task assert install_app_ssh.state == AppState.STARTED @@ -409,14 +437,12 @@ async def test_start_wait_healthcheck( start_task = await install_app_ssh.start() assert start_task - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) - await asyncio.sleep(0.01) + await _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) assert not start_task.done() assert install_app_ssh.state == AppState.STARTUP - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", state) - await asyncio.sleep(0.01) + await _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", state) assert start_task.done() assert install_app_ssh.state == AppState.STARTED @@ -455,7 +481,7 @@ async def test_restart(coresys: CoreSys, install_app_ssh: App) -> None: start_task = await install_app_ssh.restart() assert start_task - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) + await _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) await start_task assert install_app_ssh.state == AppState.STARTED @@ -654,7 +680,9 @@ async def test_backup_cold_mode_with_watchdog( async def mock_stop(*args, **kwargs): container.show.return_value["State"]["Status"] = "stopped" container.show.return_value["State"]["Running"] = False - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED + ) # Patching out the normal end of backup process leaves the container in a stopped state # Watchdog should still not try to restart it though, it should remain this way @@ -738,7 +766,9 @@ async def test_restore_while_running_with_watchdog( async def mock_stop(*args, **kwargs): container.show.return_value["State"]["Status"] = "stopped" container.show.return_value["State"]["Running"] = False - _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED) + await _fire_test_event( + coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED + ) # We restore a stopped backup so restore will not restart it # Watchdog will see it stop and should not attempt reanimation either diff --git a/tests/addons/test_manager.py b/tests/addons/test_manager.py index 6122028514e..a32f4d18ade 100644 --- a/tests/addons/test_manager.py +++ b/tests/addons/test_manager.py @@ -384,16 +384,17 @@ async def test_start_wait_resolved_on_uninstall_in_startup( start_task = await install_app_ssh.start() assert start_task - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name=f"addon_{TEST_ADDON_SLUG}", - state=ContainerState.RUNNING, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name=f"addon_{TEST_ADDON_SLUG}", + state=ContainerState.RUNNING, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0.01) assert not start_task.done() assert install_app_ssh.state == AppState.STARTUP @@ -525,6 +526,11 @@ async def mock_update(*args, **kwargs): patch.object(App, "restart") as restart, ): await coresys.apps.update("local_ssh") + # mock_update yielded once (sleep(0)), giving the watchdog task + # spawned by mock_stop time to run to completion within update's + # own awaits — so by the time we get here it's already done. + # A trailing sleep(0) defends against scheduling jitter without + # racing the assertion. await asyncio.sleep(0) start.assert_called_once() restart.assert_not_called() diff --git a/tests/api/test_addons.py b/tests/api/test_addons.py index 10dfaa33a06..1e151ab2639 100644 --- a/tests/api/test_addons.py +++ b/tests/api/test_addons.py @@ -123,7 +123,7 @@ async def test_api_app_start_healthcheck( async def container_events(): nonlocal state_changes - await asyncio.sleep(0.01) + await asyncio.sleep(0) await install_app_ssh.container_state_changed( _create_test_event(f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) @@ -162,7 +162,7 @@ async def test_api_app_restart_healthcheck( async def container_events(): nonlocal state_changes - await asyncio.sleep(0.01) + await asyncio.sleep(0) await install_app_ssh.container_state_changed( _create_test_event(f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) diff --git a/tests/api/test_backups.py b/tests/api/test_backups.py index 2fc171cbf01..25062ad394e 100644 --- a/tests/api/test_backups.py +++ b/tests/api/test_backups.py @@ -31,7 +31,7 @@ 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 mock_wait(tasks: list[asyncio.Task], *args, **kwargs): 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() diff --git a/tests/api/test_store.py b/tests/api/test_store.py index cf1f8f2dea2..735d5316606 100644 --- a/tests/api/test_store.py +++ b/tests/api/test_store.py @@ -225,7 +225,7 @@ async def test_api_store_update_healthcheck( async def container_events(): nonlocal state_changes - await asyncio.sleep(0.01) + await asyncio.sleep(0) await install_app_ssh.container_state_changed( DockerContainerStateEvent( diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py index d1de3e02149..7f2f31710ae 100644 --- a/tests/backups/test_manager.py +++ b/tests/backups/test_manager.py @@ -966,7 +966,7 @@ async def test_backup_with_healthcheck( async def container_events(): nonlocal state_changes - await asyncio.sleep(0.01) + await asyncio.sleep(0) await install_app_ssh.container_state_changed( DockerContainerStateEvent( diff --git a/tests/common.py b/tests/common.py index 08408979e77..b69e985d4be 100644 --- a/tests/common.py +++ b/tests/common.py @@ -19,6 +19,36 @@ from .dbus_service_mocks.base import DBusServiceMock +async def wait_for_task_by_name( + coresys, qualname_prefix: str, *, max_iterations: int = 3 +) -> 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. + Yields up to ``max_iterations`` times so a task created indirectly + via a 0-delay ``call_later`` (the timer needs the loop to advance + before its callback runs) can show up. Raises ``LookupError`` if + no matching task is ever recorded — the call site expects the + task to be scheduled. + """ + for _ in range(max_iterations): + 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). + # return_exceptions swallows fire-and-forget task errors so the + # helper doesn't surface unrelated background failures. + await asyncio.gather(*matched, return_exceptions=True) + return + await asyncio.sleep(0) + raise LookupError(f"No task with qualname prefix {qualname_prefix!r} was created") + + def get_fixture_path(filename: str) -> Path: """Get path for fixture.""" return Path(Path(__file__).parent.joinpath("fixtures"), filename) diff --git a/tests/conftest.py b/tests/conftest.py index ea1e2a833f8..ee902eea442 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -574,6 +574,22 @@ 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. + created_tasks: list[asyncio.Task] = [] + _orig_create_task = coresys_obj.create_task + + def _capturing_create_task(*args, **kwargs): + task = _orig_create_task(*args, **kwargs) + created_tasks.append(task) + return task + + coresys_obj.create_task = _capturing_create_task + coresys_obj.test_created_tasks = created_tasks + # Don't remove files/folders related to apps and stores with patch("supervisor.store.git.GitRepo.remove"): yield coresys_obj diff --git a/tests/docker/test_addon.py b/tests/docker/test_addon.py index 45f1e7a65f9..6fd2d123a0b 100644 --- a/tests/docker/test_addon.py +++ b/tests/docker/test_addon.py @@ -6,7 +6,7 @@ from ipaddress import IPv4Address from pathlib import Path from typing import Any -from unittest.mock import MagicMock, Mock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiodocker import pytest @@ -434,15 +434,15 @@ async def test_app_new_device( with ( patch.object(App, "write_options"), patch.object(OSManager, "available", new=PropertyMock(return_value=is_os)), - patch.object(CGroup, "add_devices_allowed") as add_devices, + patch.object( + CGroup, "add_devices_allowed", new_callable=AsyncMock + ) as add_devices, ): await install_app_ssh.start() - coresys.bus.fire_event( - BusEvent.HARDWARE_NEW_DEVICE, - TEST_HW_DEVICE, + await asyncio.gather( + *coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, TEST_HW_DEVICE) ) - await asyncio.sleep(0.01) add_devices.assert_called_once_with(123, "c 0:0 rwm") @@ -460,15 +460,15 @@ async def test_app_new_device_no_haos( with ( patch.object(App, "write_options"), patch.object(OSManager, "available", new=PropertyMock(return_value=False)), - patch.object(CGroup, "add_devices_allowed") as add_devices, + patch.object( + CGroup, "add_devices_allowed", new_callable=AsyncMock + ) as add_devices, ): await install_app_ssh.start() - coresys.bus.fire_event( - BusEvent.HARDWARE_NEW_DEVICE, - TEST_HW_DEVICE, + await asyncio.gather( + *coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, TEST_HW_DEVICE) ) - await asyncio.sleep(0.01) add_devices.assert_not_called() diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index 971be7b042e..94e2a072183 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -431,7 +431,7 @@ async def capture_log_entry(event: PullLogEntry) -> None: ) coresys.docker.images.inspect.assert_called_once_with("test:1.2.3") - await asyncio.sleep(1) + await asyncio.sleep(0) assert events == [ PullLogEntry( job_id=ANY, @@ -894,7 +894,7 @@ async def mock_install(self) -> None: ) coresys.docker.images.inspect.assert_called_once_with("test:1.2.3") - await asyncio.sleep(1) + await asyncio.sleep(0) def job_event(progress: float, done: bool = False): return { diff --git a/tests/homeassistant/test_home_assistant_watchdog.py b/tests/homeassistant/test_home_assistant_watchdog.py index 67d043b9937..c07807d2dc0 100644 --- a/tests/homeassistant/test_home_assistant_watchdog.py +++ b/tests/homeassistant/test_home_assistant_watchdog.py @@ -35,75 +35,80 @@ async def test_home_assistant_watchdog(coresys: CoreSys) -> None: ) as current_state, ): current_state.return_value = ContainerState.UNHEALTHY - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name="homeassistant", - state=ContainerState.UNHEALTHY, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name="homeassistant", + state=ContainerState.UNHEALTHY, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) restart.assert_called_once() start.assert_not_called() restart.reset_mock() current_state.return_value = ContainerState.FAILED - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name="homeassistant", - state=ContainerState.FAILED, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name="homeassistant", + state=ContainerState.FAILED, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) restart.assert_not_called() start.assert_called_once() start.reset_mock() # Do not process event if container state has changed since fired current_state.return_value = ContainerState.HEALTHY - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name="homeassistant", - state=ContainerState.FAILED, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name="homeassistant", + state=ContainerState.FAILED, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) restart.assert_not_called() start.assert_not_called() # Do not restart when home assistant stopped normally - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name="homeassistant", - state=ContainerState.STOPPED, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name="homeassistant", + state=ContainerState.STOPPED, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) restart.assert_not_called() start.assert_not_called() # Other containers ignored - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name="addon_local_other", - state=ContainerState.UNHEALTHY, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name="addon_local_other", + state=ContainerState.UNHEALTHY, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) restart.assert_not_called() start.assert_not_called() @@ -133,7 +138,7 @@ async def test_home_assistant_watchdog_rebuild_on_failure(coresys: CoreSys) -> N return_value=ContainerState.FAILED, ), ): - coresys.bus.fire_event( + listener_tasks = coresys.bus.fire_event( BusEvent.DOCKER_CONTAINER_STATE_CHANGE, DockerContainerStateEvent( name="homeassistant", @@ -142,7 +147,7 @@ async def test_home_assistant_watchdog_rebuild_on_failure(coresys: CoreSys) -> N time=1, ), ) - await asyncio.sleep(0.1) + await asyncio.gather(*listener_tasks) start.assert_called_once() rebuild.assert_called_once() @@ -207,16 +212,17 @@ async def test_home_assistant_watchdog_unregisters_on_shutdown( ), ): # Watchdog should respond to events before shutdown - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name="homeassistant", - state=ContainerState.FAILED, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name="homeassistant", + state=ContainerState.FAILED, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) start.assert_called_once() start.reset_mock() @@ -226,22 +232,26 @@ async def test_home_assistant_watchdog_unregisters_on_shutdown( coresys.homeassistant.core._watchdog_listener = watchdog_listener # Fire shutdown state change - coresys.bus.fire_event(BusEvent.SUPERVISOR_STATE_CHANGE, shutdown_state) - await asyncio.sleep(0) + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.SUPERVISOR_STATE_CHANGE, shutdown_state + ) + ) # Verify watchdog listener is unregistered assert coresys.homeassistant.core._watchdog_listener is None # Watchdog should not respond to events after shutdown - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name="homeassistant", - state=ContainerState.FAILED, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name="homeassistant", + state=ContainerState.FAILED, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) start.assert_not_called() restart.assert_not_called() diff --git a/tests/plugins/test_dns.py b/tests/plugins/test_dns.py index d3abb6f538e..e413d8e7558 100644 --- a/tests/plugins/test_dns.py +++ b/tests/plugins/test_dns.py @@ -18,6 +18,8 @@ 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 @@ def mock_call_later_with_tracking(_delay, *args, **kwargs) -> asyncio.TimerHandl 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() diff --git a/tests/plugins/test_plugin_base.py b/tests/plugins/test_plugin_base.py index 282d736c842..88ac4686a2b 100644 --- a/tests/plugins/test_plugin_base.py +++ b/tests/plugins/test_plugin_base.py @@ -74,76 +74,81 @@ async def test_plugin_watchdog(coresys: CoreSys, plugin: PluginBase) -> None: patch.object(type(plugin.instance), "current_state") as current_state, ): current_state.return_value = ContainerState.UNHEALTHY - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name=plugin.instance.name, - state=ContainerState.UNHEALTHY, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name=plugin.instance.name, + state=ContainerState.UNHEALTHY, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) rebuild.assert_called_once() start.assert_not_called() rebuild.reset_mock() current_state.return_value = ContainerState.FAILED - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name=plugin.instance.name, - state=ContainerState.FAILED, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name=plugin.instance.name, + state=ContainerState.FAILED, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) rebuild.assert_called_once() start.assert_not_called() rebuild.reset_mock() # Stop should be ignored as it means an update or system shutdown, plugins don't stop otherwise current_state.return_value = ContainerState.STOPPED - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name=plugin.instance.name, - state=ContainerState.STOPPED, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name=plugin.instance.name, + state=ContainerState.STOPPED, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) rebuild.assert_not_called() start.assert_not_called() # Do not process event if container state has changed since fired current_state.return_value = ContainerState.HEALTHY - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name=plugin.instance.name, - state=ContainerState.FAILED, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name=plugin.instance.name, + state=ContainerState.FAILED, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) rebuild.assert_not_called() start.assert_not_called() # Other containers ignored - coresys.bus.fire_event( - BusEvent.DOCKER_CONTAINER_STATE_CHANGE, - DockerContainerStateEvent( - name="addon_local_other", - state=ContainerState.UNHEALTHY, - id="abc123", - time=1, - ), + await asyncio.gather( + *coresys.bus.fire_event( + BusEvent.DOCKER_CONTAINER_STATE_CHANGE, + DockerContainerStateEvent( + name="addon_local_other", + state=ContainerState.UNHEALTHY, + id="abc123", + time=1, + ), + ) ) - await asyncio.sleep(0) rebuild.assert_not_called() start.assert_not_called() diff --git a/tests/resolution/fixup/test_store_execute_reload.py b/tests/resolution/fixup/test_store_execute_reload.py index d0aace1401f..e331ee59ca5 100644 --- a/tests/resolution/fixup/test_store_execute_reload.py +++ b/tests/resolution/fixup/test_store_execute_reload.py @@ -59,8 +59,11 @@ async def test_store_execute_reload_runs_on_connectivity_true(coresys: CoreSys): with patch.object(coresys.store, "reload") as mock_reload: # Fire event with connectivity True - coresys.supervisor._update_connectivity(True) # pylint: disable=protected-access - await asyncio.sleep(0.1) + coresys.supervisor._connectivity = True # pylint: disable=protected-access + listener_tasks = coresys.bus.fire_event( + BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, True + ) + await asyncio.gather(*listener_tasks) mock_repository.load.assert_called_once() mock_reload.assert_awaited_once_with(mock_repository) @@ -86,9 +89,12 @@ async def test_store_execute_reload_does_not_run_on_connectivity_false( suggestions=[SuggestionType.EXECUTE_RELOAD], ) - # Fire event with connectivity True - coresys.supervisor._update_connectivity(False) # pylint: disable=protected-access - await asyncio.sleep(0.1) + # Fire event with connectivity False + coresys.supervisor._connectivity = False # pylint: disable=protected-access + listener_tasks = coresys.bus.fire_event( + BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, False + ) + await asyncio.gather(*listener_tasks) mock_repository.load.assert_not_called() @@ -117,14 +123,18 @@ async def test_store_execute_reload_dismiss_suggestion_removes_listener( FixupStoreExecuteReload, "process_fixup", side_effect=ResolutionFixupError ) as mock_fixup: # Fire event with issue there to trigger fixup - coresys.bus.fire_event(BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, True) - await asyncio.sleep(0.1) + listener_tasks = coresys.bus.fire_event( + BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, True + ) + await asyncio.gather(*listener_tasks) mock_fixup.assert_called_once() # Remove issue and suggestion and re-fire to see listener is gone mock_fixup.reset_mock() coresys.resolution.dismiss_issue(issue) - coresys.bus.fire_event(BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, True) - await asyncio.sleep(0.1) + listener_tasks = coresys.bus.fire_event( + BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, True + ) + await asyncio.gather(*listener_tasks) mock_fixup.assert_not_called() diff --git a/tests/test_bus.py b/tests/test_bus.py index 4fa888acf73..6d14ed2c564 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -16,12 +16,10 @@ async def callback(data) -> None: coresys.bus.register_event(BusEvent.HARDWARE_NEW_DEVICE, callback) - coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, None) - await asyncio.sleep(0) + await asyncio.gather(*coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, None)) assert results[-1] is None - coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, "test") - await asyncio.sleep(0) + await asyncio.gather(*coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, "test")) assert results[-1] == "test" @@ -35,8 +33,7 @@ async def callback(data) -> None: coresys.bus.register_event(BusEvent.HARDWARE_NEW_DEVICE, callback) - coresys.bus.fire_event(BusEvent.HARDWARE_REMOVE_DEVICE, None) - await asyncio.sleep(0) + await asyncio.gather(*coresys.bus.fire_event(BusEvent.HARDWARE_REMOVE_DEVICE, None)) assert len(results) == 0 @@ -50,16 +47,14 @@ async def callback(data) -> None: listener = coresys.bus.register_event(BusEvent.HARDWARE_NEW_DEVICE, callback) - coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, None) - await asyncio.sleep(0) + await asyncio.gather(*coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, None)) assert results[-1] is None - coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, "test") - await asyncio.sleep(0) + await asyncio.gather(*coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, "test")) assert results[-1] == "test" coresys.bus.remove_listener(listener) - coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, None) - await asyncio.sleep(0) + # No listeners remain, so no tasks are returned to gather. + await asyncio.gather(*coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, None)) assert results[-1] == "test" diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index 5fb7369211c..a534e525de9 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -21,7 +21,7 @@ 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