|
45 | 45 |
|
46 | 46 | from __future__ import annotations |
47 | 47 |
|
| 48 | +import asyncio |
| 49 | +import logging |
| 50 | +import time |
48 | 51 | from typing import Any |
49 | 52 |
|
50 | 53 | import pytest |
51 | 54 |
|
52 | 55 | from ..utilities.assertions import parse_mcp_result, safe_call_tool |
| 56 | +from ..utilities.wait_helpers import _POLLING_TRANSIENT_ERRORS |
| 57 | + |
| 58 | +logger = logging.getLogger(__name__) |
53 | 59 |
|
54 | 60 | pytestmark = [pytest.mark.haos_only] |
55 | 61 |
|
| 62 | +# Tests that assert strictly on ``status_code`` (with no fall-back error |
| 63 | +# branch) need the addon's container to have reached Supervisor's |
| 64 | +# ``started`` state — the bake installs every addon with ``start=True``, |
| 65 | +# but the container can take tens of seconds to leave its transient boot |
| 66 | +# phase, which is enough to flake the strict assertion. Timeout sized |
| 67 | +# for cache-cold runners; 2s poll matches sibling lifecycle helpers. |
| 68 | +_ADDON_RUNNING_TIMEOUT_S = 120.0 |
| 69 | +_ADDON_RUNNING_POLL_S = 2.0 |
| 70 | + |
56 | 71 |
|
57 | 72 | # Display names as they appear in build_image.py's ADDONS tuple — slugs |
58 | 73 | # are looked up dynamically below to survive the SHA-derived slug prefix. |
@@ -88,6 +103,53 @@ async def _resolve_slug(mcp_client: Any, display_name: str) -> str: |
88 | 103 | ) |
89 | 104 |
|
90 | 105 |
|
| 106 | +async def _wait_addon_running( |
| 107 | + mcp_client: Any, |
| 108 | + slug: str, |
| 109 | + timeout: float = _ADDON_RUNNING_TIMEOUT_S, |
| 110 | +) -> None: |
| 111 | + """Block until ``ha_get_addon(slug=...)`` reports ``state=started``. |
| 112 | +
|
| 113 | + Use this before any test that asserts on the HTTP/WS contract of an |
| 114 | + addon (rather than tolerating an addon-not-running structured |
| 115 | + error). When Supervisor reports the addon as anything other than |
| 116 | + ``started``, ``ha_manage_addon`` raises ``ToolError`` from its |
| 117 | + running-state guard (``tools_addons.py`` "Verify add-on is running"); |
| 118 | + the JSON-encoded error payload carries the observed transient state. |
| 119 | + The bake installs addons with ``start=True``, but their containers |
| 120 | + can take tens of seconds to reach ``started`` — long enough to |
| 121 | + flake any strict-shape assertion on the proxy path. Mirrors |
| 122 | + ``_wait_for_state`` in ``test_addon_lifecycle.py`` (same private- |
| 123 | + sibling convention as ``_resolve_slug``). |
| 124 | +
|
| 125 | + Transient errors from ``ha_get_addon`` are caught via the project's |
| 126 | + canonical ``_POLLING_TRANSIENT_ERRORS`` tuple (see |
| 127 | + ``tests/src/e2e/utilities/wait_helpers.py``) — the same discipline |
| 128 | + every other polling helper in the suite uses. Bugs (``TypeError`` / |
| 129 | + ``AttributeError`` / ``KeyError`` / ``AssertionError``) propagate. |
| 130 | + The deadline still fires; transient errors can't mask a wedged |
| 131 | + addon forever. |
| 132 | + """ |
| 133 | + deadline = time.monotonic() + timeout |
| 134 | + last_state: str | None = None |
| 135 | + while True: |
| 136 | + try: |
| 137 | + detail_raw = await mcp_client.call_tool("ha_get_addon", {"slug": slug}) |
| 138 | + detail = parse_mcp_result(detail_raw).get("addon") or {} |
| 139 | + last_state = detail.get("state") |
| 140 | + except _POLLING_TRANSIENT_ERRORS as e: |
| 141 | + logger.debug(f"⚠️ Transient error polling addon {slug!r}: {e}") |
| 142 | + last_state = f"<transient: {str(e)[:60]}>" |
| 143 | + if last_state == "started": |
| 144 | + return |
| 145 | + if time.monotonic() >= deadline: |
| 146 | + pytest.fail( |
| 147 | + f"Addon {slug!r} did not reach state=started within " |
| 148 | + f"{timeout:.0f}s (last state: {last_state!r})" |
| 149 | + ) |
| 150 | + await asyncio.sleep(_ADDON_RUNNING_POLL_S) |
| 151 | + |
| 152 | + |
91 | 153 | # --------------------------------------------------------------------------- |
92 | 154 | # Config mode — options / boot / auto_update / watchdog round-trips |
93 | 155 | # --------------------------------------------------------------------------- |
@@ -234,6 +296,9 @@ async def test_proxy_http_request_headers_pass_through(mcp_client: Any) -> None: |
234 | 296 | invalid", which proves the value crossed the wire. |
235 | 297 | """ |
236 | 298 | slug = await _resolve_slug(mcp_client, NODERED_NAME) |
| 299 | + # Strict assertion on ``status_code`` below requires the addon to |
| 300 | + # actually answer HTTP; wait it out (see ``_wait_addon_running``). |
| 301 | + await _wait_addon_running(mcp_client, slug) |
237 | 302 | without = await safe_call_tool( |
238 | 303 | mcp_client, |
239 | 304 | "ha_manage_addon", |
|
0 commit comments