mirror of
https://github.com/esphome/esphome.git
synced 2026-10-05 10:31:29 +00:00
Merge remote-tracking branch 'origin/dev' into integration
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""Tests for locating build artifacts across the supported toolchain layouts."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.analyze_memory.toolchain import (
|
||||
find_elf_path,
|
||||
find_idedata_path,
|
||||
idedata_candidates,
|
||||
)
|
||||
from esphome.espidf.idedata import _cc_path_from_cxx
|
||||
from esphome.platformio.toolchain import IDEData
|
||||
|
||||
|
||||
def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path:
|
||||
"""Create <tmp_path>/.esphome/build/<name>, mirroring a real data dir."""
|
||||
build_path = tmp_path / ".esphome" / "build" / name
|
||||
build_path.mkdir(parents=True)
|
||||
return build_path
|
||||
|
||||
|
||||
def _touch(path: Path) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("")
|
||||
return path
|
||||
|
||||
|
||||
def test_find_elf_path_native_esp_idf(tmp_path: Path) -> None:
|
||||
"""The native ESP-IDF toolchain writes the ELF under build/."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
elf = _touch(build_path / "build" / "firmware.elf")
|
||||
|
||||
assert find_elf_path(build_path) == elf
|
||||
|
||||
|
||||
def test_find_elf_path_platformio(tmp_path: Path) -> None:
|
||||
"""The PlatformIO toolchain writes the ELF under .pioenvs/<name>/."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
elf = _touch(build_path / ".pioenvs" / build_path.name / "firmware.elf")
|
||||
|
||||
assert find_elf_path(build_path) == elf
|
||||
|
||||
|
||||
def test_find_elf_path_libretiny(tmp_path: Path) -> None:
|
||||
"""The LibreTiny toolchain names the unwrapped ELF raw_firmware.elf."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
elf = _touch(build_path / ".pioenvs" / build_path.name / "raw_firmware.elf")
|
||||
|
||||
assert find_elf_path(build_path) == elf
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"relative_elf",
|
||||
[
|
||||
# SDK < 2.9.2
|
||||
"zephyr/zephyr.elf",
|
||||
# SDK >= 2.9.2 nests the artifacts one level deeper
|
||||
"zephyr/zephyr/zephyr.elf",
|
||||
],
|
||||
)
|
||||
def test_find_elf_path_zephyr(tmp_path: Path, relative_elf: str) -> None:
|
||||
"""Zephyr (nRF52) keeps the ELF under .pioenvs/<name>/zephyr/."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
elf = _touch(build_path / ".pioenvs" / build_path.name / relative_elf)
|
||||
|
||||
assert find_elf_path(build_path) == elf
|
||||
|
||||
|
||||
def test_find_elf_path_missing(tmp_path: Path) -> None:
|
||||
"""An unknown layout resolves to None rather than a bogus path."""
|
||||
assert find_elf_path(_make_build_dir(tmp_path)) is None
|
||||
|
||||
|
||||
def test_find_idedata_path_in_data_dir(tmp_path: Path) -> None:
|
||||
"""The idedata cache sits in the data dir that holds the build dir."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
idedata = _touch(tmp_path / ".esphome" / "idedata" / f"{build_path.name}.json")
|
||||
|
||||
assert find_idedata_path(build_path) == idedata
|
||||
|
||||
|
||||
def test_find_idedata_path_in_pioenvs(tmp_path: Path) -> None:
|
||||
"""Test builds may keep idedata alongside the PlatformIO env."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
idedata = _touch(build_path / ".pioenvs" / build_path.name / "idedata.json")
|
||||
|
||||
assert find_idedata_path(build_path) == idedata
|
||||
|
||||
|
||||
def test_find_idedata_path_missing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A missing idedata resolves to None."""
|
||||
# Keep the cwd/home fallbacks from finding an unrelated file on this machine
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
|
||||
|
||||
assert find_idedata_path(_make_build_dir(tmp_path)) is None
|
||||
|
||||
|
||||
def test_idedata_candidates_are_what_find_probes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Every advertised candidate is one find_idedata_path actually accepts.
|
||||
|
||||
The candidates are reported to the user when idedata is missing, so a list
|
||||
that drifts from the lookup would send someone hunting in the wrong place.
|
||||
"""
|
||||
# Two candidates are relative to the cwd and to home; keep the test from
|
||||
# writing into the real ones.
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
|
||||
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
candidates = idedata_candidates(build_path)
|
||||
|
||||
assert candidates, "no candidates advertised"
|
||||
for candidate in candidates:
|
||||
_touch(candidate)
|
||||
assert find_idedata_path(build_path) == candidate
|
||||
candidate.unlink()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cxx_path", "expected"),
|
||||
[
|
||||
("/tools/bin/xtensa-esp32-elf-g++", "/tools/bin/xtensa-esp32-elf-gcc"),
|
||||
("/tools/bin/riscv32-esp-elf-g++", "/tools/bin/riscv32-esp-elf-gcc"),
|
||||
(
|
||||
r"C:\tools\bin\xtensa-esp32-elf-g++.exe",
|
||||
r"C:\tools\bin\xtensa-esp32-elf-gcc.exe",
|
||||
),
|
||||
# Nothing to rewrite; leave the path alone
|
||||
("/tools/bin/clang++", "/tools/bin/clang++"),
|
||||
],
|
||||
)
|
||||
def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None:
|
||||
"""cc_path is derived from the C++ compiler that compile_commands.json names."""
|
||||
assert _cc_path_from_cxx(cxx_path) == expected
|
||||
|
||||
|
||||
def test_native_idedata_resolves_toolchain_tools() -> None:
|
||||
"""The binutils paths are derived from the native ESP-IDF cc_path.
|
||||
|
||||
Without cc_path, IDEData.objdump_path raises KeyError and the memory
|
||||
analysis silently degrades to no component or symbol detail.
|
||||
"""
|
||||
idedata = IDEData(
|
||||
{
|
||||
"cc_path": _cc_path_from_cxx("/tools/bin/xtensa-esp32-elf-g++"),
|
||||
"cxx_path": "/tools/bin/xtensa-esp32-elf-g++",
|
||||
}
|
||||
)
|
||||
|
||||
assert idedata.objdump_path == "/tools/bin/xtensa-esp32-elf-objdump"
|
||||
assert idedata.readelf_path == "/tools/bin/xtensa-esp32-elf-readelf"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for script/ci_memory_impact_extract.py."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Add script directory to path so we can import the module
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "script"))
|
||||
|
||||
from ci_memory_impact_extract import main # noqa: E402
|
||||
|
||||
_COMPILE_OUTPUT = (
|
||||
"RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n"
|
||||
"Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_github_output(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
||||
|
||||
|
||||
def _run(monkeypatch: pytest.MonkeyPatch, compile_output: str, argv: list[str]) -> int:
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO(compile_output))
|
||||
monkeypatch.setattr(sys, "argv", ["ci_memory_impact_extract.py", *argv])
|
||||
return main()
|
||||
|
||||
|
||||
def test_missing_detailed_analysis_fails(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A build with no usable ELF fails instead of posting a comment without detail."""
|
||||
build_dir = tmp_path / ".esphome" / "build" / "mydevice"
|
||||
build_dir.mkdir(parents=True)
|
||||
out_json = tmp_path / "analysis.json"
|
||||
|
||||
rc = _run(
|
||||
monkeypatch,
|
||||
_COMPILE_OUTPUT,
|
||||
["--build-dir", str(build_dir), "--output-json", str(out_json)],
|
||||
)
|
||||
|
||||
assert rc == 1
|
||||
# The totals are still written so the failure can be diagnosed from the artifact
|
||||
assert out_json.is_file()
|
||||
|
||||
|
||||
def test_undetected_build_dir_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Compile output without a build path cannot be analyzed, so it fails."""
|
||||
assert _run(monkeypatch, _COMPILE_OUTPUT, []) == 1
|
||||
|
||||
|
||||
def test_unparseable_output_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Output with no memory totals at all is still a failure."""
|
||||
assert _run(monkeypatch, "nothing useful here\n", []) == 1
|
||||
@@ -2,21 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import esp32
|
||||
from esphome.components.api import client as api_client
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
|
||||
def test_decoder_swallows_esphome_error() -> None:
|
||||
"""A failing stack-trace decode must not propagate.
|
||||
|
||||
on_log runs inside an asyncio protocol callback; if EsphomeError
|
||||
escapes, the loop reports "Fatal error: protocol.data_received()
|
||||
call failed.", tears the connection down, and ReconnectLogic loops
|
||||
forever as the device replays the same crash trace on every
|
||||
reconnect.
|
||||
aioesphomeapi isolates exceptions raised by log handlers, so an
|
||||
escaping one logs a full traceback for every line it fires on rather
|
||||
than being reported once as an unavailable decoder.
|
||||
"""
|
||||
config = {"esphome": {"name": "test"}}
|
||||
|
||||
@@ -43,6 +44,32 @@ def test_decoder_swallows_platform_handler_error() -> None:
|
||||
assert processor.backtrace_state is False
|
||||
|
||||
|
||||
def test_decoder_swallows_non_esphome_error() -> None:
|
||||
"""Decoding failures that aren't EsphomeError must be contained too.
|
||||
|
||||
A missing build directory surfaces as FileNotFoundError from the toolchain
|
||||
subprocess. aioesphomeapi isolates it, so the session survives, but it logs
|
||||
a traceback for every PC/BT line and decoding is never disabled, which
|
||||
buries the crash dump the user is trying to read.
|
||||
"""
|
||||
config = {"esphome": {"name": "test"}}
|
||||
|
||||
with patch.object(
|
||||
esp32,
|
||||
"process_stacktrace",
|
||||
side_effect=FileNotFoundError(
|
||||
2, "No such file or directory", "/build/ol/build"
|
||||
),
|
||||
) as mock_process:
|
||||
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
|
||||
processor.process_line("PC: 0x4010496e")
|
||||
processor.process_line("BT0: 0x4010496e")
|
||||
|
||||
# Disabled after the first failure rather than retried per backtrace line.
|
||||
assert mock_process.call_count == 1
|
||||
assert processor.backtrace_state is False
|
||||
|
||||
|
||||
def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
|
||||
"""_run_idedata raises EsphomeError with no message; the warning
|
||||
must show a useful explanation rather than empty parens.
|
||||
@@ -61,7 +88,7 @@ def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
|
||||
def test_decoder_short_circuits_after_failure() -> None:
|
||||
"""After one failure, subsequent lines must not retry the decoder.
|
||||
|
||||
_decode_pc shells out to PlatformIO; a crash dump can contain many
|
||||
_decode_pc shells out to the toolchain; a crash dump can contain many
|
||||
PC/BT lines and retrying the failing subprocess for each one would
|
||||
stall log streaming.
|
||||
"""
|
||||
@@ -112,3 +139,30 @@ def test_decoder_uses_platform_handler_when_provided() -> None:
|
||||
assert calls == [(config, "BT0: 0x4010496e", False)]
|
||||
assert mock_generic.called is False
|
||||
assert processor.backtrace_state is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("extra_config", "expected_deep_sleep"),
|
||||
[({"deep_sleep": {}}, True), ({}, False)],
|
||||
)
|
||||
async def test_async_run_logs_passes_deep_sleep(
|
||||
extra_config: dict, expected_deep_sleep: bool
|
||||
) -> None:
|
||||
"""async_run_logs tells async_run whether the device deep sleeps, from the config."""
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config}
|
||||
# async_run blocks forever after connecting; raise to unwind async_run_logs
|
||||
# once we have captured how it was called.
|
||||
sentinel = RuntimeError("stop the wait")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
api_client, "async_run", AsyncMock(side_effect=sentinel)
|
||||
) as mock_run,
|
||||
patch.object(api_client, "APIClient"),
|
||||
pytest.raises(RuntimeError, match="stop the wait"),
|
||||
):
|
||||
await api_client.async_run_logs(config, ["1.2.3.4"])
|
||||
|
||||
assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for LibreTiny board detection, including renamed-board migration."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.libretiny import _detect_variant
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_LN882H,
|
||||
KEY_COMPONENT_DATA,
|
||||
KEY_LIBRETINY,
|
||||
)
|
||||
from esphome.components.ln882x import COMPONENT_DATA
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BOARD, CONF_FAMILY
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ln882x_core_data() -> None:
|
||||
"""Populate CORE the way the ln882x component schema does."""
|
||||
CORE.data[KEY_LIBRETINY] = {KEY_COMPONENT_DATA: COMPONENT_DATA}
|
||||
|
||||
|
||||
def test_detect_variant_known_board_passes(ln882x_core_data: None) -> None:
|
||||
"""A current board id resolves its family without warnings."""
|
||||
result = _detect_variant({CONF_BOARD: "generic-ln882h"})
|
||||
assert result[CONF_BOARD] == "generic-ln882h"
|
||||
assert result[CONF_FAMILY] == FAMILY_LN882H
|
||||
|
||||
|
||||
def test_detect_variant_renamed_board_migrates(
|
||||
ln882x_core_data: None, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A pre-rename board id validates against the new id, with a warning."""
|
||||
result = _detect_variant({CONF_BOARD: "generic-ln882hki"})
|
||||
assert result[CONF_BOARD] == "generic-ln882h"
|
||||
assert result[CONF_FAMILY] == FAMILY_LN882H
|
||||
assert "renamed to 'generic-ln882h'" in caplog.text
|
||||
|
||||
|
||||
def test_detect_variant_renamed_board_does_not_mutate_input(
|
||||
ln882x_core_data: None,
|
||||
) -> None:
|
||||
"""Migration copies the config; the caller's dict keeps the old id."""
|
||||
value = {CONF_BOARD: "generic-ln882hki"}
|
||||
_detect_variant(value)
|
||||
assert value[CONF_BOARD] == "generic-ln882hki"
|
||||
|
||||
|
||||
def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> None:
|
||||
"""Ids outside the rename map keep the family-override error."""
|
||||
with pytest.raises(cv.Invalid, match="This board is unknown"):
|
||||
_detect_variant({CONF_BOARD: "not-a-real-board"})
|
||||
@@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None:
|
||||
assert hash1 != hash2
|
||||
|
||||
|
||||
def test_config_hash_ignores_build_path() -> None:
|
||||
"""Test that config_hash does not depend on the build_path value.
|
||||
|
||||
build_path embeds ESPHOME_BUILD_PATH and OS path separators, so it must
|
||||
not make the hash differ between machines.
|
||||
"""
|
||||
CORE.reset()
|
||||
CORE.config = {"esphome": {"name": "test", "build_path": "build\\test"}}
|
||||
hash1 = CORE.config_hash
|
||||
|
||||
CORE.reset()
|
||||
CORE.config = {"esphome": {"name": "test", "build_path": "/build/test"}}
|
||||
hash2 = CORE.config_hash
|
||||
|
||||
assert hash1 == hash2
|
||||
|
||||
|
||||
def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None:
|
||||
"""Test that Path values under the config dir hash the same everywhere.
|
||||
|
||||
Simulates the same project checked out at two different locations; the
|
||||
absolute paths differ but the layout relative to the config dir is the
|
||||
same, so the hashes must match.
|
||||
"""
|
||||
dir1 = tmp_path / "machine_a" / "project"
|
||||
dir2 = tmp_path / "machine_b" / "somewhere" / "else"
|
||||
dir1.mkdir(parents=True)
|
||||
dir2.mkdir(parents=True)
|
||||
|
||||
CORE.reset()
|
||||
CORE.config_path = dir1 / "device.yaml"
|
||||
CORE.config = {"esphome": {"name": "test"}, "file": dir1 / "fonts" / "arial.ttf"}
|
||||
hash1 = CORE.config_hash
|
||||
|
||||
CORE.reset()
|
||||
CORE.config_path = dir2 / "device.yaml"
|
||||
CORE.config = {"esphome": {"name": "test"}, "file": dir2 / "fonts" / "arial.ttf"}
|
||||
hash2 = CORE.config_hash
|
||||
|
||||
assert hash1 == hash2
|
||||
|
||||
|
||||
def test_make_app_name_cpp_no_mac_simple() -> None:
|
||||
"""Test simple name without MAC suffix returns string literal."""
|
||||
cpp_expr, global_decl, byte_len = make_app_name_cpp(
|
||||
@@ -1307,3 +1349,34 @@ async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None:
|
||||
mock_cg.add_library.assert_any_call(
|
||||
"noise-c", None, "https://github.com/esphome/noise-c.git"
|
||||
)
|
||||
|
||||
|
||||
def test_esphome_build_internals_are_yaml_only() -> None:
|
||||
"""Raw build-system inputs in the ``esphome:`` block are ``YAML_ONLY``.
|
||||
|
||||
These knobs (compiler flags, raw PlatformIO options, C/C++ includes,
|
||||
libraries, build host parallelism, the min-version gate, …) are not
|
||||
meaningful as visual-editor form fields and a wrong value breaks the
|
||||
build, so they must never render in a schema-aware UI.
|
||||
"""
|
||||
# CONFIG_SCHEMA is cv.All(cv.Schema({...}), validate_hostname).
|
||||
inner = config.CONFIG_SCHEMA.validators[0].schema
|
||||
markers = {str(k): k for k in inner}
|
||||
yaml_only_fields = {
|
||||
CONF_BUILD_PATH,
|
||||
"platformio_options",
|
||||
"build_flags",
|
||||
"environment_variables",
|
||||
"includes",
|
||||
"includes_c",
|
||||
"libraries",
|
||||
"debug_scheduler",
|
||||
}
|
||||
for field in yaml_only_fields:
|
||||
assert markers[field].visibility is cv.Visibility.YAML_ONLY, field
|
||||
# Packaging / build-host knobs are real but rarely-touched overrides:
|
||||
# surface them under the editor's advanced disclosure, not yaml-only.
|
||||
for field in ("min_version", "compile_process_limit"):
|
||||
assert markers[field].visibility is cv.Visibility.ADVANCED, field
|
||||
# A regular device-config field stays on the main form.
|
||||
assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None
|
||||
|
||||
@@ -22,6 +22,7 @@ from esphome.bundle import (
|
||||
_add_bytes_to_tar,
|
||||
_default_target_dir,
|
||||
_find_used_secret_keys,
|
||||
add_bundle_file,
|
||||
extract_bundle,
|
||||
is_bundle_path,
|
||||
prepare_bundle_for_compile,
|
||||
@@ -611,6 +612,70 @@ def test_discover_files_includes_config(tmp_path: Path) -> None:
|
||||
assert "test.yaml" in paths
|
||||
|
||||
|
||||
def test_discover_files_includes_registered_files(tmp_path: Path) -> None:
|
||||
"""Files registered with add_bundle_file() are included.
|
||||
|
||||
The config does not name them, so discovery cannot find them on its own.
|
||||
"""
|
||||
config_dir = _setup_config_dir(
|
||||
tmp_path,
|
||||
files={"models/model.tflite": "fake model data"},
|
||||
)
|
||||
add_bundle_file(config_dir / "models" / "model.tflite")
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
paths = [f.path for f in files]
|
||||
assert "models/model.tflite" in paths
|
||||
|
||||
|
||||
def test_discover_files_registered_relative_file(tmp_path: Path) -> None:
|
||||
"""A relative registered path is taken as relative to the config directory.
|
||||
|
||||
Not the working directory, which is where Path.resolve() would put it.
|
||||
"""
|
||||
_setup_config_dir(
|
||||
tmp_path,
|
||||
files={"models/model.tflite": "fake model data"},
|
||||
)
|
||||
add_bundle_file(Path("models/model.tflite"))
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
paths = [f.path for f in files]
|
||||
assert "models/model.tflite" in paths
|
||||
|
||||
|
||||
def test_discover_files_registered_file_outside_config_dir(tmp_path: Path) -> None:
|
||||
"""A registered file outside the config directory is skipped, not bundled."""
|
||||
_setup_config_dir(tmp_path)
|
||||
outside = tmp_path / "outside.tflite"
|
||||
outside.write_text("fake model data")
|
||||
add_bundle_file(outside)
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
assert [f.path for f in files] == ["test.yaml"]
|
||||
|
||||
|
||||
def test_discover_files_registered_file_deduplicated(tmp_path: Path) -> None:
|
||||
"""Registering the same file twice adds it once."""
|
||||
config_dir = _setup_config_dir(
|
||||
tmp_path,
|
||||
files={"models/model.tflite": "fake model data"},
|
||||
)
|
||||
add_bundle_file(config_dir / "models" / "model.tflite")
|
||||
add_bundle_file(config_dir / "models" / "model.tflite")
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
assert [f.path for f in files].count("models/model.tflite") == 1
|
||||
|
||||
|
||||
def test_discover_files_finds_path_objects(tmp_path: Path) -> None:
|
||||
"""Path objects in validated config are discovered."""
|
||||
config_dir = _setup_config_dir(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Unit tests for esphome.config module."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Callable, Generator
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
@@ -8,7 +8,8 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
import pytest
|
||||
|
||||
from esphome import config, yaml_util
|
||||
from esphome.core import CORE
|
||||
from esphome.core import CORE, AutoLoad
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -116,6 +117,86 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None:
|
||||
assert "web_server" in platforms, f"Expected web_server platform in {platforms}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LEGACY_CONFIG_MIGRATE hook on LoadValidationStep -- the removable shim that
|
||||
# lets a platform component rewrite a pre-platform top-level config.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_load_step(
|
||||
domain: str,
|
||||
conf: object,
|
||||
migrate: Callable[[ConfigType], list | None] | None,
|
||||
) -> config.Config:
|
||||
"""Run a LoadValidationStep for a platform component with a given migrate hook."""
|
||||
component = Mock()
|
||||
component.is_platform_component = True
|
||||
component.multi_conf_no_default = False
|
||||
component.legacy_config_migrate = migrate
|
||||
|
||||
result = config.Config()
|
||||
with (
|
||||
patch("esphome.config.get_component", return_value=component),
|
||||
patch("esphome.config._process_auto_load"),
|
||||
patch("esphome.config._process_platform_config"),
|
||||
):
|
||||
config.LoadValidationStep(domain, conf).run(result)
|
||||
return result
|
||||
|
||||
|
||||
def test_legacy_migrate_rewrites_conf() -> None:
|
||||
"""A legacy config that the hook migrates is replaced with the new list."""
|
||||
migrated = [{"platform": "file", "id": "a"}]
|
||||
migrate = Mock(return_value=migrated)
|
||||
|
||||
result = _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate)
|
||||
|
||||
migrate.assert_called_once_with([{"id": "a", "file": "x.png"}])
|
||||
assert result["image"] == migrated
|
||||
|
||||
|
||||
def test_legacy_migrate_none_keeps_new_format() -> None:
|
||||
"""When the hook returns None the already-new config is left untouched."""
|
||||
new_format = [{"platform": "file", "id": "a"}]
|
||||
migrate = Mock(return_value=None)
|
||||
|
||||
result = _run_load_step("image", new_format, migrate)
|
||||
|
||||
migrate.assert_called_once_with(new_format)
|
||||
assert result["image"] == new_format
|
||||
|
||||
|
||||
def test_legacy_migrate_absent_hook_is_noop() -> None:
|
||||
"""A platform component without the hook normalizes without migration."""
|
||||
result = _run_load_step("image", {"id": "a"}, None)
|
||||
|
||||
# Bare dict still gets wrapped into a list by the normal normalization path.
|
||||
assert result["image"] == [{"id": "a"}]
|
||||
|
||||
|
||||
def test_legacy_migrate_skipped_for_empty_conf() -> None:
|
||||
"""An empty config short-circuits before the hook is consulted."""
|
||||
migrate = Mock(return_value=[{"platform": "file"}])
|
||||
|
||||
result = _run_load_step("image", [], migrate)
|
||||
|
||||
migrate.assert_not_called()
|
||||
assert result["image"] == []
|
||||
|
||||
|
||||
def test_legacy_migrate_skipped_for_autoload() -> None:
|
||||
"""An auto-loaded (AutoLoad) config is never migrated."""
|
||||
migrate = Mock(return_value=[{"platform": "file"}])
|
||||
auto = AutoLoad()
|
||||
auto["id"] = "a"
|
||||
|
||||
result = _run_load_step("image", auto, migrate)
|
||||
|
||||
migrate.assert_not_called()
|
||||
# AutoLoad is dict-like, so normalization wraps it into a single-entry list.
|
||||
assert result["image"] == [auto]
|
||||
|
||||
|
||||
def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path:
|
||||
"""Create a config where two `<<` includes both define `logger:`.
|
||||
|
||||
|
||||
@@ -1174,9 +1174,10 @@ def test_update_interval__never_passes_through() -> None:
|
||||
def test_optional_default_visibility_is_none() -> None:
|
||||
"""An ``Optional`` with no ``visibility`` kwarg reports ``None``.
|
||||
|
||||
Consumers can read the attribute directly with plain attribute
|
||||
access; absence (``None``) means "render on the editor's main
|
||||
form."
|
||||
The marker stays faithful to what the author wrote: ESPHome does
|
||||
not encode the default on it. Resolving ``None`` to an effective
|
||||
visibility is the consumer's job — a schema-aware editor treats an
|
||||
unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`).
|
||||
"""
|
||||
o = cv.Optional("foo")
|
||||
assert o.visibility is None
|
||||
@@ -1194,6 +1195,17 @@ def test_optional_visibility_yaml_only() -> None:
|
||||
assert o.visibility is cv.Visibility.YAML_ONLY
|
||||
|
||||
|
||||
def test_optional_visibility_ui() -> None:
|
||||
"""``visibility=Visibility.UI`` is recorded on the marker.
|
||||
|
||||
``UI`` promotes an ``Optional`` onto the editor's main form,
|
||||
overriding the consumer's default of ``ADVANCED`` for unset
|
||||
optionals.
|
||||
"""
|
||||
o = cv.Optional("foo", visibility=cv.Visibility.UI)
|
||||
assert o.visibility is cv.Visibility.UI
|
||||
|
||||
|
||||
def test_visibility_str_values_match_dump_emission() -> None:
|
||||
"""``Visibility`` is a ``StrEnum`` whose values are the literal
|
||||
strings the schema dumper emits.
|
||||
@@ -1203,6 +1215,7 @@ def test_visibility_str_values_match_dump_emission() -> None:
|
||||
field — pinning the on-the-wire spelling here keeps the dump
|
||||
contract stable.
|
||||
"""
|
||||
assert str(cv.Visibility.UI) == "ui"
|
||||
assert str(cv.Visibility.ADVANCED) == "advanced"
|
||||
assert str(cv.Visibility.YAML_ONLY) == "yaml_only"
|
||||
|
||||
@@ -1325,6 +1338,57 @@ def test_visibility_marker_is_per_field_no_mutation() -> None:
|
||||
assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY
|
||||
|
||||
|
||||
def test_entity_metadata_visibility_hints() -> None:
|
||||
"""Entity and value-describing metadata is classified for visual editors.
|
||||
|
||||
The headline ``name`` stays on the main form (``UI``); descriptive
|
||||
metadata (device_class, unit, …), presentation options, and per-entity
|
||||
integration plumbing (MQTT, web_server ordering) fall to the advanced
|
||||
disclosure (``ADVANCED``).
|
||||
"""
|
||||
advanced = cv.Visibility.ADVANCED
|
||||
|
||||
entity_base = {str(k): k for k in cv.ENTITY_BASE_SCHEMA.schema}
|
||||
assert entity_base["name"].visibility is cv.Visibility.UI
|
||||
for field in (
|
||||
"icon",
|
||||
"internal",
|
||||
"disabled_by_default",
|
||||
"entity_category",
|
||||
"device_id",
|
||||
):
|
||||
assert entity_base[field].visibility is advanced, field
|
||||
|
||||
mqtt = {str(k): k for k in cv.MQTT_COMPONENT_SCHEMA.schema}
|
||||
for field in ("qos", "retain", "discovery", "state_topic", "availability"):
|
||||
assert mqtt[field].visibility is advanced, field
|
||||
|
||||
from esphome.components import binary_sensor, number, sensor
|
||||
from esphome.components.web_server import WEBSERVER_SORTING_SCHEMA
|
||||
|
||||
sensor_markers = {str(k): k for k in sensor.sensor_schema().schema}
|
||||
for field in (
|
||||
"unit_of_measurement",
|
||||
"accuracy_decimals",
|
||||
"device_class",
|
||||
"state_class",
|
||||
"force_update",
|
||||
):
|
||||
assert sensor_markers[field].visibility is advanced, field
|
||||
|
||||
binary = {str(k): k for k in binary_sensor.binary_sensor_schema().schema}
|
||||
assert binary["device_class"].visibility is advanced
|
||||
|
||||
number_markers = {str(k): k for k in number.number_schema(number.Number).schema}
|
||||
assert number_markers["mode"].visibility is advanced
|
||||
assert number_markers["device_class"].visibility is advanced
|
||||
|
||||
# The whole per-entity web_server block is advanced; children inherit
|
||||
# via the consumer cascade, so only the parent key carries the hint.
|
||||
web = {str(k): k for k in WEBSERVER_SORTING_SCHEMA.schema}
|
||||
assert web["web_server"].visibility is advanced
|
||||
|
||||
|
||||
def _wrap_str(value: str) -> ESPHomeDataBase:
|
||||
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
|
||||
return make_data_base(value)
|
||||
@@ -1372,9 +1436,41 @@ def test_version_parse_with_extra() -> None:
|
||||
assert version.extra == "dev20240101"
|
||||
|
||||
|
||||
def test_version_parse_invalid() -> None:
|
||||
def test_version_parse_without_patch() -> None:
|
||||
"""A two-part version parses with patch defaulting to 0, so framework
|
||||
shorthands like '6.0' and '6.0-rc1' are accepted."""
|
||||
version = cv.Version.parse("6.0")
|
||||
assert (version.major, version.minor, version.patch, version.extra) == (
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
)
|
||||
version = cv.Version.parse("6.0-rc1")
|
||||
assert (version.major, version.minor, version.patch, version.extra) == (
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
"rc1",
|
||||
)
|
||||
|
||||
|
||||
def test_version_parse_numeric_extra() -> None:
|
||||
"""Four-part versions keep the trailing component as extra (pioarduino
|
||||
packaging revisions, e.g. 5.5.3.1)."""
|
||||
version = cv.Version.parse("5.5.3.1")
|
||||
assert (version.major, version.minor, version.patch, version.extra) == (
|
||||
5,
|
||||
5,
|
||||
3,
|
||||
"1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["not.a.version", "6", "a.b", ""])
|
||||
def test_version_parse_invalid(value: str) -> None:
|
||||
with pytest.raises(ValueError, match="Not a valid version number"):
|
||||
cv.Version.parse("not.a.version")
|
||||
cv.Version.parse(value)
|
||||
|
||||
|
||||
def test_version_is_beta() -> None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -86,6 +87,48 @@ def test_collect_filtered_files_exclude(tmp_path):
|
||||
assert str(f2) not in result
|
||||
|
||||
|
||||
def test_collect_filtered_files_exclude_pattern_in_subdir(tmp_path):
|
||||
src = tmp_path / "lib" / "src"
|
||||
src.mkdir(parents=True)
|
||||
kept = src / "a.c"
|
||||
excluded = src / "hasty.c"
|
||||
kept.write_text("int a;")
|
||||
excluded.write_text("int b;")
|
||||
|
||||
result = collect_filtered_files(tmp_path, ["+<lib/src/*.c>", "-<lib/src/hasty.c>"])
|
||||
assert str(kept) in result
|
||||
assert str(excluded) not in result
|
||||
|
||||
|
||||
def test_collect_filtered_files_exclude_unnormalized_glob_output(tmp_path, monkeypatch):
|
||||
# On Windows, glob keeps the pattern's literal separators for non-wildcard
|
||||
# path components, so the "+" wildcard pattern and the "-" literal pattern
|
||||
# yield the same file spelled differently and the exclude set difference
|
||||
# misses it. Backslash is a regular filename character on POSIX (such paths
|
||||
# fail the final is_file filter), so reproduce the unnormalized-output
|
||||
# mismatch portably with dot segments, which normpath also collapses.
|
||||
src = tmp_path / "lib" / "src"
|
||||
src.mkdir(parents=True)
|
||||
kept = src / "a.c"
|
||||
excluded = src / "hasty.c"
|
||||
kept.write_text("int a;")
|
||||
excluded.write_text("int b;")
|
||||
|
||||
real_glob = glob.glob
|
||||
|
||||
def unnormalized_glob(pattern, recursive=False):
|
||||
if "*" in pattern:
|
||||
base = str(tmp_path)
|
||||
return [base + "/lib/./src/a.c", base + "/lib/./src/hasty.c"]
|
||||
return real_glob(pattern, recursive=recursive)
|
||||
|
||||
monkeypatch.setattr(glob, "glob", unnormalized_glob)
|
||||
|
||||
result = collect_filtered_files(tmp_path, ["+<lib/src/*.c>", "-<lib/src/hasty.c>"])
|
||||
assert [Path(r).name for r in result] == ["a.c"]
|
||||
assert str(kept) in result
|
||||
|
||||
|
||||
def test_split_list_by_condition():
|
||||
items = ["-Iinclude", "-Llib", "-Wall"]
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@ def espidf_mocks(setup_core: Path):
|
||||
# extracted-marker touch writes into.
|
||||
_get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True)
|
||||
with (
|
||||
patch("esphome.espidf.framework.rmdir"),
|
||||
patch("esphome.espidf.framework.rmdir") as rmdir_mock,
|
||||
patch(
|
||||
"esphome.espidf.framework.download_from_mirrors",
|
||||
return_value="https://example.com/idf.tar.xz",
|
||||
@@ -344,6 +344,7 @@ def espidf_mocks(setup_core: Path):
|
||||
run_ok=run_ok,
|
||||
tool_paths=tool_paths,
|
||||
clone=clone,
|
||||
rmdir=rmdir_mock,
|
||||
)
|
||||
|
||||
|
||||
@@ -358,6 +359,27 @@ def test_check_esp_idf_install_fresh(espidf_mocks: SimpleNamespace) -> None:
|
||||
espidf_mocks.extract.assert_called_once()
|
||||
espidf_mocks.venv.assert_called_once()
|
||||
espidf_mocks.clone.assert_not_called()
|
||||
# the tool download cache (<IDF_TOOLS_PATH>/dist) is pruned after install
|
||||
espidf_mocks.rmdir.assert_any_call(
|
||||
get_idf_tools_path() / "dist", msg="Remove ESP-IDF tool download cache"
|
||||
)
|
||||
|
||||
|
||||
def test_check_esp_idf_install_dist_prune_failure_ignored(
|
||||
espidf_mocks: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A failure to prune the tool download cache must not fail the install."""
|
||||
tools_dist = get_idf_tools_path() / "dist"
|
||||
|
||||
def rmdir_side_effect(directory: Path, msg: str | None = None) -> None:
|
||||
if directory == tools_dist:
|
||||
raise RuntimeError("cannot remove dist")
|
||||
|
||||
espidf_mocks.rmdir.side_effect = rmdir_side_effect
|
||||
|
||||
# install still succeeds despite the failed prune
|
||||
framework_path, _ = check_esp_idf_install(_IDF_VERSION, force=True)
|
||||
assert framework_path == _get_framework_path(_IDF_VERSION)
|
||||
|
||||
|
||||
def test_check_esp_idf_install_git_source(espidf_mocks: SimpleNamespace) -> None:
|
||||
@@ -478,6 +500,34 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv(
|
||||
espidf_mocks.venv.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("lib", "expect_hint"),
|
||||
[
|
||||
(None, True),
|
||||
("libusb-1.0.so.0", False),
|
||||
],
|
||||
)
|
||||
def test_check_esp_idf_install_failure_libusb_hint(
|
||||
espidf_mocks: SimpleNamespace,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
lib: str | None,
|
||||
expect_hint: bool,
|
||||
) -> None:
|
||||
"""A failed tools install only shows the libusb hint when libusb-1.0 is
|
||||
actually missing."""
|
||||
espidf_mocks.run_ok.return_value = False
|
||||
# Fake Linux so the gate is exercised on all CI hosts; faking Linux is safe
|
||||
# everywhere (unlike faking Windows, which pulls in winreg on other hosts)
|
||||
with (
|
||||
patch("esphome.espidf.framework.find_library", return_value=lib),
|
||||
patch("esphome.espidf.framework.platform.system", return_value="Linux"),
|
||||
caplog.at_level(logging.ERROR, logger="esphome.espidf.framework"),
|
||||
pytest.raises(RuntimeError, match="framework installation failure"),
|
||||
):
|
||||
check_esp_idf_install(_IDF_VERSION, force=True)
|
||||
assert ("libusb-1.0.so.0 was not found" in caplog.text) == expect_hint
|
||||
|
||||
|
||||
def test_check_esp_idf_install_unparseable_version(
|
||||
espidf_mocks: SimpleNamespace,
|
||||
) -> None:
|
||||
@@ -489,6 +539,29 @@ def test_check_esp_idf_install_unparseable_version(
|
||||
espidf_mocks.extract.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "short_version"),
|
||||
[
|
||||
("6.0.0", "6.0"),
|
||||
("6.0.0-rc1", "6.0-rc1"),
|
||||
("5.5.4", None), # vX.Y tags only exist for X.Y.0 releases
|
||||
],
|
||||
)
|
||||
def test_check_esp_idf_install_short_version_substitution(
|
||||
espidf_mocks: SimpleNamespace, version: str, short_version: str | None
|
||||
) -> None:
|
||||
"""SHORT_VERSION is only offered for x.y.0 releases, so the vX.Y mirror
|
||||
template is never tried for versions whose tag cannot exist."""
|
||||
_get_framework_path(version).mkdir(parents=True, exist_ok=True)
|
||||
check_esp_idf_install(version, force=True)
|
||||
|
||||
# First call downloads the framework archive; a later call fetches the
|
||||
# constraints file with its own substitutions.
|
||||
substitutions = espidf_mocks.download.call_args_list[0][0][1]
|
||||
assert substitutions.get("SHORT_VERSION") == short_version
|
||||
assert substitutions["VERSION"] == version
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _patch_tools_json_for_linux_arm64 (arm64-only ninja backport)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
|
||||
from esphome.core import CORE
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
|
||||
@@ -100,7 +103,7 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cxx_path": "cached"}')
|
||||
cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}')
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
@@ -108,7 +111,31 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_not_called()
|
||||
assert result == {"cxx_path": "cached"}
|
||||
assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"}
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None:
|
||||
"""A cache predating cc_path is rebuilt even though it is newer.
|
||||
|
||||
Such a cache stays newer than the compile DB forever, so consumers that
|
||||
derive the binutils paths from cc_path would keep failing on it.
|
||||
"""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cxx_path": "cached"}')
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cc_path": "gcc", "cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result["cc_path"] == "gcc"
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
|
||||
@@ -131,6 +158,33 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
|
||||
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"])
|
||||
def test_get_idedata_regenerates_on_non_dict_cache(
|
||||
setup_core: Path, cached: str
|
||||
) -> None:
|
||||
"""A newer cache holding valid JSON that is not an object is regenerated.
|
||||
|
||||
A bare string would otherwise pass the cc_path check by substring and be
|
||||
handed to consumers expecting a dict.
|
||||
"""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(cached)
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cc_path": "gcc", "cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
|
||||
"""An unparseable (but newer) cache falls back to regeneration."""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
@@ -184,6 +238,77 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
|
||||
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
|
||||
|
||||
|
||||
def test_get_cmake_output_without_build_dir(setup_core: Path) -> None:
|
||||
"""A build dir that was never created raises EsphomeError.
|
||||
|
||||
Without this, subprocess.run(cwd=build_dir) raises FileNotFoundError, which
|
||||
the log stack-trace decoder doesn't recognise as a decode failure.
|
||||
"""
|
||||
_setup_build(setup_core)
|
||||
build_dir = CORE.relative_build_path("build")
|
||||
assert not build_dir.exists()
|
||||
|
||||
with pytest.raises(EsphomeError, match="No ESP-IDF build found"):
|
||||
toolchain._get_cmake_output(build_dir)
|
||||
|
||||
|
||||
def test_get_cmake_output_without_cmake_cache(setup_core: Path) -> None:
|
||||
"""A build dir that exists but was never configured raises EsphomeError."""
|
||||
_setup_build(setup_core)
|
||||
build_dir = CORE.relative_build_path("build")
|
||||
build_dir.mkdir(parents=True)
|
||||
|
||||
with pytest.raises(EsphomeError, match="No ESP-IDF build found"):
|
||||
toolchain._get_cmake_output(build_dir)
|
||||
|
||||
|
||||
def test_get_cmake_output_with_configured_build(setup_core: Path) -> None:
|
||||
"""A configured build still runs cmake and caches the output.
|
||||
|
||||
The missing-build guard must not get in the way of a real build.
|
||||
"""
|
||||
_setup_build(setup_core)
|
||||
build_dir = CORE.relative_build_path("build")
|
||||
build_dir.mkdir(parents=True)
|
||||
(build_dir / "CMakeCache.txt").write_text("")
|
||||
|
||||
completed = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="CMAKE_ADDR2LINE:FILEPATH=/tool/addr2line\n"
|
||||
)
|
||||
with (
|
||||
patch.object(toolchain, "_get_idf_env", return_value={}),
|
||||
patch.object(toolchain.subprocess, "run", return_value=completed) as mock_run,
|
||||
):
|
||||
assert toolchain._get_cmake_output(build_dir) == completed.stdout
|
||||
# Second call is served from the cache rather than re-running cmake.
|
||||
assert toolchain._get_cmake_output(build_dir) == completed.stdout
|
||||
|
||||
mock_run.assert_called_once()
|
||||
assert toolchain._get_cmake_tool_path("CMAKE_ADDR2LINE") == Path("/tool/addr2line")
|
||||
|
||||
|
||||
def test_get_cmake_output_missing_build_does_not_resolve_idf_env(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""The build check runs before the env is resolved.
|
||||
|
||||
Resolving the env calls check_esp_idf_install(), which can download and
|
||||
extract the whole framework. A doomed call must never start that.
|
||||
"""
|
||||
_setup_build(setup_core)
|
||||
build_dir = CORE.relative_build_path("build")
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "_get_idf_env") as mock_env,
|
||||
patch.object(toolchain.subprocess, "run") as mock_run,
|
||||
pytest.raises(EsphomeError),
|
||||
):
|
||||
toolchain._get_cmake_output(build_dir)
|
||||
|
||||
mock_env.assert_not_called()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_get_core_framework_version_from_core_data():
|
||||
"""The version is read from CORE.data when validation populated it."""
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
|
||||
|
||||
@@ -16,6 +16,7 @@ import zipfile
|
||||
import pytest
|
||||
import requests as req
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import (
|
||||
_7z_extract_all,
|
||||
_detect_archive_root,
|
||||
@@ -546,6 +547,99 @@ class TestDownloadFromMirrors:
|
||||
)
|
||||
assert mock_get.call_args[0][0] == "https://example.com/1.2.3.bin"
|
||||
|
||||
def test_template_with_missing_substitution_is_skipped(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A template referencing an unavailable substitution is skipped, not
|
||||
formatted into a bogus URL (e.g. SHORT_VERSION only exists for x.y.0
|
||||
framework versions)."""
|
||||
with patch(
|
||||
"requests.get",
|
||||
return_value=_mock_response(b"x"),
|
||||
) as mock_get:
|
||||
url = download_from_mirrors(
|
||||
[
|
||||
"https://example.com/{SHORT_VERSION}.bin",
|
||||
"https://example.com/{VERSION}.bin",
|
||||
],
|
||||
{"VERSION": "1.2.3"},
|
||||
tmp_path / "out.bin",
|
||||
)
|
||||
assert url == "https://example.com/1.2.3.bin"
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
def test_all_templates_skipped_raises_esphome_error(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("requests.get") as mock_get,
|
||||
pytest.raises(EsphomeError, match="No mirror URL template matched") as ei,
|
||||
):
|
||||
download_from_mirrors(
|
||||
["https://example.com/{MISSING}.bin"],
|
||||
{"VERSION": "1.2.3"},
|
||||
tmp_path / "out.bin",
|
||||
)
|
||||
mock_get.assert_not_called()
|
||||
# The skipped template and its missing substitution are named
|
||||
assert "https://example.com/{MISSING}.bin" in str(ei.value)
|
||||
assert "MISSING" in str(ei.value)
|
||||
|
||||
def test_failure_message_includes_skipped_templates(self, tmp_path: Path) -> None:
|
||||
"""When downloads fail, templates that were skipped for missing
|
||||
substitutions are also listed so a typo'd custom mirror is
|
||||
attributable."""
|
||||
with (
|
||||
patch(
|
||||
"requests.get",
|
||||
return_value=_mock_response(b"", ok=False),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="all mirrors") as ei,
|
||||
):
|
||||
download_from_mirrors(
|
||||
[
|
||||
"https://example.com/{TYPO}.bin",
|
||||
"https://example.com/{VERSION}.bin",
|
||||
],
|
||||
{"VERSION": "1.2.3"},
|
||||
tmp_path / "out.bin",
|
||||
)
|
||||
message = str(ei.value)
|
||||
assert "https://example.com/1.2.3.bin" in message
|
||||
assert (
|
||||
"https://example.com/{TYPO}.bin\n not applicable (TYPO not available)"
|
||||
in message
|
||||
)
|
||||
|
||||
def test_malformed_template_warns_and_is_reported(
|
||||
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A structurally malformed template is an authoring error: warned
|
||||
about even when another mirror succeeds, and named in the aggregate
|
||||
error when everything fails."""
|
||||
with (
|
||||
patch("requests.get", return_value=_mock_response(b"x")),
|
||||
caplog.at_level(logging.WARNING, logger="esphome.framework_helpers"),
|
||||
):
|
||||
url = download_from_mirrors(
|
||||
["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"],
|
||||
{"VERSION": "1.2.3"},
|
||||
tmp_path / "out.bin",
|
||||
)
|
||||
assert url == "https://example.com/1.2.3.bin"
|
||||
assert "malformed mirror URL template" in caplog.text
|
||||
|
||||
with (
|
||||
patch("requests.get", return_value=_mock_response(b"", ok=False)),
|
||||
pytest.raises(EsphomeError, match="all mirrors") as ei,
|
||||
):
|
||||
download_from_mirrors(
|
||||
["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"],
|
||||
{"VERSION": "1.2.3"},
|
||||
tmp_path / "out.bin",
|
||||
)
|
||||
assert "https://example.com/{oops.bin\n skipped (ValueError(" in str(
|
||||
ei.value
|
||||
)
|
||||
|
||||
def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None:
|
||||
with patch(
|
||||
"requests.get",
|
||||
@@ -559,15 +653,26 @@ class TestDownloadFromMirrors:
|
||||
assert url == "https://mirror2.com/f"
|
||||
assert (tmp_path / "out.bin").read_bytes() == b"second"
|
||||
|
||||
def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None:
|
||||
def test_all_mirrors_fail_raises_error_listing_every_attempt(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
with (
|
||||
patch(
|
||||
"requests.get",
|
||||
return_value=_mock_response(b"", ok=False),
|
||||
),
|
||||
pytest.raises(req.HTTPError),
|
||||
pytest.raises(EsphomeError, match="all mirrors") as excinfo,
|
||||
):
|
||||
download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin")
|
||||
download_from_mirrors(
|
||||
["https://mirror1.com/f", "https://mirror2.com/f"],
|
||||
{},
|
||||
tmp_path / "out.bin",
|
||||
)
|
||||
# Every attempted URL appears in the message, and the first mirror's
|
||||
# exception (the primary URL, usually the one that matters) is chained.
|
||||
assert "https://mirror1.com/f" in str(excinfo.value)
|
||||
assert "https://mirror2.com/f" in str(excinfo.value)
|
||||
assert isinstance(excinfo.value.__cause__, req.HTTPError)
|
||||
|
||||
def test_empty_mirrors_raises_value_error(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError, match="empty mirrors list"):
|
||||
|
||||
@@ -170,9 +170,9 @@ def setup_core(
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
|
||||
|
||||
if tmp_path is not None:
|
||||
CORE.config_path = str(tmp_path / f"{name}.yaml")
|
||||
CORE.config_path = tmp_path / f"{name}.yaml"
|
||||
CORE.name = name
|
||||
CORE.build_path = str(tmp_path / ".esphome" / "build" / name)
|
||||
CORE.build_path = tmp_path / ".esphome" / "build" / name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
"""Tests for esphome.components.nrf52.framework helpers."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.nrf52.framework import (
|
||||
_PLATFORMIO_PENV_REQUIREMENTS,
|
||||
_REQUIREMENTS,
|
||||
TOOLCHAIN_VERSION,
|
||||
_get_penv_site_packages,
|
||||
_get_platformio_penv_path,
|
||||
_get_toolchain_platform_info,
|
||||
check_and_install,
|
||||
get_build_env,
|
||||
get_sdk_nrf_tools_path,
|
||||
setup_platformio_python_env,
|
||||
)
|
||||
from esphome.config_validation import Version
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import get_python_env_executable_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -252,6 +260,218 @@ class TestCheckAndInstall:
|
||||
assert substitutions["extension"] == "tar.xz"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# setup_platformio_python_env tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _platformio_requirements_hash() -> str:
|
||||
return hashlib.sha256(
|
||||
_REQUIREMENTS.read_bytes()
|
||||
+ "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode()
|
||||
+ f"python{sys.version_info.major}.{sys.version_info.minor}".encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platformio_penv_dir() -> Path:
|
||||
"""Pre-create the PlatformIO penv dir so sentinel writes succeed.
|
||||
|
||||
create_venv is mocked in these tests, so the directory it would have
|
||||
created must exist for ``sentinel.write_text`` to work.
|
||||
"""
|
||||
penv_path = _get_platformio_penv_path()
|
||||
penv_path.mkdir(parents=True, exist_ok=True)
|
||||
return penv_path
|
||||
|
||||
|
||||
class TestSetupPlatformioPythonEnv:
|
||||
def test_fresh_install_creates_venv_and_sets_env(
|
||||
self,
|
||||
platformio_penv_dir: Path,
|
||||
mock_nrf52_ops: SimpleNamespace,
|
||||
) -> None:
|
||||
"""No sentinel → venv created, requirements installed, env exported."""
|
||||
with patch.dict(os.environ):
|
||||
os.environ.pop("PYTHONPATH", None)
|
||||
|
||||
setup_platformio_python_env()
|
||||
|
||||
mock_nrf52_ops.rmdir.assert_called_once()
|
||||
mock_nrf52_ops.create_venv.assert_called_once_with(
|
||||
platformio_penv_dir, msg="PlatformIO toolchain"
|
||||
)
|
||||
mock_nrf52_ops.run_command_ok.assert_called_once()
|
||||
cmd = mock_nrf52_ops.run_command_ok.call_args[0][0]
|
||||
assert cmd[1:4] == ["-m", "pip", "install"]
|
||||
assert "-r" in cmd
|
||||
assert str(_REQUIREMENTS) in cmd
|
||||
for requirement in _PLATFORMIO_PENV_REQUIREMENTS:
|
||||
assert requirement in cmd
|
||||
sentinel = platformio_penv_dir / ".ready"
|
||||
assert sentinel.read_text(encoding="utf-8") == (
|
||||
_platformio_requirements_hash()
|
||||
)
|
||||
|
||||
assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir)
|
||||
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
|
||||
assert os.environ["PYTHONPATH"] == site_packages
|
||||
bin_dir = str(
|
||||
get_python_env_executable_path(platformio_penv_dir, "python").parent
|
||||
)
|
||||
assert os.environ["PATH"].split(os.pathsep)[0] == bin_dir
|
||||
|
||||
def test_ready_sentinel_skips_install_but_sets_env(
|
||||
self,
|
||||
platformio_penv_dir: Path,
|
||||
mock_nrf52_ops: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Current sentinel → no install work, env vars still exported."""
|
||||
(platformio_penv_dir / ".ready").write_text(
|
||||
_platformio_requirements_hash(), encoding="utf-8"
|
||||
)
|
||||
|
||||
with patch.dict(os.environ):
|
||||
setup_platformio_python_env()
|
||||
|
||||
mock_nrf52_ops.rmdir.assert_not_called()
|
||||
mock_nrf52_ops.create_venv.assert_not_called()
|
||||
mock_nrf52_ops.run_command_ok.assert_not_called()
|
||||
assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir)
|
||||
|
||||
def test_stale_sentinel_reinstalls(
|
||||
self,
|
||||
platformio_penv_dir: Path,
|
||||
mock_nrf52_ops: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A sentinel from different requirements → venv rebuilt from scratch."""
|
||||
sentinel = platformio_penv_dir / ".ready"
|
||||
sentinel.write_text("stale-hash", encoding="utf-8")
|
||||
|
||||
with patch.dict(os.environ):
|
||||
setup_platformio_python_env()
|
||||
|
||||
mock_nrf52_ops.rmdir.assert_called_once()
|
||||
mock_nrf52_ops.create_venv.assert_called_once()
|
||||
mock_nrf52_ops.run_command_ok.assert_called_once()
|
||||
assert sentinel.read_text(encoding="utf-8") == _platformio_requirements_hash()
|
||||
|
||||
def test_install_failure_raises(
|
||||
self,
|
||||
platformio_penv_dir: Path,
|
||||
mock_nrf52_ops: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Failing pip install raises EsphomeError and writes no sentinel."""
|
||||
mock_nrf52_ops.run_command_ok.return_value = False
|
||||
|
||||
with (
|
||||
patch.dict(os.environ),
|
||||
pytest.raises(
|
||||
EsphomeError, match="Install requirements for PlatformIO toolchain"
|
||||
),
|
||||
):
|
||||
setup_platformio_python_env()
|
||||
|
||||
assert not (platformio_penv_dir / ".ready").exists()
|
||||
|
||||
def test_repeated_calls_do_not_duplicate_env_entries(
|
||||
self,
|
||||
platformio_penv_dir: Path,
|
||||
mock_nrf52_ops: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Compile then upload in one process must not grow PYTHONPATH/PATH."""
|
||||
(platformio_penv_dir / ".ready").write_text(
|
||||
_platformio_requirements_hash(), encoding="utf-8"
|
||||
)
|
||||
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
|
||||
bin_dir = str(
|
||||
get_python_env_executable_path(platformio_penv_dir, "python").parent
|
||||
)
|
||||
|
||||
with patch.dict(os.environ):
|
||||
setup_platformio_python_env()
|
||||
setup_platformio_python_env()
|
||||
|
||||
assert os.environ["PYTHONPATH"].split(os.pathsep).count(site_packages) == 1
|
||||
assert os.environ["PATH"].split(os.pathsep).count(bin_dir) == 1
|
||||
|
||||
def test_existing_pythonpath_preserved(
|
||||
self,
|
||||
platformio_penv_dir: Path,
|
||||
mock_nrf52_ops: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A pre-existing PYTHONPATH keeps its entries after the venv entry."""
|
||||
(platformio_penv_dir / ".ready").write_text(
|
||||
_platformio_requirements_hash(), encoding="utf-8"
|
||||
)
|
||||
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
|
||||
|
||||
with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}):
|
||||
setup_platformio_python_env()
|
||||
|
||||
assert os.environ["PYTHONPATH"] == os.pathsep.join(
|
||||
[site_packages, "/existing/path"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("os_name", "expected_parts"),
|
||||
[
|
||||
(
|
||||
"posix",
|
||||
(
|
||||
"lib",
|
||||
f"python{sys.version_info.major}.{sys.version_info.minor}",
|
||||
"site-packages",
|
||||
),
|
||||
),
|
||||
("nt", ("Lib", "site-packages")),
|
||||
],
|
||||
)
|
||||
def test_get_penv_site_packages(
|
||||
tmp_path: Path, os_name: str, expected_parts: tuple[str, ...]
|
||||
) -> None:
|
||||
penv_path = tmp_path / "penv"
|
||||
with patch("os.name", os_name):
|
||||
assert _get_penv_site_packages(penv_path) == penv_path.joinpath(*expected_parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_build_env tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_build_env(
|
||||
nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""get_build_env exposes ZEPHYR_SDK_INSTALL_DIR pointing at the toolchain root.
|
||||
|
||||
ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr's FindZephyr-sdk.cmake
|
||||
explicitly consumes (from the environment) and uses as a find_package
|
||||
HINT. The old Zephyr-sdk_DIR environment hint proved unreliable in
|
||||
containerized non-root builds and was removed.
|
||||
"""
|
||||
monkeypatch.setenv("SOME_PREEXISTING_VAR", "kept")
|
||||
|
||||
env = get_build_env()
|
||||
|
||||
tools = get_sdk_nrf_tools_path()
|
||||
venv_bin_dir = get_python_env_executable_path(
|
||||
tools / "penvs" / f"v{_TEST_SDK_VERSION}", "python"
|
||||
).parent
|
||||
assert env["PATH"].startswith(str(venv_bin_dir) + os.pathsep)
|
||||
assert env["ZEPHYR_BASE"] == str(
|
||||
tools / "frameworks" / f"v{_TEST_SDK_VERSION}" / "zephyr"
|
||||
)
|
||||
# Toolchain root, not the cmake/ subdir
|
||||
assert env["ZEPHYR_SDK_INSTALL_DIR"] == str(
|
||||
tools / "toolchains" / TOOLCHAIN_VERSION
|
||||
)
|
||||
assert "Zephyr-sdk_DIR" not in env
|
||||
# The rest of the process environment is inherited
|
||||
assert env["SOME_PREEXISTING_VAR"] == "kept"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_sdk_nrf_tools_path tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -146,6 +146,72 @@ class TestUploadProgramPyocd:
|
||||
upload_program(config={}, args=None, host="PYOCD")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PlatformIO toolchain paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunCompilePlatformio:
|
||||
def test_prepares_python_env_and_delegates_to_platformio(
|
||||
self, setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""The PlatformIO toolchain prepares the env, then returns False so PlatformIO builds."""
|
||||
from esphome.components.nrf52 import run_compile
|
||||
|
||||
_setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build")
|
||||
|
||||
with patch(
|
||||
"esphome.components.nrf52.setup_platformio_python_env"
|
||||
) as mock_setup:
|
||||
assert run_compile(args=None, config={}) is False
|
||||
|
||||
mock_setup.assert_called_once_with()
|
||||
|
||||
|
||||
class TestUploadProgramSerialPlatformio:
|
||||
def _upload(self, host: str, tmp_path: Path, run_result: int) -> tuple:
|
||||
from esphome.components.nrf52 import upload_program
|
||||
from esphome.upload_targets import PortType
|
||||
|
||||
_setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build")
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
|
||||
with (
|
||||
patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL),
|
||||
patch("esphome.__main__.check_permissions"),
|
||||
patch("esphome.components.nrf52.setup_platformio_python_env") as mock_setup,
|
||||
patch(
|
||||
"esphome.platformio.toolchain.run_platformio_cli_run",
|
||||
return_value=run_result,
|
||||
) as mock_run,
|
||||
):
|
||||
result = upload_program(config={}, args=None, host=host)
|
||||
return result, mock_setup, mock_run
|
||||
|
||||
def test_serial_upload_prepares_env_and_runs_platformio(
|
||||
self, setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""Serial upload with the PlatformIO toolchain runs pio with -t upload."""
|
||||
host = "/dev/ttyACM0"
|
||||
result, mock_setup, mock_run = self._upload(host, tmp_path, run_result=0)
|
||||
|
||||
assert result is True
|
||||
mock_setup.assert_called_once_with()
|
||||
mock_run.assert_called_once()
|
||||
run_args = mock_run.call_args[0]
|
||||
assert "-t" in run_args
|
||||
assert "upload" in run_args
|
||||
assert "--upload-port" in run_args
|
||||
assert host in run_args
|
||||
|
||||
def test_serial_upload_failure_raises(
|
||||
self, setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A non-zero PlatformIO result must raise EsphomeError."""
|
||||
with pytest.raises(EsphomeError, match="Upload failed"):
|
||||
self._upload("/dev/ttyACM0", tmp_path, run_result=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serial DFU upload path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None:
|
||||
assert value == "hunter2"
|
||||
|
||||
|
||||
def test_dump_path_without_relative_to_is_unchanged() -> None:
|
||||
"""Test that Path values dump as str(path) when relative_to is not given."""
|
||||
path = Path("some") / "dir" / "file.ttf"
|
||||
output = yaml_util.dump({"file": path})
|
||||
assert output.strip() == f"file: {path}"
|
||||
|
||||
|
||||
def test_dump_path_relative_to_anchor_dir() -> None:
|
||||
"""Test that Path values under relative_to dump as relative POSIX paths."""
|
||||
anchor = Path("/config/esphome").absolute()
|
||||
data = {"file": anchor / "fonts" / "arial.ttf"}
|
||||
output = yaml_util.dump(data, relative_to=anchor)
|
||||
assert output.strip() == "file: fonts/arial.ttf"
|
||||
|
||||
|
||||
def test_dump_path_outside_anchor_dir_walks_up() -> None:
|
||||
"""Test that Path values outside relative_to walk up with ".." segments."""
|
||||
anchor = Path("/config/esphome").absolute()
|
||||
outside = Path("/config/fonts/file.ttf").absolute()
|
||||
output = yaml_util.dump({"file": outside}, relative_to=anchor)
|
||||
assert output.strip() == "file: ../fonts/file.ttf"
|
||||
|
||||
|
||||
def test_dump_path_with_dotdot_segments_is_normalized() -> None:
|
||||
"""Test that ".." segments do not defeat relativization.
|
||||
|
||||
A path like /config/other/../esphome/fonts/x.ttf is under the anchor
|
||||
once normalized, so it must dump as a plain relative path.
|
||||
"""
|
||||
anchor = Path("/config/esphome").absolute()
|
||||
path = Path("/config/other/../esphome/fonts/x.ttf").absolute()
|
||||
output = yaml_util.dump({"file": path}, relative_to=anchor)
|
||||
assert output.strip() == "file: fonts/x.ttf"
|
||||
|
||||
|
||||
def test_dump_path_dotdot_reference_outside_anchor() -> None:
|
||||
"""Test the relative_config_path("../...") shape stays relative."""
|
||||
anchor = Path("/config/esphome").absolute()
|
||||
path = anchor / ".." / "shared" / "font.ttf"
|
||||
output = yaml_util.dump({"file": path}, relative_to=anchor)
|
||||
assert output.strip() == "file: ../shared/font.ttf"
|
||||
|
||||
|
||||
def test_dump_relative_to_does_not_leak_between_calls() -> None:
|
||||
"""Test that the relative_to flag is scoped to a single dump call."""
|
||||
anchor = Path("/config/esphome").absolute()
|
||||
path = anchor / "fonts" / "arial.ttf"
|
||||
assert "fonts/arial.ttf" in yaml_util.dump({"file": path}, relative_to=anchor)
|
||||
assert yaml_util.dump({"file": path}).strip() == f"file: {path}"
|
||||
|
||||
|
||||
def test_dump__redacts_sensitive_str_by_default() -> None:
|
||||
out = yaml_util.dump({"password": SensitiveStr("hunter2")})
|
||||
assert "\\033[8mhunter2\\033[28m" in out
|
||||
|
||||
Reference in New Issue
Block a user