Skip to content

Commit 55623a3

Browse files
author
Julien Larocque-Dupont
authored
Merge pull request homeassistant-ai#1145 from homeassistant-ai/fix/ci-flaky-tests
test: fix three categories of E2E test flakiness (blueprint network, startup races)
2 parents aba01a1 + eac5916 commit 55623a3

3 files changed

Lines changed: 208 additions & 7 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
blueprint:
2+
name: E2E Test Blueprint
3+
description: Minimal automation blueprint used by ha-mcp E2E tests. Safe to delete.
4+
domain: automation
5+
input:
6+
target_entity:
7+
name: Target Entity
8+
description: The entity to control
9+
selector:
10+
entity: {}
11+
12+
trigger:
13+
- platform: time
14+
at: "00:00:00"
15+
16+
action:
17+
- service: homeassistant.turn_on
18+
target:
19+
entity_id: !input target_entity

tests/src/e2e/conftest.py

Lines changed: 183 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@
1717
"""
1818

1919
import asyncio
20+
import http.server
2021
import json
2122
import logging
2223
import os
2324
import shutil
2425
import sys
2526
import tempfile
27+
import threading
2628
import time
2729
from collections.abc import AsyncGenerator
30+
from functools import partial
2831
from pathlib import Path
2932
from typing import Any
3033

@@ -184,8 +187,77 @@ async def test_settings():
184187
return settings
185188

186189

190+
def _detect_docker_host() -> dict:
191+
"""Detect the correct host address and extra_hosts config for the Docker environment.
192+
193+
Docker Desktop (WSL2 / Mac / Windows) embeds a DNS server that resolves
194+
``host.docker.internal`` inside containers automatically. On plain Linux
195+
Docker (GitHub Actions CI) that DNS is absent, so we must inject the
196+
mapping via ``--add-host host.docker.internal:host-gateway``.
197+
198+
Strategy: run a minimal probe container and ask it to resolve
199+
``host.docker.internal``. If it resolves, Docker Desktop DNS is active and
200+
we must NOT override the entry (doing so breaks the internal routing). If
201+
it does not resolve, we are on plain Linux Docker and must add extra_hosts.
202+
203+
Returns a dict with:
204+
- ``hostname`` - hostname that Docker containers use to reach the host
205+
- ``extra_hosts`` - dict passed to ``container.with_kwargs`` (may be empty)
206+
"""
207+
try:
208+
import docker as docker_sdk
209+
210+
client = docker_sdk.from_env()
211+
output = client.containers.run(
212+
"alpine",
213+
["sh", "-c", "getent hosts host.docker.internal 2>/dev/null | awk '{print $1}'"],
214+
remove=True,
215+
)
216+
if output.strip():
217+
# Docker Desktop DNS resolved the name — use hostname, no override needed
218+
logger.info("🔍 Docker Desktop DNS detected — using host.docker.internal as-is")
219+
return {"hostname": "host.docker.internal", "extra_hosts": {}}
220+
except Exception as exc:
221+
logger.debug(f"Docker Desktop DNS probe failed: {exc}")
222+
223+
# Plain Linux Docker — inject the mapping so the hostname resolves in the container
224+
logger.info("🔍 Plain Linux Docker detected — injecting host.docker.internal via extra_hosts")
225+
return {
226+
"hostname": "host.docker.internal",
227+
"extra_hosts": {"host.docker.internal": "host-gateway"},
228+
}
229+
230+
187231
@pytest.fixture(scope="session")
188-
def ha_container_with_fresh_config():
232+
def _blueprint_http_server():
233+
"""Start a local HTTP server for blueprint files before the HA container launches.
234+
235+
Must start before the container so the port is known when ``extra_hosts``
236+
is configured in ``ha_container_with_fresh_config``.
237+
"""
238+
env = _detect_docker_host()
239+
240+
assets_dir = Path(__file__).parent.parent.parent / "assets" / "blueprints"
241+
assets_dir.mkdir(parents=True, exist_ok=True)
242+
243+
handler = partial(http.server.SimpleHTTPRequestHandler, directory=str(assets_dir))
244+
handler.log_message = lambda *args: None # type: ignore[method-assign]
245+
srv = http.server.HTTPServer(("0.0.0.0", 0), handler)
246+
port = srv.server_address[1]
247+
t = threading.Thread(target=srv.serve_forever, daemon=True)
248+
t.start()
249+
250+
base_url = f"http://{env['hostname']}:{port}"
251+
logger.info(f"🌐 Blueprint HTTP server on :{port}, container URL: {base_url}")
252+
253+
try:
254+
yield {"base_url": base_url, "port": port, "extra_hosts": env["extra_hosts"]}
255+
finally:
256+
srv.shutdown()
257+
258+
259+
@pytest.fixture(scope="session")
260+
def ha_container_with_fresh_config(_blueprint_http_server):
189261
"""Create Home Assistant container with fresh config using testcontainers."""
190262
# --- Safety guard 1: ensure Docker is available before doing anything else ---
191263
try:
@@ -280,8 +352,15 @@ def ha_container_with_fresh_config():
280352
str(config_path), "/config", "rw"
281353
) # Ensure read-write mount
282354
container = container.with_env("TZ", "UTC")
283-
# Add privileged mode for Home Assistant hardware access
284-
container = container.with_kwargs(privileged=True)
355+
# Add privileged mode for Home Assistant hardware access.
356+
# On plain Linux Docker (CI) also inject the host.docker.internal mapping so
357+
# the blueprint HTTP server is reachable from within the container.
358+
# On Docker Desktop the mapping is provided by Docker's embedded DNS and must
359+
# NOT be overridden here.
360+
container_kwargs: dict = {"privileged": True}
361+
if _blueprint_http_server.get("extra_hosts"):
362+
container_kwargs["extra_hosts"] = _blueprint_http_server["extra_hosts"]
363+
container = container.with_kwargs(**container_kwargs)
285364

286365
# Remove any .HA_RESTORE file that might cause issues
287366
restore_file = config_path / ".HA_RESTORE"
@@ -444,12 +523,100 @@ def ha_container_with_fresh_config():
444523
f"(minimum: {MIN_ENTITIES}). Check Docker logs."
445524
)
446525

526+
# Wait for key HA service domains to register. Components loaded and
527+
# entities present does not guarantee services are ready — individual
528+
# integrations (input_boolean, sun) register their services
529+
# asynchronously after their entities appear.
530+
REQUIRED_SERVICES = {"input_boolean", "sun"}
531+
SERVICE_WAIT = 30
532+
logger.info("⏳ Waiting for required service domains to register...")
533+
for svc_attempt in range(SERVICE_WAIT):
534+
try:
535+
svc_resp = requests.get(
536+
f"{base_url}/api/services", timeout=5, headers=headers
537+
)
538+
if svc_resp.status_code == 200:
539+
registered = {s.get("domain") for s in svc_resp.json()}
540+
missing = REQUIRED_SERVICES - registered
541+
if not missing:
542+
logger.info(
543+
f"✅ Required service domains ready after {svc_attempt + 1}s"
544+
)
545+
break
546+
if svc_attempt % 5 == 0:
547+
logger.info(
548+
f"⏳ Waiting for service domains: {missing}"
549+
)
550+
except (requests.exceptions.RequestException, json.JSONDecodeError) as exc:
551+
logger.debug(f"Service check failed: {exc}")
552+
time.sleep(1)
553+
else:
554+
logger.warning(
555+
f"⚠️ Service domain wait timed out after {SERVICE_WAIT}s "
556+
f"— some tests may be flaky"
557+
)
558+
559+
# Wait for ha_mcp_tools custom component services (installed above).
560+
# The component is loaded after core services, so it needs its own check.
561+
HA_MCP_TOOLS_WAIT = 30
562+
ha_mcp_tools_src = repo_root / "custom_components" / "ha_mcp_tools"
563+
if ha_mcp_tools_src.exists():
564+
logger.info("⏳ Waiting for ha_mcp_tools services to register...")
565+
for mcp_attempt in range(HA_MCP_TOOLS_WAIT):
566+
try:
567+
svc_resp = requests.get(
568+
f"{base_url}/api/services", timeout=5, headers=headers
569+
)
570+
if svc_resp.status_code == 200:
571+
domains = {s.get("domain") for s in svc_resp.json()}
572+
if "ha_mcp_tools" in domains:
573+
logger.info(
574+
f"✅ ha_mcp_tools services ready after {mcp_attempt + 1}s"
575+
)
576+
break
577+
except (requests.exceptions.RequestException, json.JSONDecodeError) as exc:
578+
logger.debug(f"ha_mcp_tools service check failed: {exc}")
579+
time.sleep(1)
580+
else:
581+
logger.warning(
582+
f"⚠️ ha_mcp_tools services not registered after {HA_MCP_TOOLS_WAIT}s "
583+
f"— yaml config tests may fail"
584+
)
585+
586+
# Wait for sun.sun to leave the 'unknown' state. During HA startup the
587+
# sun integration reports 'unknown' until it computes the first position.
588+
# Template tests that assert above/below_horizon will fail if we proceed
589+
# before the sun integration finishes its first calculation.
590+
SUN_WAIT = 30
591+
logger.info("⏳ Waiting for sun.sun to reach a known state...")
592+
for sun_attempt in range(SUN_WAIT):
593+
try:
594+
sun_resp = requests.get(
595+
f"{base_url}/api/states/sun.sun", timeout=5, headers=headers
596+
)
597+
if sun_resp.status_code == 200:
598+
sun_state = sun_resp.json().get("state", "unknown")
599+
if sun_state != "unknown":
600+
logger.info(
601+
f"✅ sun.sun is '{sun_state}' after {sun_attempt + 1}s"
602+
)
603+
break
604+
except (requests.exceptions.RequestException, json.JSONDecodeError) as exc:
605+
logger.debug(f"sun.sun check failed: {exc}")
606+
time.sleep(1)
607+
else:
608+
logger.warning(
609+
f"⚠️ sun.sun still 'unknown' after {SUN_WAIT}s "
610+
f"— template tests may fail"
611+
)
612+
447613
# Store connection info for other fixtures
448614
container_info = {
449615
"container": container,
450616
"port": host_port,
451617
"base_url": base_url,
452618
"config_path": str(config_path),
619+
"blueprint_server": _blueprint_http_server,
453620
}
454621

455622
try:
@@ -759,3 +926,16 @@ async def _wait_for_state(
759926
return False
760927

761928
return _wait_for_state
929+
930+
931+
@pytest.fixture(scope="session")
932+
def local_blueprint_server(ha_container_with_fresh_config):
933+
"""Return blueprint HTTP server info for tests that need to import blueprints.
934+
935+
The server is started by ``_blueprint_http_server`` before the HA container
936+
and stored in ``ha_container_with_fresh_config``; this fixture simply exposes
937+
it so tests don't need to depend on ``ha_container_with_fresh_config`` directly.
938+
"""
939+
server = ha_container_with_fresh_config["blueprint_server"]
940+
logger.info(f"🌐 Blueprint server at {server['base_url']}")
941+
yield server

tests/src/e2e/workflows/blueprints/test_blueprints.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,18 +247,20 @@ async def test_import_blueprint_nonexistent_url(self, mcp_client):
247247
logger.info("ha_import_blueprint properly handles non-existent URL")
248248

249249
@pytest.mark.slow
250-
async def test_import_blueprint_saves_to_disk(self, mcp_client):
250+
async def test_import_blueprint_saves_to_disk(self, mcp_client, local_blueprint_server):
251251
"""
252252
Test: Import blueprint actually saves to disk (issue #685)
253253
254254
Validates that ha_import_blueprint calls both blueprint/import (validate)
255255
AND blueprint/save (persist), so the blueprint appears in the list.
256-
Uses a known community blueprint that won't already be installed.
256+
Uses a locally-served blueprint file to avoid external network dependencies.
257257
"""
258258
logger.info("Testing ha_import_blueprint saves blueprint to disk...")
259259

260-
# Use a community blueprint unlikely to be pre-installed
261-
test_url = "https://gist.github.com/Blackshome/4010fb83bb8c19b5fa1425526c6ff0e2"
260+
# Serve the blueprint from a local HTTP server accessible by the HA container.
261+
# This avoids flaky failures caused by transient GitHub network issues on CI.
262+
test_url = f"{local_blueprint_server['base_url']}/e2e_test_blueprint.yaml"
263+
logger.info(f"Using local blueprint URL: {test_url}")
262264

263265
async with MCPAssertions(mcp_client) as mcp:
264266
# List blueprints before import

0 commit comments

Comments
 (0)