mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
374 lines
14 KiB
Python
374 lines
14 KiB
Python
"""Tests for esphome.espidf.toolchain helpers."""
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT
|
|
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
|
|
|
|
|
|
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"
|
|
compile_commands = CORE.relative_build_path("build", "compile_commands.json")
|
|
cache = CORE.relative_internal_path("idedata", "test.json")
|
|
return compile_commands, cache
|
|
|
|
|
|
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_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.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_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None:
|
|
"""The jobs argument is exported to idf.py as IDF_PY_BUILD_JOBS."""
|
|
_setup_build(setup_core)
|
|
|
|
with (
|
|
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
|
|
patch.object(toolchain, "_get_idf_env", return_value={"PATH": "/bin"}),
|
|
patch.object(toolchain, "_get_idf_tool", return_value="python"),
|
|
patch.object(toolchain.subprocess, "run") as mock_run,
|
|
):
|
|
mock_run.return_value.returncode = 0
|
|
|
|
toolchain.run_idf_py("build", jobs=2)
|
|
env = mock_run.call_args.kwargs["env"]
|
|
assert env["IDF_PY_BUILD_JOBS"] == "2"
|
|
assert env["PATH"] == "/bin"
|
|
|
|
toolchain.run_idf_py("build")
|
|
env = mock_run.call_args.kwargs["env"]
|
|
assert "IDF_PY_BUILD_JOBS" not in env
|
|
|
|
|
|
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")
|
|
cmakecache.parent.mkdir(parents=True, exist_ok=True)
|
|
cmakecache.write_text("")
|
|
old = cmakecache.stat().st_mtime - 100
|
|
os.utime(cmakecache, (old, old))
|
|
|
|
with (
|
|
patch.object(toolchain, "need_reconfigure", return_value=True),
|
|
patch("esphome.build_gen.espidf.write_project"),
|
|
patch.object(toolchain, "run_reconfigure", return_value=0),
|
|
patch.object(toolchain, "run_idf_py", return_value=0),
|
|
patch.object(toolchain, "print_summary"),
|
|
):
|
|
assert toolchain.run_compile(config, verbose=False) == 0
|
|
|
|
assert cmakecache.stat().st_mtime > old
|
|
|
|
|
|
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("esphome.build_gen.espidf.write_project"),
|
|
patch.object(toolchain, "run_reconfigure", return_value=0),
|
|
patch.object(toolchain, "run_idf_py", 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()
|
|
|
|
|
|
def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None:
|
|
"""compile_process_limit is forwarded to run_idf_py as the job limit."""
|
|
_setup_build(setup_core)
|
|
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 1}}
|
|
|
|
with (
|
|
patch.object(toolchain, "need_reconfigure", return_value=False),
|
|
patch.object(toolchain, "run_idf_py", return_value=0) as mock_run,
|
|
patch.object(toolchain, "print_summary"),
|
|
):
|
|
assert toolchain.run_compile(config, verbose=False) == 0
|
|
|
|
mock_run.assert_called_once_with("build", "size", jobs=1)
|
|
|
|
|
|
def test_run_compile_without_compile_process_limit(setup_core: Path) -> None:
|
|
"""When no compile_process_limit is set, no job limit is passed to idf.py."""
|
|
_setup_build(setup_core)
|
|
config = {CONF_ESPHOME: {}}
|
|
|
|
with (
|
|
patch.object(toolchain, "need_reconfigure", return_value=False),
|
|
patch.object(toolchain, "run_idf_py", return_value=0) as mock_run,
|
|
patch.object(toolchain, "print_summary"),
|
|
):
|
|
assert toolchain.run_compile(config, verbose=False) == 0
|
|
|
|
mock_run.assert_called_once_with("build", "size", jobs=None)
|
|
|
|
|
|
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"
|