Files
esphome/tests/unit_tests/test_espidf_toolchain.py

1574 lines
60 KiB
Python

"""Tests for esphome.espidf.toolchain helpers."""
# pylint: disable=protected-access
from collections.abc import Iterator
from contextlib import contextmanager
import json
import os
from pathlib import Path
import subprocess
from unittest.mock import MagicMock, call, patch
import pytest
from esphome.components.esp32.const import (
KEY_ESP32,
KEY_FLASH_SIZE,
KEY_IDF_VERSION,
KEY_VARIANT,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
CONF_FRAMEWORK,
CONF_SOURCE,
)
from esphome.core import CORE, EsphomeError
from esphome.espidf import toolchain
def test_get_framework_source_override_no_config():
"""When CORE.config hasn't been set, no override is returned."""
CORE.config = None
assert toolchain._get_framework_source_override() is None
def test_get_framework_source_override_no_esp32_section():
"""A config without an esp32 section yields no override."""
CORE.config = {}
assert toolchain._get_framework_source_override() is None
def test_get_framework_source_override_no_framework_source():
"""An esp32 section without framework.source yields no override."""
CORE.config = {"esp32": {CONF_FRAMEWORK: {}}}
assert toolchain._get_framework_source_override() is None
def test_get_framework_source_override_returns_value():
"""A user-supplied framework source is returned verbatim."""
url = "https://example.com/esp-idf-v{VERSION}.tar.xz"
CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}}
assert toolchain._get_framework_source_override() == url
def test_get_esphome_esp_idf_paths_forwards_source_override():
"""_get_esphome_esp_idf_paths threads the override into check_esp_idf_install."""
url = "https://my-mirror/esp-idf-v{VERSION}.tar.xz"
CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}}
# Hit a fresh cache key so check_esp_idf_install is actually called.
toolchain._cache().paths.clear()
with patch.object(
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", targets=None, source_url=url)
def test_get_esphome_esp_idf_paths_no_override():
"""When no source override is configured, source_url=None is passed."""
CORE.config = {}
toolchain._cache().paths.clear()
with patch.object(
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", targets=None, source_url=None)
def test_get_configured_targets_from_variant(monkeypatch: pytest.MonkeyPatch):
"""The configured variant restricts the toolchain install to its target."""
monkeypatch.delenv("CI", raising=False)
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
assert toolchain._get_configured_targets() == ["esp32s3"]
def test_get_configured_targets_without_variant(monkeypatch: pytest.MonkeyPatch):
"""No stored variant (e.g. tooling outside a build) keeps the default."""
monkeypatch.delenv("CI", raising=False)
CORE.data.pop(KEY_ESP32, None)
assert toolchain._get_configured_targets() is None
def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch):
"""CI installs every target so the shared cache covers all variants."""
monkeypatch.setenv("CI", "true")
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
assert toolchain._get_configured_targets() is None
@pytest.fixture(autouse=True)
def _no_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
"""Deterministic run_compile: no host ccache probe, no pch work."""
monkeypatch.setenv("IDF_CCACHE_ENABLE", "0")
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
def _setup_build(setup_core: Path) -> tuple[Path, Path]:
"""Point CORE at a build dir; return (compile_commands, idedata cache) paths."""
CORE.name = "test"
CORE.build_path = setup_core / "build" / "test"
CORE.data.setdefault(KEY_ESP32, {})[KEY_IDF_VERSION] = cv.Version(5, 5, 5)
compile_commands = CORE.relative_build_path("build", "compile_commands.json")
cache = CORE.relative_internal_path("idedata", "test.json")
return compile_commands, cache
@contextmanager
def _up_to_date_compile(ninja_side_effect=None) -> Iterator[tuple]:
"""Patch run_compile's staleness checks to "up to date"; yield the ninja
and print_summary mocks."""
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "_cache_entries_changed", return_value=False),
patch.object(
toolchain, "_run_ninja", return_value=0, side_effect=ninja_side_effect
) as mock_ninja,
patch.object(toolchain, "print_summary") as mock_summary,
):
yield mock_ninja, mock_summary
def test_has_outdated_files_detects_exclusion_change(setup_core: Path) -> None:
"""A newer exclude_components.esphomeinternal stamp forces a reconfigure
so components that leave the exclusion set get rediscovered."""
CORE.build_path = setup_core
build = setup_core / "build"
(build / "config").mkdir(parents=True)
(build / "config" / "sdkconfig.h").write_text("")
cmakecache = build / "CMakeCache.txt"
cmakecache.write_text("")
(build / "build.ninja").write_text("")
with patch.object(CORE, "name", "test"):
assert not toolchain.has_outdated_files()
stamp = setup_core / "exclude_components.esphomeinternal"
stamp.write_text("unity")
os.utime(stamp, (cmakecache.stat().st_mtime + 10,) * 2)
assert toolchain.has_outdated_files()
# The flag must clear once the reference file is restamped (as
# run_compile does after a successful discovery reconfigure);
# otherwise every later build would repeat the discovery pass.
os.utime(cmakecache, (stamp.stat().st_mtime + 10,) * 2)
assert not toolchain.has_outdated_files()
def test_get_idedata_returns_none_without_compile_commands(setup_core: Path) -> None:
"""No compile DB yet -> None (rather than an error)."""
_setup_build(setup_core)
assert toolchain.get_idedata() is None
def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
"""Generates from the compile DB and writes the cache."""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
with patch(
"esphome.build_helpers.idedata.idedata_from_build",
return_value={"cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
prog_path = str(toolchain.get_elf_path())
assert result == {"cxx_path": "g++", "prog_path": prog_path}
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
"""The idedata exposes prog_path (the ELF) so consumers like build-action
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
compile_commands, _ = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
with patch(
"esphome.build_helpers.idedata.idedata_from_build",
return_value={"cxx_path": "g++"},
):
result = toolchain.get_idedata()
# Use Path semantics so the contract holds on Windows too (backslashes).
prog_path = Path(result["prog_path"])
assert prog_path.name == "firmware.elf"
assert prog_path.parent.name == "build"
def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
"""The IDF env caps git's upward search at the config directory.
This stops ESP-IDF's `git describe` from walking into an uninitialized or
corrupt git repo in a parent directory and failing the build.
"""
toolchain._cache().env.clear()
# Set IDF_PATH so the framework-install branch is skipped.
with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}):
env = toolchain._get_idf_env(version="5.5.4")
assert CORE.config_dir == setup_core
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
def test_get_idf_env_serves_the_component_mirror(setup_core: Path) -> None:
"""The mirror env rides the managed-IDF branch, next to its sync gate."""
toolchain._cache().env.clear()
with (
patch.object(
toolchain,
"get_framework_env",
return_value={"PATH": "/penv"},
),
patch.object(toolchain, "_get_esphome_esp_idf_paths", return_value=((), {})),
patch.object(
toolchain,
"component_mirror_env",
return_value={"IDF_COMPONENT_LOCAL_STORAGE_URL": "file:///mirror"},
),
):
env = toolchain._get_idf_env(version="5.5.4")
assert env["IDF_COMPONENT_LOCAL_STORAGE_URL"] == "file:///mirror"
def test_get_idf_env_user_idf_skips_the_mirror(setup_core: Path) -> None:
"""A user-managed IDF gets neither the framework env nor the mirror."""
toolchain._cache().env.clear()
with (
patch.dict(os.environ, {"IDF_PATH": str(setup_core)}),
patch.object(toolchain, "component_mirror_env") as mock_env,
):
env = toolchain._get_idf_env(version="5.5.4")
mock_env.assert_not_called()
assert "IDF_COMPONENT_LOCAL_STORAGE_URL" not in env
def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None:
"""A PYTHONPATH from the parent environment must not reach idf.py.
It would override the IDF venv's isolation, shadowing its pinned
packages and failing idf.py's dependency check.
"""
toolchain._cache().env.clear()
with patch.dict(
os.environ,
{"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"},
):
env = toolchain._get_idf_env(version="5.5.4")
assert "PYTHONPATH" not in env
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, "_get_idf_tool", return_value="/idf/tools/cmake"),
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()
# The resolved path, never a bare "cmake": Windows locates the child
# through the parent's PATH, where the IDF-managed cmake is missing.
assert mock_run.call_args.args[0][0] == "/idf/tools/cmake"
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_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> None:
"""After a successful discovery reconfigure the reference CMakeCache.txt
is restamped; cmake does not rewrite it when only properties or plain
variables change, so the staleness flag would otherwise never clear."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
cmakecache = CORE.relative_build_path("build/CMakeCache.txt")
build_ninja = CORE.relative_build_path("build/build.ninja")
cmakecache.parent.mkdir(parents=True, exist_ok=True)
cmakecache.write_text("")
build_ninja.write_text("")
old = cmakecache.stat().st_mtime - 100
os.utime(cmakecache, (old, old))
os.utime(build_ninja, (old, old))
with (
patch.object(toolchain, "need_reconfigure", return_value=True),
patch.object(toolchain, "load_cached_builtin_components", return_value=None),
patch.object(toolchain, "save_cached_builtin_components"),
patch(
"esphome.build_gen.espidf.get_available_components", return_value=["lwip"]
),
patch("esphome.build_gen.espidf.write_project"),
patch.object(toolchain, "run_reconfigure", return_value=0),
patch.object(toolchain, "_run_ninja", return_value=0),
patch.object(toolchain, "print_summary"),
):
assert toolchain.run_compile(config, verbose=False) == 0
assert cmakecache.stat().st_mtime > old
# build.ninja must not be older than the cache or ninja re-runs cmake
assert build_ninja.stat().st_mtime >= cmakecache.stat().st_mtime
def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None:
"""A discovery pass that produced no CMakeCache.txt (nothing to restamp)
still completes normally."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
with (
patch.object(toolchain, "need_reconfigure", return_value=True),
patch.object(toolchain, "load_cached_builtin_components", return_value=None),
patch.object(toolchain, "save_cached_builtin_components"),
patch(
"esphome.build_gen.espidf.get_available_components", return_value=["lwip"]
),
patch("esphome.build_gen.espidf.write_project"),
patch.object(toolchain, "run_reconfigure", return_value=0),
patch.object(toolchain, "_run_ninja", return_value=0),
patch.object(toolchain, "print_summary"),
):
assert toolchain.run_compile(config, verbose=False) == 0
assert not CORE.relative_build_path("build/CMakeCache.txt").exists()
@pytest.mark.parametrize(
("cmake_rc", "expected"),
[(0, ["sync", "cmake", "sync"]), (1, ["sync", "cmake"])],
)
def test_run_reconfigure_syncs_mirror_around_the_configure(
setup_core: Path, cmake_rc: int, expected: list[str]
) -> None:
"""Every configure path syncs: before, so the solve installs from the
mirror; after a success, so solver-added lock entries are mirrored."""
_setup_build(setup_core)
calls: list[str] = []
with (
_fake_tools() as mock_run,
patch.object(
toolchain,
"_sync_component_mirror",
side_effect=lambda: calls.append("sync"),
),
# The real hint printer resolves an IDF install (or downloads one).
patch.object(toolchain, "_print_hints"),
):
mock_run.side_effect = lambda *a, **k: calls.append("cmake") or cmake_rc
assert toolchain.run_reconfigure() == cmake_rc
assert calls == expected
def test_run_compile_syncs_mirror_when_up_to_date(setup_core: Path) -> None:
"""No reconfigure still refreshes the mirror once for ninja-driven
cmake re-runs."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
with (
_up_to_date_compile(),
patch.object(toolchain, "_sync_component_mirror") as mock_sync,
):
assert toolchain.run_compile(config, verbose=False) == 0
mock_sync.assert_called_once()
def test_sync_component_mirror_skips_user_idf(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A user-managed IDF never gets the mirror env, so a sync is unread."""
monkeypatch.setenv("IDF_PATH", "/opt/esp-idf")
with patch.object(toolchain, "sync_component_mirror") as mock_sync:
toolchain._sync_component_mirror()
mock_sync.assert_not_called()
def test_sync_component_mirror_failure_not_retried(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""One failed attempt (e.g. offline) must not stall every later call."""
monkeypatch.delenv("IDF_PATH", raising=False)
_setup_build(setup_core)
with patch.object(
toolchain, "sync_component_mirror", return_value=False
) as mock_sync:
toolchain._sync_component_mirror()
toolchain._sync_component_mirror()
mock_sync.assert_called_once()
def test_run_compile_reconfigures_after_full_write_outside_testing_mode(
setup_core: Path,
) -> None:
"""The full CMakeLists write is followed by a reconfigure (#18682); a
failure there stops the build and leaves the cache unstamped."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
cmakecache = CORE.relative_build_path("build/CMakeCache.txt")
cmakecache.parent.mkdir(parents=True, exist_ok=True)
cmakecache.write_text("")
old = cmakecache.stat().st_mtime - 100
os.utime(cmakecache, (old, old))
calls: list[tuple] = []
reconfigures = 0
def record_write(minimal: bool = False, builtin_components=None) -> None:
calls.append(("write_project", minimal))
def record_reconfigure(verbose: bool = False) -> int:
nonlocal reconfigures
reconfigures += 1
calls.append(("run_reconfigure",))
return 1 if reconfigures == 2 else 0
with (
patch.object(toolchain, "need_reconfigure", return_value=True),
patch.object(toolchain, "load_cached_builtin_components", return_value=None),
patch.object(toolchain, "save_cached_builtin_components"),
patch(
"esphome.build_gen.espidf.get_available_components", return_value=["lwip"]
),
patch("esphome.build_gen.espidf.write_project", side_effect=record_write),
patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure),
patch.object(toolchain, "_run_ninja", return_value=0) as mock_build,
patch.object(toolchain, "print_summary"),
):
assert not CORE.testing_mode
assert toolchain.run_compile(config, verbose=False) == 1
assert calls == [
("write_project", True),
("run_reconfigure",),
("write_project", False),
("run_reconfigure",),
]
mock_build.assert_not_called()
assert cmakecache.stat().st_mtime == old
def _record_compile_calls(
cached: list[str] | None,
saved: list[str] | None = None,
reconfigure_rcs: tuple[int, ...] = (),
cache_file: Path | None = None,
) -> tuple[int, list[tuple]]:
"""Run run_compile with a stubbed cache and return (rc, call log).
``reconfigure_rcs`` overrides the exit codes of the first reconfigures;
later ones succeed.
"""
calls: list[tuple] = []
rcs = iter(reconfigure_rcs)
def record_reconfigure(verbose: bool = False) -> int:
calls.append(("run_reconfigure",))
return next(rcs, 0)
def record_write(minimal: bool = False, builtin_components=None) -> None:
calls.append(("write_project", minimal, builtin_components))
def record_save(components: list[str]) -> None:
calls.append(("save", components))
def record_ninja(*targets: str, **kwargs: object) -> int:
if "all" in targets:
calls.append(("build",))
return 0
with (
patch.object(toolchain, "need_reconfigure", return_value=True),
patch.object(toolchain, "load_cached_builtin_components", return_value=cached),
patch.object(
toolchain, "save_cached_builtin_components", side_effect=record_save
),
patch("esphome.build_gen.espidf.get_available_components", return_value=saved),
patch("esphome.build_gen.espidf.write_project", side_effect=record_write),
patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure),
patch.object(
toolchain, "_builtin_component_cache_path", return_value=cache_file
),
patch.object(toolchain, "_run_ninja", side_effect=record_ninja),
patch.object(toolchain, "print_summary"),
):
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
return rc, calls
def test_run_compile_poisoned_cache_is_dropped_and_rediscovered(
setup_core: Path, tmp_path: Path
) -> None:
"""A cached list that fails the configure is deleted and discovery runs
once more instead of every later build failing the same way."""
_setup_build(setup_core)
cache_file = tmp_path / "esp32-abc.json"
cache_file.write_text("[]")
rc, calls = _record_compile_calls(
["stale"], saved=["lwip"], reconfigure_rcs=(1,), cache_file=cache_file
)
assert rc == 0
assert not cache_file.exists()
assert calls == [
("write_project", False, ["stale"]),
("run_reconfigure",),
("write_project", True, None),
("run_reconfigure",),
("write_project", False, ["lwip"]),
("run_reconfigure",),
("save", ["lwip"]),
("build",),
]
def test_run_compile_cache_miss_discovers_and_saves(setup_core: Path) -> None:
"""Without a cached list the discovery configure runs, the discovered list
feeds the full write and is cached only after that configure succeeds."""
_setup_build(setup_core)
rc, calls = _record_compile_calls(None, saved=["lwip"])
assert rc == 0
assert calls == [
("write_project", True, None),
("run_reconfigure",),
("write_project", False, ["lwip"]),
("run_reconfigure",),
("save", ["lwip"]),
("build",),
]
def test_run_compile_discovery_failure_stops_before_full_write(
setup_core: Path,
) -> None:
"""A failed discovery configure returns its exit code and never writes
the full CMakeLists, a cache entry or a build."""
_setup_build(setup_core)
rc, calls = _record_compile_calls(None, reconfigure_rcs=(2,))
assert rc == 2
assert calls == [("write_project", True, None), ("run_reconfigure",)]
@pytest.mark.parametrize("discovered", [None, []], ids=["no_manifest", "empty"])
def test_run_compile_fails_when_discovery_finds_nothing(
setup_core: Path,
caplog: pytest.LogCaptureFixture,
discovered: list[str] | None,
) -> None:
_setup_build(setup_core)
rc, calls = _record_compile_calls(None, saved=discovered)
assert rc == 1
assert calls == [("write_project", True, None), ("run_reconfigure",)]
assert "found no built-in ESP-IDF components" in caplog.text
def test_run_compile_does_not_cache_a_list_that_failed_to_configure(
setup_core: Path,
) -> None:
_setup_build(setup_core)
rc, calls = _record_compile_calls(None, saved=["lwip"], reconfigure_rcs=(0, 3))
assert rc == 3
assert ("save", ["lwip"]) not in calls
assert ("build",) not in calls
def test_run_compile_cache_hit_skips_discovery(setup_core: Path) -> None:
"""A cached list goes straight to the full write; the explicit reconfigure
after it (#18730) still runs."""
_setup_build(setup_core)
rc, calls = _record_compile_calls(["esp_timer", "lwip"])
assert rc == 0
assert calls == [
("write_project", False, ["esp_timer", "lwip"]),
("run_reconfigure",),
("build",),
]
@contextmanager
def _cache_env(tmp_path: Path, excluded: str) -> Iterator[Path]:
"""Patch everything the cache key derives from onto a temp IDF tree and
yield that tree's path."""
idf_path = tmp_path / "idf"
(idf_path / "components").mkdir(parents=True, exist_ok=True)
with (
patch.object(toolchain, "_get_idf_path", return_value=idf_path),
patch.dict(CORE.data, {KEY_ESP32: {KEY_VARIANT: "ESP32"}}),
patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": excluded}),
):
yield idf_path
def test_component_cache_round_trip(setup_core: Path, tmp_path: Path) -> None:
"""A saved list is read back until it is dropped."""
_setup_build(setup_core)
with _cache_env(tmp_path, "fatfs") as idf_path:
for name in ("lwip", "esp_timer"):
(idf_path / "components" / name).mkdir()
assert toolchain.load_cached_builtin_components() is None
toolchain.save_cached_builtin_components(["esp_timer", "lwip"])
assert toolchain.load_cached_builtin_components() == ["esp_timer", "lwip"]
toolchain._builtin_component_cache_path().unlink()
assert toolchain.load_cached_builtin_components() is None
def test_component_cache_misses_on_key_change_or_missing_component(
setup_core: Path, tmp_path: Path
) -> None:
"""A different exclusion set uses another entry, an entry naming a
component that no longer exists is ignored, and a custom IDF_PATH is
never cached."""
_setup_build(setup_core)
with _cache_env(tmp_path, "fatfs") as idf_path:
(idf_path / "components" / "lwip").mkdir()
toolchain.save_cached_builtin_components(["lwip"])
path = toolchain._builtin_component_cache_path()
assert path.parent == idf_path / ".esphome_component_lists"
assert path.name.startswith("esp32-")
assert toolchain.load_cached_builtin_components() == ["lwip"]
with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}):
assert toolchain.load_cached_builtin_components() is None
with _cache_env(tmp_path, "fatfs;unity"):
assert toolchain.load_cached_builtin_components() is None
with _cache_env(tmp_path, "fatfs") as idf_path:
path.write_text(json.dumps(["lwip", "gone"]))
assert toolchain.load_cached_builtin_components() is None
# A plain file with the right name is not a component directory.
(idf_path / "components" / "gone").write_text("not a directory")
assert toolchain.load_cached_builtin_components() is None
def test_component_cache_save_skips_empty_list_or_custom_idf_path(
setup_core: Path, tmp_path: Path
) -> None:
_setup_build(setup_core)
with _cache_env(tmp_path, "") as idf_path:
toolchain.save_cached_builtin_components([])
with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}):
toolchain.save_cached_builtin_components(["lwip"])
assert not (idf_path / ".esphome_component_lists").exists()
def test_component_cache_write_failure_is_logged(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
_setup_build(setup_core)
with (
_cache_env(tmp_path, ""),
patch.object(toolchain, "write_file", side_effect=EsphomeError("disk full")),
):
toolchain.save_cached_builtin_components(["lwip"])
assert toolchain.load_cached_builtin_components() is None
assert "Could not write component list cache" in caplog.text
def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path) -> None:
_setup_build(setup_core)
with _cache_env(tmp_path, ""):
path = toolchain._builtin_component_cache_path()
path.parent.mkdir(parents=True)
path.write_text("{not json")
assert toolchain.load_cached_builtin_components() is None
path.write_text(json.dumps({"components": ["lwip"]}))
assert toolchain.load_cached_builtin_components() is None
@pytest.fixture(autouse=True)
def _clear_ldgen_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Isolate tests from ambient ldgen escape hatch and strict knobs."""
monkeypatch.delenv("ESPHOME_LDGEN_STRICT", raising=False)
monkeypatch.delenv("ESPHOME_LDGEN_FULL_DEPS", raising=False)
def _write_fragments_build_ninja(tmp_path: Path, fragments: list[Path]) -> None:
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True, exist_ok=True)
frag_list = ";".join(str(f) for f in fragments)
(build_dir / "build.ninja").write_text(
f' COMMAND = python ldgen.py --fragments-list "{frag_list}" --input x\n'
)
def test_warn_if_app_archive_mapped_warns(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A fragment naming the app archive, even with trailing text or leading
whitespace, triggers the loud warning."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\n archive: libsrc.a # app\nentries:\n")
_write_fragments_build_ninja(tmp_path, [frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" in caplog.text
def test_warn_if_app_archive_mapped_scans_past_unreadable(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable fragment doesn't stop later fragments being checked."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\narchive: libsrc.a\n")
_write_fragments_build_ninja(tmp_path, [tmp_path / "missing.lf", frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" in caplog.text
def test_warn_if_app_archive_mapped_strict_no_fragments_list(
setup_core: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Under strict, a build.ninja the check can't parse fails the build."""
monkeypatch.setenv("ESPHOME_LDGEN_STRICT", "1")
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "build.ninja").write_text("rule CXX\n command = gcc\n")
with pytest.raises(EsphomeError, match="no --fragments-list"):
toolchain._warn_if_app_archive_mapped()
def test_warn_if_app_archive_mapped_strict_raises(
setup_core: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Under ESPHOME_LDGEN_STRICT a mapped app archive fails the build."""
monkeypatch.setenv("ESPHOME_LDGEN_STRICT", "1")
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\narchive: libsrc.a\n")
_write_fragments_build_ninja(tmp_path, [frag])
with pytest.raises(EsphomeError, match="maps the app archive"):
toolchain._warn_if_app_archive_mapped()
def test_warn_if_app_archive_mapped_glob(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A glob archive spec that selects the app archive is also flagged."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\narchive: lib*\n")
_write_fragments_build_ninja(tmp_path, [frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" in caplog.text
def test_warn_if_app_archive_mapped_clean(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Normal fragments, including IDF's stock archive: * catch-all,
produce no warning."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text(
"[mapping:freertos]\narchive: libfreertos.a\n[mapping:default]\narchive: *\n"
)
_write_fragments_build_ninja(tmp_path, [frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" not in caplog.text
def test_warn_if_app_archive_mapped_missing_fragment(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable fragment file is non-fatal."""
_setup_build(setup_core)
_write_fragments_build_ninja(tmp_path, [tmp_path / "missing.lf"])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" not in caplog.text
def test_warn_if_app_archive_mapped_no_build_ninja(setup_core: Path) -> None:
"""No build.ninja yet is a quiet no-op."""
_setup_build(setup_core)
toolchain._warn_if_app_archive_mapped()
def test_warn_if_app_archive_mapped_no_fragments_list(setup_core: Path) -> None:
"""A build.ninja without a fragments-list argument is a quiet no-op."""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "build.ninja").write_text("rule CXX\n command = gcc\n")
toolchain._warn_if_app_archive_mapped()
def test_run_compile_runs_fragment_check(setup_core: Path) -> None:
"""The fragment belt runs by default on every compile."""
_setup_build(setup_core)
with (
_up_to_date_compile(),
patch.object(toolchain, "_warn_if_app_archive_mapped") as mock_check,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
mock_check.assert_called_once()
def test_run_compile_full_deps_skips_fragment_check(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ESPHOME_LDGEN_FULL_DEPS disables the fragment belt with the override."""
monkeypatch.setenv("ESPHOME_LDGEN_FULL_DEPS", "1")
_setup_build(setup_core)
with (
_up_to_date_compile(),
patch.object(toolchain, "_warn_if_app_archive_mapped") as mock_check,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
mock_check.assert_not_called()
@pytest.mark.parametrize("limit", [1, None])
def test_run_compile_passes_compile_process_limit(
setup_core: Path, limit: int | None
) -> None:
"""compile_process_limit is the job limit of the one ninja run."""
_setup_build(setup_core)
esphome = {} if limit is None else {CONF_COMPILE_PROCESS_LIMIT: limit}
with _up_to_date_compile() as (mock_run, _):
assert toolchain.run_compile({CONF_ESPHOME: esphome}, verbose=False) == 0
assert mock_run.call_args_list == [
call(
"all",
"size",
verbose=False,
jobs=limit,
progress=True,
extra_env=toolchain._size_env(),
),
]
def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None:
"""print_summary receives the size json, partitions.csv, and the built
ELF from get_built_elf_path, which must stay in lockstep with the
project() name in the generated CMakeLists."""
_setup_build(setup_core)
with _up_to_date_compile() as (_, mock_summary):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
mock_summary.assert_called_once_with(
CORE.relative_build_path("build", "esp_idf_size.json"),
CORE.relative_build_path("partitions.csv"),
CORE.relative_build_path("build", f"{CORE.name}.elf"),
)
def test_create_elf_copy(setup_core: Path) -> None:
"""The built <name>.elf is copied to the firmware.elf dashboard name."""
_setup_build(setup_core)
src = toolchain.get_built_elf_path()
src.parent.mkdir(parents=True, exist_ok=True)
src.write_bytes(b"elf")
assert toolchain.create_elf_copy() is True
assert toolchain.get_elf_path().read_bytes() == b"elf"
def test_create_elf_copy_missing_source(setup_core: Path) -> None:
"""A missing built ELF is a warning and False, not a crash."""
_setup_build(setup_core)
assert toolchain.create_elf_copy() is False
def test_run_compile_writes_the_pch_checksum_before_the_build(
setup_core: Path,
) -> None:
_setup_build(setup_core)
order: list[str] = []
with (
_up_to_date_compile(lambda *a, **k: order.append("build") or 0),
patch(
"esphome.build_gen.espidf.write_pch_checksum",
side_effect=lambda: order.append("checksum"),
),
):
toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
assert order[:2] == ["checksum", "build"]
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
import esphome.config_validation as cv
CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}}
assert toolchain._get_core_framework_version() == "5.5.4"
@contextmanager
def _fake_tools(env: dict[str, str] | None = None) -> Iterator:
"""Stub the IDF env and tool lookup; yield the run_build_tool mock."""
with (
patch.object(
toolchain,
"_get_idf_env",
return_value={"PATH": "/bin", "IDF_CCACHE_ENABLE": "0", **(env or {})},
),
patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"),
patch.object(toolchain, "run_build_tool", return_value=0) as mock_run,
):
yield mock_run
def test_run_reconfigure_cmake_argv_matches_idf_py(setup_core: Path) -> None:
"""The configure is the command idf.py runs.
IDF 5.5.5 tools/idf_py_actions/tools.py ensure_build_directory: cmake
-G Ninja -DPYTHON_DEPS_CHECKED=1 -DPYTHON=<python> -DESP_PLATFORM=1,
then the -D entries (SDKCONFIG, CCACHE_ENABLE), then the real project
path, in the build dir. IDF 6.1 adds -B <build dir>, the same as the cwd.
"""
_setup_build(setup_core)
sdkconfig = CORE.relative_build_path("sdkconfig.test")
sdkconfig.parent.mkdir(parents=True)
sdkconfig.write_text("")
project = os.path.realpath(CORE.build_path)
with _fake_tools() as mock_run:
assert toolchain.run_reconfigure() == 0
assert mock_run.call_args.args[0] == [
"/tools/cmake",
"-G",
"Ninja",
"-DPYTHON_DEPS_CHECKED=1",
"-DPYTHON=/tools/python",
"-DESP_PLATFORM=1",
f"-DSDKCONFIG={sdkconfig}",
"-DCCACHE_ENABLE=0",
"-DESPHOME_SKIP_BOOTLOADER=0",
project,
]
kwargs = mock_run.call_args.kwargs
assert kwargs["cwd"] == Path(project) / "build"
assert kwargs["cwd"].is_dir()
assert kwargs["env"]["CLICOLOR_FORCE"] == "1"
assert kwargs["filter_lines"] is toolchain.FILTER_IDF_LINES
@pytest.mark.parametrize(("ccache", "expected"), [("1", "True"), ("0", "False")])
def test_run_reconfigure_cmake_argv_matches_idf6_py(
setup_core: Path, ccache: str, expected: str
) -> None:
"""IDF 6 idf.py adds -B <build dir> and formats CCACHE_ENABLE as True/False.
IDF 6.1 tools/idf_py_actions/tools.py ensure_build_directory.
"""
_setup_build(setup_core)
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = cv.Version(6, 1, 0)
build_dir = Path(os.path.realpath(CORE.build_path)) / "build"
with _fake_tools({"IDF_CCACHE_ENABLE": ccache}) as mock_run:
assert toolchain.run_reconfigure() == 0
assert mock_run.call_args.args[0] == [
"/tools/cmake",
"-G",
"Ninja",
"-B",
str(build_dir),
"-DPYTHON_DEPS_CHECKED=1",
"-DPYTHON=/tools/python",
"-DESP_PLATFORM=1",
f"-DCCACHE_ENABLE={expected}",
"-DESPHOME_SKIP_BOOTLOADER=0",
str(build_dir.parent),
]
def test_run_reconfigure_without_sdkconfig_or_filter(setup_core: Path) -> None:
"""No sdkconfig file means no SDKCONFIG entry; -v shows every line."""
_setup_build(setup_core)
with _fake_tools() as mock_run:
assert toolchain.run_reconfigure(verbose=True) == 0
cmd = mock_run.call_args.args[0]
assert not any(arg.startswith("-DSDKCONFIG=") for arg in cmd)
assert cmd[-3] == "-DCCACHE_ENABLE=0"
assert cmd[-2] == "-DESPHOME_SKIP_BOOTLOADER=0"
assert mock_run.call_args.kwargs["filter_lines"] is None
def test_run_reconfigure_failure_removes_cmakecache(
setup_core: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Like idf.py, a failed configure must not leave a cache that looks valid."""
_setup_build(setup_core)
cache = Path(os.path.realpath(CORE.build_path)) / "build" / "CMakeCache.txt"
cache.parent.mkdir(parents=True)
cache.write_text("")
with (
_fake_tools() as mock_run,
patch.object(toolchain, "_print_hints") as mock_hints,
):
mock_run.return_value = 4
assert toolchain.run_reconfigure() == 4
assert not cache.exists()
assert "CMake configure failed with exit code 4" in caplog.text
log_path = mock_run.call_args.kwargs["log_path"]
assert log_path == cache.parent / "log" / "cmake_output.log"
mock_hints.assert_called_once_with(log_path)
@pytest.mark.parametrize(
("value", "expected"),
[
("1", "1"),
("on", "1"),
(" Yes ", "1"),
("0", "0"),
("", "0"),
("disable", "0"),
],
)
def test_cache_entries_ccache_follows_click_booleans(
setup_core: Path, value: str, expected: str
) -> None:
"""IDF_CCACHE_ENABLE maps to CCACHE_ENABLE the way idf.py's click flag does."""
_setup_build(setup_core)
with _fake_tools({"IDF_CCACHE_ENABLE": value}):
assert toolchain._cache_entries() == {"CCACHE_ENABLE": expected}
def test_parse_cmakecache(tmp_path: Path) -> None:
cache = tmp_path / "CMakeCache.txt"
cache.write_text(
"# comment\n"
"// help text\n"
"SDKCONFIG:UNINITIALIZED=/a/sdkconfig.test\n"
"CCACHE_ENABLE:UNINITIALIZED=1\n"
"FLAGS:STRING=-DX=1\n"
"\n"
)
assert toolchain._parse_cmakecache(cache) == {
"SDKCONFIG": "/a/sdkconfig.test",
"CCACHE_ENABLE": "1",
"FLAGS": "-DX=1",
}
# The constant -D entries every configure writes (see _configure_defines).
_CONFIGURED = (
"PYTHON_DEPS_CHECKED:UNINITIALIZED=1\n"
"PYTHON:UNINITIALIZED=/tools/python\n"
"ESP_PLATFORM:UNINITIALIZED=1\n"
"ESPHOME_SKIP_BOOTLOADER:UNINITIALIZED=0\n"
)
@pytest.mark.parametrize(
("cache_text", "expected"),
[
(None, True),
(_CONFIGURED + "CCACHE_ENABLE:UNINITIALIZED=0\n", False),
(_CONFIGURED + "CCACHE_ENABLE:UNINITIALIZED=1\n", True),
(
_CONFIGURED.replace("/tools/python", "/old/python")
+ "CCACHE_ENABLE:UNINITIALIZED=0\n",
True,
),
(_CONFIGURED, True),
],
ids=["no_cache", "same", "changed", "python_moved", "missing"],
)
def test_cache_entries_changed(
setup_core: Path, cache_text: str | None, expected: bool
) -> None:
"""Mirrors idf.py's _new_cmakecache_entries, plus the cached PYTHON."""
_setup_build(setup_core)
if cache_text is not None:
cache = Path(os.path.realpath(CORE.build_path)) / "build" / "CMakeCache.txt"
cache.parent.mkdir(parents=True)
cache.write_text(cache_text)
with _fake_tools():
assert toolchain._cache_entries_changed() is expected
@pytest.mark.parametrize(
("version", "size_ng"), [(cv.Version(5, 5, 5), True), (cv.Version(6, 1, 0), False)]
)
def test_size_env(setup_core: Path, version: cv.Version, size_ng: bool) -> None:
"""IDF 5.x idf.py sets ESP_IDF_SIZE_NG; 6.x dropped it (core_ext.py size_target)."""
CORE.data[KEY_ESP32] = {KEY_IDF_VERSION: version}
env = toolchain._size_env()
assert env["ESP_IDF_SIZE_FORCE_TERMINAL"] == "1"
assert env["SIZE_OUTPUT_FORMAT"] == "default"
assert ("ESP_IDF_SIZE_NG" in env) is size_ng
def test_run_ninja_matches_idf_py_run_target(setup_core: Path) -> None:
"""The command is ninja [-j N] [-v] <target> in the build dir."""
_setup_build(setup_core)
with _fake_tools() as mock_run:
assert (
toolchain._run_ninja(
"size", verbose=True, jobs=2, progress=True, extra_env={"A": "b"}
)
== 0
)
assert mock_run.call_args.args[0] == ["/tools/ninja", "-j", "2", "-v", "size"]
kwargs = mock_run.call_args.kwargs
assert kwargs["cwd"] == Path(os.path.realpath(CORE.build_path)) / "build"
assert kwargs["env"]["CLICOLOR_FORCE"] == "1"
assert kwargs["env"]["A"] == "b"
# -v shows every line as it comes, as idf.py does
assert kwargs["filter_lines"] is None
assert kwargs["progress"] is False
def test_run_ninja_filters_and_reports_failure(
setup_core: Path, caplog: pytest.LogCaptureFixture
) -> None:
_setup_build(setup_core)
with (
_fake_tools() as mock_run,
patch.object(toolchain, "_print_hints") as mock_hints,
):
mock_run.return_value = 1
assert (
toolchain._run_ninja("all", "size", verbose=False, jobs=None, progress=True)
== 1
)
log_path = mock_run.call_args.kwargs["log_path"]
assert log_path.name == "ninja_all_size_output.log"
mock_hints.assert_called_once_with(log_path)
assert mock_run.call_args.args[0] == ["/tools/ninja", "all", "size"]
assert mock_run.call_args.kwargs["filter_lines"] is toolchain.FILTER_IDF_LINES
assert mock_run.call_args.kwargs["progress"] is True
assert "ninja all size failed with exit code 1" in caplog.text
@pytest.mark.parametrize("reconfigure_rc", [0, 5])
def test_run_compile_reconfigures_when_cache_entries_change(
setup_core: Path, reconfigure_rc: int
) -> None:
"""A changed -D entry (ccache toggled) reconfigures, as idf.py build did."""
_setup_build(setup_core)
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "_cache_entries_changed", return_value=True),
patch.object(
toolchain, "run_reconfigure", return_value=reconfigure_rc
) as mock_reconfigure,
patch.object(toolchain, "_run_ninja", return_value=0) as mock_ninja,
patch.object(toolchain, "print_summary"),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == reconfigure_rc
mock_reconfigure.assert_called_once_with(True)
assert mock_ninja.called is (reconfigure_rc == 0)
def test_run_compile_stops_on_ninja_failure(setup_core: Path) -> None:
"""A failed ninja run skips the summary."""
_setup_build(setup_core)
with _up_to_date_compile(lambda *targets, **kw: 7) as (mock_ninja, mock_summary):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 7
assert [c.args for c in mock_ninja.call_args_list] == [("all", "size")]
mock_summary.assert_not_called()
@pytest.mark.parametrize("memory_ld_rc", [0, 3])
def test_run_compile_testing_mode_builds_memory_ld_first(
setup_core: Path, memory_ld_rc: int
) -> None:
"""Testing mode builds and patches memory.ld before the main build."""
_setup_build(setup_core)
CORE.testing_mode = True
targets: list[tuple[str, ...]] = []
def record(*run_targets: str, **kwargs: object) -> int:
targets.append(run_targets)
return memory_ld_rc if run_targets[0].endswith("memory.ld") else 0
with (
_up_to_date_compile(record),
patch.object(toolchain, "_patch_memory_segments") as mock_patch,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == memory_ld_rc
memory_ld = str(Path("esp-idf", "esp_system", "ld", "memory.ld"))
if memory_ld_rc:
assert targets == [(memory_ld,)]
mock_patch.assert_not_called()
else:
assert targets == [(memory_ld,), ("all", "size")]
mock_patch.assert_called_once()
@pytest.mark.parametrize("version", [cv.Version(5, 5, 5), cv.Version(6, 1, 0)])
@pytest.mark.parametrize(
("env", "expected"),
[
({}, {"CLICOLOR_FORCE": "1", "FORCE_COLOR": "1"}),
({"CLICOLOR_FORCE": "0"}, {"CLICOLOR_FORCE": "0", "FORCE_COLOR": "1"}),
({"NO_COLOR": "1"}, {}),
],
ids=["default", "user-value", "no-color"],
)
def test_tool_env_colors(
setup_core: Path, version: cv.Version, env: dict[str, str], expected: dict[str, str]
) -> None:
"""IDF 6 RunTool.__call__ color handling, used for every IDF version."""
_setup_build(setup_core)
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = version
with _fake_tools(env):
result = toolchain._tool_env()
colors = {k: v for k, v in result.items() if k in ("CLICOLOR_FORCE", "FORCE_COLOR")}
assert colors == expected
@pytest.mark.parametrize(
("limit", "env_value", "expected"),
[
(2, "8", 2),
(None, "8", 8),
(None, "", None),
(None, None, None),
],
ids=["limit-wins", "env", "empty-env", "unset"],
)
def test_build_jobs_honors_idf_py_build_jobs(
monkeypatch: pytest.MonkeyPatch,
limit: int | None,
env_value: str | None,
expected: int | None,
) -> None:
"""compile_process_limit first, else IDF_PY_BUILD_JOBS as idf.py read it."""
if env_value is None:
monkeypatch.delenv("IDF_PY_BUILD_JOBS", raising=False)
else:
monkeypatch.setenv("IDF_PY_BUILD_JOBS", env_value)
esphome = {} if limit is None else {CONF_COMPILE_PROCESS_LIMIT: limit}
assert toolchain._build_jobs({CONF_ESPHOME: esphome}) == expected
@pytest.mark.parametrize("value", ["0", "-1", "many"])
def test_build_jobs_rejects_invalid_idf_py_build_jobs(
monkeypatch: pytest.MonkeyPatch, value: str
) -> None:
"""Like idf.py, a value that is not a positive integer is an error."""
monkeypatch.setenv("IDF_PY_BUILD_JOBS", value)
with pytest.raises(EsphomeError, match="positive integer"):
toolchain._build_jobs({CONF_ESPHOME: {}})
@contextmanager
def _hint_env(tmp_path: Path, **run_kwargs: object) -> Iterator:
with (
patch.object(toolchain, "_get_idf_tool", return_value="/py"),
patch.object(toolchain, "_get_idf_path", return_value=tmp_path / "idf"),
patch.object(toolchain, "_get_idf_env", return_value={}),
patch.object(toolchain.subprocess, "run", **run_kwargs) as mock_run,
):
yield mock_run
def test_print_hints_shows_idf_advice(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The hint step runs IDF's own generate_hints on the failed tool's log."""
log = tmp_path / "ninja_all_output.log"
done = subprocess.CompletedProcess([], 0, "HINT: the binary is too big\n", "")
with _hint_env(tmp_path, return_value=done) as mock_run:
toolchain._print_hints(log)
cmd = mock_run.call_args.args[0]
assert cmd[0] == "/py"
assert "generate_hints" in cmd[2]
assert cmd[3:] == [str(tmp_path / "idf" / "tools"), str(log)]
assert "HINT: the binary is too big" in caplog.text
@pytest.mark.parametrize(
"run_kwargs",
[
{"return_value": subprocess.CompletedProcess([], 1, "", "ImportError")},
{"return_value": subprocess.CompletedProcess([], 0, " \n", "")},
{"side_effect": subprocess.TimeoutExpired("py", 60)},
{"side_effect": OSError("gone")},
],
ids=["script-fails", "no-hints", "timeout", "oserror"],
)
def test_print_hints_never_fails_the_build(
tmp_path: Path, caplog: pytest.LogCaptureFixture, run_kwargs: dict
) -> None:
"""No hints is fine; the hint step must not raise or add warnings."""
with caplog.at_level("WARNING"), _hint_env(tmp_path, **run_kwargs):
toolchain._print_hints(tmp_path / "log")
assert caplog.records == []
def test_get_cmake_cache_value_reads_the_configured_cache(setup_core: Path) -> None:
_setup_build(setup_core)
output = "CMAKE_OBJDUMP:FILEPATH=/tools/objdump\nESPHOME_PCH:BOOL=OFF\n"
with patch.object(toolchain, "_get_cmake_output", return_value=output):
assert toolchain.get_cmake_cache_value("ESPHOME_PCH") == "OFF"
assert toolchain.get_cmake_cache_value("ESPHOME_MISSING") is None
def test_skip_bootloader_requires_flag_and_matching_macro(
setup_core: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The switch is explicit, and an IDF macro change wins over it."""
from esphome.build_gen import espidf as build_gen
assert toolchain._skip_bootloader() is False
CORE.skip_bootloader = True
toolchain._cache().skip_bootloader = None # decision is memoized per run
with (
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
patch.object(build_gen, "idf_macro_matches", return_value=True),
):
assert toolchain._skip_bootloader() is True
toolchain._cache().skip_bootloader = None
with (
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
patch.object(build_gen, "idf_macro_matches", return_value=False) as mock_match,
caplog.at_level("WARNING"),
):
assert toolchain._skip_bootloader() is False
assert toolchain._skip_bootloader() is False
mock_match.assert_called_once() # the memo also dedupes the warning
assert "--skip-bootloader ignored" in caplog.text
def test_configure_defines_follow_skip_bootloader(setup_core: Path) -> None:
with (
patch.object(toolchain, "_get_idf_tool", return_value="/tools/python"),
patch.object(toolchain, "_cache_entries", return_value={}),
):
with patch.object(toolchain, "_skip_bootloader", return_value=True):
assert (
toolchain._configure_defines()[toolchain.SKIP_BOOTLOADER_DEFINE] == "1"
)
with patch.object(toolchain, "_skip_bootloader", return_value=False):
assert (
toolchain._configure_defines()[toolchain.SKIP_BOOTLOADER_DEFINE] == "0"
)
def test_tree_skips_bootloader_reads_the_define(tmp_path: Path) -> None:
build = tmp_path / "build"
build.mkdir()
assert toolchain.tree_skips_bootloader(build) is False # not configured
cache = build / "CMakeCache.txt"
cache.write_text(f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n")
assert toolchain.tree_skips_bootloader(build) is True
cache.write_text(f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n")
assert toolchain.tree_skips_bootloader(build) is False
def test_tree_skips_bootloader_unreadable_cache_reads_full(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Unreadable trees fall back to the safe full build, with a trace."""
build = tmp_path / "build"
(build / "CMakeCache.txt").mkdir(parents=True) # read raises OSError
with caplog.at_level("DEBUG"):
assert toolchain.tree_skips_bootloader(build) is False
assert "assuming a full build" in caplog.text
def test_create_factory_bin_merges_the_listed_flash_files(
setup_core: Path,
) -> None:
"""Every listed file lands in the esptool merge argv, address sorted."""
_setup_build(setup_core)
CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = "4MB"
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n"
)
(build / "app.bin").write_bytes(b"app")
(build / "boot.bin").write_bytes(b"boot")
(build / "flasher_args.json").write_text(
'{"flash_files": {"0x10000": "app.bin", "0x0": "boot.bin"},'
' "extra_esptool_args": {"chip": "esp32"}}'
)
with (
patch.object(toolchain, "_get_idf_env", return_value={}),
patch.object(toolchain, "_get_idf_tool", return_value="/py"),
patch.object(
toolchain.subprocess, "run", return_value=MagicMock(returncode=0)
) as mock_run,
):
assert toolchain.create_factory_bin() is True
argv = mock_run.call_args.args[0]
boot = argv.index("0x0")
assert argv[boot + 1].endswith("boot.bin")
assert argv[boot + 2] == "0x10000"
def test_create_factory_bin_fails_on_a_missing_listed_flash_file(
setup_core: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A partial factory image would not boot; never write one."""
_setup_build(setup_core)
CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = "4MB"
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n"
)
(build / "flasher_args.json").write_text('{"flash_files": {"0x0": "missing.bin"}}')
stale = toolchain.get_factory_firmware_path()
stale.write_bytes(b"old")
with patch.object(toolchain.subprocess, "run") as mock_run:
assert toolchain.create_factory_bin() is False
mock_run.assert_not_called()
assert "Flash file not found" in caplog.text
# The image from an earlier build must not be served as this one.
assert not stale.exists()
def test_create_factory_bin_skip_mode_creates_nothing(
setup_core: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The flip into skip mode already cleaned up; the merge just no-ops."""
_setup_build(setup_core)
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
)
with (
patch.object(toolchain.subprocess, "run") as mock_run,
caplog.at_level("INFO"),
):
assert toolchain.create_factory_bin() is True
mock_run.assert_not_called()
assert "no factory image" in caplog.text
def test_run_reconfigure_flip_into_skip_mode_cleans_up(setup_core: Path) -> None:
"""Full-mode leftovers are stale for OTA --bootloader and downloads, and
a partial cleanup would poison the flip back to full mode."""
_setup_build(setup_core)
build = CORE.relative_build_path("build")
(build / "bootloader").mkdir(parents=True)
(build / "bootloader" / "bootloader.bin").write_bytes(b"old")
# bootloader-prefix deliberately absent: cleanup skips what is not there.
stale = toolchain.get_factory_firmware_path()
stale.parent.mkdir(parents=True, exist_ok=True)
stale.write_bytes(b"old")
with (
patch.object(toolchain, "_skip_bootloader", return_value=True),
patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"),
patch.object(toolchain, "_tool_env", return_value={}),
patch.object(toolchain, "run_build_tool", return_value=0),
patch.object(toolchain, "_idf_py") as mock_idf_py,
):
mock_idf_py.return_value.binary_dir_arg = False
assert toolchain.run_reconfigure() == 0
assert not (build / "bootloader").exists()
assert not stale.exists()
def test_run_reconfigure_skip_steady_state_cleans_nothing(setup_core: Path) -> None:
"""Cleanup belongs to the flip; a reconfigure of a skip tree touches nothing."""
_setup_build(setup_core)
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
)
marker = build / "bootloader"
marker.mkdir()
with (
patch.object(toolchain, "_skip_bootloader", return_value=True),
patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"),
patch.object(toolchain, "_tool_env", return_value={}),
patch.object(toolchain, "run_build_tool", return_value=0),
patch.object(toolchain, "_idf_py") as mock_idf_py,
):
mock_idf_py.return_value.binary_dir_arg = False
assert toolchain.run_reconfigure() == 0
assert marker.exists()
def test_create_factory_bin_full_mode_needs_flasher_args(setup_core: Path) -> None:
"""Past the skip gate, a full tree without flasher_args fails as before."""
_setup_build(setup_core)
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n"
)
assert toolchain.create_factory_bin() is False
def test_missing_image_hint_names_the_flag(setup_core: Path) -> None:
_setup_build(setup_core)
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
assert toolchain.missing_image_hint() is None # stock tree
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
)
assert "--skip-bootloader" in toolchain.missing_image_hint()