[espidf] Run cmake and ninja directly instead of through idf.py (#19871)

This commit is contained in:
J. Nick Koston
2026-09-29 19:55:22 +02:00
committed by GitHub
parent a0f933ea60
commit b8ba8aa1a8
25 changed files with 1840 additions and 996 deletions
@@ -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)
+69 -20
View File
@@ -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
@@ -0,0 +1,338 @@
"""Tests for esphome.build_helpers.tool_runner."""
# pylint: disable=protected-access
import io
import os
from pathlib import Path
import sys
import threading
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from esphome.build_helpers import tool_runner
from esphome.build_helpers.tool_runner import (
UTF8_CODEPAGE,
ToolOutput,
Utf8Console,
run_build_tool,
)
from esphome.core import CORE
FILTER = [r"-- Component paths:", r"\s*$"]
def _child(code: str) -> list[str]:
return [sys.executable, "-c", code]
def _run(
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
code: str,
**kwargs: object,
) -> tuple[int, str]:
rc = run_build_tool(_child(code), cwd=tmp_path, env=dict(os.environ), **kwargs)
# The child's print() ends lines with \r\n on Windows.
return rc, capsys.readouterr().out.replace("\r\n", "\n")
def test_run_build_tool_filters_and_returns_exit_code(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
rc, out = _run(
capsys,
tmp_path,
"import sys\n"
"print('-- Component paths: /a /b')\n"
"print('')\n"
"print('Compiling main.cpp')\n"
"sys.exit(3)",
filter_lines=FILTER,
)
assert rc == 3
assert out == "Compiling main.cpp\n"
def test_run_build_tool_passes_everything_without_filter(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
rc, out = _run(capsys, tmp_path, "print('-- Component paths: /a')\nprint('')")
assert rc == 0
assert out == "-- Component paths: /a\n\n"
def test_run_build_tool_merges_stderr_and_runs_in_cwd(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
_, out = _run(
capsys,
tmp_path,
"import os, sys\nprint(os.getcwd(), flush=True)\nprint('oops', file=sys.stderr)",
filter_lines=FILTER,
)
assert out.splitlines() == [os.path.realpath(tmp_path), "oops"]
def test_run_build_tool_drains_a_partial_line(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
"""A tool that dies mid line still shows that line, terminated."""
rc, out = _run(
capsys,
tmp_path,
"import sys\nsys.stdout.write('ld returned 1 exit status')\nsys.exit(1)",
filter_lines=FILTER,
)
assert rc == 1
assert out == "ld returned 1 exit status\n"
def test_run_build_tool_replaces_invalid_utf8(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
_, out = _run(
capsys,
tmp_path,
# A multi-byte character split across writes must survive too.
"import sys\nb = sys.stdout.buffer\n"
"b.write(b'\\xc3'); b.flush(); b.write(b'\\xa9 ok\\n\\xff bad\\n')",
filter_lines=FILTER,
)
assert out == "é ok\n� bad\n"
def test_run_build_tool_streams_before_the_tool_exits(tmp_path: Path) -> None:
"""Output must reach the user while the tool runs, not when it ends."""
seen = threading.Event()
lines: list[str] = []
class _Out(io.StringIO):
def write(self, s: str) -> int:
lines.append(s)
if "first" in s:
seen.set()
return len(s)
release = tmp_path / "release"
code = (
"import os, time\nprint('first', flush=True)\n"
f"while not os.path.exists({str(release)!r}): time.sleep(0.02)\n"
)
with patch.object(tool_runner.sys, "stdout", _Out()):
thread = threading.Thread(
target=run_build_tool,
args=(_child(code),),
kwargs={"cwd": tmp_path, "env": dict(os.environ), "filter_lines": FILTER},
)
thread.start()
try:
assert seen.wait(30)
finally:
release.touch()
thread.join(30)
assert "first" in "".join(lines)
def _tool_output(progress: bool = True) -> tuple[ToolOutput, io.StringIO]:
out = io.StringIO()
return ToolOutput(out, FILTER, progress), out
def test_tool_output_collapses_progress_lines() -> None:
"""Ninja status lines overwrite each other, as idf.py shows them."""
output, out = _tool_output()
output.write("[1/2] Building a.o\n[2/2] Linking app\nwarning: x\n")
assert out.getvalue() == (
"\r[1/2] Building a.o\x1b[K\r[2/2] Linking app\x1b[K"
+ os.linesep
+ "warning: x\n"
)
def test_tool_output_drain_ends_a_progress_line() -> None:
output, out = _tool_output()
output.write("[1/1] Linking app\n")
output.drain()
assert out.getvalue() == "\r[1/1] Linking app\x1b[K" + os.linesep
# Nothing is pending any more.
output.drain()
assert out.getvalue().count(os.linesep) == 1
def test_tool_output_drain_survives_a_broken_stream() -> None:
output, out = _tool_output()
output.write("[1/1] Linking app\n")
out.close()
output.drain()
def test_tool_output_filters_a_matching_progress_line() -> None:
out = io.StringIO()
output = ToolOutput(out, [r"\[1/2\]"], True)
output.write("[1/2] hidden\n[2/2] shown\n")
assert out.getvalue() == "\r[2/2] shown\x1b[K"
def test_tool_output_without_progress_keeps_status_lines() -> None:
output, out = _tool_output(progress=False)
output.write("[1/2] Building a.o\n")
assert out.getvalue() == "[1/2] Building a.o\n"
def test_tool_output_escapes_colors_for_the_dashboard() -> None:
CORE.dashboard = True
output, out = _tool_output()
output.write("[1/1] \x1b[1mLinking\x1b[0m\n")
assert "\x1b" not in out.getvalue()
assert "\\033[K" in out.getvalue()
@pytest.mark.parametrize(
("width", "text", "expected"),
[
(0, "x" * 50, "x" * 50),
(3, "abcdef", "..."),
(20, "short", "short"),
(11, "abcdefghijklmnop", "abcd...mnop"),
],
ids=["unknown", "tiny", "fits", "elided"],
)
def test_fit_terminal(width: int, text: str, expected: str) -> None:
with patch.object(
tool_runner.shutil,
"get_terminal_size",
return_value=os.terminal_size((width, 24)),
):
assert tool_runner._fit_terminal(text) == expected
class _FakeKernel32:
"""Stand-in for the Windows kernel32 console code page calls."""
def __init__(self, input_cp: int, output_cp: int) -> None:
self.input_cp = input_cp
self.output_cp = output_cp
self.calls: list[tuple[str, int]] = []
def GetConsoleCP(self) -> int: # noqa: N802
return self.input_cp
def GetConsoleOutputCP(self) -> int: # noqa: N802
return self.output_cp
def SetConsoleCP(self, codepage: int) -> int: # noqa: N802
self.calls.append(("SetConsoleCP", codepage))
self.input_cp = codepage
return 1
def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802
self.calls.append(("SetConsoleOutputCP", codepage))
self.output_cp = codepage
return 1
def test_run_build_tool_switches_the_console_to_utf8(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
"""An attached console runs the tool on UTF-8 and is then put back."""
kernel32 = _FakeKernel32(850, 850)
with patch.object(tool_runner, "_get_kernel32", return_value=kernel32):
_run(capsys, tmp_path, "print('x')")
assert kernel32.calls == [
("SetConsoleCP", UTF8_CODEPAGE),
("SetConsoleOutputCP", UTF8_CODEPAGE),
("SetConsoleCP", 850),
("SetConsoleOutputCP", 850),
]
def test_utf8_console_restores_after_an_error() -> None:
kernel32 = _FakeKernel32(437, 437)
with pytest.raises(RuntimeError), Utf8Console(kernel32):
raise RuntimeError
assert (kernel32.input_cp, kernel32.output_cp) == (437, 437)
def test_utf8_console_restores_when_the_switch_fails_part_way() -> None:
kernel32 = _FakeKernel32(850, 850)
def _refuse(codepage: int) -> int:
kernel32.calls.append(("SetConsoleOutputCP", codepage))
return 0
kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign]
with Utf8Console(kernel32):
pass
assert kernel32.input_cp == 850
assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)]
@pytest.mark.parametrize(
"codepages", [(0, 0), (UTF8_CODEPAGE, UTF8_CODEPAGE)], ids=["none", "utf8"]
)
def test_utf8_console_leaves_the_console_alone(codepages: tuple[int, int]) -> None:
"""No console, or one already on UTF-8 (an overlapping build), is untouched."""
kernel32 = _FakeKernel32(*codepages)
with Utf8Console(kernel32):
pass
assert kernel32.calls == []
def test_utf8_console_without_kernel32() -> None:
with Utf8Console(None):
pass
@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows")
def test_get_kernel32_is_none_off_windows() -> None:
assert tool_runner._get_kernel32() is None
def test_tool_output_collapses_progress_without_a_filter() -> None:
"""Progress mode splits lines on its own; it does not need a filter."""
out = io.StringIO()
output = ToolOutput(out, None, True)
output.write("[1/1] Linking app\ndone\n")
assert out.getvalue() == "\r[1/1] Linking app\x1b[K" + os.linesep + "done\n"
def test_run_build_tool_flushes_a_truncated_character(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
"""Output that ends inside a multi-byte character still shows up."""
_, out = _run(
capsys,
tmp_path,
"import sys\nsys.stdout.buffer.write(b'end \\xc3')",
filter_lines=FILTER,
)
assert out == "end �\n"
def test_get_kernel32_loads_it_on_windows(monkeypatch: pytest.MonkeyPatch) -> None:
kernel32 = object()
fake_ctypes = SimpleNamespace(windll=SimpleNamespace(kernel32=kernel32))
monkeypatch.setattr(tool_runner.sys, "platform", "win32")
monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
assert tool_runner._get_kernel32() is kernel32
def test_run_build_tool_logs_the_unfiltered_output(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
"""The log gets every line, filtered or not, for idf.py's hint matcher."""
log = tmp_path / "log" / "ninja_all_output.log"
_, out = _run(
capsys,
tmp_path,
"print('-- Component paths: /a')\nprint('\\x1b[1merror:\\x1b[0m boom')",
filter_lines=FILTER,
log_path=log,
)
assert out == "\x1b[1merror:\x1b[0m boom\n"
assert log.read_text(encoding="utf-8").replace("\r\n", "\n") == (
"-- Component paths: /a\nerror: boom\n"
)
@@ -1,11 +0,0 @@
"""Leave a partial line behind and then close the stream under the runner.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. Draining
cannot work here; the point is that the failure is reported rather than
raised out of the runner's cleanup, where it would bury the exit code.
"""
import sys
sys.stdout.write("partial before close")
sys.stdout.close()
@@ -1,11 +0,0 @@
"""Die part way through a line, the way a build that blows up does.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The
message has no trailing newline, so the runner's shim is holding it when
the process exits; nothing else will ever come to release it.
"""
import sys
sys.stdout.write("FATAL: ld returned 1 exit status")
sys.exit(2)
@@ -1,15 +0,0 @@
"""Write a mix of noisy and useful build lines, without flushing.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The
runner's shim owns both the filtering and the flushing, so this script
only writes.
"""
import sys
sys.stdout.write("Project build complete.\n")
sys.stdout.write("Compiling main.cpp\n")
sys.stdout.write("-- Component paths: /a /b /c\n")
sys.stdout.write("[2/9] Building C object\n")
# No terminator, so the shim has to hold this one back.
sys.stdout.write("still going")
@@ -1,12 +0,0 @@
"""Write a form feed part way through the output.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. A form
feed is not a line terminator here, so everything written must still come
out, including the complete lines that follow it.
"""
import sys
sys.stdout.write("Compiling main.cpp\n")
sys.stdout.write("page one\x0cpage two\n")
sys.stdout.write("[2/9] Building C object\n")
@@ -1,10 +0,0 @@
"""End on an unterminated line that the filter is supposed to drop.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py, to
check that releasing a held-back line still applies the filter.
"""
import sys
sys.stdout.write("Compiling main.cpp\n")
sys.stdout.write("Project build complete.")
@@ -1,14 +0,0 @@
"""Print one line, then stay alive so the caller can prove it streamed.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The
runner wraps stdout in its filtering shim, so this script deliberately
does not flush: the shim has to do it. The long sleep keeps the process
running, so anything the caller reads must have arrived while the build
was still going rather than at exit.
"""
import sys
import time
sys.stdout.write("Compiling main.cpp\n")
time.sleep(60)
@@ -1,5 +1,7 @@
"""Tests for esphome.espidf.clang_tidy tidy-project generation."""
# pylint: disable=protected-access
import json
import os
from pathlib import Path
@@ -99,3 +101,39 @@ def test_idedata_from_tidy_project_missing_tu_raises(tmp_path) -> None:
compile_commands.write_text(json.dumps([]))
with pytest.raises(RuntimeError, match="tidy.cpp not found"):
clang_tidy._idedata_from_tidy_project(compile_commands)
@pytest.mark.parametrize(
("reconfigure_rcs", "error"),
[
((1,), "ESP-IDF CMake configure \\(discovery\\) failed"),
((0, 1), "ESP-IDF CMake configure failed"),
((0, 0), None),
],
ids=["discovery", "full", "ok"],
)
def test_generate_compile_commands_configures_twice(
tmp_path: Path, reconfigure_rcs: tuple[int, ...], error: str | None
) -> None:
"""Discovery configure, then a configure requiring what it found."""
with (
patch.object(clang_tidy, "_setup_core"),
patch.object(clang_tidy, "_convert_pio_libs", return_value={}),
patch.object(clang_tidy, "_write_tidy_project") as mock_write,
patch("esphome.espidf.toolchain.run_reconfigure", side_effect=reconfigure_rcs),
patch(
"esphome.build_gen.espidf.get_available_components",
return_value=["lwip", "esp_timer"],
),
):
if error:
with pytest.raises(RuntimeError, match=error):
clang_tidy._generate_compile_commands(
tmp_path, _settings(), tmp_path / "platformio.ini"
)
return
result = clang_tidy._generate_compile_commands(
tmp_path, _settings(), tmp_path / "platformio.ini"
)
assert result == tmp_path / "build" / "compile_commands.json"
assert mock_write.call_args_list[1].args[1] == ["esp_timer", "lwip"]
+6 -6
View File
@@ -1627,8 +1627,8 @@ def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None:
# short-circuits before build_path is needed.
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None)
with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3:
# The canonical off spelling is exported: the raw value is inherited
# by idf.py, where a spelling like "disable" would read as truthy
# The canonical off spelling is exported, so every reader of the
# env sees the same answer
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
@@ -1636,7 +1636,7 @@ def test_ccache_env_opt_in_without_binary(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Explicit IDF_CCACHE_ENABLE=1 forces it on; without a usable binary
# idf.py silently skips ccache, so this branch must say so out loud.
# IDF's CMake silently skips ccache, so this branch must say so out loud.
p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build")
with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3:
env = _ccache_env()
@@ -1669,7 +1669,7 @@ def test_ccache_env_opt_in_with_working_binary(
def test_ccache_env_opt_in_with_rejected_binary(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Forced on with a present-but-rejected binary: idf.py does its own
# Forced on with a present-but-rejected binary: IDF's CMake does its own
# PATH lookup and uses it anyway; the warning must say so, not claim
# the build runs without ccache.
# A present but non-executable file: the real probe fails and logs
@@ -1686,7 +1686,7 @@ def test_ccache_env_opt_in_with_rejected_binary(
):
env = _ccache_env()
assert env["IDF_CCACHE_ENABLE"] == "1"
assert "idf.py will use it anyway" in caplog.text
assert "the build will use it anyway" in caplog.text
# Exactly one story: the resolver's contradictory "compiling without
# ccache" must not precede it
assert "compiling without ccache" not in caplog.text
@@ -1715,7 +1715,7 @@ def test_ccache_env_idf_knob_unrecognized_warns_and_defers(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unparsable IDF_CCACHE_ENABLE warns, defers to the shared resolver,
and is not forwarded to idf.py as truthy."""
and is not forwarded to the build as truthy."""
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
env_vars = {"IDF_CCACHE_ENABLE": "enabled"}
with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3:
-315
View File
@@ -1,315 +0,0 @@
"""Tests for esphome.espidf.runner."""
from __future__ import annotations
import io
import os
from pathlib import Path
import subprocess
import sys
import threading
import pytest
from esphome.espidf import runner
# A flushing runner delivers the first line in well under a second; this is
# only ever waited out when the shim has gone back to buffering, so keep it
# just long enough to cover interpreter startup on a loaded CI machine.
FIRST_LINE_TIMEOUT = 10.0
def _prepare_main(
monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str
) -> tuple[io.BytesIO, io.TextIOWrapper]:
"""Point ``runner.main()`` at *probe* with a buffered fake stdout.
``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and
``os.get_terminal_size``; every one of those is monkeypatched so it is
put back afterwards. The fake stdout is block buffered like a pipe, so
the caller can tell whether the shim flushed. The wrapper comes back with
the buffer because dropping it would close the buffer underneath us.
"""
buf = io.BytesIO()
stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False)
monkeypatch.setattr(sys, "path", list(sys.path))
monkeypatch.setattr(sys, "argv", ["runner.py", str(probe), *args])
monkeypatch.setattr(sys, "stdout", stream)
monkeypatch.setattr(sys, "stderr", stream)
monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size)
return buf, stream
def _run_main(
monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str
) -> tuple[io.BytesIO, io.TextIOWrapper]:
"""Run ``runner.main()`` against *probe* and expect a clean exit."""
buf, stream = _prepare_main(monkeypatch, probe, *args)
assert runner.main() == 0
return buf, stream
def test_main_filters_noise_and_flushes_each_write(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""Useful lines reach the stream right away; noisy ones are dropped."""
buf, _stream = _run_main(
monkeypatch, fixture_path / "espidf" / "filtering_probe.py"
)
# Read before any flush of our own: the shim has to have flushed.
output = buf.getvalue().decode("utf-8")
assert "Compiling main.cpp\n" in output
assert "[2/9] Building C object\n" in output
# Matched by FILTER_IDF_LINES, so they never leave the runner.
assert "Project build complete." not in output
assert "-- Component paths:" not in output
# Held back until the end because no terminator arrived.
assert output.endswith("still going\n")
def test_main_keeps_output_after_a_form_feed(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""A form feed is text, not a line break, so nothing after it is lost."""
buf, _stream = _run_main(monkeypatch, fixture_path / "espidf" / "formfeed_probe.py")
assert buf.getvalue().decode("utf-8") == (
"Compiling main.cpp\npage one\x0cpage two\n[2/9] Building C object\n"
)
def test_main_drains_a_partial_line_when_the_build_dies(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""A build that stops mid line must still show that line.
This is the whole point of draining: the message explaining why the
build failed is exactly the one most likely to arrive without a
trailing newline.
"""
buf, _stream = _prepare_main(
monkeypatch, fixture_path / "espidf" / "crashing_probe.py"
)
with pytest.raises(SystemExit) as excinfo:
runner.main()
assert excinfo.value.code == 2
assert buf.getvalue().decode("utf-8") == "FATAL: ld returned 1 exit status\n"
def test_main_reports_rather_than_raises_when_draining_fails(
monkeypatch: pytest.MonkeyPatch,
fixture_path: Path,
capfd: pytest.CaptureFixture[str],
) -> None:
"""A stream that closed under us must not crash the runner's cleanup.
The drain runs from a ``finally``, so an exception there would replace
whatever exit code the build was carrying back.
"""
_prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py")
assert runner.main() == 0
reported = capfd.readouterr().err
assert "Could not write out remaining output" in reported
# The held line has to come along; the stream it was meant for is gone.
assert "partial before close" in reported
def test_main_survives_a_drain_failure_with_nowhere_to_report_it(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""With no real stderr to report to, cleanup still must not raise.
``sys.__stderr__`` is None on some interpreters, and ``print(file=None)``
falls back to ``sys.stdout``, which here is the shim wrapping the stream
that just failed.
"""
monkeypatch.setattr(sys, "__stderr__", None)
_prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py")
assert runner.main() == 0
def test_main_still_filters_a_drained_partial_line(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""Releasing a held line does not smuggle noise past the filter."""
buf, _stream = _run_main(
monkeypatch, fixture_path / "espidf" / "partial_noise_probe.py"
)
assert buf.getvalue().decode("utf-8") == "Compiling main.cpp\n"
def test_main_keeps_everything_in_verbose_mode(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""``-v`` turns the filter off so the noisy lines survive."""
buf, _stream = _run_main(
monkeypatch, fixture_path / "espidf" / "filtering_probe.py", "-v"
)
output = buf.getvalue().decode("utf-8")
assert "Project build complete.\n" in output
assert "-- Component paths: /a /b /c\n" in output
# With no filter there is no line buffering, so the partial line goes
# straight through as well.
assert output.endswith("still going")
def test_runner_streams_output_before_the_build_finishes(
fixture_path: Path, probe_env: dict[str, str]
) -> None:
"""The runner must flush, or a dashboard build looks frozen.
``toolchain.py`` spawns the runner as a plain script with no ``-u``, and
hands it a pipe when esphome itself is running under the dashboard. A
pipe is block buffered, so without a flush in the shim's ``write()`` the
output sits in the child until 8 KiB piles up or the build ends.
"""
runner_py = Path(runner.__file__)
probe = fixture_path / "espidf" / "streaming_probe.py"
with subprocess.Popen(
[sys.executable, str(runner_py), str(probe)],
stdout=subprocess.PIPE,
# Keep stderr: if the runner dies on startup, its traceback is the
# only clue about why no line showed up.
stderr=subprocess.PIPE,
env=probe_env,
text=True,
) as proc:
assert proc.stdout is not None
assert proc.stderr is not None
first_line: list[str] = []
reader = threading.Thread(
target=lambda: first_line.append(proc.stdout.readline()), daemon=True
)
try:
reader.start()
reader.join(FIRST_LINE_TIMEOUT)
still_running = proc.poll() is None
# The probe sleeps for a minute after writing, so reaching us at
# all means the line was flushed rather than released at exit.
assert first_line == ["Compiling main.cpp\n"], (
f"runner stderr: {'' if still_running else proc.stderr.read()}"
)
assert still_running
finally:
proc.kill()
proc.wait()
# Join before leaving the block, so the reader is done rather than
# racing ``Popen`` closing the pipe under it.
reader.join(1.0)
class _FakeKernel32:
"""Stand-in for the Windows kernel32 console code page calls."""
def __init__(self, input_cp: int, output_cp: int) -> None:
self.input_cp = input_cp
self.output_cp = output_cp
self.calls: list[tuple[str, int]] = []
def GetConsoleCP(self) -> int: # noqa: N802
return self.input_cp
def GetConsoleOutputCP(self) -> int: # noqa: N802
return self.output_cp
def SetConsoleCP(self, codepage: int) -> int: # noqa: N802
self.calls.append(("SetConsoleCP", codepage))
self.input_cp = codepage
return 1
def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802
self.calls.append(("SetConsoleOutputCP", codepage))
self.output_cp = codepage
return 1
def test_main_runs_the_build_with_a_utf8_console(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""An attached console is switched to UTF-8 and then put back."""
kernel32 = _FakeKernel32(850, 850)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.calls == [
("SetConsoleCP", runner.UTF8_CODEPAGE),
("SetConsoleOutputCP", runner.UTF8_CODEPAGE),
("SetConsoleCP", 850),
("SetConsoleOutputCP", 850),
]
def test_main_restores_the_console_when_the_build_dies(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""A failing build must not leave the user's console on UTF-8."""
kernel32 = _FakeKernel32(437, 437)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_prepare_main(monkeypatch, fixture_path / "espidf" / "crashing_probe.py")
with pytest.raises(SystemExit):
runner.main()
assert (kernel32.input_cp, kernel32.output_cp) == (437, 437)
def test_main_restores_the_console_when_the_switch_fails_part_way(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""A failed output page switch must not strand the changed input page."""
kernel32 = _FakeKernel32(850, 850)
def _refuse(codepage: int) -> int:
kernel32.calls.append(("SetConsoleOutputCP", codepage))
return 0
kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign]
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.input_cp == 850
assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)]
def test_main_leaves_the_console_alone_when_there_is_none(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""Without a console the code page calls return 0 and nothing is set."""
kernel32 = _FakeKernel32(0, 0)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.calls == []
def test_main_leaves_a_console_already_on_utf8_alone(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""An overlapping build must not save UTF-8 as the page to restore."""
kernel32 = _FakeKernel32(runner.UTF8_CODEPAGE, runner.UTF8_CODEPAGE)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.calls == []
@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows")
def test_get_kernel32_is_none_off_windows() -> None:
assert runner._get_kernel32() is None
+443 -72
View File
@@ -8,11 +8,12 @@ import json
import os
from pathlib import Path
import subprocess
from unittest.mock import patch
from unittest.mock import call, patch
import pytest
from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT
import esphome.config_validation as cv
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
@@ -104,11 +105,27 @@ 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."""
@@ -279,28 +296,6 @@ def test_get_cmake_output_missing_build_does_not_resolve_idf_env(
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
@@ -325,7 +320,7 @@ def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> No
),
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, "_run_ninja", return_value=0),
patch.object(toolchain, "print_summary"),
):
assert toolchain.run_compile(config, verbose=False) == 0
@@ -350,7 +345,7 @@ def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None:
),
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, "_run_ninja", return_value=0),
patch.object(toolchain, "print_summary"),
):
assert toolchain.run_compile(config, verbose=False) == 0
@@ -376,7 +371,7 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode(
def record_write(minimal: bool = False, builtin_components=None) -> None:
calls.append(("write_project", minimal))
def record_reconfigure() -> int:
def record_reconfigure(verbose: bool = False) -> int:
nonlocal reconfigures
reconfigures += 1
calls.append(("run_reconfigure",))
@@ -391,7 +386,7 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode(
),
patch("esphome.build_gen.espidf.write_project", side_effect=record_write),
patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure),
patch.object(toolchain, "run_idf_py", return_value=0) as mock_build,
patch.object(toolchain, "_run_ninja", return_value=0) as mock_build,
patch.object(toolchain, "print_summary"),
):
assert not CORE.testing_mode
@@ -421,7 +416,7 @@ def _record_compile_calls(
calls: list[tuple] = []
rcs = iter(reconfigure_rcs)
def record_reconfigure() -> int:
def record_reconfigure(verbose: bool = False) -> int:
calls.append(("run_reconfigure",))
return next(rcs, 0)
@@ -431,6 +426,11 @@ def _record_compile_calls(
def record_save(components: list[str]) -> None:
calls.append(("save", components))
def record_ninja(target: str, **kwargs: object) -> int:
if target == "all":
calls.append(("build",))
return 0
with (
patch.object(toolchain, "need_reconfigure", return_value=True),
patch.object(toolchain, "load_cached_builtin_components", return_value=cached),
@@ -443,11 +443,7 @@ def _record_compile_calls(
patch.object(
toolchain, "_builtin_component_cache_path", return_value=cache_file
),
patch.object(
toolchain,
"run_idf_py",
side_effect=lambda *a, **kw: calls.append(("build",)) or 0,
),
patch.object(toolchain, "_run_ninja", side_effect=record_ninja),
patch.object(toolchain, "print_summary"),
):
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
@@ -630,19 +626,21 @@ def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path)
assert toolchain.load_cached_builtin_components() is None
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."""
@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 for both ninja runs."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 1}}
esphome = {} if limit is None else {CONF_COMPILE_PROCESS_LIMIT: limit}
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
with _up_to_date_compile() as (mock_run, _):
assert toolchain.run_compile({CONF_ESPHOME: esphome}, verbose=False) == 0
mock_run.assert_called_once_with("build", "size", jobs=1)
assert mock_run.call_args_list == [
call("all", verbose=False, jobs=limit, progress=True),
call("size", verbose=False, jobs=limit, extra_env=toolchain._size_env()),
]
def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None:
@@ -650,14 +648,8 @@ def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None:
ELF from get_built_elf_path, which must stay in lockstep with the
project() name in the generated CMakeLists."""
_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),
patch.object(toolchain, "print_summary") as mock_summary,
):
assert toolchain.run_compile(config, verbose=False) == 0
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"),
@@ -682,21 +674,6 @@ def test_create_elf_copy_missing_source(setup_core: Path) -> None:
assert toolchain.create_elf_copy() is False
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_run_compile_writes_the_pch_checksum_before_the_build(
setup_core: Path,
) -> None:
@@ -704,11 +681,7 @@ def test_run_compile_writes_the_pch_checksum_before_the_build(
order: list[str] = []
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(
toolchain, "run_idf_py", side_effect=lambda *a, **k: order.append("build")
),
patch.object(toolchain, "print_summary"),
_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"),
@@ -727,6 +700,404 @@ def test_get_core_framework_version_from_core_data():
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",
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}",
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[-2] == "-DCCACHE_ENABLE=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"
)
@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", verbose=False, jobs=None, progress=True) == 1
log_path = mock_run.call_args.kwargs["log_path"]
assert log_path.name == "ninja_all_output.log"
mock_hints.assert_called_once_with(log_path)
assert mock_run.call_args.args[0] == ["/tools/ninja", "all"]
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 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)
@pytest.mark.parametrize("failing", ["all", "size"])
def test_run_compile_stops_on_ninja_failure(setup_core: Path, failing: str) -> None:
"""A failed build skips size; either failure skips the summary."""
_setup_build(setup_core)
with _up_to_date_compile(lambda target, **kw: 7 if target == failing else 0) as (
mock_ninja,
mock_summary,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 7
targets = [c.args[0] for c in mock_ninja.call_args_list]
assert targets == (["all"] if failing == "all" else ["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[str] = []
def record(target: str, **kwargs: object) -> int:
targets.append(target)
return memory_ld_rc if target.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"