mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 03:16:37 +00:00
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:
@@ -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 { }"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user