mirror of
https://github.com/esphome/esphome.git
synced 2026-10-06 10:56:43 +00:00
[espidf] Run cmake and ninja directly instead of through idf.py (#19871)
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"""Tests for script/check_idf_py_equivalence.py."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
|
||||
|
||||
import check_idf_py_equivalence as guard # noqa: E402
|
||||
|
||||
from esphome.core import CORE # noqa: E402
|
||||
from esphome.espidf import toolchain # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_core() -> Iterator[None]:
|
||||
"""check() points the global CORE at the tree it inspects."""
|
||||
yield
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def _make_tree(tmp_path: Path) -> Path:
|
||||
tree = tmp_path / "config" / ".esphome" / "build" / "dev"
|
||||
build = tree / "build"
|
||||
for name in (*guard.watched("dev"), *guard.NINJA_LOGS):
|
||||
(tree / name).parent.mkdir(parents=True, exist_ok=True)
|
||||
(tree / name).write_bytes(b"x")
|
||||
(build / "project_description.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"project_name": "dev",
|
||||
"idf_path": "/idf/frameworks/5.5.5",
|
||||
"target": "esp32",
|
||||
}
|
||||
)
|
||||
)
|
||||
(build / ".ninja_log").write_text(
|
||||
"# ninja log v7\n1\t2\t10\tesp-idf/a.obj\t0\n"
|
||||
"1\t2\t10\tbootloader/bootloader.bin\t0\n"
|
||||
)
|
||||
(build / "bootloader" / ".ninja_log").write_text(
|
||||
"# ninja log v7\n1\t2\t10\tbootloader.elf\t0\n"
|
||||
)
|
||||
(tree / "sdkconfig.dev").write_text("")
|
||||
return tree
|
||||
|
||||
|
||||
def _run_check(
|
||||
tree: Path,
|
||||
side_effect: Callable[[list[str]], None] = lambda cmd: None,
|
||||
rc: int = 0,
|
||||
esphome_rcs: tuple[int, int] = (0, 0),
|
||||
) -> tuple[list[str], list[list[str]]]:
|
||||
"""Run check() with idf.py replaced by ``side_effect``; return problems, calls."""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess:
|
||||
calls.append(cmd)
|
||||
side_effect(cmd)
|
||||
return subprocess.CompletedProcess(cmd, rc, "out\n", "err\n")
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "_get_idf_env", return_value={}),
|
||||
patch.object(toolchain, "_get_idf_tool", return_value="/py"),
|
||||
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
|
||||
patch.object(toolchain, "run_reconfigure", return_value=esphome_rcs[0]),
|
||||
patch.object(toolchain, "_run_ninja", return_value=esphome_rcs[1]),
|
||||
patch.object(guard.subprocess, "run", side_effect=run),
|
||||
):
|
||||
return guard.check(tree), calls
|
||||
|
||||
|
||||
def test_check_passes_when_idf_py_changes_nothing(tmp_path: Path) -> None:
|
||||
tree = _make_tree(tmp_path)
|
||||
problems, calls = _run_check(tree)
|
||||
assert problems == []
|
||||
sdkconfig = f"SDKCONFIG={tree / 'sdkconfig.dev'}"
|
||||
assert calls == [
|
||||
["/py", str(Path("/idf/tools/idf.py")), "-D", sdkconfig, "reconfigure"],
|
||||
["/py", str(Path("/idf/tools/idf.py")), "-D", sdkconfig, "build"],
|
||||
]
|
||||
|
||||
|
||||
def test_check_reports_changed_files_and_rebuilt_outputs(tmp_path: Path) -> None:
|
||||
tree = _make_tree(tmp_path)
|
||||
build = tree / "build"
|
||||
|
||||
def drift(cmd: list[str]) -> None:
|
||||
if cmd[-1] == "reconfigure":
|
||||
(build / "CMakeCache.txt").write_text("changed")
|
||||
return
|
||||
# Compacted log. The re-logged bootloader byproduct and a stamp are
|
||||
# not work; a new object mtime is.
|
||||
(build / ".ninja_log").write_text(
|
||||
"# ninja log v7\n3\t4\t20\tesp-idf/a.obj\t0\n"
|
||||
"5\t6\t30\tbootloader/bootloader.bin\t0\n"
|
||||
"5\t6\t30\tbootloader-stamp\t0\n"
|
||||
)
|
||||
# The bootloader sub-build is judged by its own log.
|
||||
(build / "bootloader" / ".ninja_log").write_text(
|
||||
"# ninja log v7\n1\t2\t40\tbootloader.elf\t0\n"
|
||||
)
|
||||
|
||||
problems, _ = _run_check(tree, drift)
|
||||
assert problems == [
|
||||
"idf.py changed build/CMakeCache.txt",
|
||||
"idf.py rebuilt esp-idf/a.obj",
|
||||
"idf.py rebuilt bootloader.elf",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("after_build", "problem"),
|
||||
[
|
||||
(lambda log: log.unlink(), "missing build/.ninja_log"),
|
||||
(
|
||||
lambda log: log.write_text("# ninja log v7\n"),
|
||||
"no build entries parsed from build/.ninja_log",
|
||||
),
|
||||
(
|
||||
lambda log: log.write_text("# ninja log v7\n1\t2\t10\tesp-idf/b.obj\t0\n"),
|
||||
"idf.py dropped esp-idf/a.obj from build/.ninja_log",
|
||||
),
|
||||
],
|
||||
ids=["log-removed", "log-emptied", "entry-dropped"],
|
||||
)
|
||||
def test_check_reports_a_log_idf_py_left_unusable(
|
||||
tmp_path: Path, after_build: Callable[[Path], None], problem: str
|
||||
) -> None:
|
||||
"""The comparison side gets the same log checks as the baseline."""
|
||||
tree = _make_tree(tmp_path)
|
||||
log = tree / guard.TOP_NINJA_LOG
|
||||
|
||||
def run(cmd: list[str]) -> None:
|
||||
if cmd[-1] == "build":
|
||||
after_build(log)
|
||||
|
||||
problems, _ = _run_check(tree, run)
|
||||
assert problem in problems
|
||||
|
||||
|
||||
def test_check_stops_when_idf_py_fails(tmp_path: Path) -> None:
|
||||
problems, calls = _run_check(_make_tree(tmp_path), rc=2)
|
||||
assert problems == ["idf.py reconfigure failed:\nout\nerr\n"]
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"remove", ["build/build.ninja", "build/dev.bin", *guard.NINJA_LOGS]
|
||||
)
|
||||
def test_check_fails_when_an_input_is_missing(tmp_path: Path, remove: str) -> None:
|
||||
"""A moved or renamed output must not compare as unchanged."""
|
||||
tree = _make_tree(tmp_path)
|
||||
(tree / remove).unlink()
|
||||
problems, calls = _run_check(tree)
|
||||
assert problems == [f"missing {remove}"]
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("esphome_rcs", "problem"),
|
||||
[
|
||||
((3, 0), "ESPHome's CMake configure failed with exit code 3"),
|
||||
((0, 4), "ESPHome's ninja build failed with exit code 4"),
|
||||
],
|
||||
ids=["configure", "build"],
|
||||
)
|
||||
def test_check_stops_when_the_esphome_baseline_fails(
|
||||
tmp_path: Path, esphome_rcs: tuple[int, int], problem: str
|
||||
) -> None:
|
||||
"""The baseline is ESPHome's own reconfigure and build."""
|
||||
problems, calls = _run_check(_make_tree(tmp_path), esphome_rcs=esphome_rcs)
|
||||
assert problems == [problem]
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log", guard.NINJA_LOGS)
|
||||
def test_check_fails_when_a_ninja_log_has_no_entries(tmp_path: Path, log: str) -> None:
|
||||
"""A log format change must not leave the rebuild check with nothing to compare."""
|
||||
tree = _make_tree(tmp_path)
|
||||
(tree / log).write_text("# ninja log v99\n1 2 3\n")
|
||||
problems, calls = _run_check(tree)
|
||||
assert problems == [f"no build entries parsed from {log}"]
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_main_rejects_a_path_that_is_not_a_tree(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
tree = _make_tree(tmp_path / "a")
|
||||
stale = tmp_path / "stale"
|
||||
with (
|
||||
patch.object(sys, "argv", ["check", str(tree), str(stale)]),
|
||||
patch.object(guard, "check", return_value=[]) as mock_check,
|
||||
):
|
||||
assert guard.main() == 1
|
||||
assert f"{stale}: not a configured native ESP-IDF build tree" in (
|
||||
capsys.readouterr().out
|
||||
)
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
def test_main_without_build_trees(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
with (
|
||||
patch.object(sys, "argv", ["check"]),
|
||||
patch.object(guard, "REPO_ROOT", tmp_path),
|
||||
):
|
||||
assert guard.main() == 1
|
||||
assert "No native ESP-IDF build tree found" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("problems", "rc"), [([], 0), (["idf.py changed x"], 1)])
|
||||
def test_main_reports_each_tree(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
problems: list[str],
|
||||
rc: int,
|
||||
) -> None:
|
||||
tree = _make_tree(tmp_path)
|
||||
with (
|
||||
patch.object(sys, "argv", ["check", str(tree)]),
|
||||
patch.object(guard, "check", return_value=problems),
|
||||
):
|
||||
assert guard.main() == rc
|
||||
out = capsys.readouterr().out
|
||||
assert f"{tree}: {'DIFFERS' if problems else 'OK'}" in out
|
||||
assert ("no longer matches idf.py" in out) is bool(problems)
|
||||
|
||||
|
||||
def test_main_checks_only_the_first_found_tree(tmp_path: Path) -> None:
|
||||
"""The contract does not depend on the target; one tree per batch is enough."""
|
||||
first = _make_tree(tmp_path / "a")
|
||||
_make_tree(tmp_path / "b")
|
||||
with (
|
||||
patch.object(sys, "argv", ["check"]),
|
||||
patch.object(guard, "REPO_ROOT", tmp_path),
|
||||
patch.object(guard, "DEFAULT_GLOB", "*/config/.esphome/build/*"),
|
||||
patch.object(guard, "check", return_value=[]) as mock_check,
|
||||
):
|
||||
assert guard.main() == 0
|
||||
mock_check.assert_called_once_with(first)
|
||||
@@ -249,11 +249,61 @@ def test_main_all_tests_should_run(
|
||||
# Should contain at least one component (no empty batches)
|
||||
assert len(batch["components"]) > 0
|
||||
assert isinstance(batch["needs_idf"], bool)
|
||||
assert isinstance(batch["check_idf_py"], bool)
|
||||
assert isinstance(batch["needs_nrf"], bool)
|
||||
assert isinstance(batch["needs_arduino8266"], bool)
|
||||
|
||||
|
||||
def test_main_batch_flags_count_variant_tests(
|
||||
def _batch(components: str, idf: bool, check: bool, a8266: bool) -> dict:
|
||||
return {
|
||||
"components": components,
|
||||
"needs_idf": idf,
|
||||
"check_idf_py": check,
|
||||
"needs_nrf": False,
|
||||
"needs_arduino8266": a8266,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("batches", "component_platforms", "validate_only", "expected"),
|
||||
[
|
||||
# The compile stage builds test-<variant>.<platform>.yaml too, so a
|
||||
# component tested on esp8266 only by a variant still needs the toolchain.
|
||||
(
|
||||
[["safe_mode"]],
|
||||
{"safe_mode": {"esp8266-ard"}},
|
||||
set(),
|
||||
[_batch("safe_mode", idf=False, check=False, a8266=True)],
|
||||
),
|
||||
# Only the first esp32 batch runs the idf.py equivalence check.
|
||||
(
|
||||
[["a"], ["b"], ["c"]],
|
||||
{"a": {"esp8266-ard"}, "b": {"esp32-idf"}, "c": {"esp32-c3-idf"}},
|
||||
set(),
|
||||
[
|
||||
_batch("a", idf=False, check=False, a8266=True),
|
||||
_batch("b", idf=True, check=True, a8266=False),
|
||||
_batch("c", idf=True, check=False, a8266=False),
|
||||
],
|
||||
),
|
||||
# A validate-only batch never compiles, so the next esp32 batch runs it.
|
||||
(
|
||||
[["b"], ["c"]],
|
||||
{"b": {"esp32-idf"}, "c": {"esp32-c3-idf"}},
|
||||
{"b"},
|
||||
[
|
||||
_batch("b", idf=True, check=False, a8266=False),
|
||||
_batch("c", idf=True, check=True, a8266=False),
|
||||
],
|
||||
),
|
||||
],
|
||||
ids=["variant", "idf-check-once", "idf-check-skips-validate-only"],
|
||||
)
|
||||
def test_main_batch_flags(
|
||||
batches: list[list[str]],
|
||||
component_platforms: dict[str, set[str]],
|
||||
validate_only: set[str],
|
||||
expected: list[dict],
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
@@ -267,8 +317,7 @@ def test_main_batch_flags_count_variant_tests(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The compile stage builds test-<variant>.<platform>.yaml too, so a
|
||||
component tested on esp8266 only by a variant still needs the toolchain."""
|
||||
"""Each batch carries the toolchain flags its test platforms need."""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
@@ -282,13 +331,20 @@ def test_main_batch_flags_count_variant_tests(
|
||||
mock_changed_files.return_value = ["esphome/components/safe_mode/__init__.py"]
|
||||
|
||||
def platforms(component: str, *, base_only: bool = True) -> set[str]:
|
||||
return set() if base_only else {"esp8266-ard"}
|
||||
return set() if base_only else component_platforms[component]
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["determine-jobs.py"]),
|
||||
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
||||
patch.object(
|
||||
determine_jobs, "get_changed_components", return_value=["safe_mode"]
|
||||
determine_jobs,
|
||||
"get_changed_components",
|
||||
return_value=[c for batch in batches for c in batch],
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"_component_change_is_validate_only",
|
||||
side_effect=lambda component, changed: component in validate_only,
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
@@ -298,7 +354,7 @@ def test_main_batch_flags_count_variant_tests(
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
return_value=["safe_mode"],
|
||||
return_value=[c for batch in batches for c in batch],
|
||||
),
|
||||
patch.object(determine_jobs, "_component_has_tests", return_value=True),
|
||||
patch.object(
|
||||
@@ -307,9 +363,7 @@ def test_main_batch_flags_count_variant_tests(
|
||||
return_value={"should_run": "false"},
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"create_intelligent_batches",
|
||||
return_value=([["safe_mode"]], {}),
|
||||
determine_jobs, "create_intelligent_batches", return_value=(batches, {})
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs, "get_component_test_platforms", side_effect=platforms
|
||||
@@ -318,14 +372,7 @@ def test_main_batch_flags_count_variant_tests(
|
||||
determine_jobs.main()
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["component_test_batches"] == [
|
||||
{
|
||||
"components": "safe_mode",
|
||||
"needs_idf": False,
|
||||
"needs_nrf": False,
|
||||
"needs_arduino8266": True,
|
||||
}
|
||||
]
|
||||
assert output["component_test_batches"] == expected
|
||||
|
||||
|
||||
def test_main_no_tests_should_run(
|
||||
@@ -1231,8 +1278,10 @@ def test_should_run_esp32_platformio_with_branch() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected"),
|
||||
[
|
||||
# ESP-IDF runner / framework / build generator -> trigger
|
||||
(["esphome/espidf/runner.py"], True),
|
||||
# ESP-IDF toolchain / framework / build generator -> trigger
|
||||
(["esphome/espidf/toolchain.py"], True),
|
||||
(["esphome/build_helpers/tool_runner.py"], True),
|
||||
(["script/check_idf_py_equivalence.py"], True),
|
||||
(["esphome/espidf/framework.py"], True),
|
||||
(["esphome/build_gen/espidf.py"], True),
|
||||
# Shared native-build modules the IDF build imports -> trigger
|
||||
@@ -1250,7 +1299,7 @@ def test_should_run_esp32_platformio_with_branch() -> None:
|
||||
],
|
||||
)
|
||||
def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None:
|
||||
"""ESP-IDF build/runner infra paths are detected; other paths are not."""
|
||||
"""ESP-IDF build infra paths are detected; other paths are not."""
|
||||
assert determine_jobs._esp_idf_infra_changed(changed_files) is expected
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user