diff --git a/esphome/arduino8266/build_tool.py b/esphome/arduino8266/build_tool.py index bf08662e3e..bfcf4128eb 100644 --- a/esphome/arduino8266/build_tool.py +++ b/esphome/arduino8266/build_tool.py @@ -32,5 +32,5 @@ def main() -> int: return 1 -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover sys.exit(main()) diff --git a/esphome/arduino8266/component.py b/esphome/arduino8266/component.py index e08eafd96c..89636ec6fa 100644 --- a/esphome/arduino8266/component.py +++ b/esphome/arduino8266/component.py @@ -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 diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 60db123598..8353d6c461 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -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 diff --git a/tests/unit_tests/test_arduino8266_build_tool.py b/tests/unit_tests/test_arduino8266_build_tool.py new file mode 100644 index 0000000000..66d11a6700 --- /dev/null +++ b/tests/unit_tests/test_arduino8266_build_tool.py @@ -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" diff --git a/tests/unit_tests/test_arduino8266_component.py b/tests/unit_tests/test_arduino8266_component.py new file mode 100644 index 0000000000..f0e339c0ec --- /dev/null +++ b/tests/unit_tests/test_arduino8266_component.py @@ -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"] diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py new file mode 100644 index 0000000000..13eb5a4539 --- /dev/null +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -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") diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py new file mode 100644 index 0000000000..6045f919bf --- /dev/null +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -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" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index a4adf734c0..beebb7d4e4 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -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 diff --git a/tests/unit_tests/test_espidf_idedata.py b/tests/unit_tests/test_espidf_idedata.py index 1088517ed1..f6b25bd1de 100644 --- a/tests/unit_tests/test_espidf_idedata.py +++ b/tests/unit_tests/test_espidf_idedata.py @@ -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 diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a40341e194..d5dc1e71d8 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -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() diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py index 933be88476..e65195aab5 100644 --- a/tests/unit_tests/test_size_summary.py +++ b/tests/unit_tests/test_size_summary.py @@ -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