|
46 | 46 | from __future__ import annotations |
47 | 47 |
|
48 | 48 | import asyncio |
| 49 | +import logging |
49 | 50 | import time |
50 | 51 | from typing import Any |
51 | 52 |
|
52 | 53 | import pytest |
53 | 54 |
|
54 | 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__) |
55 | 59 |
|
56 | 60 | pytestmark = [pytest.mark.haos_only] |
57 | 61 |
|
58 | | -# Node-RED's container takes 20–60s after install to leave "startup" and |
59 | | -# enter "started". Any test whose contract requires the addon to actually |
60 | | -# answer HTTP (i.e. asserts on ``status_code`` rather than tolerating a |
61 | | -# structured error) must wait for it; otherwise CI flakes whenever the |
62 | | -# runner is slow enough that the addon isn't ready by test-call time. |
63 | | -# The bake installs Node-RED with ``start=True`` (build_image.py ADDONS), |
64 | | -# so we only need to wait — never to start it ourselves. |
| 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. |
65 | 68 | _ADDON_RUNNING_TIMEOUT_S = 120.0 |
66 | 69 | _ADDON_RUNNING_POLL_S = 2.0 |
67 | 70 |
|
@@ -109,31 +112,34 @@ async def _wait_addon_running( |
109 | 112 |
|
110 | 113 | Use this before any test that asserts on the HTTP/WS contract of an |
111 | 114 | addon (rather than tolerating an addon-not-running structured |
112 | | - error). ``ha_manage_addon`` short-circuits with |
113 | | - ``{"success": False, "error": {...}, "state": "startup"}`` when |
114 | | - Supervisor reports the addon as anything other than ``started``; |
115 | | - the bake installs addons with ``start=True`` but their containers |
116 | | - can take 20-60s to leave the ``startup`` phase, which is enough to |
117 | | - flake any strict-shape assertion. Mirrors the |
118 | | - ``wait_for_entity_registered`` discipline already mandated by |
119 | | - AGENTS.md for tests that act on freshly created entities. |
120 | | -
|
121 | | - Transient ``ToolError`` from ``ha_get_addon`` (e.g. a momentary |
122 | | - Supervisor 5xx during boot) is treated as "not ready yet" and the |
123 | | - poll continues until the outer timeout. The deadline still fires; |
124 | | - transient errors can't mask a wedged addon forever. |
| 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. |
125 | 132 | """ |
126 | | - from fastmcp.exceptions import ToolError |
127 | | - |
128 | 133 | deadline = time.monotonic() + timeout |
129 | 134 | last_state: str | None = None |
130 | 135 | while True: |
131 | 136 | try: |
132 | 137 | detail_raw = await mcp_client.call_tool("ha_get_addon", {"slug": slug}) |
133 | 138 | detail = parse_mcp_result(detail_raw).get("addon") or {} |
134 | 139 | last_state = detail.get("state") |
135 | | - except ToolError as e: |
136 | | - last_state = f"<ToolError: {e!s}[:60]>" |
| 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]}>" |
137 | 143 | if last_state == "started": |
138 | 144 | return |
139 | 145 | if time.monotonic() >= deadline: |
|
0 commit comments