mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Add full test coverage for the native ESP8266 toolchain
This commit is contained in:
@@ -32,5 +32,5 @@ def main() -> int:
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user