Skip to content

Commit a91f97a

Browse files
test(e2e): assert backend dispatch matches workflow env on every lane (#1409)
* test(e2e): assert backend dispatch matches workflow env vars on every lane Adds a single smoke test at tests/src/e2e/basic/test_backend_dispatch_smoke.py that runs on all three e2e lanes (testcontainer, HAOS external, HAOS inaddon) and asserts ha_container_with_fresh_config["backend"] matches what the HAOS_TEST_IMAGE_PATH and HAOS_TEST_MODE env vars imply. Lane → expected backend: e2e-tests.yml (testcontainer): container haos-e2e-tests.yml (external): haos haos-e2e-inaddon-tests.yml: haos_inaddon Silent-failure modes this catches (would have left previous CI green): - HAOS_TEST_IMAGE_PATH set but conftest falls through to testcontainer (the lane is unknowingly testing the wrong HA instance) - HAOS_TEST_MODE=inaddon ignored, inaddon lane silently runs external dispatch — the addon integration is never actually exercised - haos_inaddon backend reached but addon_mcp_url missing, leaving mcp_client fixtures pointing at the wrong endpoint Placed under basic/ (NOT haos_only/) on purpose: the auto-applied haos_only marker would skip the test when is_haos_backend_selected() returns False, which is exactly the silent-failure case we want to catch. basic/ has no auto-applied markers so the test runs everywhere. * test(e2e): add behavioral cross-check + collection floor to dispatch smoke The original smoke test (test_backend_dispatch_matches_workflow_env) only asserted the ``backend`` field that conftest sets. That guards against the dispatch picking the wrong code path, but it can't catch the case where conftest reports backend=X while a different HA instance is actually running. Adds two layered guards: - test_supervisor_addon_tool_behavior_matches_backend: calls ha_get_addon (which only works when a real Supervisor is running) and asserts success-vs-failure matches the claimed backend. - HAOS external + inaddon: tool returns success=True with non-empty addons list (the bake installs Node-RED, ESPHome, etc.) - testcontainer: tool returns success=False because no Supervisor proxy exists (RESOURCE_NOT_FOUND from supervisor/api WebSocket call) The asymmetry makes it impossible for one backend to impersonate the other while keeping the test green — exactly the "goes both ways" guarantee. - test_session_collected_test_count_above_floor: asserts request.session.items length >= 850 (current baseline is ~913 across all lanes). Catches collection-time regressions where a test file fails to import and pytest silently drops tens of tests, leaving the suite green with reduced coverage. All three tests stay under basic/ (no auto-applied haos_only marker) so they run unconditionally on every lane. * test(e2e): add per-lane skip-count ceiling + tighten dispatch smoke docstrings Addresses the gap surfaced by pr-test-analyzer review: none of the three existing dispatch smoke tests catch the "tests transition pass → skip silently" failure mode. The conftest itself documents a real prior incident of this class at tests/src/e2e/conftest.py:158-166 (PR #1375 audit, 14 supervisor_mock tests silently skipping on every testcontainer run because an external_only marker was scoped wrong). Adds: - test_session_skipped_count_below_ceiling: counts items with skip markers in request.session.items and asserts the count stays below a per-lane ceiling. Baseline 2026-05-22: container=46, haos=14, haos_inaddon=22. Ceilings set 5-9 above current to absorb normal marker-gated additions without flapping, while still catching a ~10+ test mass-skip incident. Also tightens three docstrings flagged by the comment-analyzer review: - Test 1 docstring now notes test-side env check is an approximation of conftest's is_haos_backend_selected (which also checks path existence), and explains why the test deliberately doesn't share the helper. - Test 2 docstring is precise about the failure path on testcontainer (raises ToolError → safe_call_tool decodes to success=False) and flags the dict-conversion as load-bearing so a future maintainer doesn't "simplify" to assert_mcp_failure. - Test 3 docstring acknowledges the 3-test variance between container (912) and HAOS (915) lanes rather than calling collection "mode-independent." * test(e2e): complete schema validation for all backend dispatch branches Addresses Gemini review feedback on PR #1409. The dispatch smoke test now asserts the full container_info schema on every branch: - haos_inaddon: add ``port is None`` and ``config_path is None`` assertions (matching the external branch's coverage). - container: add ``config_path is not None`` and ``.get('addon_mcp_url') is None`` assertions. The container-branch ``addon_mcp_url`` assertion uses ``.get()`` instead of bracket access because the container fixture dict at conftest.py:1698-1706 does NOT include an ``addon_mcp_url`` key — only the HAOS branches do. Inline comment notes the asymmetry. --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com>
1 parent 5de1d6e commit a91f97a

1 file changed

Lines changed: 239 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""Multi-layer smoke tests for backend dispatch correctness.
2+
3+
The three e2e CI lanes set env vars that ``conftest.ha_container_with_fresh_config``
4+
reads to choose a backend:
5+
6+
| Lane | HAOS_TEST_IMAGE_PATH | HAOS_TEST_MODE | expected backend |
7+
| ----------------------------- | -------------------- | -------------- | ---------------- |
8+
| e2e-tests.yml (testcontainer) | unset | unset | ``container`` |
9+
| haos-e2e-tests.yml (external) | set | unset | ``haos`` |
10+
| haos-e2e-inaddon-tests.yml | set | ``inaddon`` | ``haos_inaddon`` |
11+
12+
Three layers of guard, each catching a different silent-failure mode:
13+
14+
1. ``test_backend_dispatch_matches_workflow_env`` — asserts the ``backend``
15+
field that conftest sets matches what the workflow env vars imply.
16+
Catches dispatch falling through to the wrong code path.
17+
18+
2. ``test_supervisor_addon_tool_behavior_matches_backend`` — calls
19+
``ha_get_addon`` (which only works when a real Supervisor is present)
20+
and asserts success-vs-failure matches the claimed backend. Catches
21+
the case where conftest reports ``backend=X`` but actually a different
22+
HA instance is running (e.g. mock Supervisor on testcontainer making
23+
addon calls succeed when the real backend has no Supervisor at all,
24+
or HAOS reporting itself as container).
25+
26+
3. ``test_session_collected_test_count_above_floor`` — asserts the
27+
session collected at least the baseline number of tests. Catches
28+
collection-time regressions where a test file fails to import and
29+
pytest silently drops tens of tests.
30+
31+
4. ``test_session_skipped_count_below_ceiling`` — per-lane skip-count
32+
ceiling. Catches the inverse of #3: collection size unchanged but
33+
tests transition pass→skip silently because a marker was applied
34+
too broadly. The conftest documents a prior incident of this kind
35+
(PR #1375 audit, 14 ``supervisor_mock`` tests silently skipping on
36+
every testcontainer run — see ``tests/src/e2e/conftest.py:158-166``).
37+
38+
This file is placed under ``basic/`` (NOT ``haos_only/``) on purpose: the
39+
auto-applied ``haos_only`` marker would skip these whenever
40+
``is_haos_backend_selected()`` returns False, which is exactly the
41+
silent-failure case we want to catch. ``basic/`` has no auto-applied
42+
markers so the tests run unconditionally on every lane.
43+
"""
44+
45+
from __future__ import annotations
46+
47+
import os
48+
from typing import Any
49+
50+
from ..utilities.assertions import safe_call_tool
51+
52+
# Floor for total collected tests across all lanes. As of 2026-05-22
53+
# container lanes collect 912 and HAOS lanes collect 915 (small per-mode
54+
# variance from parametrize/fixture-driven cases). Floor sits well below
55+
# all three to allow normal test-add/remove churn while still catching
56+
# the case where ~50+ tests vanish from collection.
57+
_COLLECTION_FLOOR = 850
58+
59+
# Per-lane ceilings for the count of skip-marked tests. Set 5-9 above
60+
# current per-lane skip counts (as of 2026-05-22: container=46,
61+
# haos=14, haos_inaddon=22). A buffer of 5-9 absorbs PRs that
62+
# legitimately add a few new marker-gated tests, but catches a
63+
# mass-skip incident like PR #1375 (14 tests started skipping silently
64+
# because a marker was applied too broadly).
65+
_SKIP_CEILING_PER_LANE = {
66+
"container": 55,
67+
"haos": 20,
68+
"haos_inaddon": 30,
69+
}
70+
71+
72+
def test_backend_dispatch_matches_workflow_env(
73+
ha_container_with_fresh_config: dict[str, Any],
74+
) -> None:
75+
"""Conftest dispatch must pick the backend the workflow env implies.
76+
77+
Runs unconditionally on every lane — branches off env vars to
78+
approximate conftest's dispatch logic. (Conftest's
79+
``is_haos_backend_selected`` additionally requires the qcow2 file
80+
to exist on disk; this test only checks env-var truthiness. The
81+
test deliberately re-derives the expected backend independently
82+
rather than calling the same helper, so a helper-side regression
83+
can't make the test silently agree with the bug.) Mismatch means
84+
the dispatch silently picked a different backend than CI asked for.
85+
"""
86+
image_path = os.environ.get("HAOS_TEST_IMAGE_PATH")
87+
mode = os.environ.get("HAOS_TEST_MODE", "")
88+
backend = ha_container_with_fresh_config["backend"]
89+
90+
if image_path and mode == "inaddon":
91+
assert backend == "haos_inaddon", (
92+
f"Workflow set HAOS_TEST_IMAGE_PATH + HAOS_TEST_MODE=inaddon "
93+
f"but dispatch picked backend={backend!r}. The inaddon "
94+
f"integration is NOT being exercised by this run."
95+
)
96+
addon_mcp_url = ha_container_with_fresh_config.get("addon_mcp_url")
97+
assert addon_mcp_url and addon_mcp_url.startswith("http"), (
98+
f"haos_inaddon backend reported but addon_mcp_url is "
99+
f"{addon_mcp_url!r}. mcp_client fixtures will route to the "
100+
f"wrong endpoint."
101+
)
102+
assert ha_container_with_fresh_config["container"] is None
103+
assert ha_container_with_fresh_config["port"] is None
104+
assert ha_container_with_fresh_config["config_path"] is None
105+
elif image_path:
106+
assert backend == "haos", (
107+
f"Workflow set HAOS_TEST_IMAGE_PATH but dispatch picked "
108+
f"backend={backend!r}. The lane silently fell through to "
109+
f"the testcontainer path; tests are running against the "
110+
f"wrong HA instance."
111+
)
112+
assert ha_container_with_fresh_config["container"] is None
113+
assert ha_container_with_fresh_config["port"] is None
114+
assert ha_container_with_fresh_config["config_path"] is None
115+
assert ha_container_with_fresh_config["addon_mcp_url"] is None
116+
else:
117+
assert backend == "container", (
118+
f"No HAOS env vars set, expected testcontainer backend, "
119+
f"got backend={backend!r}."
120+
)
121+
assert ha_container_with_fresh_config["container"] is not None
122+
assert ha_container_with_fresh_config["port"] is not None
123+
assert ha_container_with_fresh_config["config_path"] is not None
124+
# The container branch (conftest.py:1698-1706) does NOT include
125+
# an addon_mcp_url key at all, unlike the HAOS branches. Use
126+
# .get() so the assertion holds against either absence or None.
127+
assert ha_container_with_fresh_config.get("addon_mcp_url") is None
128+
129+
130+
async def test_supervisor_addon_tool_behavior_matches_backend(
131+
mcp_client: Any,
132+
ha_container_with_fresh_config: dict[str, Any],
133+
) -> None:
134+
"""Behavioral cross-check: ``ha_get_addon`` must succeed on HAOS, fail on container.
135+
136+
Stronger guarantee than the dispatch-field check: conftest could
137+
self-report ``backend=container`` but actually have HAOS running
138+
(or vice versa). A real Supervisor only exists on HAOS — the HA
139+
Core testcontainer has no Supervisor service running. So:
140+
141+
- HAOS external + inaddon: ``ha_get_addon`` returns a populated
142+
addons list (the bake installs several addons).
143+
- testcontainer: ``ha_get_addon`` raises ToolError
144+
(RESOURCE_NOT_FOUND from the ``supervisor/api`` WebSocket proxy
145+
because no Supervisor is running); ``safe_call_tool`` catches
146+
and decodes the structured error to ``{"success": False, ...}``.
147+
The dict conversion is load-bearing — a future maintainer should
148+
NOT switch to ``assert_mcp_failure`` or similar.
149+
150+
The asymmetry of this check makes it impossible for one backend to
151+
impersonate the other while keeping this test green.
152+
"""
153+
backend = ha_container_with_fresh_config["backend"]
154+
result = await safe_call_tool(mcp_client, "ha_get_addon", {})
155+
156+
if backend in ("haos", "haos_inaddon"):
157+
assert result.get("success") is True, (
158+
f"ha_get_addon failed on {backend} backend; Supervisor must "
159+
f"be running. Result: {result!r}"
160+
)
161+
# list_addons returns ``{"success": True, "addons": [...], "summary": {...}}``
162+
# — ``addons`` is a top-level key, not nested under ``data``.
163+
addons = result.get("addons") or []
164+
assert isinstance(addons, list) and len(addons) > 0, (
165+
f"Expected installed addons on {backend} (the bake installs "
166+
f"Node-RED, ESPHome, AppDaemon, dev addon, etc.); got "
167+
f"{addons!r}"
168+
)
169+
else:
170+
# testcontainer has no Supervisor → ha_get_addon must fail
171+
assert result.get("success") is False, (
172+
f"ha_get_addon unexpectedly succeeded on {backend} backend. "
173+
f"Testcontainer has no Supervisor service; success here "
174+
f"means we're actually running on HAOS but conftest reported "
175+
f"backend={backend!r}. Result: {result!r}"
176+
)
177+
178+
179+
def test_session_collected_test_count_above_floor(request: Any) -> None:
180+
"""Session collected at least ``_COLLECTION_FLOOR`` tests.
181+
182+
Catches collection-time regressions: a test file fails to import,
183+
pytest collects tens of fewer tests, the suite stays green with
184+
reduced coverage. Collection count varies by a handful across
185+
modes (parametrize/fixture-driven — currently 912 container vs
186+
915 HAOS lanes); the floor sits well below all three lanes' actuals.
187+
"""
188+
total = len(request.session.items)
189+
assert total >= _COLLECTION_FLOOR, (
190+
f"Only {total} tests collected, expected >= {_COLLECTION_FLOOR}. "
191+
f"A test file likely failed to import, dropping coverage. Check "
192+
f"for collection errors in the pytest output."
193+
)
194+
195+
196+
def test_session_skipped_count_below_ceiling(
197+
request: Any,
198+
ha_container_with_fresh_config: dict[str, Any],
199+
) -> None:
200+
"""Per-lane skip-count must stay below ``_SKIP_CEILING_PER_LANE[backend]``.
201+
202+
Catches the inverse of the collection-floor check: the suite still
203+
collects the expected total, but tests transition pass→skip silently
204+
because a marker was applied too broadly in conftest's
205+
``pytest_collection_modifyitems`` hook.
206+
207+
The conftest itself documents a real prior incident of this kind
208+
(``tests/src/e2e/conftest.py:158-166`` — PR #1375 audit, 14
209+
``supervisor_mock`` tests silently skipping on every testcontainer
210+
run because an ``external_only`` skip was scoped wrong). A
211+
skip-count ceiling per lane catches that whole class of bug.
212+
213+
Ceilings sit 5-9 above current per-lane skip counts; updates are
214+
only required when a PR legitimately introduces enough new
215+
marker-gated tests to cross the threshold (uncommon).
216+
"""
217+
backend = ha_container_with_fresh_config["backend"]
218+
ceiling = _SKIP_CEILING_PER_LANE.get(backend)
219+
assert ceiling is not None, (
220+
f"Unknown backend {backend!r} — add to _SKIP_CEILING_PER_LANE "
221+
f"at the top of this file"
222+
)
223+
# Items in request.session.items already have skip markers applied
224+
# by pytest_collection_modifyitems (which ran before any test).
225+
# Under pytest-xdist with --dist loadscope, each worker collects
226+
# the full session, so this count is consistent per worker.
227+
skipped = sum(
228+
1
229+
for item in request.session.items
230+
if any(m.name == "skip" for m in item.iter_markers())
231+
)
232+
assert skipped <= ceiling, (
233+
f"{skipped} tests have skip markers on the {backend} lane, "
234+
f"which exceeds the ceiling of {ceiling}. A marker may be "
235+
f"applied too broadly in pytest_collection_modifyitems — "
236+
f"check tests/src/e2e/conftest.py:115-168 for recent changes. "
237+
f"If the increase is intentional (legitimate new marker-gated "
238+
f"tests), bump _SKIP_CEILING_PER_LANE[{backend!r}] in this file."
239+
)

0 commit comments

Comments
 (0)