Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch

This commit is contained in:
J. Nick Koston
2026-09-02 11:18:07 +02:00
191 changed files with 5173 additions and 570 deletions
+407
View File
@@ -0,0 +1,407 @@
"""Unit tests for encrypted OTA uploads in esphome.espota2.
A fake device implementing the responder side of the wire protocol (via
noiseprotocol, which esphome already has through aioesphomeapi) serves a real
TCP loopback connection, so these exercise the actual handshake, framing, and
cipher interop of the client code. Tests that need the client-side crypto skip
when the installed aioesphomeapi predates the noise module.
"""
from __future__ import annotations
import base64
import hashlib
import io
from pathlib import Path
import socket
import sys
import threading
from unittest.mock import Mock, patch
import pytest
from esphome import espota2
PSK = base64.b64encode(bytes(range(32))).decode()
OTHER_PSK = base64.b64encode(bytes(range(1, 33))).decode()
MAGIC = bytes(espota2.MAGIC_BYTES)
def _recv_exact(sock: socket.socket, amount: int) -> bytes:
data = b""
while len(data) < amount:
chunk = sock.recv(amount - len(data))
if not chunk:
raise ConnectionError("client closed")
data += chunk
return data
def _frame(payload: bytes) -> bytes:
return (
bytes([espota2.NOISE_FRAME_INDICATOR, len(payload) >> 8, len(payload) & 0xFF])
+ payload
)
def _send_frame(sock: socket.socket, payload: bytes) -> None:
sock.sendall(_frame(payload))
def _recv_frame(sock: socket.socket) -> bytes:
header = _recv_exact(sock, 3)
assert header[0] == 0x01
return _recv_exact(sock, (header[1] << 8) | header[2])
class FakeEncryptedDevice(threading.Thread):
"""Responder side of the encrypted OTA wire protocol."""
def __init__(
self,
psk: str = PSK,
version: int = 2,
offer_noise: bool = True,
require_noise: bool = True,
prologue_features_override: int | None = None,
) -> None:
super().__init__(daemon=True)
self.psk = psk
self.version = version
self.offer_noise = offer_noise
self.require_noise = require_noise
self.prologue_features_override = prologue_features_override
self.received: bytes | None = None
self.error: Exception | None = None
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.listener.bind(("127.0.0.1", 0))
self.listener.listen(1)
self.port = self.listener.getsockname()[1]
def run(self) -> None:
try:
sock, _ = self.listener.accept()
sock.settimeout(10)
with sock:
self._serve(sock)
except Exception as err: # noqa: BLE001 - surfaced via join_and_check
self.error = err
finally:
self.listener.close()
def join_and_check(self) -> None:
self.join(timeout=10)
assert not self.is_alive(), "fake device did not finish"
if self.error is not None:
raise self.error
def _serve(self, sock: socket.socket) -> None:
assert _recv_exact(sock, 5) == MAGIC
sock.sendall(bytes([espota2.RESPONSE_OK, self.version]))
features = _recv_exact(sock, 1)[0]
noise_negotiated = bool(
features & espota2.CLIENT_FEATURE_SUPPORTS_NOISE
and features & espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
)
if self.require_noise and not noise_negotiated:
sock.sendall(bytes([espota2.RESPONSE_ERROR_ENCRYPTION_REQUIRED]))
return
server_flags = espota2.SERVER_FEATURE_SUPPORTS_NOISE if self.offer_noise else 0
sock.sendall(bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags]))
if not (self.offer_noise and noise_negotiated):
return # the client fails closed; nothing further arrives
from cryptography.exceptions import InvalidTag
from noise.connection import NoiseConnection
prologue_features = (
features
if self.prologue_features_override is None
else self.prologue_features_override
)
prologue = (
espota2.NOISE_PROLOGUE_INIT
+ MAGIC
+ bytes([espota2.RESPONSE_OK, self.version, prologue_features])
+ bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags])
)
proto = NoiseConnection.from_name(b"Noise_NNpsk0_25519_ChaChaPoly_SHA256")
proto.set_as_responder()
proto.set_psks(base64.b64decode(self.psk))
proto.set_prologue(prologue)
proto.start_handshake()
msg1 = _recv_frame(sock)
assert msg1[0] == 0x00
try:
proto.read_message(msg1[1:])
except InvalidTag:
_send_frame(sock, b"\x01" + espota2.NOISE_MAC_FAILURE_REASON.encode())
return
_send_frame(sock, b"\x00" + bytes(proto.write_message()))
def send_byte(byte: int) -> None:
_send_frame(sock, proto.encrypt(bytes([byte])))
def recv_unit(length: int) -> bytes:
plaintext = proto.decrypt(_recv_frame(sock))
assert len(plaintext) == length, "control units must be one per frame"
return plaintext
send_byte(espota2.RESPONSE_AUTH_OK)
recv_unit(1) # ota type
size = int.from_bytes(recv_unit(4), "big")
send_byte(espota2.RESPONSE_UPDATE_PREPARE_OK)
md5_hex = recv_unit(32)
send_byte(espota2.RESPONSE_BIN_MD5_OK)
received = b""
acked = 0
while len(received) < size:
plaintext = proto.decrypt(_recv_frame(sock))
assert 0 < len(plaintext) <= espota2.NOISE_MAX_PLAINTEXT
received += plaintext
if self.version >= espota2.OTA_VERSION_2_0:
while acked + espota2.UPLOAD_BLOCK_SIZE <= len(received) or (
len(received) == size and acked < size
):
send_byte(espota2.RESPONSE_CHUNK_OK)
acked += espota2.UPLOAD_BLOCK_SIZE
assert hashlib.md5(received).hexdigest().encode() == md5_hex
send_byte(espota2.RESPONSE_RECEIVE_OK)
send_byte(espota2.RESPONSE_UPDATE_END_OK)
assert recv_unit(1) == bytes([espota2.RESPONSE_OK])
self.received = received
def _upload(
device: FakeEncryptedDevice, firmware: bytes, noise_psk: str | None
) -> None:
device.start()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect(("127.0.0.1", device.port))
try:
espota2.perform_ota(
sock, None, io.BytesIO(firmware), Path("firmware.bin"), noise_psk=noise_psk
)
finally:
sock.close()
def test_encrypted_upload_success() -> None:
"""A full encrypted v2 upload spanning several 8192-byte blocks."""
pytest.importorskip("aioesphomeapi.noise")
firmware = bytes(range(256)) * 80 # 20480 bytes, crosses chunk-ack boundaries
device = FakeEncryptedDevice()
with patch("time.sleep"):
_upload(device, firmware, PSK)
device.join_and_check()
assert device.received == firmware
def test_encrypted_upload_version_1() -> None:
"""Version 1 protocol (no chunk acks) works through the noise transport."""
pytest.importorskip("aioesphomeapi.noise")
firmware = b"v1 firmware image" * 100
device = FakeEncryptedDevice(version=1)
with patch("time.sleep"):
_upload(device, firmware, PSK)
device.join_and_check()
assert device.received == firmware
def test_wrong_key_fails_with_clear_error() -> None:
"""A key mismatch surfaces the device's handshake reject readably."""
pytest.importorskip("aioesphomeapi.noise")
device = FakeEncryptedDevice(psk=OTHER_PSK)
with pytest.raises(espota2.OTAError, match="encryption key correct"):
_upload(device, b"firmware", PSK)
device.join_and_check()
def test_tampered_negotiation_breaks_handshake() -> None:
"""A negotiation byte differing between the sides breaks the prologue MAC."""
pytest.importorskip("aioesphomeapi.noise")
device = FakeEncryptedDevice(
prologue_features_override=espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
)
with pytest.raises(espota2.OTAError, match="encryption key correct"):
_upload(device, b"firmware", PSK)
device.join_and_check()
def test_client_fails_closed_when_device_lacks_encryption() -> None:
"""With a key configured, a device not offering noise aborts the upload."""
device = FakeEncryptedDevice(offer_noise=False, require_noise=False)
with pytest.raises(espota2.OTAError, match="refusing to send the image"):
_upload(device, b"firmware", PSK)
device.join_and_check()
def test_plaintext_client_gets_encryption_required_error() -> None:
"""A client without a key gets the device's 0x94 error message."""
device = FakeEncryptedDevice()
with pytest.raises(espota2.OTAError, match="requires an encrypted OTA"):
_upload(device, b"firmware", None)
device.join_and_check()
def test_missing_aioesphomeapi_noise_module_message() -> None:
"""An aioesphomeapi without the noise module produces a clear error."""
with (
patch.dict(sys.modules, {"aioesphomeapi.noise": None}),
pytest.raises(espota2.OTAError, match="requires a newer aioesphomeapi"),
):
espota2.NoiseSocketWrapper(Mock(), PSK, b"prologue")
class ScriptedSocket:
"""Serves scripted recv chunks; b"" means the peer closed."""
def __init__(self, *chunks: bytes | Exception) -> None:
self.chunks = list(chunks)
self.sent: list[bytes] = []
def sendall(self, data: bytes) -> None:
self.sent.append(data)
def settimeout(self, timeout: float) -> None:
pass
def recv(self, amount: int) -> bytes:
if not self.chunks:
return b""
chunk = self.chunks[0]
if isinstance(chunk, Exception):
self.chunks.pop(0)
raise chunk
take, rest = chunk[:amount], chunk[amount:]
if rest:
self.chunks[0] = rest
else:
self.chunks.pop(0)
return take
def _wrapper(*chunks: bytes | Exception) -> espota2.NoiseSocketWrapper:
pytest.importorskip("aioesphomeapi.noise")
return espota2.NoiseSocketWrapper(ScriptedSocket(*chunks), PSK, b"prologue")
def test_wrapper_rejects_malformed_psk() -> None:
pytest.importorskip("aioesphomeapi.noise")
with pytest.raises(espota2.OTAError, match="Invalid OTA encryption key"):
espota2.NoiseSocketWrapper(ScriptedSocket(), "not-base64!!!", b"prologue")
def test_handshake_socket_error_is_network_error() -> None:
wrapper = _wrapper(OSError("boom"))
with pytest.raises(espota2.OTANetworkError, match="noise handshake"):
wrapper.do_handshake()
def test_handshake_closed_at_frame_boundary() -> None:
wrapper = _wrapper()
with pytest.raises(espota2.OTANetworkError, match="closed connection during"):
wrapper.do_handshake()
def test_handshake_reject_with_other_reason() -> None:
wrapper = _wrapper(_frame(b"\x01Handshake error"))
with pytest.raises(
espota2.OTAError, match="rejected the noise handshake: Handshake error"
):
wrapper.do_handshake()
def test_handshake_garbage_second_message() -> None:
"""A valid-looking point with a garbage MAC fails cleanly."""
wrapper = _wrapper(_frame(b"\x00" + bytes(range(48))))
with pytest.raises(
espota2.OTAError, match="handshake failed; is the OTA encryption key"
):
wrapper.do_handshake()
def test_handshake_invalid_curve_point() -> None:
"""An all-zero x25519 point is rejected as a clean error, not a crash."""
wrapper = _wrapper(_frame(b"\x00" + bytes(48)))
with pytest.raises(
espota2.OTAError, match="handshake failed; is the OTA encryption key"
):
wrapper.do_handshake()
def test_recv_closed_at_frame_boundary_returns_empty() -> None:
wrapper = _wrapper()
assert wrapper.recv(1) == b""
def test_recv_corrupt_frame_is_retryable_network_error() -> None:
from cryptography.exceptions import InvalidTag
wrapper = _wrapper(_frame(b"ciphertext"))
wrapper._decrypt = Mock(decrypt=Mock(side_effect=InvalidTag()))
with pytest.raises(espota2.OTANetworkError, match="decryption failed"):
wrapper.recv(1)
def test_wrapper_blocks_unencrypted_socket_methods() -> None:
"""Byte-moving socket methods must not bypass the encrypted transport."""
wrapper = _wrapper()
# The harmless socket controls pass through to the wrapped socket
wrapper._sock = Mock()
wrapper.settimeout(1)
wrapper._sock.settimeout.assert_called_once_with(1)
wrapper.setsockopt(6, 1, 1)
wrapper._sock.setsockopt.assert_called_once_with(6, 1, 1)
wrapper.close()
wrapper._sock.close.assert_called_once_with()
with pytest.raises(AttributeError):
_ = wrapper.send
with pytest.raises(AttributeError):
_ = wrapper.recv_into
def test_recv_empty_plaintext_frame_is_protocol_error() -> None:
"""A MAC-only frame decrypts to nothing; b'' from recv must mean close."""
wrapper = _wrapper(_frame(bytes(16)))
wrapper._decrypt = Mock(decrypt=Mock(return_value=b""))
with pytest.raises(espota2.OTANetworkError, match="empty noise frame"):
wrapper.recv(1)
def test_recv_frame_bad_indicator_is_retryable() -> None:
wrapper = _wrapper(b"\x02\x00\x01x")
with pytest.raises(espota2.OTANetworkError, match="Bad noise frame indicator"):
wrapper._recv_frame()
def test_recv_frame_zero_length_is_retryable() -> None:
wrapper = _wrapper(bytes([espota2.NOISE_FRAME_INDICATOR, 0, 0]))
with pytest.raises(espota2.OTANetworkError, match="empty noise frame"):
wrapper._recv_frame()
def test_perform_ota_blank_key_refuses_plaintext() -> None:
with pytest.raises(espota2.OTAError, match="empty OTA encryption key"):
espota2.perform_ota(
ScriptedSocket(), None, io.BytesIO(b"x"), Path("f.bin"), noise_psk=""
)
def test_recv_exact_closed_mid_frame() -> None:
wrapper = _wrapper(_frame(b"partial")[:5])
with pytest.raises(OSError, match="closed inside a noise frame"):
wrapper._recv_frame()
def test_recv_serves_buffered_plaintext_without_new_frame() -> None:
"""A second recv drains the decrypted buffer without reading another frame."""
wrapper = _wrapper(_frame(b"ciphertext"))
wrapper._decrypt = Mock(decrypt=Mock(return_value=b"AB"))
assert wrapper.recv(1) == b"A" # reads and decrypts one frame
assert wrapper.recv(1) == b"B" # served from the buffer, no new frame
wrapper._decrypt.decrypt.assert_called_once()
+19
View File
@@ -714,6 +714,25 @@ def test_run_git_command_without_git_dir_raises_error(
git.run_git_command(["git", "clone", "https://invalid.url/repo.git"])
def test_has_complete_clone(tmp_path: Path) -> None:
"""The lock-free probe tracks the completion marker, subpath included."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
subpath = Path("lib")
assert not git.has_complete_clone(url, "v1", "test_domain", subpath)
repo_dir = _compute_repo_dir(url, "v1", "test_domain") / subpath
(repo_dir / ".git").mkdir(parents=True)
# A directory without the marker is an incomplete clone
assert not git.has_complete_clone(url, "v1", "test_domain", subpath)
_mark_clone_complete(repo_dir)
assert git.has_complete_clone(url, "v1", "test_domain", subpath)
# The ref is part of the cache key
assert not git.has_complete_clone(url, "v2", "test_domain", subpath)
def test_clone_or_update_with_never_refresh(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
+155 -5
View File
@@ -87,7 +87,9 @@ from esphome.const import (
CONF_BROKER,
CONF_DISABLED,
CONF_DISCOVER_IP,
CONF_ENCRYPTION,
CONF_ESPHOME,
CONF_KEY,
CONF_LEVEL,
CONF_LOG,
CONF_LOG_TOPIC,
@@ -113,6 +115,7 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_HOST,
PLATFORM_NRF52,
PLATFORM_RP2,
Toolchain,
@@ -2106,10 +2109,65 @@ def test_upload_program_ota_success(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP
["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP, None
)
def test_upload_program_ota_encryption_key(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""The resolved encryption key is passed through to run_ota."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
mock_run_ota.return_value = (0, "192.168.1.100")
key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
CONF_ENCRYPTION: {CONF_KEY: key},
}
]
}
exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"])
assert exit_code == 0
assert host == "192.168.1.100"
expected_firmware = (
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, key
)
def test_upload_program_ota_encryption_without_key_fails_closed(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""An encryption block with no resolved key must never upload plaintext."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
CONF_ENCRYPTION: {},
}
]
}
with pytest.raises(EsphomeError, match="no key was resolved"):
upload_program(config, MockArgs(), ["192.168.1.100"])
mock_run_ota.assert_not_called()
def test_upload_program_ota_with_file_arg(
mock_run_ota: Mock,
mock_get_port_type: Mock,
@@ -2137,7 +2195,7 @@ def test_upload_program_ota_with_file_arg(
assert exit_code == 0
assert host == "192.168.1.100"
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP
["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP, None
)
@@ -2192,6 +2250,7 @@ def test_upload_program_ota_partition_table_with_file_arg(
None,
partition_file,
OTA_TYPE_UPDATE_PARTITION_TABLE,
None,
)
@@ -2253,6 +2312,7 @@ def test_upload_program_ota_partition_table_mqttip(
None,
partition_file,
OTA_TYPE_UPDATE_PARTITION_TABLE,
None,
)
@@ -2440,6 +2500,7 @@ def test_upload_program_ota_bootloader_with_file_arg(
None,
bootloader_file,
OTA_TYPE_UPDATE_BOOTLOADER,
None,
)
@@ -2602,6 +2663,42 @@ def test_has_web_server_logging_respects_log_disabled() -> None:
assert has_web_server_logging() is False
def test_upload_program_web_server_warns_when_encryption_configured(
mock_run_web_server_ota: Mock,
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Explicitly picking web_server OTA on an encrypted config warns about
the plaintext upload path."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
mock_run_web_server_ota.return_value = (0, "192.168.1.100")
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
CONF_ENCRYPTION: {CONF_KEY: "test_key"},
},
{CONF_PLATFORM: CONF_WEB_SERVER},
],
CONF_WEB_SERVER: {
CONF_PORT: 80,
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "pw"},
},
}
args = MockArgs(ota_platform=CONF_WEB_SERVER)
with caplog.at_level(logging.WARNING):
exit_code, _ = upload_program(config, args, ["192.168.1.100"])
assert exit_code == 0
assert any("plaintext HTTP" in record.message for record in caplog.records)
mock_run_ota.assert_not_called()
def test_upload_program_web_server_only_auto_dispatches(
mock_run_web_server_ota: Mock,
mock_run_ota: Mock,
@@ -2892,7 +2989,7 @@ def test_upload_program_ota_with_mqtt_resolution(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None
)
@@ -2942,7 +3039,7 @@ def test_upload_program_ota_with_mqtt_empty_broker(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None
)
# Verify warning was logged
assert "MQTT IP discovery failed" in caplog.text
@@ -5114,6 +5211,7 @@ def test_upload_program_ota_static_ip_with_mqttip(
None,
expected_firmware,
OTA_TYPE_UPDATE_APP,
None,
)
@@ -5163,6 +5261,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once(
None,
expected_firmware,
OTA_TYPE_UPDATE_APP,
None,
)
@@ -5340,7 +5439,7 @@ def test_upload_program_ota_mqtt_timeout_fallback(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None
)
@@ -7513,3 +7612,54 @@ async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
assert first == second
assert second.index("alpha") < second.index("beta")
assert second.index("a: 2") < second.index("z: 1")
def test_host_program_path_platformio_toolchain() -> None:
"""Host + PlatformIO toolchain reads the memoized idedata path."""
setup_core(platform=PLATFORM_HOST)
idedata = SimpleNamespace(firmware_elf_path="/build/x/.pioenvs/x/program")
with patch(
"esphome.platformio.toolchain.get_idedata", return_value=idedata
) as mock_get:
assert main._host_program_path({}) == "/build/x/.pioenvs/x/program"
mock_get.assert_called_once_with({})
def test_host_program_path_esp_idf_toolchain() -> None:
"""Host + native ESP-IDF toolchain asks the espidf toolchain for the ELF."""
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.ESP_IDF
with patch(
"esphome.espidf.toolchain.get_elf_path", return_value=Path("/b/app.elf")
):
assert main._host_program_path({}) == str(Path("/b/app.elf"))
def test_command_compile_host_logs_program_path(
caplog: pytest.LogCaptureFixture,
) -> None:
"""command_compile on host logs the compiled program path."""
setup_core(platform=PLATFORM_HOST)
with (
patch.object(main, "write_cpp", return_value=0),
patch.object(main, "compile_program", return_value=0),
patch.object(main, "_host_program_path", return_value="/b/program"),
caplog.at_level(logging.INFO),
):
assert main.command_compile(SimpleNamespace(only_generate=False), {}) == 0
assert "Successfully compiled program to path '/b/program'" in caplog.text
def test_command_run_host_executes_program(caplog: pytest.LogCaptureFixture) -> None:
"""command_run on host logs and executes the compiled program directly."""
setup_core(platform=PLATFORM_HOST)
with (
patch.object(main, "write_cpp", return_value=0),
patch.object(main, "compile_program", return_value=0),
patch.object(main, "_host_program_path", return_value="/b/program"),
patch.object(main, "run_external_process", return_value=0) as mock_run,
caplog.at_level(logging.INFO),
):
assert main.command_run(SimpleNamespace(), {}) == 0
mock_run.assert_called_with("/b/program")
assert "Running program from path '/b/program'" in caplog.text
+79 -2
View File
@@ -638,7 +638,7 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Registry archives in one wave download concurrently, deduped by URL;
git/local sources and failures are left to the sequential call."""
local sources and failures are left to the sequential call."""
calls: list[str] = []
def fake_download(
@@ -658,7 +658,7 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
# into the same cache directory)
("b2", ConvertedLibrary("b2", "1.0", URLSource("https://x/b.tar.gz", 1))),
("c", ConvertedLibrary("c", "1.0", URLSource("https://x/boom.tar.gz", 1))),
("g", ConvertedLibrary("g", "*", lib.GitSource("https://x/g.git", None))),
("l", ConvertedLibrary("l", "*", LocalSource("/some/lib"))),
]
lib._prefetch_wave(wave, "", "idf")
assert sorted(calls) == [
@@ -670,6 +670,83 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
assert "Prefetch of c failed (retrying sequentially)" in caplog.text
def test_prefetch_wave_clones_git_sources_in_parallel(
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Git sources join the same prefetch batch as the archives, deduped by
clone target; a clone failure warns and is left to the sequential call."""
caplog.set_level("INFO")
calls: list[str] = []
def fake_clone(self, dir_suffix, force=False, salt="", namespace=""):
calls.append(f"{self}/{dir_suffix}")
if "boom" in self.url:
raise RuntimeError("boom")
monkeypatch.setattr(GitSource, "download", fake_clone)
wave = [
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
("g", ConvertedLibrary("g", "*", GitSource("https://x/g.git", "v1"))),
# Same url@ref and target dir must clone once
("g2", ConvertedLibrary("g", "*", GitSource("https://x/g.git", "v1"))),
("h", ConvertedLibrary("h", "*", GitSource("https://x/boom.git", None))),
]
monkeypatch.setattr(
URLSource, "download", lambda self, dir_suffix, progress=None, **kw: None
)
lib._prefetch_wave(wave, "", "idf")
assert sorted(calls) == ["https://x/boom.git/h", "https://x/g.git#v1/g"]
assert "Cloning 2 library repo(s): g, h" in caplog.text
assert "Prefetch of h failed (retrying sequentially)" in caplog.text
def test_source_base_prefetch_defaults() -> None:
"""The base Source is not prefetchable and reports cached (nothing to do)."""
source = Source()
assert source.prefetch_key("x") is None
assert source.is_cached("x") is True
def test_prefetch_wave_single_clone_uses_the_batch(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A wave with only git sources still clones through the batch runner."""
caplog.set_level("INFO")
calls: list[str] = []
monkeypatch.setattr(GitSource, "is_cached", lambda self, *a, **kw: False)
monkeypatch.setattr(
GitSource,
"download",
lambda self, dir_suffix, force=False, salt="", namespace="": calls.append(
self.url
),
)
lib._prefetch_wave(
[("g", ConvertedLibrary("g", "*", GitSource("https://x/g.git", None)))],
"",
"idf",
)
assert calls == ["https://x/g.git"]
assert "Cloning 1 library repo(s): g" in caplog.text
assert "Downloading" not in caplog.text
def test_prefetch_wave_warm_git_cache_is_silent(
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""An already-complete clone is neither re-fetched nor announced."""
caplog.set_level("INFO")
monkeypatch.setattr(
GitSource,
"download",
lambda self, dir_suffix, **kw: (_ for _ in ()).throw(AssertionError("cloned")),
)
monkeypatch.setattr(GitSource, "is_cached", lambda self, *a, **kw: True)
wave = [("g", ConvertedLibrary("g", "*", GitSource("https://x/g.git", None)))]
lib._prefetch_wave(wave, "", "idf")
assert "Cloning" not in caplog.text
def test_prefetch_wave_unknown_size_left_to_sequential(
setup_core, monkeypatch: pytest.MonkeyPatch
) -> None:
+80 -6
View File
@@ -13,6 +13,7 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from filelock import Timeout
from platformio.dependencies import get_core_dependencies
from platformio.package.manager._install import PackageManagerInstallMixin
from platformio.package.manager.base import BasePackageManager
from platformio.package.manager.library import LibraryPackageManager
@@ -1417,6 +1418,76 @@ def test_prefetch_installs_cached_archives_without_downloads(
assert not (tmp_path / pf._SENTINEL_NAME).exists()
@pytest.mark.parametrize(
("platform_group", "lib_group", "expected"),
[
(
[("toolchain-x@1", _FakeSpec(name="toolchain-x"))],
[],
["configure", "install", "configure"],
),
([], [("noise-c@1.0", _FakeSpec(name="noise-c"))], ["configure", "install"]),
],
)
def test_prefetch_reconfigures_only_after_platform_installs(
tmp_path: Path, platform_group: list, lib_group: list, expected: list[str]
) -> None:
"""Installed platform packages get a second configure pass; libraries do not."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
order: list[str] = []
fake_platform = MagicMock()
fake_platform.packages = {}
fake_platform.configure_project_packages.side_effect = lambda env, targets: (
order.append("configure")
)
config = _fake_config(
tmp_path, {"platform": "fake/p@1", "lib_deps": ["esphome/noise-c@1.0"]}
)
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
with (
patch.dict("sys.modules", modules),
patch.object(
pf,
"_registry_jobs",
side_effect=[([], 0, platform_group), ([], 0, lib_group)],
),
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
patch.object(pf, "_preinstall", side_effect=lambda *_: order.append("install")),
):
pf._prefetch(tmp_path, "testenv")
assert order == expected
@pytest.mark.parametrize(
"err", [RuntimeError("idf_tools.py failed"), SystemExit("postinstall exited")]
)
def test_prefetch_settle_failure_warns_and_continues(
tmp_path: Path, caplog: pytest.LogCaptureFixture, err: BaseException
) -> None:
"""A failing second configure pass only costs the speedup."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
fake_platform = MagicMock()
fake_platform.packages = {}
fake_platform.configure_project_packages.side_effect = [None, err]
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
with (
patch.dict("sys.modules", modules),
patch.object(
pf,
"_registry_jobs",
side_effect=[
([], 0, [("toolchain-x@1", _FakeSpec(name="toolchain-x"))]),
([], 0, []),
],
),
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
patch.object(pf, "_preinstall"),
):
pf._prefetch(tmp_path, "testenv")
assert f"Could not settle platform packages: {err}" in caplog.text
def test_preinstall_extracts_in_parallel_under_one_lock(tmp_path: Path) -> None:
"""The manager lock wraps the whole batch; per-thread managers share
its package dir; one failing install leaves the rest alone."""
@@ -1658,30 +1729,31 @@ def test_preinstall_unlocks_even_when_pool_fails(tmp_path: Path) -> None:
m.unlock.assert_called_once_with()
def test_prefetch_skips_duplicate_tool_scons(tmp_path: Path) -> None:
"""A platform that lists tool-scons itself does not get it appended."""
def test_prefetch_replaces_platform_tool_scons_with_core_spec(tmp_path: Path) -> None:
"""A platform's own tool-scons spec gives way to the core's registry spec."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
fake_platform = MagicMock()
fake_platform.packages = {"tool-scons": {"optional": False}}
fake_platform.get_package_spec.side_effect = lambda name: _FakeSpec(
uri=None, name=name
uri="https://x/scons.zip", name=name, owner=None
)
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
batches: list[list[str]] = []
batches: list[list] = []
with (
patch.dict("sys.modules", modules),
patch.object(
pf,
"_registry_jobs",
side_effect=lambda mgr, specs, seen: (
batches.append([s.name for s in specs]) or ([], 0, [])
batches.append(list(specs)) or ([], 0, [])
),
),
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
):
pf._prefetch(tmp_path, "testenv")
assert batches[0] == ["tool-scons"]
(spec,) = batches[0]
assert (spec.name, spec.owner, spec.uri) == ("tool-scons", "platformio", None)
def test_platformio_private_api_contract() -> None:
@@ -1714,6 +1786,8 @@ def test_platformio_private_api_contract() -> None:
assert callable(getattr(BasePackageManager, name))
# The dependency wave mirrors install_dependency's builtin skip
assert callable(LibraryPackageManager.is_builtin_lib)
# The prefetch keys tool-scons on this core dependency
assert "tool-scons" in get_core_dependencies()
# The pre-install passes these positionally / by keyword
assert "compatibility" in inspect.signature(BasePackageManager.__init__).parameters
lib_params = inspect.signature(LibraryPackageManager.__init__).parameters