Add full test coverage for the native ESP8266 toolchain

This commit is contained in:
J. Nick Koston
2026-08-19 22:59:05 -05:00
parent 373bee8aca
commit 8811224c0e
11 changed files with 1167 additions and 17 deletions
+1 -1
View File
@@ -32,5 +32,5 @@ def main() -> int:
return 1
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+10 -13
View File
@@ -59,12 +59,10 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
"""Resolve one library's sources, include dirs, and flags (PIO semantics)."""
build = data.get("build", {})
src_dir = build.get("srcDir")
if not src_dir:
for d in ("src", "Src", "."):
if (read_path / d).is_dir():
src_dir = d
break
# PIO's source-dir resolution: manifest srcDir, else src/Src, else the root
src_dir = build.get("srcDir") or next(
(d for d in ("src", "Src") if (read_path / d).is_dir()), "."
)
src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER))
# PlatformIO shell-lexes each build.flags entry
@@ -91,15 +89,14 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR)
for d in [include_dir, src_dir, *include_flags]:
if d and (path := (read_path / d)).is_dir():
if (path := (read_path / d)).is_dir():
lib.include_dirs.append(path.resolve())
if src_dir:
lib.sources = sorted(
Path(f).resolve()
for f in collect_filtered_files(read_path / src_dir, src_filter)
if Path(f).suffix in SRC_FILE_EXTENSIONS
)
lib.sources = sorted(
Path(f).resolve()
for f in collect_filtered_files(read_path / src_dir, src_filter)
if Path(f).suffix in SRC_FILE_EXTENSIONS
)
return lib
+234 -2
View File
@@ -10,8 +10,9 @@ PlatformIO toolchain produces for the same configuration.
from __future__ import annotations
from collections.abc import Generator
from pathlib import Path
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@@ -28,7 +29,7 @@ from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def _setup_core(tmp_path: Path) -> None:
def _setup_core(tmp_path: Path) -> Generator[None]:
CORE.name = "test8266"
CORE.build_path = tmp_path
CORE.testing_mode = False
@@ -39,6 +40,9 @@ def _setup_core(tmp_path: Path) -> None:
KEY_FLASH_MODE: "dout",
KEY_SCANF_FLOAT: False,
}
yield
# CORE.reset() (the suite-wide autouse fixture) does not clear this flag
CORE.testing_mode = False
def _set_flags(*flags: str) -> None:
@@ -259,3 +263,231 @@ def test_write_project_scanf_float_and_waveform_kept(tmp_path: Path) -> None:
# Waveform not stubbed out: both implementations stay in the archive
assert "core_esp8266_waveform_pwm.cpp.o" in content
assert "core_esp8266_waveform_phase.cpp.o" in content
@pytest.mark.parametrize(
("knob", "lib", "mss", "features", "ipv6"),
[
("PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", "lwip6-536-feat", 536, 1, 1),
(
"PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH",
"lwip6-1460-feat",
1460,
1,
1,
),
("PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH", "lwip2-1460-feat", 1460, 1, 0),
("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH", "lwip2-536", 536, 0, 0),
],
)
def test_build_config_lwip_variants(
knob: str, lib: str, mss: int, features: int, ipv6: int
) -> None:
"""Every lwIP knob maps to the same defines and library as the PIO builder."""
from esphome.build_gen.arduino8266 import _flag_defines, _resolve_build_config
_set_flags(f"-D{knob}")
config = _resolve_build_config(_flag_defines())
assert config.lwip_lib == lib
assert f"TCP_MSS={mss}" in config.knob_defines
assert f"LWIP_FEATURES={features}" in config.knob_defines
assert f"LWIP_IPV6={ipv6}" in config.knob_defines
@pytest.mark.parametrize(
("knob", "expected"),
[
(
"PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED",
["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000", "MMU_IRAM_HEAP"],
),
(
"PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED",
[
"MMU_IRAM_SIZE=0x8000",
"MMU_ICACHE_SIZE=0x4000",
"MMU_SEC_HEAP_SIZE=0x4000",
"MMU_SEC_HEAP=0x40108000",
],
),
(
"PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K",
["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=128"],
),
(
"PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K",
["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=256"],
),
],
)
def test_build_config_mmu_variants(knob: str, expected: list[str]) -> None:
from esphome.build_gen.arduino8266 import _flag_defines, _resolve_build_config
_set_flags(f"-D{knob}")
assert _resolve_build_config(_flag_defines()).mmu_defines == expected
def test_build_config_waveform_locked_phase() -> None:
from esphome.build_gen.arduino8266 import _flag_defines, _resolve_build_config
_set_flags("-DPIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE", "-DFP_IN_IROM")
config = _resolve_build_config(_flag_defines())
assert "WAVEFORM_LOCKED_PHASE=1" in config.knob_defines
assert config.fp_in_irom
_COMMON_LD_H_OUTPUT = """\
.data : ALIGN(4)
{
_data_start = ABSOLUTE(.);
} >dram0_0_seg :dram0_0_phdr
"""
def _run_generate_ld_scripts(paths: dict[str, Path]) -> Path:
from esphome.build_gen import arduino8266
config = arduino8266._resolve_build_config(arduino8266._flag_defines())
arduino8266.generate_ld_scripts(paths, config, "eagle.flash.4m.ld")
return CORE.relative_pioenvs_path(CORE.name, "ld")
def test_generate_ld_scripts(tmp_path: Path) -> None:
from esphome.build_gen import arduino8266
from esphome.components.esp8266.build_surgery import RATETABLE_RULE
paths = _make_framework(tmp_path)
_set_flags("-DFP_IN_IROM")
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT)
with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run:
ld_dir = _run_generate_ld_scripts(paths)
content = (ld_dir / "local.eagle.app.v6.common.ld").read_text()
assert RATETABLE_RULE in content
cmd = mock_run.call_args[0][0]
assert "-DVTABLES_IN_FLASH" in cmd
assert "-DMMU_IRAM_SIZE=0x8000" in cmd
assert "-DFP_IN_IROM" in cmd
# Unchanged inputs skip the preprocessor spawn on the next run
with patch.object(arduino8266.subprocess, "run") as mock_run:
_run_generate_ld_scripts(paths)
mock_run.assert_not_called()
def test_generate_ld_scripts_failure(tmp_path: Path) -> None:
from esphome.build_gen import arduino8266
paths = _make_framework(tmp_path)
result = MagicMock(returncode=1, stderr="nope")
with (
patch.object(arduino8266.subprocess, "run", return_value=result),
pytest.raises(EsphomeError, match="linker script failed"),
):
_run_generate_ld_scripts(paths)
def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None:
from esphome.build_gen import arduino8266
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"
)
CORE.testing_mode = True
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT)
with patch.object(arduino8266.subprocess, "run", return_value=result):
ld_dir = _run_generate_ld_scripts(paths)
patched = (ld_dir / "testing_eagle.flash.4m.ld").read_text()
assert "len = 0x2000000" in patched
def test_write_project_libraries_and_variant(tmp_path: Path) -> None:
from esphome.arduino8266.component import ArduinoLibrary
from esphome.build_gen import arduino8266
paths = _make_framework(tmp_path)
variant_src = paths["framework_path"] / "variants" / "nodemcu" / "variant.cpp"
variant_src.write_text("")
lib_dir = tmp_path / "libsrc"
lib_dir.mkdir()
(lib_dir / "lib.cpp").write_text("")
headers_only = ArduinoLibrary(name="HeadersOnly", include_dirs=[lib_dir])
library = ArduinoLibrary(
name="MyLib",
sources=[lib_dir / "lib.cpp"],
include_dirs=[lib_dir],
flags=["-DMYLIB=1"],
link_dirs=[lib_dir / "blobs"],
link_libs=["algobsec"],
)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS")
src = CORE.relative_src_path()
(src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True)
(src / "main.cpp").write_text("")
with (
patch.object(arduino8266, "generate_ld_scripts"),
patch(
"esphome.arduino8266.component.resolve_libraries",
return_value=[library, headers_only],
),
patch("esphome.arduino8266.framework.ccache_path", return_value="/cc/ccache"),
):
arduino8266.write_project(paths)
content = (CORE.relative_pioenvs_path(CORE.name) / "build.ninja").read_text()
assert "build libFrameworkArduinoVariant.a: ar" in content
assert "build libMyLib.a: ar" in content
# A headers-only library contributes includes but no archive
assert "libHeadersOnly.a" not in content
assert " flags = -DMYLIB=1" in content
assert "-lalgobsec" in content
assert f'-L"{lib_dir / "blobs"}"' in content
# Exceptions knob: -fexceptions and the exception-enabled stdc++
assert "-fexceptions" in content
assert "-lstdc++-exc" in content
assert 'ccache = "/cc/ccache"' in content
def test_get_flash_ld_path(tmp_path: Path) -> None:
from esphome.build_gen import arduino8266
CORE.testing_mode = True
assert arduino8266.get_flash_ld_path(tmp_path) == (
tmp_path / "ld" / "testing_eagle.flash.4m.ld"
)
CORE.testing_mode = False
with (
patch(
"esphome.arduino8266.framework.get_framework_path",
return_value=tmp_path / "framework",
),
patch(
"esphome.arduino8266.framework.framework_package_version",
return_value="3.30102.0",
),
):
assert arduino8266.get_flash_ld_path(tmp_path) == (
tmp_path / "framework" / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld"
)
def test_flash_size_str() -> None:
from esphome.build_gen.arduino8266 import _flash_size_str
assert _flash_size_str("eagle.flash.4m.ld") == "4M"
assert _flash_size_str("eagle.flash.512k.ld") == "512K"
with pytest.raises(EsphomeError, match="Cannot parse flash size"):
_flash_size_str("bogus.ld")
def test_write_project_testing_mode(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
CORE.testing_mode = True
_set_flags()
content = _write_ninja(paths)
assert "-T testing_eagle.flash.4m.ld" in content
assert "ld/testing_eagle.flash.4m.ld" in content
@@ -0,0 +1,63 @@
"""Tests for the ninja build-tool helper script."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.arduino8266 import build_tool
def test_ar_removes_stale_archive(tmp_path: Path) -> None:
archive = tmp_path / "lib.a"
archive.write_text("stale")
rsp = tmp_path / "lib.a.rsp"
rsp.write_text("a.o\n")
with (
patch.object(
build_tool.sys,
"argv",
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
),
patch.object(
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
) as mock_run,
):
assert build_tool.main() == 0
assert not archive.exists()
assert mock_run.call_args[0][0] == ["ar-bin", "rc", str(archive), f"@{rsp}"]
def test_copy(tmp_path: Path) -> None:
src = tmp_path / "firmware.bin"
src.write_text("data")
dst = tmp_path / "firmware.factory.bin"
with patch.object(
build_tool.sys, "argv", ["build_tool", "copy", str(src), str(dst)]
):
assert build_tool.main() == 0
assert dst.read_text() == "data"
def test_unknown_mode(capsys: pytest.CaptureFixture[str]) -> None:
with patch.object(build_tool.sys, "argv", ["build_tool", "bogus"]):
assert build_tool.main() == 1
assert "unknown build_tool mode" in capsys.readouterr().err
def test_runs_as_script(tmp_path: Path) -> None:
"""The ninja rules invoke the file as a plain script."""
import subprocess
import sys
src = tmp_path / "a.bin"
src.write_text("x")
dst = tmp_path / "b.bin"
result = subprocess.run(
[sys.executable, build_tool.__file__, "copy", str(src), str(dst)],
check=False,
)
assert result.returncode == 0
assert dst.read_text() == "x"
@@ -0,0 +1,176 @@
"""Tests for esphome.arduino8266.component (library resolution)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.arduino8266 import component
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_ESP8266
from esphome.core import CORE, Library
from esphome.platformio.library import ConvertedLibrary, LibraryBackend
@pytest.fixture(autouse=True)
def _reset_libraries() -> None:
CORE.platformio_libraries = {}
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP8266}
def _add_library(name: str, version: str | None, repository: str | None = None) -> None:
CORE.add_library(Library(name=name, version=version, repository=repository))
def _make_framework(tmp_path: Path) -> Path:
framework = tmp_path / "framework"
lib = framework / "libraries" / "ESP8266WiFi" / "src"
lib.mkdir(parents=True)
(lib / "ESP8266WiFi.cpp").write_text("")
(lib / "ESP8266WiFi.h").write_text("")
(lib.parent / "library.properties").write_text("name=ESP8266WiFi\nversion=1.0\n")
root_lib = framework / "libraries" / "Wire"
root_lib.mkdir(parents=True)
(root_lib / "Wire.cpp").write_text("")
(root_lib / "examples").mkdir()
(root_lib / "examples" / "scan.ino").write_text("")
return framework
def test_library_info_src_layout(tmp_path: Path) -> None:
framework = _make_framework(tmp_path)
lib = component._bundled_library(framework, "ESP8266WiFi")
assert lib.name == "ESP8266WiFi"
assert [p.name for p in lib.sources] == ["ESP8266WiFi.cpp"]
assert lib.include_dirs == [(framework / "libraries/ESP8266WiFi/src").resolve()]
def test_library_info_root_layout_excludes_examples(tmp_path: Path) -> None:
framework = _make_framework(tmp_path)
lib = component._bundled_library(framework, "Wire")
assert [p.name for p in lib.sources] == ["Wire.cpp"]
assert lib.include_dirs == [(framework / "libraries/Wire").resolve()]
def test_library_info_flags_parsing(tmp_path: Path) -> None:
read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True)
(read_path / "src" / "a.cpp").write_text("")
(read_path / "inc").mkdir()
(read_path / "blobs").mkdir()
data = {
"build": {
"flags": [
"-DFOO=1 -I inc",
"-lalgobsec",
"-fno-lto",
"-l",
"m",
"-L",
"blobs",
],
}
}
lib = component._library_info("x", read_path, data)
assert lib.flags == ["-DFOO=1", "-fno-lto"]
assert lib.include_dirs == [
(read_path / "src").resolve(),
(read_path / "inc").resolve(),
]
assert lib.link_dirs == [(read_path / "blobs").resolve()]
assert lib.link_libs == ["algobsec", "m"]
def test_library_info_no_src_dir(tmp_path: Path) -> None:
read_path = tmp_path / "empty"
read_path.mkdir()
lib = component._library_info("x", read_path, {})
# With no manifest hints the source dir falls back to the library root
assert lib.sources == []
assert lib.include_dirs == [read_path.resolve()]
def test_resolve_libraries_bundled_and_unknown(tmp_path: Path) -> None:
framework = _make_framework(tmp_path)
_add_library("ESP8266WiFi", None)
_add_library("Updater", None) # not a bundled library: skipped
libs = component.resolve_libraries(framework)
assert [lib.name for lib in libs] == ["ESP8266WiFi"]
def _converted(name: str, source_dir: Path, data: dict) -> ConvertedLibrary:
converted = ConvertedLibrary(name, "1.0.0", source=None)
converted.path = source_dir
converted.data = data
return converted
def test_resolve_libraries_external_and_bundled_deps(tmp_path: Path) -> None:
framework = _make_framework(tmp_path)
_add_library("ESP32Async/ESPAsyncWebServer", "3.9.6")
lib_dir = tmp_path / "converted" / "webserver"
(lib_dir / "src").mkdir(parents=True)
(lib_dir / "src" / "server.cpp").write_text("")
converted = _converted(
"esp32async__ESPAsyncWebServer",
lib_dir,
{
"build": {},
"dependencies": [
# Version-less bundled dependency: resolved from the framework
{"name": "Wire", "platforms": "espressif8266"},
# Wrong platform: skipped
{"name": "ESP8266WiFi", "platforms": "espressif32"},
# Registry dependency with a version: handled by the converter
{"name": "ESPAsyncTCP", "owner": "ESP32Async", "version": "^2.0.0"},
# Not bundled: skipped
{"name": "NotBundled"},
],
},
)
def fake_convert(libraries: list, backend: LibraryBackend) -> list:
assert backend.platform == "espressif8266"
assert backend.framework == "arduino"
assert backend.cache_key == "arduino8266"
backend.emit(converted)
return [converted]
with (
patch.object(component, "convert_libraries", side_effect=fake_convert),
patch.object(component, "apply_extra_script") as mock_extra,
):
libs = component.resolve_libraries(framework)
mock_extra.assert_called_once_with(converted, "esp8266")
assert [lib.name for lib in libs] == [
"Wire",
"esp32async__ESPAsyncWebServer",
]
def test_resolve_libraries_bundled_dep_already_present(tmp_path: Path) -> None:
framework = _make_framework(tmp_path)
_add_library("Wire", None)
_add_library("Some/External", "1.0.0")
lib_dir = tmp_path / "converted" / "external"
lib_dir.mkdir(parents=True)
converted = _converted(
"some__External", lib_dir, {"dependencies": [{"name": "Wire"}]}
)
def fake_convert(libraries: list, backend: LibraryBackend) -> list:
backend.emit(converted)
return [converted]
with (
patch.object(component, "convert_libraries", side_effect=fake_convert),
patch.object(component, "apply_extra_script"),
):
libs = component.resolve_libraries(framework)
# Wire appears once (from the explicit registration), not twice
assert [lib.name for lib in libs] == ["Wire", "some__External"]
@@ -0,0 +1,287 @@
"""Tests for esphome.arduino8266.framework (downloads and environment)."""
from __future__ import annotations
import os
from pathlib import Path
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from esphome.arduino8266 import framework
import esphome.config_validation as cv
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def _clear_caches(tmp_path: Path) -> None:
framework.ccache_path.cache_clear()
CORE.build_path = tmp_path
def test_framework_package_version() -> None:
assert framework.framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0"
assert framework.framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0"
def test_tools_path_default_and_prefix(tmp_path: Path) -> None:
with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}):
assert framework.get_arduino8266_tools_path() == tmp_path.resolve()
# A blank prefix must be treated as unset, not as the CWD
with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": " "}):
path = framework.get_arduino8266_tools_path()
assert path.name == "arduino8266"
assert path != Path.cwd()
@pytest.mark.parametrize(
("system", "machine", "expected"),
[
("Darwin", "arm64", "darwin_arm64"),
("Darwin", "x86_64", "darwin_x86_64"),
("Windows", "AMD64", "windows_amd64"),
("Windows", "ARM64", "windows_amd64"),
("Windows", "x86", "windows_x86"),
("Linux", "x86_64", "linux_x86_64"),
("Linux", "aarch64", "linux_aarch64"),
("Linux", "i686", "linux_i686"),
("Linux", "armv7l", "linux_armv7l"),
],
)
def test_pio_system(system: str, machine: str, expected: str) -> None:
with (
patch("platform.system", return_value=system),
patch("platform.machine", return_value=machine),
):
assert framework._pio_system() == expected
def _registry_response(files: list[dict]) -> MagicMock:
resp = MagicMock()
resp.json.return_value = {"versions": [{"name": "1.0.0", "files": files}]}
return resp
def test_registry_download_url_matches_system() -> None:
resp = _registry_response(
[
{"system": ["windows_amd64"], "download_url": "http://x/win"},
{"system": ["linux_x86_64"], "download_url": "http://x/linux"},
]
)
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"
def test_registry_download_url_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"
def test_registry_download_url_no_system_match() -> None:
resp = _registry_response(
[{"system": ["windows_amd64"], "download_url": "http://x/win"}]
)
with (
patch("requests.get", return_value=resp),
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")
def test_registry_download_url_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")
def test_install_package_skips_when_marker_exists(tmp_path: Path) -> None:
dest = tmp_path / "pkg"
dest.mkdir()
(dest / ".esphome_extracted").touch()
with patch.object(framework, "download_from_mirrors") as mock_download:
framework._install_package("pkg", "1.0.0", dest, [])
mock_download.assert_not_called()
def test_install_package_downloads_via_mirrors(tmp_path: Path) -> None:
dest = tmp_path / "pkg"
mirrors = ["http://mirror/{VERSION}/{SYSTEM}.tar.gz"]
with (
patch.object(framework, "download_from_mirrors") as mock_download,
patch.object(framework, "archive_extract_all") as mock_extract,
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
):
# Extraction is expected to create the directory
mock_extract.side_effect = lambda *_a, **_kw: dest.mkdir()
framework._install_package("pkg", "1.0.0", dest, mirrors)
assert mock_download.call_args[0][0] is mirrors
assert mock_download.call_args[0][1] == {
"VERSION": "1.0.0",
"SYSTEM": "linux_x86_64",
}
assert (dest / ".esphome_extracted").is_file()
def test_install_package_downloads_via_registry(tmp_path: Path) -> None:
dest = tmp_path / "pkg"
with (
patch.object(framework, "download_from_mirrors") 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"
),
):
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"]
@pytest.mark.parametrize(
("system", "machine", "expected"),
[
("Darwin", "arm64", "ninja-mac.zip"),
("Windows", "AMD64", "ninja-win.zip"),
("Windows", "arm64", "ninja-winarm64.zip"),
("Linux", "x86_64", "ninja-linux.zip"),
("Linux", "aarch64", "ninja-linux-aarch64.zip"),
],
)
def test_ninja_archive_name(system: str, machine: str, expected: str) -> None:
with (
patch("platform.system", return_value=system),
patch("platform.machine", return_value=machine),
):
assert framework._ninja_archive_name() == expected
def test_check_ninja_install_prefers_path(tmp_path: Path) -> None:
with patch("shutil.which", return_value=str(tmp_path / "ninja")):
assert framework._check_ninja_install() == tmp_path / "ninja"
def test_check_ninja_install_cached_binary(tmp_path: Path) -> None:
binary = (
tmp_path
/ "tools"
/ "ninja"
/ framework.NINJA_VERSION
/ ("ninja.exe" if os.name == "nt" else "ninja")
)
binary.parent.mkdir(parents=True)
binary.touch()
with (
patch("shutil.which", return_value=None),
patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}),
):
assert framework._check_ninja_install() == binary
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()
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),
):
binary = framework._check_ninja_install()
assert binary.is_file()
assert os.access(binary, os.X_OK)
def test_check_ninja_install_missing_after_extract(tmp_path: Path) -> None:
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"),
pytest.raises(EsphomeError, match="ninja binary missing"),
):
framework._check_ninja_install()
def test_check_and_install_returns_paths(tmp_path: Path) -> None:
with (
patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}),
patch.object(framework, "_install_package") as mock_install,
patch.object(
framework, "_check_ninja_install", return_value=tmp_path / "ninja"
),
):
paths = framework.check_and_install(cv.Version(3, 1, 2))
assert paths["framework_path"] == tmp_path / "frameworks" / "3.30102.0"
assert (
paths["toolchain_path"] == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION
)
assert paths["ninja_path"] == tmp_path / "ninja"
assert mock_install.call_count == 2
def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None:
with patch.object(framework, "ccache_env", return_value={"CCACHE_DIR": "x"}):
env = framework.get_build_env(tmp_path)
assert env["PATH"].startswith(str(tmp_path / "bin") + os.pathsep)
assert env["CCACHE_DIR"] == "x"
def test_ccache_path_disabled_by_env() -> None:
with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}):
assert framework.ccache_path() is None
def test_ccache_path_no_binary() -> None:
with (
patch.dict(os.environ, {}, clear=False),
patch("shutil.which", return_value=None),
):
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
assert framework.ccache_path() is None
def test_ccache_path_probe_failure() -> None:
with (
patch("shutil.which", return_value="/usr/bin/ccache"),
patch("subprocess.run", side_effect=subprocess.SubprocessError),
):
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
assert framework.ccache_path() is None
def test_ccache_path_ok() -> None:
with (
patch("shutil.which", return_value="/usr/bin/ccache"),
patch("subprocess.run"),
):
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
assert framework.ccache_path() == "/usr/bin/ccache"
def test_ccache_env(tmp_path: Path) -> None:
with patch.object(framework, "ccache_path", return_value=None):
assert framework.ccache_env() == {}
with (
patch.object(framework, "ccache_path", return_value="/usr/bin/ccache"),
patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}),
):
env = framework.ccache_env()
# User-set values are respected; the rest get defaults
assert "CCACHE_NOHASHDIR" not in env
assert env["CCACHE_DEPEND"] == "1"
assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve())
assert env["CCACHE_DIR"].endswith("ccache")
@@ -0,0 +1,186 @@
"""Tests for esphome.arduino8266.toolchain (the ninja build driver)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.arduino8266 import framework, toolchain
import esphome.config_validation as cv
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
)
from esphome.core import CORE
_SIZE_OUTPUT = """\
firmware.elf :
section size addr
.data 1924 1073643520
.noinit 56 1073645444
.text 496 1074790400
.irom0.text 342804 1075843088
.text1 27489 1074790896
.rodata 2588 1073645504
.bss 26504 1073648096
.comment abc 0
Total 401861
"""
@pytest.fixture(autouse=True)
def _setup_core(tmp_path: Path) -> None:
CORE.name = "test8266"
CORE.config_path = tmp_path / "test8266.yaml"
CORE.build_path = tmp_path
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)}
def _paths(tmp_path: Path) -> dict[str, Path]:
return {
"framework_path": tmp_path / "framework",
"toolchain_path": tmp_path / "toolchain",
"ninja_path": tmp_path / "ninja",
}
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"
def test_run_compile_build_failure(tmp_path: Path) -> None:
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project"),
patch.object(
toolchain.subprocess, "run", return_value=MagicMock(returncode=2)
) as mock_run,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2
cmd = mock_run.call_args[0][0]
assert "-v" in cmd
def test_run_compile_success(tmp_path: Path) -> None:
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project"),
patch.object(
toolchain.subprocess, "run", return_value=MagicMock(returncode=0)
) as mock_run,
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary") as mock_size,
patch.object(toolchain, "get_idedata") as mock_idedata,
):
rc = toolchain.run_compile(
{CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}, verbose=False
)
assert rc == 0
cmd = mock_run.call_args[0][0]
assert cmd[-2:] == ["-j", "4"]
mock_compdb.assert_called_once()
mock_size.assert_called_once()
mock_idedata.assert_called_once()
def test_write_compile_commands(tmp_path: Path) -> None:
build_dir = tmp_path / "build"
build_dir.mkdir()
with patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="[]\n"),
):
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
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"),
):
toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {})
assert "Could not generate compile_commands.json" in caplog.text
def test_parse_app_size(tmp_path: Path) -> None:
ld = tmp_path / "eagle.flash.4m.ld"
ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n")
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
assert toolchain._parse_app_size(tmp_path) == 0xFEFF0
ld.write_text("MEMORY { }\n")
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
assert toolchain._parse_app_size(tmp_path) is None
with patch(
"esphome.build_gen.arduino8266.get_flash_ld_path",
return_value=tmp_path / "missing.ld",
):
assert toolchain._parse_app_size(tmp_path) is None
def test_print_size_summary(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT),
),
patch.object(toolchain, "_parse_app_size", return_value=1044464),
):
toolchain._print_size_summary(tmp_path, tmp_path / "toolchain")
out = capsys.readouterr().out
# Exact PlatformIO shape so script/ci_memory_impact_extract.py can parse it
assert "RAM: [==== ] 37.9% (used 31016 bytes from 81920 bytes)" in out
assert "Flash: [==== ] 35.9% (used 375301 bytes from 1044464 bytes)" in out
def test_print_size_summary_no_app_size(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT),
),
patch.object(toolchain, "_parse_app_size", return_value=None),
):
toolchain._print_size_summary(tmp_path, tmp_path / "toolchain")
out = capsys.readouterr().out
assert "RAM:" in out
assert "Flash:" not in out
def test_print_size_summary_size_tool_failure(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
with patch.object(
toolchain.subprocess, "run", return_value=MagicMock(returncode=1, stdout="")
):
toolchain._print_size_summary(tmp_path, tmp_path / "toolchain")
assert capsys.readouterr().out == ""
def test_get_idedata_delegates(tmp_path: Path) -> None:
with patch(
"esphome.espidf.idedata.load_or_build_idedata", return_value={"cc_path": "x"}
) as mock_load:
assert toolchain.get_idedata() == {"cc_path": "x"}
compile_commands, elf, cache = mock_load.call_args[0]
assert compile_commands.name == "compile_commands.json"
assert elf.name == "firmware.elf"
assert cache.name == "test8266.json"
+64
View File
@@ -1190,3 +1190,67 @@ def test_idf_component_download_passes_salt() -> None:
"owner/name", force=True, salt="abcd1234", namespace="idf"
)
assert c.path == Path("/converted/owner/name")
def test_apply_extra_script_callable_target_and_str_flags(tmp_path) -> None:
"""The shared helper resolves a callable idf_target lazily and normalizes
a string ``build.flags`` value into a list before extending it."""
from esphome.espidf.extra_script import apply_extra_script
(tmp_path / "src").mkdir()
script = tmp_path / "extra.py"
script.write_text("env.Append(LIBS=[env.get('BOARD_MCU')])\n")
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"extraScript": "extra.py", "flags": "-DBASE=1"}}
apply_extra_script(c, lambda: "esp8266")
assert c.data["build"]["flags"] == ["-DBASE=1", "-lesp8266"]
def test_apply_extra_script_no_script_and_no_flags(tmp_path) -> None:
from esphome.espidf.extra_script import apply_extra_script
# No extraScript declared: nothing happens, the target is never resolved
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {}}
apply_extra_script(c, lambda: pytest.fail("target resolved without a script"))
# A script that captures nothing leaves the flags untouched
script = tmp_path / "noop.py"
script.write_text("pass\n")
c.data = {"build": {"extraScript": "noop.py"}}
apply_extra_script(c, "esp8266")
assert "flags" not in c.data["build"]
def test_apply_extra_script_ignores_uncaptured_env_calls(tmp_path) -> None:
"""Un-captured env vars and unsupported env methods are silent no-ops."""
from esphome.espidf.extra_script import apply_extra_script
script = tmp_path / "extra.py"
script.write_text(
"env.Replace(CC='clang')\nenv.Append(UNCAPTURED=['x'], LIBS='single')\n"
)
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"extraScript": "extra.py"}}
apply_extra_script(c, "esp8266")
assert c.data["build"]["flags"] == ["-lsingle"]
def test_apply_extra_script_swallows_script_errors(tmp_path, caplog) -> None:
"""A raising extra-script is best-effort: logged and skipped."""
from esphome.espidf.extra_script import apply_extra_script
script = tmp_path / "extra.py"
script.write_text("raise RuntimeError('boom')\n")
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"extraScript": "extra.py"}}
apply_extra_script(c, "esp8266")
assert "flags" not in c.data["build"]
assert "skipping" in caplog.text
+77
View File
@@ -262,3 +262,80 @@ def test_parse_entry_normalizes_windows_cxx_path() -> None:
assert "\\" not in cxx_path
assert 'VER="1.2.3"' in defines
assert "C:/inc/a" in includes
def test_parse_entry_strips_ccache_prefix() -> None:
"""A ccache-wrapped compile names the compiler second; the wrapper must
not be mistaken for the compiler path."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -DUSE_ESP8266 "
"-c app.cpp -o app.cpp.o",
)
cxx_path, defines, _, _ = idedata._parse_entry(entry)
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
assert defines == ["USE_ESP8266"]
def _write_compile_commands(tmp_path: Path) -> Path:
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(
json.dumps(
[
_entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/tools/g++ -DUSE_ESP8266 -c app.cpp -o app.cpp.o",
)
]
)
)
return compile_commands
def test_load_or_build_idedata_missing_compile_db(tmp_path: Path) -> None:
assert (
idedata.load_or_build_idedata(
tmp_path / "compile_commands.json", tmp_path / "f.elf", tmp_path / "c.json"
)
is None
)
def test_load_or_build_idedata_builds_and_caches(tmp_path: Path) -> None:
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "cache" / "test.json"
with patch.object(
idedata, "_get_toolchain_includes", return_value=["/toolchain/include"]
):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "firmware.elf", cache
)
assert data["cc_path"] == "/tools/gcc"
assert data["prog_path"] == str(tmp_path / "firmware.elf")
assert json.loads(cache.read_text()) == data
# A fresh cache is served without re-parsing the compile DB
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
with patch.object(idedata, "idedata_from_build") as mock_build:
assert (
idedata.load_or_build_idedata(
compile_commands, tmp_path / "firmware.elf", cache
)
== data
)
mock_build.assert_not_called()
def test_load_or_build_idedata_rebuilds_bad_cache(tmp_path: Path) -> None:
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "cache.json"
for bad in ("not json", json.dumps({"no_cc_path": True})):
cache.write_text(bad)
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
with patch.object(idedata, "_get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert "cc_path" in data
+39 -1
View File
@@ -12,7 +12,7 @@ import re
import sys
import time
from typing import Any, Self
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
import pytest
from pytest import CaptureFixture
@@ -7130,3 +7130,41 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails(
# Same tree, so the path comparison still finds them equal and stays silent
assert not caplog.text
def test_upload_using_esptool_arduino_toolchain(
tmp_path: Path,
mock_run_external_command_main: Mock,
) -> None:
"""The native ESP8266 Arduino toolchain flashes CORE.firmware_bin at 0x0."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
CORE.toolchain = Toolchain.ARDUINO
config = {CONF_ESPHOME: {"platformio_options": {}}}
result = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
assert result == 0
cmd_list = list(mock_run_external_command_main.call_args[0][1:])
firmware_offset_idx = cmd_list.index("write-flash") + 4
assert cmd_list[firmware_offset_idx] == "0x0"
assert cmd_list[firmware_offset_idx + 1] == str(CORE.firmware_bin)
def test_write_cpp_file_arduino_toolchain_writes_no_project(tmp_path: Path) -> None:
"""The native ESP8266 Arduino toolchain generates its project at compile
time, so write_cpp_file must not write a platformio.ini."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
CORE.toolchain = Toolchain.ARDUINO
with (
patch("esphome.writer.write_cpp") as mock_write_cpp,
patch("esphome.build_gen.platformio.write_project") as mock_pio_project,
patch.object(
type(CORE), "cpp_main_section", new_callable=PropertyMock
) as mock_section,
):
mock_section.return_value = ""
assert main.write_cpp_file() == 0
mock_write_cpp.assert_called_once()
mock_pio_project.assert_not_called()
+30
View File
@@ -126,3 +126,33 @@ def test_print_summary_handles_no_memory_types(
size_json = _write_size_json(tmp_path, {"image_size": 0})
print_summary(size_json, partitions_csv=None)
assert capsys.readouterr().out == ""
def test_format_bar_zero_total() -> None:
"""A zero total must not divide by zero."""
from esphome.espidf.size_summary import format_bar
assert format_bar(0, 0) == "[ ] 0.0% (used 0 bytes from 0 bytes)"
def test_print_summary_flash_line(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""image_size + a factory app partition produce the Flash line."""
size_json = tmp_path / "esp_idf_size.json"
size_json.write_text(
json.dumps(
{
"memory_types": {"DRAM": {"used": 100, "size": 200}},
"image_size": 500,
}
)
)
partitions = tmp_path / "partitions.csv"
partitions.write_text(
"# name, type, subtype, offset, size\napp0, app, factory, 0x10000, 0x100000\n"
)
print_summary(size_json, partitions)
out = capsys.readouterr().out
assert "RAM: [===== ] 50.0% (used 100 bytes from 200 bytes)" in out
assert "Flash: [ ] 0.0% (used 500 bytes from 1048576 bytes)" in out