|
17 | 17 | """ |
18 | 18 |
|
19 | 19 | import asyncio |
| 20 | +import http.server |
20 | 21 | import json |
21 | 22 | import logging |
22 | 23 | import os |
23 | 24 | import shutil |
24 | 25 | import sys |
25 | 26 | import tempfile |
| 27 | +import threading |
26 | 28 | import time |
27 | 29 | from collections.abc import AsyncGenerator |
| 30 | +from functools import partial |
28 | 31 | from pathlib import Path |
29 | 32 | from typing import Any |
30 | 33 |
|
@@ -184,8 +187,77 @@ async def test_settings(): |
184 | 187 | return settings |
185 | 188 |
|
186 | 189 |
|
| 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 | + |
187 | 231 | @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): |
189 | 261 | """Create Home Assistant container with fresh config using testcontainers.""" |
190 | 262 | # --- Safety guard 1: ensure Docker is available before doing anything else --- |
191 | 263 | try: |
@@ -280,8 +352,15 @@ def ha_container_with_fresh_config(): |
280 | 352 | str(config_path), "/config", "rw" |
281 | 353 | ) # Ensure read-write mount |
282 | 354 | 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) |
285 | 364 |
|
286 | 365 | # Remove any .HA_RESTORE file that might cause issues |
287 | 366 | restore_file = config_path / ".HA_RESTORE" |
@@ -444,12 +523,100 @@ def ha_container_with_fresh_config(): |
444 | 523 | f"(minimum: {MIN_ENTITIES}). Check Docker logs." |
445 | 524 | ) |
446 | 525 |
|
| 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 | + |
447 | 613 | # Store connection info for other fixtures |
448 | 614 | container_info = { |
449 | 615 | "container": container, |
450 | 616 | "port": host_port, |
451 | 617 | "base_url": base_url, |
452 | 618 | "config_path": str(config_path), |
| 619 | + "blueprint_server": _blueprint_http_server, |
453 | 620 | } |
454 | 621 |
|
455 | 622 | try: |
@@ -759,3 +926,16 @@ async def _wait_for_state( |
759 | 926 | return False |
760 | 927 |
|
761 | 928 | 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 |
0 commit comments