Address review: wire analyze-memory and idedata for the native toolchain, warn on ignored platformio_options, verify downloads, surface silent failures

This commit is contained in:
J. Nick Koston
2026-08-20 01:34:15 -05:00
parent 60b7b874f1
commit 5df08d36be
14 changed files with 360 additions and 84 deletions
@@ -184,6 +184,7 @@ def _make_framework(tmp_path: Path) -> dict[str, Path]:
(framework / "libraries").mkdir()
toolchain = tmp_path / "toolchain"
(toolchain / "bin").mkdir(parents=True)
(toolchain / "include").mkdir()
return {
"framework_path": framework,
"toolchain_path": toolchain,
@@ -397,7 +398,11 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
(paths["framework_path"] / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text(
"MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n"
"MEMORY\n{\n"
" dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n"
" iram1_0_seg : org = 0x40100000, len = 0x8000\n"
" irom0_0_seg : org = 0x40201010, len = 0xfeff0\n"
"}\n"
)
CORE.testing_mode = True
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT)
@@ -69,3 +69,14 @@ def test_segment_length() -> None:
assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0
assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None
def test_testing_memory_patches_require() -> None:
"""With require, a segment the patch could not find raises instead of
silently keeping the real memory limits."""
patched = apply_testing_memory_patches(_FLASH_LD_SNIPPET, require=True)
assert "0x2000000" in patched
with pytest.raises(RuntimeError, match="iram1_0_seg"):
apply_testing_memory_patches("MEMORY { }", require=True)
# Without require (the common linker script has no MEMORY block) it is a no-op
assert apply_testing_memory_patches("MEMORY { }") == "MEMORY { }"
+24
View File
@@ -1389,3 +1389,27 @@ def test_esphome_build_internals_are_yaml_only() -> None:
assert markers[field].visibility is cv.Visibility.ADVANCED, field
# A regular device-config field stays on the main form.
assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None
@pytest.mark.asyncio
async def test_add_platformio_options_native_arduino(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The native ESP8266 Arduino toolchain warns about ignored options the
same way the native IDF toolchain does."""
CORE.toolchain = Toolchain.ARDUINO
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp8266",
KEY_TARGET_FRAMEWORK: "arduino",
}
await config._add_platformio_options(
{
"board_build.f_cpu": "160000000L",
"upload_speed": "115200",
}
)
assert "esphome->platformio_options->board_build.f_cpu is ignored" in caplog.text
assert "'arduino' toolchain" in caplog.text
assert "upload_speed" not in caplog.text
@@ -93,12 +93,17 @@ def test_library_info_no_src_dir(tmp_path: Path) -> None:
assert lib.include_dirs == [read_path.resolve()]
def test_resolve_libraries_bundled_and_unknown(tmp_path: Path) -> None:
def test_resolve_libraries_bundled_and_unknown(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
framework = _make_framework(tmp_path)
_add_library("ESP8266WiFi", None)
_add_library("Updater", None) # not a bundled library: skipped
_add_library("Updater", None) # known-absent: skipped silently
_add_library("Typoo", None) # unknown: skipped with a warning
libs = component.resolve_libraries(framework)
assert [lib.name for lib in libs] == ["ESP8266WiFi"]
assert "Typoo" in caplog.text
assert "Updater" not in caplog.text
def _converted(name: str, source_dir: Path, data: dict) -> ConvertedLibrary:
+61 -21
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import os
from pathlib import Path
import stat
import subprocess
from unittest.mock import MagicMock, patch
@@ -63,27 +64,40 @@ def _registry_response(files: list[dict]) -> MagicMock:
return resp
def test_registry_download_url_matches_system() -> None:
def test_registry_download_matches_system() -> None:
resp = _registry_response(
[
{"system": ["windows_amd64"], "download_url": "http://x/win"},
{"system": ["linux_x86_64"], "download_url": "http://x/linux"},
{
"system": ["linux_x86_64"],
"download_url": "http://x/linux",
"checksum": {"sha256": "abc123"},
"size": 42,
},
]
)
with (
patch("requests.get", return_value=resp),
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
):
assert framework._registry_download_url("pkg", "1.0.0") == "http://x/linux"
assert framework._registry_download("pkg", "1.0.0") == (
"http://x/linux",
"abc123",
42,
)
def test_registry_download_url_wildcard_system() -> None:
def test_registry_download_wildcard_system() -> None:
resp = _registry_response([{"system": "*", "download_url": "http://x/any"}])
with patch("requests.get", return_value=resp):
assert framework._registry_download_url("pkg", "1.0.0") == "http://x/any"
assert framework._registry_download("pkg", "1.0.0") == (
"http://x/any",
None,
None,
)
def test_registry_download_url_no_system_match() -> None:
def test_registry_download_no_system_match() -> None:
resp = _registry_response(
[{"system": ["windows_amd64"], "download_url": "http://x/win"}]
)
@@ -92,17 +106,17 @@ def test_registry_download_url_no_system_match() -> None:
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
pytest.raises(EsphomeError, match="No pkg 1.0.0 build"),
):
framework._registry_download_url("pkg", "1.0.0")
framework._registry_download("pkg", "1.0.0")
def test_registry_download_url_version_not_found() -> None:
def test_registry_download_version_not_found() -> None:
resp = _registry_response([])
resp.json.return_value = {"versions": [{"name": "2.0.0", "files": []}]}
with (
patch("requests.get", return_value=resp),
pytest.raises(EsphomeError, match="not found"),
):
framework._registry_download_url("pkg", "1.0.0")
framework._registry_download("pkg", "1.0.0")
def test_install_package_skips_when_marker_exists(tmp_path: Path) -> None:
@@ -134,17 +148,21 @@ def test_install_package_downloads_via_mirrors(tmp_path: Path) -> None:
def test_install_package_downloads_via_registry(tmp_path: Path) -> None:
"""The registry path downloads with the registry's sha256 and size."""
dest = tmp_path / "pkg"
with (
patch.object(framework, "download_from_mirrors") as mock_download,
patch.object(framework, "download_with_resume") as mock_download,
patch.object(framework, "archive_extract_all") as mock_extract,
patch.object(
framework, "_registry_download_url", return_value="http://x/pkg.tar.gz"
framework,
"_registry_download",
return_value=("http://x/pkg.tar.gz", "abc123", 42),
),
):
mock_extract.side_effect = lambda *_a, **_kw: dest.mkdir()
framework._install_package("pkg", "1.0.0", dest, [])
assert mock_download.call_args[0][0] == ["http://x/pkg.tar.gz"]
assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz"
assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42}
@pytest.mark.parametrize(
@@ -187,22 +205,44 @@ def test_check_ninja_install_cached_binary(tmp_path: Path) -> None:
assert framework._check_ninja_install() == binary
def _fake_ninja_extract(_archive, ninja_dir, **_kw) -> None:
ninja_dir.mkdir(parents=True, exist_ok=True)
(ninja_dir / ("ninja.exe" if os.name == "nt" else "ninja")).touch()
def test_check_ninja_install_downloads(tmp_path: Path) -> None:
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
def fake_extract(_tmp, ninja_dir, **_kw) -> None:
ninja_dir.mkdir(parents=True, exist_ok=True)
(ninja_dir / binary_name).touch()
"""The default source is integrity-checked against the pinned sha256."""
with (
patch("shutil.which", return_value=None),
patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}),
patch.object(framework, "download_from_mirrors"),
patch.object(framework, "archive_extract_all", side_effect=fake_extract),
patch.object(framework, "download_with_resume") as mock_download,
patch.object(framework, "archive_extract_all", side_effect=_fake_ninja_extract),
):
binary = framework._check_ninja_install()
assert binary.is_file()
assert os.access(binary, os.X_OK)
# X_OK would consult mount flags (fails on noexec /tmp); check mode bits
assert binary.stat().st_mode & stat.S_IXUSR
archive_name = framework._ninja_archive_name()
assert mock_download.call_args[1]["sha256"] == framework._NINJA_SHA256[archive_name]
def test_check_ninja_install_mirror_override_skips_checksum(tmp_path: Path) -> None:
"""A mirror override is trusted as configured (no pinned checksum)."""
with (
patch("shutil.which", return_value=None),
patch.dict(
os.environ,
{
"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path),
"ESPHOME_ARDUINO8266_NINJA_MIRRORS": "http://mirror/{ARCHIVE}",
},
),
patch.object(framework, "download_from_mirrors") as mock_download,
patch.object(framework, "archive_extract_all", side_effect=_fake_ninja_extract),
):
binary = framework._check_ninja_install()
assert binary.is_file()
mock_download.assert_called_once()
def test_check_ninja_install_missing_after_extract(tmp_path: Path) -> None:
+16 -8
View File
@@ -52,6 +52,8 @@ def test_path_getters(tmp_path: Path) -> None:
assert toolchain.get_build_dir() == CORE.relative_pioenvs_path("test8266")
assert toolchain.get_elf_path().name == "firmware.elf"
assert toolchain.get_addr2line_path().name == "xtensa-lx106-elf-addr2line"
assert toolchain.get_objdump_path().name == "xtensa-lx106-elf-objdump"
assert toolchain.get_readelf_path().name == "xtensa-lx106-elf-readelf"
def test_run_compile_build_failure(tmp_path: Path) -> None:
@@ -103,16 +105,22 @@ def test_write_compile_commands(tmp_path: Path) -> None:
assert (build_dir / "compile_commands.json").read_text() == "[]\n"
def test_write_compile_commands_failure(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
with patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=1, stderr="boom"),
def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None:
"""A failed compdb run must not leave a stale database behind."""
from esphome.core import EsphomeError
stale = tmp_path / "compile_commands.json"
stale.write_text("[]")
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=1, stderr="boom"),
),
pytest.raises(EsphomeError, match="compile_commands"),
):
toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {})
assert "Could not generate compile_commands.json" in caplog.text
assert not stale.exists()
def test_parse_app_size(tmp_path: Path) -> None:
+69
View File
@@ -7207,3 +7207,72 @@ def test_write_cpp_file_arduino_toolchain_other_platform_falls_through(
assert main.write_cpp_file() == 0
mock_pio_project.assert_called_once()
def test_command_idedata_arduino_prints_json(
tmp_path: Path, capsys: CaptureFixture
) -> None:
"""Under the native ESP8266 Arduino toolchain, idedata is emitted as JSON."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path)
CORE.toolchain = Toolchain.ARDUINO
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
with patch(
"esphome.arduino8266.toolchain.get_idedata", return_value=data
) as mock_get:
result = command_idedata(MagicMock(), CORE.config)
assert result == 0
mock_get.assert_called_once_with()
assert json.loads(capsys.readouterr().out) == data
def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None:
"""A missing native build (no idedata) returns an error, not a crash."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path)
CORE.toolchain = Toolchain.ARDUINO
with patch("esphome.arduino8266.toolchain.get_idedata", return_value=None):
result = command_idedata(MagicMock(), CORE.config)
assert result == 1
def test_command_analyze_memory_arduino_toolchain(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
mock_ram_strings_analyzer: Mock,
) -> None:
"""analyze-memory uses the native toolchain's binutils under
'toolchain: arduino' instead of falling into the PlatformIO branch."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
CORE.toolchain = Toolchain.ARDUINO
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
with (
patch(
"esphome.arduino8266.toolchain.get_objdump_path",
return_value=Path("/tc/objdump"),
),
patch(
"esphome.arduino8266.toolchain.get_readelf_path",
return_value=Path("/tc/readelf"),
),
patch(
"esphome.arduino8266.toolchain.get_elf_path",
return_value=Path("/build/firmware.elf"),
),
):
result = command_analyze_memory(MockArgs(), config)
assert result == 0
mock_memory_analyzer_cli.assert_called_once_with(
"/build/firmware.elf",
"/tc/objdump",
"/tc/readelf",
set(),
idedata=None,
)