mirror of
https://github.com/esphome/esphome.git
synced 2025-09-02 03:12:20 +01:00
Add integration tests for host (#8912)
This commit is contained in:
402
tests/integration/conftest.py
Normal file
402
tests/integration/conftest.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""Common fixtures for integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import signal
|
||||
import socket
|
||||
import tempfile
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError, ReconnectLogic
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
# Skip all integration tests on Windows
|
||||
if platform.system() == "Windows":
|
||||
pytest.skip(
|
||||
"Integration tests are not supported on Windows", allow_module_level=True
|
||||
)
|
||||
|
||||
from .const import (
|
||||
API_CONNECTION_TIMEOUT,
|
||||
DEFAULT_API_PORT,
|
||||
LOCALHOST,
|
||||
PORT_POLL_INTERVAL,
|
||||
PORT_WAIT_TIMEOUT,
|
||||
SIGINT_TIMEOUT,
|
||||
SIGTERM_TIMEOUT,
|
||||
)
|
||||
from .types import (
|
||||
APIClientConnectedFactory,
|
||||
APIClientFactory,
|
||||
CompileFunction,
|
||||
ConfigWriter,
|
||||
RunCompiledFunction,
|
||||
RunFunction,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def integration_test_dir() -> Generator[Path]:
|
||||
"""Create a temporary directory for integration tests."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]:
|
||||
"""Reserve an unused TCP port by holding the socket open."""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("", 0))
|
||||
port = s.getsockname()[1]
|
||||
try:
|
||||
yield port, s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int:
|
||||
"""Get the reserved TCP port number."""
|
||||
return reserved_tcp_port[0]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
|
||||
"""Load YAML configuration based on test name."""
|
||||
# Get the test function name
|
||||
test_name: str = request.node.name
|
||||
# Extract the base test name (remove test_ prefix and any parametrization)
|
||||
base_name = test_name.replace("test_", "").partition("[")[0]
|
||||
|
||||
# Load the fixture file
|
||||
fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml"
|
||||
if not fixture_path.exists():
|
||||
raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
content = await loop.run_in_executor(None, fixture_path.read_text)
|
||||
|
||||
# Replace the port in the config if it contains api section
|
||||
if "api:" in content:
|
||||
# Add port configuration after api:
|
||||
content = content.replace("api:", f"api:\n port: {unused_tcp_port}")
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def write_yaml_config(
|
||||
integration_test_dir: Path, request: pytest.FixtureRequest
|
||||
) -> AsyncGenerator[ConfigWriter]:
|
||||
"""Write YAML configuration to a file."""
|
||||
# Get the test name for default filename
|
||||
test_name = request.node.name
|
||||
base_name = test_name.replace("test_", "").split("[")[0]
|
||||
|
||||
async def _write_config(content: str, filename: str | None = None) -> Path:
|
||||
if filename is None:
|
||||
filename = f"{base_name}.yaml"
|
||||
config_path = integration_test_dir / filename
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, config_path.write_text, content)
|
||||
return config_path
|
||||
|
||||
yield _write_config
|
||||
|
||||
|
||||
async def _run_esphome_command(
|
||||
command: str,
|
||||
config_path: Path,
|
||||
cwd: Path,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Run an ESPHome command with the given arguments."""
|
||||
return await asyncio.create_subprocess_exec(
|
||||
"esphome",
|
||||
command,
|
||||
str(config_path),
|
||||
cwd=cwd,
|
||||
stdout=None, # Inherit stdout
|
||||
stderr=None, # Inherit stderr
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
# Start in a new process group to isolate signal handling
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def compile_esphome(
|
||||
integration_test_dir: Path,
|
||||
) -> AsyncGenerator[CompileFunction]:
|
||||
"""Compile an ESPHome configuration."""
|
||||
|
||||
async def _compile(config_path: Path) -> None:
|
||||
proc = await _run_esphome_command("compile", config_path, integration_test_dir)
|
||||
await proc.wait()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to compile {config_path}, return code: {proc.returncode}. "
|
||||
f"Run with 'pytest -s' to see compilation output."
|
||||
)
|
||||
|
||||
yield _compile
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def run_esphome_process(
|
||||
integration_test_dir: Path,
|
||||
) -> AsyncGenerator[RunFunction]:
|
||||
"""Run an ESPHome process and manage its lifecycle."""
|
||||
processes: list[asyncio.subprocess.Process] = []
|
||||
|
||||
async def _run(config_path: Path) -> asyncio.subprocess.Process:
|
||||
process = await _run_esphome_command("run", config_path, integration_test_dir)
|
||||
processes.append(process)
|
||||
return process
|
||||
|
||||
yield _run
|
||||
|
||||
# Cleanup: terminate all "run" processes gracefully
|
||||
for process in processes:
|
||||
if process.returncode is None:
|
||||
# Send SIGINT (Ctrl+C) for graceful shutdown of the running ESPHome instance
|
||||
process.send_signal(signal.SIGINT)
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=SIGINT_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
# If SIGINT didn't work, try SIGTERM
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=SIGTERM_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
# Last resort: SIGKILL
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_api_client(
|
||||
address: str = LOCALHOST,
|
||||
port: int = DEFAULT_API_PORT,
|
||||
password: str = "",
|
||||
noise_psk: str | None = None,
|
||||
client_info: str = "integration-test",
|
||||
) -> AsyncGenerator[APIClient]:
|
||||
"""Create an API client context manager."""
|
||||
client = APIClient(
|
||||
address=address,
|
||||
port=port,
|
||||
password=password,
|
||||
noise_psk=noise_psk,
|
||||
client_info=client_info,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def api_client_factory(
|
||||
unused_tcp_port: int,
|
||||
) -> AsyncGenerator[APIClientFactory]:
|
||||
"""Factory for creating API client context managers."""
|
||||
|
||||
def _create_client(
|
||||
address: str = LOCALHOST,
|
||||
port: int | None = None,
|
||||
password: str = "",
|
||||
noise_psk: str | None = None,
|
||||
client_info: str = "integration-test",
|
||||
) -> AbstractAsyncContextManager[APIClient]:
|
||||
return create_api_client(
|
||||
address=address,
|
||||
port=port if port is not None else unused_tcp_port,
|
||||
password=password,
|
||||
noise_psk=noise_psk,
|
||||
client_info=client_info,
|
||||
)
|
||||
|
||||
yield _create_client
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def wait_and_connect_api_client(
|
||||
address: str = LOCALHOST,
|
||||
port: int = DEFAULT_API_PORT,
|
||||
password: str = "",
|
||||
noise_psk: str | None = None,
|
||||
client_info: str = "integration-test",
|
||||
timeout: float = API_CONNECTION_TIMEOUT,
|
||||
) -> AsyncGenerator[APIClient]:
|
||||
"""Wait for API to be available and connect."""
|
||||
client = APIClient(
|
||||
address=address,
|
||||
port=port,
|
||||
password=password,
|
||||
noise_psk=noise_psk,
|
||||
client_info=client_info,
|
||||
)
|
||||
|
||||
# Create a future to signal when connected
|
||||
loop = asyncio.get_running_loop()
|
||||
connected_future: asyncio.Future[None] = loop.create_future()
|
||||
|
||||
async def on_connect() -> None:
|
||||
"""Called when successfully connected."""
|
||||
if not connected_future.done():
|
||||
connected_future.set_result(None)
|
||||
|
||||
async def on_disconnect(expected_disconnect: bool) -> None:
|
||||
"""Called when disconnected."""
|
||||
if not connected_future.done() and not expected_disconnect:
|
||||
connected_future.set_exception(
|
||||
APIConnectionError("Disconnected before fully connected")
|
||||
)
|
||||
|
||||
async def on_connect_error(err: Exception) -> None:
|
||||
"""Called when connection fails."""
|
||||
if not connected_future.done():
|
||||
connected_future.set_exception(err)
|
||||
|
||||
# Create and start the reconnect logic
|
||||
reconnect_logic = ReconnectLogic(
|
||||
client=client,
|
||||
on_connect=on_connect,
|
||||
on_disconnect=on_disconnect,
|
||||
zeroconf_instance=None, # Not using zeroconf for integration tests
|
||||
name=f"{address}:{port}",
|
||||
on_connect_error=on_connect_error,
|
||||
)
|
||||
|
||||
try:
|
||||
# Start the connection
|
||||
await reconnect_logic.start()
|
||||
|
||||
# Wait for connection with timeout
|
||||
try:
|
||||
await asyncio.wait_for(connected_future, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"Failed to connect to API after {timeout} seconds")
|
||||
|
||||
yield client
|
||||
finally:
|
||||
# Stop reconnect logic and disconnect
|
||||
await reconnect_logic.stop()
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def api_client_connected(
|
||||
unused_tcp_port: int,
|
||||
) -> AsyncGenerator[APIClientConnectedFactory]:
|
||||
"""Factory for creating connected API client context managers."""
|
||||
|
||||
def _connect_client(
|
||||
address: str = LOCALHOST,
|
||||
port: int | None = None,
|
||||
password: str = "",
|
||||
noise_psk: str | None = None,
|
||||
client_info: str = "integration-test",
|
||||
timeout: float = API_CONNECTION_TIMEOUT,
|
||||
) -> AbstractAsyncContextManager[APIClient]:
|
||||
return wait_and_connect_api_client(
|
||||
address=address,
|
||||
port=port if port is not None else unused_tcp_port,
|
||||
password=password,
|
||||
noise_psk=noise_psk,
|
||||
client_info=client_info,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
yield _connect_client
|
||||
|
||||
|
||||
async def wait_for_port_open(
|
||||
host: str, port: int, timeout: float = PORT_WAIT_TIMEOUT
|
||||
) -> None:
|
||||
"""Wait for a TCP port to be open and accepting connections."""
|
||||
loop = asyncio.get_running_loop()
|
||||
start_time = loop.time()
|
||||
|
||||
# Small yield to ensure the process has a chance to start
|
||||
await asyncio.sleep(0)
|
||||
|
||||
while loop.time() - start_time < timeout:
|
||||
try:
|
||||
# Try to connect to the port
|
||||
_, writer = await asyncio.open_connection(host, port)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return # Port is open
|
||||
except (ConnectionRefusedError, OSError):
|
||||
# Port not open yet, wait a bit and try again
|
||||
await asyncio.sleep(PORT_POLL_INTERVAL)
|
||||
|
||||
raise TimeoutError(f"Port {port} on {host} did not open within {timeout} seconds")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def run_compiled_context(
|
||||
yaml_content: str,
|
||||
filename: str | None,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
run_esphome_process: RunFunction,
|
||||
port: int,
|
||||
port_socket: socket.socket | None = None,
|
||||
) -> AsyncGenerator[asyncio.subprocess.Process]:
|
||||
"""Context manager to write, compile and run an ESPHome configuration."""
|
||||
# Write the YAML config
|
||||
config_path = await write_yaml_config(yaml_content, filename)
|
||||
|
||||
# Compile the configuration
|
||||
await compile_esphome(config_path)
|
||||
|
||||
# Close the port socket right before running to release the port
|
||||
if port_socket is not None:
|
||||
port_socket.close()
|
||||
|
||||
# Run the ESPHome device
|
||||
process = await run_esphome_process(config_path)
|
||||
assert process.returncode is None, "Process died immediately"
|
||||
|
||||
# Wait for the API server to start listening
|
||||
await wait_for_port_open(LOCALHOST, port, timeout=PORT_WAIT_TIMEOUT)
|
||||
|
||||
try:
|
||||
yield process
|
||||
finally:
|
||||
# Process cleanup is handled by run_esphome_process fixture
|
||||
pass
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def run_compiled(
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
run_esphome_process: RunFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> AsyncGenerator[RunCompiledFunction]:
|
||||
"""Write, compile and run an ESPHome configuration."""
|
||||
port, port_socket = reserved_tcp_port
|
||||
|
||||
def _run_compiled(
|
||||
yaml_content: str, filename: str | None = None
|
||||
) -> AbstractAsyncContextManager[asyncio.subprocess.Process]:
|
||||
return run_compiled_context(
|
||||
yaml_content,
|
||||
filename,
|
||||
write_yaml_config,
|
||||
compile_esphome,
|
||||
run_esphome_process,
|
||||
port,
|
||||
port_socket,
|
||||
)
|
||||
|
||||
yield _run_compiled
|
Reference in New Issue
Block a user