"""Tests for esphome.arduino8266.toolchain (the ninja build driver).""" 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, toolchain import esphome.config_validation as cv from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain, ) from esphome.core import CORE, EsphomeError _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 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)} # run_compile verifies the produced artifacts; give every test a build # that "produced" them (tests for the guard delete them again). The # manifest comes first: artifacts must not be older than build.ninja. build_dir = CORE.relative_pioenvs_path("test8266") build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "build.ninja").write_text("# manifest") for artifact in ( "firmware.elf", "firmware.bin", "firmware.factory.bin", "firmware.ota.bin", ): (build_dir / artifact).write_bytes(b"") def _paths(tmp_path: Path) -> framework.InstalledPaths: return framework.InstalledPaths( framework=tmp_path / "framework", toolchain=tmp_path / "toolchain", ninja=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" # The framework accessor owns the layout and the Windows suffix suffix = ".exe" if os.name == "nt" else "" assert toolchain.get_addr2line_path().name == f"xtensa-lx106-elf-addr2line{suffix}" assert toolchain.get_objdump_path().name == f"xtensa-lx106-elf-objdump{suffix}" assert toolchain.get_readelf_path().name == f"xtensa-lx106-elf-readelf{suffix}" 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, patch.object(toolchain, "refresh_compile_commands") as mock_compdb, ): assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2 cmd = mock_run.call_args[0][0] assert "-v" in cmd # The compile database is generated before the build runs, so a failed # build cannot leave a stale database behind. mock_compdb.assert_called_once() 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", return_value=False), patch.object( toolchain.subprocess, "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ) as mock_run, patch.object(toolchain, "refresh_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 call = next(c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])) cmd = call[0][0] # Explicit targets: a manifest missing them fails as "unknown target" assert {"firmware.factory.bin", "firmware.ota.bin"} <= set(cmd) assert cmd[cmd.index("-j") + 1] == "4" # cwd, not -C, so ninja prints no "Entering directory" banner assert "-C" not in cmd assert call[1]["cwd"] is not None # An unchanged manifest is passed on, so the shared refresh can skip assert mock_compdb.call_args.args[3] is False mock_size.assert_called_once() mock_idedata.assert_called_once() def test_run_compile_missing_artifact_fails( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A zero ninja exit that produced no firmware must not be a green build (size summary and idedata only warn).""" (toolchain.get_build_dir() / "firmware.elf").unlink() 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, stdout="", stderr=""), ), patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary") as mock_size, patch.object(toolchain, "get_idedata"), ): rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) assert rc == 1 assert "Build produced no" in caplog.text mock_size.assert_not_called() def test_run_compile_warns_when_idedata_fails( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A failed idedata generation right after a successful build is visible, not deferred to a misleading error in a later command.""" 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, stdout="", stderr=""), ), patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary"), patch.object(toolchain, "get_idedata", return_value=None), ): assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 assert "No idedata was generated for this build" 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, _paths(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, _paths(tmp_path)) is None # A zero-length segment is bad data, not a budget; warn and drop it ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0x0\n}\n") with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): assert toolchain._parse_app_size(tmp_path, _paths(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, _paths(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, _paths(tmp_path)) 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_missing_size_tool_warns( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A missing size binary degrades to a warning; the firmware already linked and must not be discarded.""" with patch.object( toolchain.subprocess, "run", side_effect=FileNotFoundError("no size") ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert "Could not summarize firmware size" in caplog.text 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, _paths(tmp_path)) out = capsys.readouterr().out # Both lines are skipped together: a RAM line without Flash would skew # CI's memory-impact sums across builds assert out == "" def test_print_size_summary_size_tool_failure( tmp_path: Path, capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, ) -> None: with patch.object( toolchain.subprocess, "run", side_effect=subprocess.CalledProcessError(1, "size", stderr="bad elf"), ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert capsys.readouterr().out == "" assert "Could not summarize firmware size" in caplog.text def test_get_idedata_delegates(tmp_path: Path) -> None: with ( patch( "esphome.build_helpers.idedata.load_or_build_idedata", return_value={"cc_path": "x"}, ) as mock_load, patch.object( toolchain, "resolve_absolute_ccache_path", return_value="/cc/ccache" ), ): 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.arduino.json" # The exact configured launcher string is passed for compile DB parsing # (resolve_ccache_path returns a str, untouched on every platform) assert mock_load.call_args.kwargs["launcher"] == "/cc/ccache" def test_get_idedata_no_ccache(tmp_path: Path) -> None: with ( patch( "esphome.build_helpers.idedata.load_or_build_idedata", return_value={} ) as mock_load, patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None), ): toolchain.get_idedata() assert mock_load.call_args.kwargs["launcher"] is None def test_print_size_summary_unparsable_section( tmp_path: Path, capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, ) -> None: """A totals-relevant section that fails to parse must not produce a confident wrong number; an irrelevant one is ignored.""" bad = _SIZE_OUTPUT.replace(".bss 26504", ".bss abc") with patch.object( toolchain.subprocess, "run", return_value=MagicMock(returncode=0, stdout=bad), ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert capsys.readouterr().out == "" assert "missing section(s) .bss" in caplog.text caplog.clear() harmless = _SIZE_OUTPUT + ".broken abc 0\n" with ( patch.object( toolchain.subprocess, "run", return_value=MagicMock(returncode=0, stdout=harmless), ), patch.object(toolchain, "_parse_app_size", return_value=1044464), ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert "RAM:" in capsys.readouterr().out assert not caplog.text def test_print_size_summary_missing_section_skips_summary( tmp_path: Path, capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, ) -> None: """A totals section absent from the output must not default to zero.""" without_bss = "\n".join( line for line in _SIZE_OUTPUT.splitlines() if ".bss" not in line ) with patch.object( toolchain.subprocess, "run", return_value=MagicMock(returncode=0, stdout=without_bss), ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert capsys.readouterr().out == "" assert "missing section(s) .bss" in caplog.text def test_run_compile_idedata_error_does_not_fail_build( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """An unusable compile DB after a successful build warns, never fails.""" 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, stdout="", stderr=""), ), patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary"), patch.object( toolchain, "get_idedata", side_effect=EsphomeError("compile database is unusable"), ), ): assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 assert "Could not generate idedata: compile database is unusable" in caplog.text def test_run_compile_skipped_size_summary_names_consequence( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A skipped RAM/Flash summary warns about the missing metric; the per-cause warnings alone are invisible to CI harnesses.""" 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, stdout="", stderr=""), ), patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary", return_value=False), patch.object(toolchain, "get_idedata", return_value=None), ): assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 assert "Firmware size summary unavailable for this build" in caplog.text def test_parse_app_size_non_utf8_ld_warns( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A corrupt (non-UTF-8) linker script degrades to the same warning as an unreadable one, never a traceback after a successful link.""" paths = _paths(tmp_path) ld = tmp_path / "corrupt.ld" ld.write_bytes(b"\xff\xfe not utf8") with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): assert toolchain._parse_app_size(tmp_path, paths) is None assert "Cannot read linker script" in caplog.text def test_get_idedata_accepts_preresolved_ccache() -> None: """run_compile threads its resolved ccache through; the probe must not run again.""" with ( patch( "esphome.build_helpers.idedata.load_or_build_idedata", return_value={"ok": True}, ) as mock_build, patch.object(toolchain, "resolve_absolute_ccache_path") as mock_resolve, ): assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True} mock_resolve.assert_not_called() assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache" def test_run_compile_warns_about_dropped_platformio_options( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Component-added options the native build drops are warned by name; the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags, flash_mode) stay quiet.""" CORE.toolchain = Toolchain.ARDUINO CORE.platformio_options = { "board_build.ldscript": "eagle.flash.4m2m.ld", "board_build.f_cpu": "160000000L", "board_build.filesystem": "littlefs", "board_build.flash_mode": "dio", "build_src_flags": "-include throw_stubs.h", "lib_ignore": ["Updater"], "upload_speed": "460800", } 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", return_value=True), patch.object( toolchain.subprocess, "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ), patch.object(toolchain, "refresh_compile_commands") as mock_compdb, patch.object(toolchain, "_print_size_summary"), patch.object(toolchain, "get_idedata"), ): assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 assert "platformio_options->board_build.filesystem is ignored" in caplog.text assert "platformio_options->upload_speed is ignored" in caplog.text assert "native 'arduino' toolchain" in caplog.text assert "board_build.ldscript is ignored" not in caplog.text assert "board_build.f_cpu is ignored" not in caplog.text assert "lib_ignore" not in caplog.text assert "build_src_flags" not in caplog.text assert "flash_mode" not in caplog.text # A rewritten manifest is passed on, so the compile DB is regenerated assert mock_compdb.call_args.args[3] is True