diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3b4459db09..8a2c3dc23e 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -11,7 +11,6 @@ import logging import os from pathlib import Path import platform -import re import shutil import signal import socket @@ -67,6 +66,12 @@ def pytest_configure(config: pytest.Config) -> None: ) +FIXTURES_DIR = Path(__file__).parent / "fixtures" + +# CI caches parts of this path; keep in sync with ci.yml integration-tests. +INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests" + + def _get_platformio_env(cache_dir: Path) -> dict[str, str]: """Get environment variables for PlatformIO with shared cache.""" env = os.environ.copy() @@ -99,8 +104,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: def shared_platformio_cache() -> Generator[Path]: """Initialize a shared PlatformIO cache for all integration tests.""" # Use a dedicated directory for integration tests to avoid conflicts. - # CI caches parts of this path; keep in sync with ci.yml integration-tests. - test_cache_dir = Path.home() / ".esphome-integration-tests" + test_cache_dir = INTEGRATION_TESTS_ROOT cache_dir = test_cache_dir / "platformio" # Use a lock file in the home directory to ensure only one process initializes the cache @@ -195,15 +199,14 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int: @pytest_asyncio.fixture async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str: """Load YAML configuration based on test name.""" - marker = request.node.get_closest_marker("shared_yaml") - if marker is not None: - base_name = marker.args[0] - else: - # Base test name: test_ prefix and any parametrization stripped - base_name = request.node.name.replace("test_", "").partition("[")[0] + # Base test name: test_ prefix and any parametrization stripped + base_name = ( + _shared_yaml_name(request) + or request.node.name.replace("test_", "").partition("[")[0] + ) # Load the fixture file - fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml" + fixture_path = FIXTURES_DIR / f"{base_name}.yaml" if not fixture_path.exists(): raise FileNotFoundError(f"Fixture file not found: {fixture_path}") @@ -260,16 +263,21 @@ async def write_yaml_config( yield _write_config -SHARED_BUILDS_ROOT = Path.home() / ".esphome-integration-tests" / "builds" +SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds" -_API_PORT_LINE_RE = re.compile(r"^(\s*port:) \d+$", re.MULTILINE) +# ELF path per shared build dir; constant once compiled, so resolve it only once +_shared_elf_paths: dict[Path, Path] = {} -def _shared_build_key(yaml_content: str) -> str: - """Key shared build dirs by the config with the injected api port normalized.""" - return hashlib.sha256( - _API_PORT_LINE_RE.sub(r"\1 0", yaml_content).encode() - ).hexdigest()[:16] +def _shared_yaml_name(request: pytest.FixtureRequest) -> str | None: + """Name passed to the shared_yaml marker, or None when unmarked.""" + marker = request.node.get_closest_marker("shared_yaml") + return marker.args[0] if marker is not None else None + + +def _shared_build_key(name: str) -> str: + """Key shared build dirs by the fixture source, before per-test injections.""" + return hashlib.sha256((FIXTURES_DIR / f"{name}.yaml").read_bytes()).hexdigest()[:16] async def _run_esphome_compile( @@ -321,7 +329,10 @@ def _resolve_compiled_binary(config_path: Path) -> Path: if config is None: raise RuntimeError(f"Failed to read config from {config_path}") idedata = get_idedata(config) - return Path(idedata.firmware_elf_path) + binary_path = Path(idedata.firmware_elf_path) + if not binary_path.exists(): + raise RuntimeError(f"Compiled binary not found at {binary_path}") + return binary_path @pytest_asyncio.fixture @@ -338,24 +349,20 @@ async def compile_esphome( env = _get_platformio_env(shared_platformio_cache) loop = asyncio.get_running_loop() - marker = request.node.get_closest_marker("shared_yaml") - if marker is None: + name = _shared_yaml_name(request) + if name is None: await _run_esphome_compile(config_path, integration_test_dir, env) - binary_path = await loop.run_in_executor( + return await loop.run_in_executor( None, _resolve_compiled_binary, config_path ) - if not binary_path.exists(): - raise RuntimeError(f"Compiled binary not found at {binary_path}") - return binary_path # Shared fixture: build in a hash-keyed dir so tests sharing a config # pay one full compile and later only a main.cpp (port) rebuild + relink - name = marker.args[0] - content = await loop.run_in_executor(None, config_path.read_text) - shared_dir = SHARED_BUILDS_ROOT / f"{name}-{_shared_build_key(content)}" + shared_dir = SHARED_BUILDS_ROOT / f"{name}-{_shared_build_key(name)}" shared_dir.mkdir(parents=True, exist_ok=True) shared_config = shared_dir / f"{name}.yaml" private_binary = integration_test_dir / f"{name}.elf" + content = await loop.run_in_executor(None, config_path.read_text) # flock serializes concurrent xdist workers; closing the fd releases it with (shared_dir / ".lock").open("w") as lock_file: await loop.run_in_executor( @@ -363,11 +370,11 @@ async def compile_esphome( ) await loop.run_in_executor(None, shared_config.write_text, content) await _run_esphome_compile(shared_config, shared_dir, env) - built = await loop.run_in_executor( - None, _resolve_compiled_binary, shared_config - ) - if not built.exists(): - raise RuntimeError(f"Compiled binary not found at {built}") + if (built := _shared_elf_paths.get(shared_dir)) is None: + built = await loop.run_in_executor( + None, _resolve_compiled_binary, shared_config + ) + _shared_elf_paths[shared_dir] = built # Copy out before unlocking: another worker may relink firmware.elf # while this test is still running its private copy await loop.run_in_executor(None, shutil.copy2, built, private_binary) diff --git a/tests/integration/fixtures/uart_mock_modbus_loopback.yaml b/tests/integration/fixtures/uart_mock_modbus_loopback.yaml index fa2dae6506..20b9588890 100644 --- a/tests/integration/fixtures/uart_mock_modbus_loopback.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_loopback.yaml @@ -17,9 +17,8 @@ uart: baud_rate: 115200 port: /dev/null -# Shared loopback fixture for the custom_pdu, register_offset, lambda_write, -# lambda_invert and deprecated_write_buffer tests; register spaces are disjoint -# so each test only observes its own entities. +# Shared loopback fixture (see the shared_yaml markers in the test file); +# register spaces are disjoint so each test only observes its own entities. uart_mock: - id: virtual_uart_server baud_rate: 9600 @@ -101,7 +100,6 @@ modbus_server: - address: 0x13 value_type: U_WORD read_lambda: return id(reg13); - write_lambda: id(reg13) = x; return true; - address: 0x30 value_type: U_WORD read_lambda: return id(reg30); diff --git a/tests/integration/fixtures/uart_mock_modbus_mesh.yaml b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml index 7575920665..963f478f1c 100644 --- a/tests/integration/fixtures/uart_mock_modbus_mesh.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml @@ -17,17 +17,15 @@ uart: baud_rate: 115200 port: /dev/null -# Shared 3-bus mesh for the server_controller, server_controller_multiple -# and client_read_write tests: two server hubs and one client hub, every bus -# forwarding to both others. Servers: addr 1 = typed read-only registers, -# addr 5 = client_read_write's server, addr 2/3 on the second server hub. +# Shared 3-bus mesh (see the shared_yaml markers in the test file): two +# server hubs and one client hub, every bus forwarding to both others. +# Servers: addr 1 = typed read-only registers, addr 5 = the read/write 0x17 +# target, addr 2/3 on the second server hub. +# auto_start on every bus: the controller polls at boot, so the forwarding +# must already be live or early requests are lost and generate warnings. uart_mock: - id: virtual_uart_server baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. auto_start: true debug: on_tx: @@ -40,7 +38,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_server_2 baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true debug: on_tx: - then: @@ -52,7 +50,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_controller baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true debug: on_tx: - then: @@ -145,13 +143,11 @@ modbus_server: modbus_id: virtual_modbus_server id: modbus_server_5 registers: - # Writable + readable register: the read publishes what it returns, so the test can confirm the - # write half of the 0x17 ran before the read half (Modbus 6.17). + # Writable + readable register: srv_write_1 plus the client's read-back + # confirm the write half of the 0x17 ran before the read half (Modbus 6.17). - address: 0x01 value_type: U_WORD - read_lambda: |- - id(srv_read_1).publish_state(id(stored_1)); - return id(stored_1); + read_lambda: return id(stored_1); write_lambda: |- id(stored_1) = x; id(srv_write_1).publish_state(x); @@ -280,9 +276,6 @@ sensor: - platform: template name: "srv_write_1" id: srv_write_1 - - platform: template - name: "srv_read_1" - id: srv_read_1 - platform: template name: "client_read_0" id: client_read_0 diff --git a/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml index b379069860..18574ffcc9 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml @@ -17,8 +17,8 @@ uart: baud_rate: 115200 port: /dev/null -# Shared server-role fixture for the server and server_read_write tests; the -# injections concatenate and each test waits only on its own sensors. +# Shared server-role fixture (see the shared_yaml markers in the test file); +# the injections concatenate and each test waits only on its own sensors. uart_mock: - id: virtual_uart_dev baud_rate: 9600 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 8a02becbd7..9e66e80514 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -233,8 +233,8 @@ async def test_uart_mock_modbus_server_read_write( "rw_write_1": 4660, # 0x1234 written to reg 0x0001 "rw_read_1": 4660, # reg 0x0001 reads back the just-written value "rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request - "rw_write_3": 22136, # 0x5678 written to reg 0x0003 - "rw_read_3": 22136, # reg 0x0003 reads back the just-written value + "rw_write_3": 22136, # 0x5678 written to reg 0x0006 + "rw_read_3": 22136, # reg 0x0006 reads back the just-written value } ) @@ -953,9 +953,7 @@ async def test_uart_mock_modbus_client_read_write( """ line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - tracker = SensorTracker( - ["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"] - ) + tracker = SensorTracker(["srv_write_1", "client_read_0", "client_read_1"]) futures = tracker.expect_all( { "srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001