Apply simplify pass: key shared builds off the fixture source, hoist the cache root, dedup helpers

This commit is contained in:
J. Nick Koston
2026-09-02 12:20:13 +02:00
parent 92e663821c
commit e3820e62bc
5 changed files with 57 additions and 61 deletions
+39 -32
View File
@@ -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)
@@ -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);
@@ -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
@@ -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
+3 -5
View File
@@ -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