Files
esphome/tests/unit_tests/build_gen/test_arduino8266.py
T
2026-08-22 15:06:30 -05:00

1391 lines
52 KiB
Python

"""Drift tests for the native ESP8266 Arduino build generator.
Pin the transliterated flag/define/link sets against literals audited from
the PlatformIO builder. Upstream drift is caught by the A/B build check on
version bumps, not here.
"""
from __future__ import annotations
from collections.abc import Generator
import logging
import os
from pathlib import Path
import shutil
from unittest.mock import MagicMock, patch
import pytest
from esphome.arduino.library import ArduinoLibrary
from esphome.arduino8266.framework import InstalledPaths, toolchain_tool
from esphome.build_gen import arduino8266
from esphome.build_gen.arduino8266 import (
_defines_flags,
_flag_defines,
_flash_size_str,
_resolve_build_config,
get_flash_ld_path,
)
from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD
from esphome.components.esp8266.build_surgery import RATETABLE_RULE
from esphome.components.esp8266.const import (
KEY_BOARD,
KEY_ESP8266,
KEY_FLASH_MODE,
KEY_SCANF_FLOAT,
)
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def _setup_core(tmp_path: Path) -> Generator[None]:
CORE.name = "test8266"
CORE.build_path = tmp_path
CORE.testing_mode = False
CORE.cpp_standard = "gnu++20"
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)}
CORE.data[KEY_ESP8266] = {
KEY_BOARD: "nodemcuv2",
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:
CORE.build_flags = set(flags)
def _shq(tok: str) -> str:
"""The platform's shell_token quote wrapper (argv rule on Windows)."""
return f'"{tok}"' if os.name == "nt" else f"'{tok}'"
def _resolve(*flags: str):
"""Set the build flags and resolve the knob config in one step."""
_set_flags(*flags)
return _resolve_build_config(_flag_defines(set(), arduino8266._lexed_build_flags()))
def test_build_config_defaults() -> None:
config = _resolve()
assert config.nonosdk == "NONOSDK22x_190703"
assert config.lwip_lib == "lwip2-536-feat"
assert not config.exceptions
assert config.vtables == "VTABLES_IN_FLASH"
assert config.knob_defines == [
"NONOSDK22x_190703=1",
"TCP_MSS=536",
"LWIP_FEATURES=1",
"LWIP_IPV6=0",
]
assert config.mmu_defines == ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000"]
def test_build_config_esphome_lwip_knob() -> None:
"""The lwIP variant ESPHome selects maps to the same defines and library
as the PlatformIO builder."""
config = _resolve("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
assert config.lwip_lib == "lwip2-1460"
assert "TCP_MSS=1460" in config.knob_defines
assert "LWIP_FEATURES=0" in config.knob_defines
assert "LWIP_IPV6=0" in config.knob_defines
def test_build_config_knobs() -> None:
_set_flags(
"-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305",
"-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS",
"-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48",
"-DVTABLES_IN_DRAM",
)
config = _resolve_build_config(
_flag_defines(set(), arduino8266._lexed_build_flags())
)
assert config.nonosdk == "NONOSDK305"
assert config.exceptions
assert config.vtables == "VTABLES_IN_DRAM"
assert config.mmu_defines == ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000"]
def test_build_config_mmu_custom_requires_sizes() -> None:
with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE"):
_resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM")
_set_flags(
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM",
"-DMMU_IRAM_SIZE=0xC000",
"-DMMU_ICACHE_SIZE=0x4000",
)
config = _resolve_build_config(
_flag_defines(set(), arduino8266._lexed_build_flags())
)
# Emitted pre-sorted so build.ninja stays byte-stable across runs
assert config.mmu_defines == [
"MMU_ICACHE_SIZE=0x4000",
"MMU_IRAM_SIZE=0xC000",
]
def test_defines_match_platformio_builder() -> None:
"""The exact define set the PlatformIO builder passes for nodemcuv2/dout."""
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
assert _defines_flags(
_resolve_build_config(_flag_defines(set(), arduino8266._lexed_build_flags())),
"dout",
"nodemcuv2",
ESP8266_BOARD_BUILD["nodemcuv2"]["defines"],
) == [
"-DF_CPU=80000000L",
"-D__ets__",
"-DICACHE_FLASH",
"-D_GNU_SOURCE",
"-DARDUINO=10805",
'-DARDUINO_BOARD=\\"PLATFORMIO_NODEMCUV2\\"',
'-DARDUINO_BOARD_ID=\\"nodemcuv2\\"',
"-DFLASHMODE_DOUT",
"-DLWIP_OPEN_SRC",
"-DNONOSDK22x_190703=1",
"-DTCP_MSS=1460",
"-DLWIP_FEATURES=0",
"-DLWIP_IPV6=0",
"-DVTABLES_IN_FLASH",
"-DMMU_IRAM_SIZE=0x8000",
"-DMMU_ICACHE_SIZE=0x8000",
"-DESP8266",
"-DARDUINO_ARCH_ESP8266",
"-DARDUINO_ESP8266_NODEMCU_ESP12E",
]
def _make_framework(tmp_path: Path) -> InstalledPaths:
framework = tmp_path / "framework"
core = framework / "cores" / "esp8266"
core.mkdir(parents=True)
for name in (
"core_esp8266_main.cpp",
"Updater.cpp",
"core_esp8266_waveform_pwm.cpp",
"core_esp8266_waveform_phase.cpp",
"cont.S",
"abi.c",
):
(core / name).write_text("")
(framework / "variants" / "nodemcu").mkdir(parents=True)
for sub in ("include", "ld", "lwip2/include", "lib"):
(framework / "tools" / "sdk" / sub).mkdir(parents=True)
(framework / "libraries").mkdir()
(framework / "tools" / "elf2bin.py").write_text("")
eboot = framework / "bootloaders" / "eboot"
eboot.mkdir(parents=True)
(eboot / "eboot.elf").write_text("")
toolchain = tmp_path / "toolchain"
(toolchain / "bin").mkdir(parents=True)
(toolchain / "include").mkdir()
return InstalledPaths(framework=framework, toolchain=toolchain, ninja=Path("ninja"))
def _write_ninja(
paths: InstalledPaths,
libraries: list | None = None,
ccache: str | None = None,
) -> str:
src = CORE.relative_src_path()
(src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True)
(src / "main.cpp").write_text("")
(src / "esphome" / "vendor.c").write_text("")
with (
patch.object(arduino8266, "generate_ld_scripts"),
patch(
"esphome.arduino.library.resolve_libraries",
return_value=libraries or [],
),
):
arduino8266.write_project(paths, ccache)
return (CORE.relative_pioenvs_path(CORE.name) / "build.ninja").read_text()
def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
_set_flags(
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH",
"-DUSE_ESP8266_WAVEFORM_STUBS",
"-Wl,--wrap=millis",
"-Wl,--wrap=printf",
"-Wno-nonnull-compare",
"-L/opt/blobs",
"-luser_blob",
"-L /spc/blobs -l spaced_blob",
)
content = _write_ninja(paths)
# Base link flags from the PlatformIO builder
for flag in (
"-Wl,--no-check-sections",
"-Wl,-static",
"-Wl,--gc-sections",
"-Wl,-wrap,system_restart_local",
"-Wl,-wrap,spi_flash_read",
"-u app_entry",
"-u _printf_float",
"-u _DebugExceptionVector",
"-u _DoubleExceptionVector",
"-u _KernelExceptionVector",
"-u _NMIExceptionVector",
"-u _UserExceptionVector",
):
assert flag in content
# ESPHome's link flags and the board linker script
assert "-Wl,--wrap=millis" in content
assert "-Wl,--wrap=printf" in content
assert "-T eagle.flash.4m.ld" in content
# scanf float disabled: the forced-link flag must not appear
assert "_scanf_float" not in content
# $in/$out must stay unquoted; ninja escapes its own path variables
assert "-c $in -o $out" in content
assert "--app $in --flash_mode" in content
assert '"$in"' not in content
assert '"$out"' not in content
# -L/-l from esphome build_flags reach the link line, not the compiles;
# spaced forms ("-L /path") are shell-lexed the way PlatformIO does.
# str(Path(...)) so the separator matches the host platform.
opt_blobs = str(Path("/opt/blobs"))
spc_blobs = str(Path("/spc/blobs"))
assert f"-L{_shq(opt_blobs)}" in content
assert "-luser_blob" in content
assert f"-L{_shq(spc_blobs)}" in content
assert "-lspaced_blob" in content
for line in content.splitlines():
if line.split(" = ")[0] in ("cflags", "cxxflags", "asflags"):
assert "user_blob" not in line
assert opt_blobs not in line
assert "spaced_blob" not in line
assert spc_blobs not in line
# System libraries with the selected lwIP variant, in the builder's order
assert (
"-lhal -lphy -lpp -lnet80211 -llwip2-1460 -lwpa -lcrypto -lmain -lwps "
"-lbearssl -lespnow -lsmartconfig -lairkiss -lwpa2 -lspaced_blob "
"-luser_blob "
"-lstdc++ -lm -lc -lgcc" in content
)
# Core exclusions: native OTA backend and waveform stubs
assert "Updater.cpp" not in content
assert "core_esp8266_waveform_pwm.cpp" not in content
assert "core_esp8266_waveform_phase.cpp" not in content
assert "core_esp8266_main.cpp.o" in content
# Assembly and C sources compile through their own rules
assert "cont.S.o: asm" in content
assert "abi.c.o: c" in content
# throw_stubs is force-included for ESPHome sources only, via one shared
# srcflags variable rather than a copy of the flags line per edge
src_lines = [line for line in content.splitlines() if "obj/src/" in line]
assert any("main.cpp.o: cxx" in line for line in src_lines)
assert content.count("throw_stubs.h") == 1
assert "srcflags = -include" in content
flags_lines = [
line for line in content.splitlines() if line.startswith(" flags = ")
]
assert flags_lines
assert all(line == " flags = $srcflags" for line in flags_lines)
def test_write_project_scanf_float_and_waveform_kept(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = True
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
content = _write_ninja(paths)
assert "-u _scanf_float" in content
# 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."""
config = _resolve(f"-D{knob}")
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:
_set_flags(f"-D{knob}")
assert (
_resolve_build_config(
_flag_defines(set(), arduino8266._lexed_build_flags())
).mmu_defines
== expected
)
def test_build_config_waveform_locked_phase() -> None:
config = _resolve("-DPIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE", "-DFP_IN_IROM")
assert "WAVEFORM_LOCKED_PHASE=1" in config.knob_defines
assert config.fp_in_irom
_COMMON_LD_H_OUTPUT = """\
MEMORY
{
iram1_0_seg : org = 0x40100000, len = 0x8000
}
SECTIONS
{
.data : ALIGN(4)
{
_data_start = ABSOLUTE(.);
} >dram0_0_seg :dram0_0_phdr
}
"""
def _run_generate_ld_scripts(paths: InstalledPaths) -> Path:
config = _resolve_build_config(
_flag_defines(set(), arduino8266._lexed_build_flags())
)
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:
paths = _make_framework(tmp_path)
_set_flags("-DFP_IN_IROM")
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with (
patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run,
patch.object(arduino8266._LOGGER, "warning") as mock_warn,
):
ld_dir = _run_generate_ld_scripts(paths)
# A clean preprocessor run must be quiet
mock_warn.assert_not_called()
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()
# An edit to the surgery constants invalidates the stamp (a stale linker
# script would otherwise persist until an esphome clean)
with (
patch.object(
arduino8266.build_surgery, "surgery_fingerprint", return_value="changed"
),
patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run,
):
_run_generate_ld_scripts(paths)
mock_run.assert_called_once()
def test_generate_ld_scripts_corrupt_cache_regenerates(tmp_path: Path) -> None:
"""A truncated cached linker script regenerates even with a fresh stamp."""
paths = _make_framework(tmp_path)
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with patch.object(arduino8266.subprocess, "run", return_value=result):
ld_dir = _run_generate_ld_scripts(paths)
output = ld_dir / "local.eagle.app.v6.common.ld"
output.write_text("truncated garbage")
with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run:
_run_generate_ld_scripts(paths)
mock_run.assert_called_once()
assert RATETABLE_RULE in output.read_text()
def test_generate_ld_scripts_failure(tmp_path: Path) -> None:
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:
paths = _make_framework(tmp_path)
(paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text(
"MEMORY\n{\n"
" dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n"
" irom0_0_seg : org = 0x40201010, len = 0xfeff0\n"
"}\n"
)
CORE.testing_mode = True
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
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, caplog: pytest.LogCaptureFixture
) -> None:
paths = _make_framework(tmp_path)
variant_src = paths.framework / "variants" / "nodemcu" / "variant.cpp"
variant_src.write_text("")
lib_dir = tmp_path / "libsrc"
lib_dir.mkdir()
(lib_dir / "lib.cpp").write_text("")
(lib_dir / "impl.cc").write_text("")
headers_only = ArduinoLibrary(name="HeadersOnly", include_dirs=[lib_dir])
library = ArduinoLibrary(
name="MyLib",
sources=[lib_dir / "impl.cc", lib_dir / "lib.cpp"],
include_dirs=[lib_dir],
flags=["-DMYLIB=1"],
link_dirs=[lib_dir / "blobs"],
link_libs=["algobsec"],
link_flags=["-Wl,--wrap=malloc"],
)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS")
with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.arduino8266"):
content = _write_ninja(
paths, libraries=[library, headers_only], ccache="/cc/ccache"
)
assert "build libFrameworkArduinoVariant.a: ar" in content
assert "build libMyLib.a: ar" in content
# A headers-only library contributes includes but no archive, with a
# debug log distinguishing it from a resolution failure
assert "libHeadersOnly.a" not in content
assert "Library HeadersOnly has no source files" in caplog.text
assert " flags = -DMYLIB=1" in content
assert "-lalgobsec" in content
# Library link flags reach the firmware link line; .cc compiles as C++
assert "-Wl,--wrap=malloc" in content
assert "impl.cc.o: cxx" in content
assert f"-L{_shq(str(lib_dir / 'blobs'))}" in content
# Exceptions knob: -fexceptions and the exception-enabled stdc++
assert "-fexceptions" in content
assert "-lstdc++-exc" in content
assert f"ccache = {_shq('/cc/ccache')}" in content
def test_get_flash_ld_path(tmp_path: Path) -> None:
paths = InstalledPaths(
framework=tmp_path / "framework",
toolchain=tmp_path / "toolchain",
ninja=Path("ninja"),
)
CORE.testing_mode = True
assert get_flash_ld_path(tmp_path, paths) == (
tmp_path / "ld" / "testing_eagle.flash.4m.ld"
)
CORE.testing_mode = False
# Reads the same install the ninja file linked against; no re-resolve
assert get_flash_ld_path(tmp_path, paths) == (
tmp_path / "framework" / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld"
)
def test_flash_size_str() -> None:
assert _flash_size_str(4 * 1024 * 1024) == "4M"
assert _flash_size_str(512 * 1024) == "512K"
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
def test_write_project_missing_framework_dir_raises(tmp_path: Path) -> None:
"""An incomplete framework install fails naming the missing path."""
paths = _make_framework(tmp_path)
shutil.rmtree(paths.framework / "tools" / "sdk" / "lwip2")
_set_flags()
with pytest.raises(EsphomeError, match="incomplete.*lwip2"):
_write_ninja(paths)
def test_generate_ld_scripts_testing_mode_missing_flash_ld_raises(
tmp_path: Path,
) -> None:
"""A missing flash ld in testing mode names the file and the fix."""
paths = _make_framework(tmp_path)
CORE.testing_mode = True
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with (
patch.object(arduino8266.subprocess, "run", return_value=result),
pytest.raises(EsphomeError, match="Could not read .*clean-all"),
):
_run_generate_ld_scripts(paths)
def test_build_config_nonosdk_precedence() -> None:
"""With two SDK knobs set (a pathological config), ties break
deterministically by table order."""
_set_flags(
"-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305",
"-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK221",
)
assert (
_resolve_build_config(
_flag_defines(set(), arduino8266._lexed_build_flags())
).nonosdk
== "NONOSDK221"
)
def test_write_project_build_unflags_apply_to_framework_flags(tmp_path: Path) -> None:
"""build_unflags removes flags from the framework sets, as PlatformIO does."""
paths = _make_framework(tmp_path)
_set_flags()
CORE.build_unflags = {"-fipa-pta", "-Wl,--gc-sections"}
content = _write_ninja(paths)
for line in content.splitlines():
key = line.split(" = ")[0]
if key in ("cflags", "cxxflags", "asflags"):
assert "-fipa-pta" not in line
if key == "linkflags":
assert "-Wl,--gc-sections" not in line
def test_project_flags_trailing_bare_linker_flag_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
_set_flags("-l")
compile_flags, link_flags, lib_dirs, libs = arduino8266._project_flags(
arduino8266._unflag_tokens(), arduino8266._lexed_build_flags()
)
assert "Ignoring trailing '-l'" in caplog.text
assert not libs
assert not lib_dirs
assert "-l" not in compile_flags
assert "-l" not in link_flags
def test_project_flags_lexed_entry_scatters_non_linker_tokens() -> None:
_set_flags("-L /d -Wl,-Map=m stray")
compile_flags, link_flags, lib_dirs, libs = arduino8266._project_flags(
arduino8266._unflag_tokens(), arduino8266._lexed_build_flags()
)
assert lib_dirs == [Path("/d")]
assert link_flags == ["-Wl,-Map=m"]
assert "stray" in compile_flags
assert not libs
def test_flag_defines_lexes_multi_token_entries() -> None:
"""A knob inside a multi-token entry is detected like PlatformIO does."""
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH -DFOO=1 -Os")
defines = _flag_defines(set(), arduino8266._lexed_build_flags())
assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines
assert defines["FOO"] == "FOO=1"
config = _resolve_build_config(defines)
assert config.lwip_lib == "lwip2-1460"
def test_project_flags_lexes_every_entry() -> None:
"""A linker flag anywhere in an entry reaches the link line (PIO parity)."""
_set_flags("-DFOO=1 -lbar")
compile_flags, _link, _dirs, libs = arduino8266._project_flags(
arduino8266._unflag_tokens(), arduino8266._lexed_build_flags()
)
assert libs == ["bar"]
assert "-DFOO=1" in compile_flags
def test_project_flags_unflags_match_tokens() -> None:
"""build_unflags removes a token embedded in a multi-token entry."""
_set_flags("-Os -g3")
CORE.build_unflags = {"-Os"}
compile_flags, _link, _dirs, _libs = arduino8266._project_flags(
arduino8266._unflag_tokens(), arduino8266._lexed_build_flags()
)
assert "-g3" in compile_flags
assert "-Os" not in compile_flags
def test_project_flags_requotes_lexed_defines() -> None:
"""A quoted spaced value stays one compiler argument after lex/emit."""
_set_flags('-DGREETING="hello world"')
compile_flags, _link, _dirs, _libs = arduino8266._project_flags(
arduino8266._unflag_tokens(), arduino8266._lexed_build_flags()
)
# shlex folds the quotes (as PIO's ParseFlags does); _shell_token
# re-quotes the spaced token so the shell passes one argv element
assert compile_flags == [_shq("-DGREETING=hello world")]
def test_write_project_empty_core_raises(tmp_path: Path) -> None:
"""A framework tree with no core sources fails at generation, not link."""
paths = _make_framework(tmp_path)
core = paths.framework / "cores" / "esp8266"
for f in core.iterdir():
f.unlink()
_set_flags()
with pytest.raises(EsphomeError, match="no core sources"):
_write_ninja(paths)
def test_flag_defines_joins_spaced_define() -> None:
"""A spaced "-D KNOB" entry is detected exactly as PlatformIO detects it."""
_set_flags("-D PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
defines = _flag_defines(set(), arduino8266._lexed_build_flags())
assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines
assert "" not in defines
def test_ninja_path_escaping() -> None:
"""Build-statement paths and command-line paths escape differently."""
assert arduino8266._e("a b:$c") == "a$ b$:$$c"
assert arduino8266._q("/a b/$x") == _shq("/a b/$$x")
def test_write_project_asm_excludes_non_define_user_flags(tmp_path: Path) -> None:
"""The ASPPCOM command under PlatformIO never sees CCFLAGS, so only -D/-I user flags
reach assembly compiles."""
paths = _make_framework(tmp_path)
_set_flags("-DUSER_KNOB=1", "-Wno-volatile")
content = _write_ninja(paths)
asflags = next(line for line in content.splitlines() if line.startswith("asflags"))
assert "-DUSER_KNOB=1" in asflags
assert "-Wno-volatile" not in asflags
cxxflags = next(
line for line in content.splitlines() if line.startswith("cxxflags")
)
assert "-Wno-volatile" in cxxflags
def test_write_project_returns_changed(tmp_path: Path) -> None:
"""The documented contract: True when build.ninja changed, False on an
identical regeneration (pins byte-stable output too)."""
paths = _make_framework(tmp_path)
_set_flags()
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.arduino.library.resolve_libraries", return_value=[]),
patch("esphome.arduino8266.framework.ccache_path", return_value=None),
):
assert arduino8266.write_project(paths, None) is True
assert arduino8266.write_project(paths, None) is False
def test_write_project_missing_elf2bin_raises(tmp_path: Path) -> None:
"""A half-extracted package must fail by name at generation, not after
the full compile at the elf2bin edge."""
paths = _make_framework(tmp_path)
(paths.framework / "tools" / "elf2bin.py").unlink()
_set_flags()
src = CORE.relative_src_path()
(src / "main.cpp").parent.mkdir(parents=True, exist_ok=True)
(src / "main.cpp").write_text("")
with (
patch.object(arduino8266, "generate_ld_scripts"),
patch("esphome.arduino.library.resolve_libraries", return_value=[]),
pytest.raises(EsphomeError, match="elf2bin"),
):
arduino8266.write_project(paths, None)
def test_write_project_missing_src_dir_raises(tmp_path: Path) -> None:
"""A missing generated source tree is its own error, not an install one."""
paths = _make_framework(tmp_path)
_set_flags()
with (
patch.object(arduino8266, "generate_ld_scripts"),
patch("esphome.arduino.library.resolve_libraries", return_value=[]),
patch.object(
arduino8266.CORE, "relative_src_path", return_value=tmp_path / "nope"
),
pytest.raises(EsphomeError, match="source directory"),
):
arduino8266.write_project(paths, None)
def test_build_config_custom_mmu_without_knob_raises() -> None:
"""Custom MMU sizes without the CUSTOM knob are refused."""
with pytest.raises(EsphomeError, match="PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"):
_resolve("-DMMU_IRAM_SIZE=0xC000")
def test_flag_defines_lexes_quoted_single_tokens() -> None:
"""A quoted single-token define reads the same as on the compile line."""
_set_flags('-DMMU_SEC_HEAP="0x40108000"')
assert (
_flag_defines(set(), arduino8266._lexed_build_flags())["MMU_SEC_HEAP"]
== "MMU_SEC_HEAP=0x40108000"
)
def test_flag_defines_duplicate_defines_resolve_deterministically() -> None:
"""Duplicate conflicting defines pick the same winner every run (sorted
iteration, last writer wins), independent of the set's hash seed."""
_set_flags("-DMMU_IRAM_SIZE=0x8000", "-DMMU_IRAM_SIZE=0xC000")
assert (
_flag_defines(set(), arduino8266._lexed_build_flags())["MMU_IRAM_SIZE"]
== "MMU_IRAM_SIZE=0xC000"
)
def test_flag_tables_match_platformio_builder() -> None:
"""The transliterated flag lists pinned verbatim, like the define set:
a drift lands as a test failure, not a binary-size regression."""
assert arduino8266._ASFLAGS == ["-mlongcalls", "-mtext-section-literals"]
assert arduino8266._CFLAGS == [
"-std=gnu17",
"-Wpointer-arith",
"-Wno-implicit-function-declaration",
"-Wl,-EL",
"-fno-inline-functions",
"-nostdlib",
]
assert arduino8266._CCFLAGS == [
"-Os",
"-mlongcalls",
"-mtext-section-literals",
"-falign-functions=4",
"-U__STRICT_ANSI__",
"-ffunction-sections",
"-fdata-sections",
"-Wall",
"-Werror=return-type",
"-free",
"-fipa-pta",
]
# Pins the deliberate -u _scanf_float omission
assert arduino8266._LINKFLAGS == [
"-Os",
"-nostdlib",
"-Wl,--no-check-sections",
"-Wl,-static",
"-Wl,--gc-sections",
"-Wl,-wrap,system_restart_local",
"-Wl,-wrap,spi_flash_read",
"-u",
"app_entry",
"-u",
"_printf_float",
"-u",
"_DebugExceptionVector",
"-u",
"_DoubleExceptionVector",
"-u",
"_KernelExceptionVector",
"-u",
"_NMIExceptionVector",
"-u",
"_UserExceptionVector",
]
# Order is load-bearing: upstream's LIBS order resolves symbols correctly
assert arduino8266._SYSTEM_LIBS_PRE_LWIP == ["hal", "phy", "pp", "net80211"]
assert arduino8266._SYSTEM_LIBS_POST_LWIP == [
"wpa",
"crypto",
"main",
"wps",
"bearssl",
"espnow",
"smartconfig",
"airkiss",
"wpa2",
]
def test_generate_ld_scripts_missing_compiler_is_clean(tmp_path: Path) -> None:
"""A half-deleted toolchain cache fails with an ESPHome error naming the
binary, not a FileNotFoundError traceback."""
paths = _make_framework(tmp_path)
_set_flags()
with pytest.raises(EsphomeError, match="Could not run"):
_run_generate_ld_scripts(paths)
def test_write_project_asm_keeps_quoted_defines(tmp_path: Path) -> None:
"""A spaced -D/-I user flag arrives shell-quoted; assembly must still
receive it."""
paths = _make_framework(tmp_path)
_set_flags('-DGREETING="hello world"', "-Wno-volatile")
content = _write_ninja(paths)
asflags = next(line for line in content.splitlines() if line.startswith("asflags"))
assert _shq("-DGREETING=hello world") in asflags
assert "-Wno-volatile" not in asflags
def test_write_project_unarchived_library_links_objects(tmp_path: Path) -> None:
"""A libArchive:false library's objects reach the link directly."""
paths = _make_framework(tmp_path)
lib_src = tmp_path / "gdb" / "src"
lib_src.mkdir(parents=True)
(lib_src / "GDBStub.cpp").write_text("")
_set_flags()
lib = ArduinoLibrary(
name="GDBStub",
sources=[lib_src / "GDBStub.cpp"],
include_dirs=[lib_src],
lib_archive=False,
)
content = _write_ninja(paths, libraries=[lib])
assert "libGDBStub.a" not in content
link_line = next(
line for line in content.splitlines() if line.startswith("build firmware.elf")
)
assert "GDBStub.cpp.o" in link_line
def test_write_project_unknown_board_fails_by_name(tmp_path: Path) -> None:
"""A caller bypassing config validation gets the board named, not a
KeyError."""
paths = _make_framework(tmp_path)
_set_flags()
CORE.data[KEY_ESP8266][KEY_BOARD] = "not_a_board"
with pytest.raises(EsphomeError, match="'not_a_board' is not supported"):
_write_ninja(paths)
def test_unflag_tokens_join_spaced_entries() -> None:
"""Spaced build_unflags entries ("-D FOO") match the joined token."""
CORE.build_unflags = {"-D FOO", "-l bar"}
tokens = arduino8266._unflag_tokens()
assert tokens == {"-DFOO", "-lbar"}
CORE.build_flags = {"-DFOO -lbar", "-DBAR"}
compile_flags, _link, _dirs, libs = arduino8266._project_flags(
tokens, arduino8266._lexed_build_flags()
)
assert compile_flags == ["-DBAR"]
assert libs == []
def test_flag_defines_respects_unflags() -> None:
"""An unflagged knob must not drive the derived toolchain config."""
_set_flags("-DVTABLES_IN_DRAM")
defines = _flag_defines({"-DVTABLES_IN_DRAM"}, arduino8266._lexed_build_flags())
assert "VTABLES_IN_DRAM" not in defines
config = _resolve_build_config(defines)
assert config.vtables == "VTABLES_IN_FLASH"
def test_vtables_unknown_raises() -> None:
"""An unknown VTABLES_IN_* knob fails by name."""
with pytest.raises(EsphomeError, match="Unknown VTABLES_IN_.*BANANA"):
_resolve("-DVTABLES_IN_BANANA")
def test_vtables_conflicting_raises() -> None:
with pytest.raises(EsphomeError, match="Conflicting VTABLES_IN_"):
_resolve("-DVTABLES_IN_DRAM", "-DVTABLES_IN_IRAM")
def test_project_flags_empty_lib_flags_warn(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A bare -L must not silently add the CWD to the search path."""
CORE.build_flags = {'-L ""', '-l ""'}
_c, _l, lib_dirs, libs = arduino8266._project_flags(
set(), arduino8266._lexed_build_flags()
)
assert lib_dirs == []
assert libs == []
assert "Ignoring empty -L" in caplog.text
assert "Ignoring empty -l" in caplog.text
def test_generate_ld_scripts_surfaces_preprocessor_warnings(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Preprocessor stderr on a zero exit reaches the user; degenerate output is refused."""
paths = _make_framework(tmp_path)
_set_flags()
result = MagicMock(
returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="warning: something"
)
with patch.object(arduino8266.subprocess, "run", return_value=result):
_run_generate_ld_scripts(paths)
assert "Linker-script preprocessor: warning: something" in caplog.text
# New flags invalidate the stamp so the degenerate run regenerates
_set_flags("-DVTABLES_IN_DRAM")
result = MagicMock(returncode=0, stdout="", stderr="")
with (
patch.object(arduino8266.subprocess, "run", return_value=result),
pytest.raises(EsphomeError, match="SECTIONS"),
):
_run_generate_ld_scripts(paths)
def test_build_config_mmu_knob_with_raw_mmu_flag_raises() -> None:
"""A variant knob plus a raw MMU_* define would split the compile line
from the linker script; refuse like the no-knob case."""
with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE conflict with .*CACHE16"):
_resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", "-DMMU_IRAM_SIZE=0x4000")
def test_build_config_raw_lwip_define_raises() -> None:
"""TCP_MSS/LWIP_* belong to the lwIP knobs: a raw value would win the
compile line while the prebuilt library stays the knob's."""
with pytest.raises(EsphomeError, match="TCP_MSS are set by the .*LWIP2"):
_resolve("-DTCP_MSS=1024")
def test_build_config_mmu_defines_do_not_alias_the_table() -> None:
"""The resolved list must be a copy; mutating it must not corrupt the
module table for later builds in the same process."""
config = _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48")
config.mmu_defines.append("MMU_BOGUS")
again = _resolve_build_config(
_flag_defines(set(), arduino8266._lexed_build_flags())
)
assert "MMU_BOGUS" not in again.mmu_defines
assert all(isinstance(v, tuple) for _k, v in arduino8266._MMU_VARIANTS)
def test_lexed_build_flags_shared_between_consumers(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Lexing once and passing the tokens to both consumers yields the same
result as each lexing itself, with a malformed entry warned once."""
_set_flags("-DFOO=1 -l", "-Wl,--wrap=x")
tokens = arduino8266._lexed_build_flags()
assert caplog.text.count("Ignoring trailing '-l'") == 1
assert _flag_defines(set(), tokens) == _flag_defines(
set(), arduino8266._lexed_build_flags()
)
assert arduino8266._project_flags(set(), tokens) == arduino8266._project_flags(
set(), arduino8266._lexed_build_flags()
)
@pytest.mark.parametrize(
"tok", ["-Tcustom.ld", "-Xlinker", "-u", "-e", "-s", "-static", "-nostartfiles"]
)
def test_project_flags_rejects_plain_linker_forms(tok: str) -> None:
"""Plain-form linker flags are refused, naming the -Wl, form."""
_set_flags(tok)
with pytest.raises(EsphomeError, match="use the -Wl, form"):
arduino8266._project_flags(set(), arduino8266._lexed_build_flags())
def test_project_flags_plain_compile_flags_pass() -> None:
_set_flags("-Os")
compile_flags, _l, _d, _libs = arduino8266._project_flags(
set(), arduino8266._lexed_build_flags()
)
assert "-Os" in compile_flags
def test_generate_ld_scripts_header_change_invalidates_stamp(
tmp_path: Path,
) -> None:
"""An in-place framework edit at the same path regenerates the script."""
paths = _make_framework(tmp_path)
header = paths.framework / "tools" / "sdk" / "ld" / "eagle.app.v6.common.ld.h"
header.write_text("v1")
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with patch.object(arduino8266.subprocess, "run", return_value=result):
_run_generate_ld_scripts(paths)
header.write_text("v2 (longer)")
with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run:
_run_generate_ld_scripts(paths)
mock_run.assert_called_once()
def test_generate_ld_scripts_unreadable_stamp_regenerates(tmp_path: Path) -> None:
"""A non-UTF-8 stamp is a damaged cache: regenerate, never abort."""
paths = _make_framework(tmp_path)
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with patch.object(arduino8266.subprocess, "run", return_value=result):
ld_dir = _run_generate_ld_scripts(paths)
(ld_dir / ".local.eagle.app.v6.common.ld.stamp").write_bytes(b"\xff\xfe")
with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run:
_run_generate_ld_scripts(paths)
mock_run.assert_called_once()
def test_vtables_valued_define_raises() -> None:
"""A VTABLES_IN_* body would split the compile line from the linker
script, which always defines the bare name."""
with pytest.raises(EsphomeError, match="take no value.*VTABLES_IN_FLASH=0"):
_resolve("-DVTABLES_IN_FLASH=0")
def test_defines_flags_invalid_board_raises() -> None:
"""The board name lands unquoted in two -D bodies; reject it by name."""
with pytest.raises(EsphomeError, match="Invalid board name"):
_defines_flags(_resolve(), "dout", "evil board", ())
def test_generate_ld_scripts_invalid_flash_ld_name_raises(tmp_path: Path) -> None:
"""The script name joins under the SDK and build ld dirs; a traversal
or path is rejected by name."""
paths = _make_framework(tmp_path)
_set_flags()
config = _resolve()
with pytest.raises(EsphomeError, match="Invalid flash linker script name"):
arduino8266.generate_ld_scripts(paths, config, "../evil.ld")
def test_write_generated_replaces_damaged_file(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-UTF-8 existing copy is logged and overwritten; the write path
still raises for real failures."""
target = tmp_path / "gen.ld"
target.write_bytes(b"\xff\xfe")
arduino8266._write_generated(target, "SECTIONS { }")
assert target.read_text(encoding="utf-8") == "SECTIONS { }"
assert "Replacing damaged generated file" in caplog.text
def test_generate_ld_scripts_edited_output_regenerates(tmp_path: Path) -> None:
"""The stamp records the content hash, so an externally edited cached
script regenerates instead of linking untrusted content."""
paths = _make_framework(tmp_path)
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with patch.object(arduino8266.subprocess, "run", return_value=result):
ld_dir = _run_generate_ld_scripts(paths)
output = ld_dir / "local.eagle.app.v6.common.ld"
output.write_text(output.read_text() + "\n/* tampered */\n")
with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run:
_run_generate_ld_scripts(paths)
mock_run.assert_called_once()
assert "tampered" not in output.read_text()
def test_generate_ld_scripts_corrupt_output_is_overwritten(tmp_path: Path) -> None:
"""A non-UTF-8 cached script must be overwritten by the regeneration,
not abort it (write_file_if_changed reads the old content)."""
paths = _make_framework(tmp_path)
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with patch.object(arduino8266.subprocess, "run", return_value=result):
ld_dir = _run_generate_ld_scripts(paths)
output = ld_dir / "local.eagle.app.v6.common.ld"
output.write_bytes(b"\xff\xfe")
with patch.object(arduino8266.subprocess, "run", return_value=result):
_run_generate_ld_scripts(paths)
assert "SECTIONS" in output.read_text(encoding="utf-8")
def test_generate_ld_scripts_unreadable_note_still_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A cached diagnostic that cannot be read must not vanish silently."""
paths = _make_framework(tmp_path)
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="warn!")
with patch.object(arduino8266.subprocess, "run", return_value=result):
ld_dir = _run_generate_ld_scripts(paths)
(ld_dir / ".local.eagle.app.v6.common.ld.stderr").write_bytes(b"\xff\xfe")
with patch.object(arduino8266.subprocess, "run", return_value=result):
_run_generate_ld_scripts(paths)
assert "could not be read" in caplog.text
def test_pio_option_blank_value_raises() -> None:
"""An empty or blank platformio_options value is a config error, not a
silent fallback to the default."""
CORE.platformio_options = {"board_build.f_cpu": " "}
with pytest.raises(EsphomeError, match="board_build.f_cpu is empty"):
arduino8266._pio_option("board_build.f_cpu", "80000000L")
CORE.platformio_options = {"board_build.f_cpu": []}
with pytest.raises(EsphomeError, match="board_build.f_cpu is empty"):
arduino8266._pio_option("board_build.f_cpu", "80000000L")
def test_defines_flags_invalid_f_cpu_raises() -> None:
"""A non-numeric board_build.f_cpu is rejected by name; it would land
unquoted on the compile line."""
CORE.platformio_options = {"board_build.f_cpu": "160 MHz"}
with pytest.raises(EsphomeError, match="Invalid board_build.f_cpu"):
_defines_flags(
_resolve(),
"dout",
"nodemcuv2",
ESP8266_BOARD_BUILD["nodemcuv2"]["defines"],
)
def test_generate_ld_scripts_surgery_failure_is_named(tmp_path: Path) -> None:
"""A moved rate-table anchor surfaces as a build error, not a traceback
or a silently unrelocated table."""
paths = _make_framework(tmp_path)
result = MagicMock(returncode=0, stdout="SECTIONS { no anchor here }", stderr="")
with (
patch.object(arduino8266.subprocess, "run", return_value=result),
pytest.raises(EsphomeError, match="anchor not found"),
):
_run_generate_ld_scripts(paths)
def test_write_project_unmatched_unflag_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unflag that removes nothing is named; a matching one is silent."""
paths = _make_framework(tmp_path)
_set_flags("-DUSE_FOO=1")
CORE.build_unflags = {"-DUSE_FOO", "-Os"}
content = _write_ninja(paths)
assert "matched no build flag: -DUSE_FOO" in caplog.text
assert "-Os" not in caplog.text.split("matched no build flag")[-1].splitlines()[0]
# The matching -Os unflag really removed the framework flag
cflags = next(line for line in content.splitlines() if line.startswith("cflags"))
assert " -Os " not in cflags
def test_write_project_lexes_build_flags_once(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A malformed build_flags entry warns once per generation."""
paths = _make_framework(tmp_path)
_set_flags("-DFOO=1 -l")
_write_ninja(paths)
assert caplog.text.count("Ignoring trailing '-l'") == 1
def test_build_config_mmu_conflict_names_the_variant_knob_with_custom() -> None:
"""With MMU_CUSTOM also set, the actionable fix is dropping the variant
knob, not setting the knob the user already set."""
with pytest.raises(EsphomeError, match="drop PIO_FRAMEWORK_ARDUINO_MMU_CACHE16"):
_resolve(
"-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48",
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM",
"-DMMU_IRAM_SIZE=0xC000",
"-DMMU_ICACHE_SIZE=0x4000",
)
def test_generate_ld_scripts_testing_surgery_failure_is_named(
tmp_path: Path,
) -> None:
"""A testing-mode segment patch failing on a changed linker script is a
named error, like the ratetable surgery."""
paths = _make_framework(tmp_path)
CORE.testing_mode = True
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with (
patch.object(arduino8266.subprocess, "run", return_value=result),
patch.object(
arduino8266.build_surgery,
"apply_testing_memory_patches",
side_effect=RuntimeError("iram1_0_seg not found"),
),
pytest.raises(EsphomeError, match="iram1_0_seg not found"),
):
_run_generate_ld_scripts(paths)
def test_generate_ld_scripts_testing_flash_ld_surgery_failure_is_named(
tmp_path: Path,
) -> None:
"""The flash-ld segment patch gets the same named-error wrap."""
paths = _make_framework(tmp_path)
(paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text(
"MEMORY { }"
)
CORE.testing_mode = True
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with (
patch.object(arduino8266.subprocess, "run", return_value=result),
patch.object(
arduino8266.build_surgery,
"apply_testing_memory_patches",
side_effect=["patched common", RuntimeError("dram0_0_seg mismatch")],
),
pytest.raises(EsphomeError, match="dram0_0_seg mismatch"),
):
_run_generate_ld_scripts(paths)
def test_generate_ld_scripts_reemits_cached_preprocessor_warning(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A preprocessor diagnostic survives cache hits instead of appearing
once and vanishing for the life of the build dir."""
paths = _make_framework(tmp_path)
result = MagicMock(
returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="warning: something odd"
)
with patch.object(arduino8266.subprocess, "run", return_value=result):
_run_generate_ld_scripts(paths)
assert caplog.text.count("warning: something odd") == 1
with patch.object(arduino8266.subprocess, "run") as mock_run:
_run_generate_ld_scripts(paths)
mock_run.assert_not_called()
assert caplog.text.count("warning: something odd") == 2
def test_generate_ld_scripts_unreadable_header_forces_regeneration(
tmp_path: Path,
) -> None:
"""A stat failure other than absence must miss the cache every run, not
pin the stamp to a constant that can never notice a later edit."""
paths = _make_framework(tmp_path)
header_name = "eagle.app.v6.common.ld.h"
(paths.framework / "tools" / "sdk" / "ld" / header_name).write_text("v1")
real_stat = Path.stat
def fake_stat(self: Path, **kwargs: object):
if self.name == header_name:
raise PermissionError(13, "denied")
return real_stat(self, **kwargs)
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with patch.object(Path, "stat", fake_stat):
with patch.object(
arduino8266.subprocess, "run", return_value=result
) as mock_run:
_run_generate_ld_scripts(paths)
mock_run.assert_called_once()
with patch.object(
arduino8266.subprocess, "run", return_value=result
) as mock_run:
_run_generate_ld_scripts(paths)
mock_run.assert_called_once()
def test_board_tables_are_equal() -> None:
"""BOARDS and ESP8266_BOARD_BUILD must stay exactly in sync."""
assert set(BOARDS) == set(ESP8266_BOARD_BUILD)
def test_project_flags_warns_on_bare_include_and_define(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An empty-argument -I or -D must not reach gcc, which would eat the
next flag as the argument."""
CORE.build_flags = {'-I ""', '-D ""'}
compile_flags, _l, _d, _libs = arduino8266._project_flags(
set(), arduino8266._lexed_build_flags()
)
assert "-I" not in compile_flags
assert "-D" not in compile_flags
assert "Ignoring empty -I in build_flags" in caplog.text
assert "Ignoring empty -D in build_flags" in caplog.text
def test_generate_ld_scripts_gcc_change_invalidates_stamp(tmp_path: Path) -> None:
"""An in-place toolchain re-extraction regenerates the script, same as
the header stat."""
paths = _make_framework(tmp_path)
gcc = toolchain_tool(paths.toolchain, "gcc")
gcc.write_text("v1")
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="")
with patch.object(arduino8266.subprocess, "run", return_value=result):
_run_generate_ld_scripts(paths)
gcc.write_text("v2 (longer)")
with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run:
_run_generate_ld_scripts(paths)
mock_run.assert_called_once()
def test_defines_flags_honors_f_cpu_override() -> None:
"""board_build.f_cpu (a published-config overclock knob) reaches the
compile line; the default stays 80 MHz."""
_set_flags()
config = _resolve_build_config(_flag_defines(set(), []))
board_build = ESP8266_BOARD_BUILD["nodemcuv2"]
defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"])
assert "-DF_CPU=80000000L" in defines
CORE.platformio_options = {"board_build.f_cpu": "160000000L"}
defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"])
assert "-DF_CPU=160000000L" in defines
# A repeated option accumulates as a list; the last value wins
CORE.platformio_options = {"board_build.f_cpu": ["80000000L", "160000000L"]}
defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"])
assert "-DF_CPU=160000000L" in defines
def test_flash_ld_name_honors_ldscript_override(tmp_path: Path) -> None:
"""board_build.ldscript (filesystem reservation, corrected flash size)
replaces the board default; a path is rejected since the name resolves
via the -L search path."""
assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld"
CORE.platformio_options = {"board_build.ldscript": "eagle.flash.4m2m.ld"}
assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m2m.ld"
paths = _make_framework(tmp_path)
_set_flags()
content = _write_ninja(paths)
assert "-T eagle.flash.4m2m.ld" in content
CORE.platformio_options = {"board_build.ldscript": "../evil.ld"}
with pytest.raises(EsphomeError, match="bare script name"):
arduino8266._flash_ld_name("nodemcuv2")
def test_write_project_quotes_spaced_ldscript_override(tmp_path: Path) -> None:
"""An overridden script name re-quotes on the link line like every other
user token (a space would otherwise split into two argv elements)."""
CORE.platformio_options = {"board_build.ldscript": "my script.ld"}
paths = _make_framework(tmp_path)
_set_flags()
content = _write_ninja(paths)
assert "'my script.ld'" in content or '"my script.ld"' in content
assert "-T my script.ld" not in content