Files

1935 lines
73 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,
_elf2bin_flash_size,
_flag_defines,
_flash_size_str,
_resolve_build_config,
get_flash_ld_path,
)
from esphome.components.esp8266.boards import (
BOARDS,
ESP8266_BOARD_BUILD,
KEY_FLASH_SIZE,
board_ld_script,
)
from esphome.components.esp8266.build_surgery import RATETABLE_RULE
from esphome.components.esp8266.const import (
KEY_BOARD,
KEY_ESP8266,
KEY_SCANF_FLOAT,
THROW_STUBS_HEADER,
)
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_SCANF_FLOAT: False,
}
# The producers esp8266/__init__ pins unconditionally
CORE.platformio_options = {
"board_build.flash_mode": "dout",
"build_src_flags": "-include esphome/components/esp8266/throw_stubs.h",
}
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) -> arduino8266._BuildConfig:
"""Set the build flags and resolve the knob config in one step."""
_set_flags(*flags)
return _resolve_current()
def _defines() -> dict[str, str]:
"""The -D map for the current build flags."""
return _flag_defines(set(), arduino8266._lexed_build_flags())
def _resolve_current() -> arduino8266._BuildConfig:
"""Resolve whatever flags are already set (must not clear them)."""
return _resolve_build_config(_defines())
def _split_flags() -> tuple[list[str], list[str], list[Path], list[str]]:
"""Classify the current build flags the way write_project does."""
return arduino8266._project_flags(
arduino8266._unflag_tokens(), arduino8266._lexed_build_flags()
)
def _ok_result(
stdout: str | bytes | None = None, stderr: str | bytes = ""
) -> MagicMock:
"""A successful preprocessor spawn (defaults to the common ld output).
Streams are bytes, as the un-decoded subprocess.run delivers them.
"""
stdout = _COMMON_LD_H_OUTPUT if stdout is None else stdout
return MagicMock(
returncode=0,
stdout=stdout.encode() if isinstance(stdout, str) else stdout,
stderr=stderr.encode() if isinstance(stderr, str) else stderr,
)
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_current()
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_current()
# 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_current(),
"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_rejects_bad_flash_mode(tmp_path: Path) -> None:
"""A flash mode outside the closed set fails by name before landing
unquoted in the elf2bin command."""
paths = _make_framework(tmp_path)
CORE.platformio_options["board_build.flash_mode"] = "dout; rm -rf /"
with pytest.raises(EsphomeError, match="Invalid flash mode"):
_write_ninja(paths)
def test_write_project_flash_mode_reaches_define_and_elf2bin(
tmp_path: Path,
) -> None:
"""A non-default board_flash_mode lands in both the compile define and
the elf2bin image header, never silently falling back to dout."""
paths = _make_framework(tmp_path)
CORE.platformio_options["board_build.flash_mode"] = "dio"
content = _write_ninja(paths)
assert "-DFLASHMODE_DIO" in content
assert "--flash_mode dio" in content
assert "FLASHMODE_DOUT" not in content
def test_write_project_trailing_include_raises(tmp_path: Path) -> None:
"""A dangling -include must fail by name, not become -include <src_dir>."""
paths = _make_framework(tmp_path)
CORE.platformio_options["build_src_flags"] = "-include"
with pytest.raises(EsphomeError, match="trailing '-include'"):
_write_ninja(paths)
def test_write_project_passes_other_src_flags_through(tmp_path: Path) -> None:
"""Non-include build_src_flags tokens are requoted onto the src edges."""
paths = _make_framework(tmp_path)
CORE.platformio_options["build_src_flags"] = (
"-include esphome/components/esp8266/throw_stubs.h -DSRC_ONLY=1"
)
content = _write_ninja(paths)
assert "throw_stubs.h" in content
assert "-DSRC_ONLY=1" in content
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: aspp" in content
assert "abi.c.o: c" in content
# throw_stubs reaches src through srcflags (after the pch include) and the
# core through frameworkflags: one shared variable each, not a copy 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") == 2
assert "srcflags = -include" in content
flags_lines = [
line for line in content.splitlines() if line.startswith(" flags = ")
]
assert flags_lines
# C++ src edges consume the precompiled header; C/assembly keep srcflags
assert set(flags_lines) == {
" flags = $srcflags",
" flags = $srccxxflags",
" flags = $frameworkflags",
}
def test_write_project_pch(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
content = _write_ninja(paths, ccache="/usr/bin/ccache")
build_dir = CORE.relative_pioenvs_path(CORE.name)
assert "rule pch" in content
# Compiled from the include list, and again when the checksum changes
gch_edge = next(
line
for line in content.splitlines()
if line.startswith("build esphome_pch.h.gch: pch ")
)
assert gch_edge.endswith("esphome_pch_src.h | esphome_pch.h.gch.sum")
for line in content.splitlines():
# C++ edges wait on the .gch; the C edge must not reference it
if line.startswith("build obj/src/main.cpp.o:"):
assert line.endswith("| esphome_pch.h.gch")
if line.startswith("build obj/src/esphome/vendor.c.o:"):
assert "esphome_pch" not in line
assert (build_dir / "esphome_pch_src.h").read_text().splitlines() == [
'#include "esphome/components/esp8266/throw_stubs.h"',
'#include "esphome/core/pch_prefix.h"',
]
assert "#error" in (build_dir / "esphome_pch.h").read_text()
assert (build_dir / "esphome_pch.h.gch.sum").read_text().strip()
def test_write_project_pch_folds_joined_src_force_include(
tmp_path: Path,
) -> None:
"""-includefoo.h in build_src_flags must fold into the pch like the
separated spelling, not precede and defeat it."""
paths = _make_framework(tmp_path)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
CORE.platformio_options["build_src_flags"] = "-includeesphome/core/defines.h"
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert "build esphome_pch.h.gch: pch" in content
assert "srccxxflags" in content
assert "-includeesphome" not in content
@pytest.mark.parametrize("flag", ["-includefoo.h", "--include=foo.h"])
def test_write_project_pch_skipped_for_other_force_include_spellings(
tmp_path: Path, caplog: pytest.LogCaptureFixture, flag: str
) -> None:
paths = _make_framework(tmp_path)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", flag)
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert "esphome_pch" not in content
assert "prevents the precompiled header" in caplog.text
def test_write_project_pch_skipped_when_user_force_include_precedes(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A -include in build_flags lands ahead of the pch include, so GCC
would never load the .gch; skip it and say so."""
paths = _make_framework(tmp_path)
_set_flags(
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", "-include foo.h"
)
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert "esphome_pch" not in content
assert "srccxxflags" not in content
assert "prevents the precompiled header" in caplog.text
def test_write_project_pch_disabled(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
paths = _make_framework(tmp_path)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
content = _write_ninja(paths)
assert "esphome_pch" not in content
assert "srccxxflags" not in content
assert " flags = $srcflags" in content
def test_write_project_pch_asks_the_toolchain_compiler_on_windows(
windows_gcc_rule: None, tmp_path: Path
) -> None:
from esphome.build_helpers import pch
paths = _make_framework(tmp_path)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
with patch.object(pch, "gcc_version", return_value=(10, 3, 0)) as asked:
content = _write_ninja(paths)
assert asked.call_args.args[0] == (toolchain_tool(paths.toolchain, "g++"),)
assert "esphome_pch" not in content
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),
# LOW_MEMORY has no upstream branch: it is the default (else) variant
("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY", "lwip2-536-feat", 536, 1, 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
def test_lwip_low_memory_loses_to_listed_knobs() -> None:
"""The ordinary SNTP multi-server config: sntp emits LOW_MEMORY, esp8266
always emits HIGHER_BANDWIDTH_LOW_FLASH, and the listed knob must win
exactly as in platformio-build.py's elif chain."""
config = _resolve(
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY",
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH",
)
assert config.lwip_lib == "lwip2-1460"
def test_lwip_ipv6_wins_over_default_knob() -> None:
"""The shipping IPv6 config: network emits IPV6_LOW_MEMORY, esp8266
always emits HIGHER_BANDWIDTH_LOW_FLASH; IPv6 must win exactly as in
platformio-build.py's elif chain."""
config = _resolve(
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY",
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH",
)
assert config.lwip_lib == "lwip6-536-feat"
assert "LWIP_IPV6=1" in config.knob_defines
def test_unrecognized_compile_flag_shape_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A link-only spelling missing from the deny lists still reaches the
compile line, but no longer silently."""
_set_flags("-shared")
compile_flags, _link_flags, _lib_dirs, _libs = _split_flags()
assert "-shared is not a recognized compile-flag shape" in caplog.text
assert "-shared" in compile_flags
@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(_defines()).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_current()
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 = _ok_result()
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 = _ok_result()
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=b"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 = _ok_result()
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_helpers.ninja_gen"):
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
# With exceptions on, nothing gets the stubs
assert "throw_stubs.h" not in content
# A library's own include dirs lead its compile lines
assert " own_includes = -I" in content
assert "$own_includes $cxxflags $flags" 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_write_project_throw_stubs_reach_core_and_libraries(tmp_path: Path) -> None:
"""Without exceptions the core and libraries take the throw stubs too."""
paths = _make_framework(tmp_path)
lib_dir = tmp_path / "libsrc"
lib_dir.mkdir()
(lib_dir / "lib.cpp").write_text("")
library = ArduinoLibrary(
name="MyLib",
sources=[lib_dir / "lib.cpp"],
include_dirs=[lib_dir],
flags=["-DMYLIB=1"],
)
content = _write_ninja(paths, libraries=[library])
stubs = CORE.relative_src_path() / THROW_STUBS_HEADER
assert f"frameworkflags = -include {_shq(str(stubs))}" in content
# Before the library's own flags; core edges take the variable alone
assert " flags = $frameworkflags -DMYLIB=1" in content
lines = content.splitlines()
core_edge = next(
i for i, line in enumerate(lines) if "core_esp8266_main.cpp.o: cxx" in line
)
assert lines[core_edge + 1] == " flags = $frameworkflags"
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_elf2bin_flash_size() -> None:
"""The image-header size follows the ldscript filename like PlatformIO,
falling back to board_upload.maximum_size and then the board table."""
assert _elf2bin_flash_size("esp8285", "eagle.flash.2m.ld") == "2M"
assert _elf2bin_flash_size("esp01", "eagle.flash.512k.ld") == "512K"
assert _elf2bin_flash_size("nodemcuv2", "eagle.flash.4m1m.ld") == "4M"
# The testing-mode prefix still matches (search, not match)
assert _elf2bin_flash_size("esp8285", "testing_eagle.flash.2m.ld") == "2M"
# Custom ldscript name: board_upload.maximum_size wins over the board
CORE.platformio_options["board_upload.maximum_size"] = "2097152"
assert _elf2bin_flash_size("esp8285", "custom.ld") == "2M"
del CORE.platformio_options["board_upload.maximum_size"]
assert _elf2bin_flash_size("esp8285", "custom.ld") == "1M"
@pytest.mark.parametrize("bad", ["2MB", "3145728", "-1"])
def test_elf2bin_flash_size_rejects_bad_maximum_size(bad: str) -> None:
"""A non-numeric or unsupported board_upload.maximum_size fails by name
instead of a ValueError or a late elf2bin choices error."""
CORE.platformio_options["board_upload.maximum_size"] = bad
with pytest.raises(EsphomeError, match="board_upload.maximum_size"):
_elf2bin_flash_size("esp8285", "custom.ld")
def test_elf2bin_flash_size_default_matches_board_table() -> None:
"""Without an ldscript override, every board's own ldscript parses to
the board-table size, so the emitted --flash_size is unchanged."""
for board, entry in BOARDS.items():
assert _elf2bin_flash_size(board, board_ld_script(entry)) == _flash_size_str(
entry[KEY_FLASH_SIZE]
), board
def test_write_project_flash_size_follows_ldscript_override(
tmp_path: Path,
) -> None:
"""An ldscript overriding the board's flash size drives the image header
too (the Athom shape: esp8285 with eagle.flash.2m.ld). A 1M header over
a 2M layout clamps the chip below the OTA scratch area and bricks OTA."""
paths = _make_framework(tmp_path)
(paths.framework / "variants" / "esp8285").mkdir()
CORE.data[KEY_ESP8266][KEY_BOARD] = "esp8285"
CORE.platformio_options["board_build.ldscript"] = "eagle.flash.2m.ld"
content = _write_ninja(paths)
assert "--flash_size 2M" in content
assert "eagle.flash.2m.ld" in content
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 = _ok_result()
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(_defines()).nonosdk == "NONOSDK221"
def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None:
"""A lowercase .s source assembles plain (SCons AS), never through the
preprocessor rule that a .S source gets."""
paths = _make_framework(tmp_path)
core_dir = paths.framework / "cores" / "esp8266"
(core_dir / "lowlevel.s").write_text("nop\n")
_set_flags()
content = _write_ninja(paths)
assert "lowlevel.s.o: asm " in content
assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in (
content
)
def test_write_project_unflags_operandless_linker_flag(tmp_path: Path) -> None:
"""build_unflags: -nostdlib filters whole-token from both lines, as
PlatformIO allows; only operand-taking flags hard-error."""
paths = _make_framework(tmp_path)
_set_flags()
CORE.build_unflags = {"-nostdlib"}
content = _write_ninja(paths)
assert "-nostdlib" not in content
def test_write_project_unflagged_symbol_takes_its_dash_u(tmp_path: Path) -> None:
"""Unflagging a -u symbol drops the -u that carried it; a dangling -u
would consume the next token and hand ld a symbol as an input file."""
paths = _make_framework(tmp_path)
_set_flags()
CORE.build_unflags = {"_printf_float"}
content = _write_ninja(paths)
link_line = next(
line for line in content.splitlines() if line.startswith("linkflags = ")
)
assert "_printf_float" not in link_line
assert "-u -u" not in link_line
# The neighbors survive as intact pairs
assert "-u app_entry" in link_line
assert "-u _DebugExceptionVector" in link_line
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 = _split_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 = _split_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 = _defines()
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 = _split_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 = _split_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 = _split_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 = _defines()
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=[]),
):
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 _defines()["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 _defines()["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_empty_lib_flags_warned_and_dropped(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A bare -L would silently add the CWD to the search path; the lex
funnel warns and drops it for every consumer."""
CORE.build_flags = {'-L ""', '-l ""', "-DFOO"}
assert arduino8266._lexed_build_flags() == ["-DFOO"]
assert "Ignoring '-L' with empty argument" in caplog.text
assert "Ignoring '-l' with empty argument" 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 = _ok_result(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 = _ok_result(stdout="")
with (
patch.object(arduino8266.subprocess, "run", return_value=result),
pytest.raises(EsphomeError, match="SECTIONS"),
):
_run_generate_ld_scripts(paths)
def test_generate_ld_scripts_lost_warn_note_vetoes_the_stamp(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A warn note that could not persist skips the stamp, so the next build
re-runs -E and re-derives the diagnostic instead of losing it."""
paths = _make_framework(tmp_path)
_set_flags()
result = _ok_result(stderr="warning: something")
real_write_text = Path.write_text
def fail_note_writes(self: Path, text: str, encoding: str = "utf-8") -> int:
if self.name.endswith(".stderr"):
raise OSError("read-only build dir")
return real_write_text(self, text, encoding=encoding)
with (
patch.object(arduino8266.subprocess, "run", return_value=result) as run1,
patch.object(Path, "write_text", fail_note_writes),
):
_run_generate_ld_scripts(paths)
run1.assert_called_once()
assert "Could not write" in caplog.text
# Unstamped: the second build re-runs the preprocessor
with patch.object(arduino8266.subprocess, "run", return_value=result) as run2:
_run_generate_ld_scripts(paths)
run2.assert_called_once()
assert caplog.text.count("Linker-script preprocessor: warning: something") == 2
def test_generate_ld_scripts_unremovable_stale_note_vetoes_the_stamp(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A stale warn note that cannot be removed skips the stamp, so the
obsolete diagnostic is not re-emitted on cache hits forever."""
paths = _make_framework(tmp_path)
_set_flags()
warn = _ok_result(stderr="warning: old")
clean = _ok_result()
with patch.object(arduino8266.subprocess, "run", return_value=warn):
_run_generate_ld_scripts(paths)
real_unlink = Path.unlink
def fail_note_unlink(self: Path, missing_ok: bool = False) -> None:
if self.name.endswith(".stderr"):
raise OSError("locked")
real_unlink(self, missing_ok=missing_ok)
# Flags changed -> regenerate; clean stderr but the stale note is stuck
_set_flags("-DVTABLES_IN_DRAM")
with (
patch.object(arduino8266.subprocess, "run", return_value=clean),
patch.object(Path, "unlink", fail_note_unlink),
):
_run_generate_ld_scripts(paths)
# Unstamped: the next build re-runs -E instead of trusting the cache
with patch.object(arduino8266.subprocess, "run", return_value=clean) as run3:
_run_generate_ld_scripts(paths)
run3.assert_called_once()
@pytest.mark.parametrize("name", ["MMU_IRAM_SIZE", "MMU_ICACHE_SIZE"])
def test_mmu_custom_valueless_segment_size_raises(name: str) -> None:
"""A bare -Dname would preprocess to len = 1 and fail far away in ld."""
other = "MMU_ICACHE_SIZE" if name == "MMU_IRAM_SIZE" else "MMU_IRAM_SIZE"
with pytest.raises(EsphomeError, match=f"{name} must be a hex literal"):
_resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", f"-D{name}", f"-D{other}=0x8000")
@pytest.mark.parametrize(
"flag", ["-fuse-ld=lld", "--specs=nano.specs", "-specs=nano.specs"]
)
def test_driver_link_flags_rejected_without_wl_advice(flag: str) -> None:
"""No -Wl, spelling exists for these; the message must not suggest one."""
CORE.build_flags = {flag, "-DFOO"}
with pytest.raises(EsphomeError, match="not supported by the native toolchain"):
arduino8266._project_flags(set(), arduino8266._lexed_build_flags())
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_current()
assert "MMU_BOGUS" not in again.mmu_defines
assert all(isinstance(v, tuple) for v in arduino8266._MMU_VARIANTS.values())
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 = _ok_result()
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 = _ok_result()
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_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 = _ok_result()
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 = _ok_result()
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_non_utf8_output_raises(tmp_path: Path) -> None:
"""A non-UTF-8 byte in the preprocessed script fails by header name; a
U+FFFD-mangled script must never be cached as valid."""
paths = _make_framework(tmp_path)
result = _ok_result(stdout=b"SECTIONS { }\xff\xfe")
with (
patch.object(arduino8266.subprocess, "run", return_value=result),
pytest.raises(EsphomeError, match="is not UTF-8"),
):
_run_generate_ld_scripts(paths)
def test_generate_ld_scripts_note_read_race_still_warns(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A note that turns unreadable between the digest check and the
re-emit read (TOCTOU) warns by path instead of dropping silently."""
paths = _make_framework(tmp_path)
result = _ok_result(stderr="warn!")
with patch.object(arduino8266.subprocess, "run", return_value=result):
_run_generate_ld_scripts(paths)
orig = Path.read_text
def flaky(self, *args, **kwargs):
if self.name.endswith(".stderr"):
raise OSError("EIO")
return orig(self, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", flaky)
caplog.clear()
with patch.object(arduino8266.subprocess, "run", return_value=result):
_run_generate_ld_scripts(paths)
assert "could not be read" in caplog.text
@pytest.mark.parametrize("damage", ["corrupt", "remove"])
def test_generate_ld_scripts_damaged_note_invalidates_cache(
tmp_path: Path, caplog: pytest.LogCaptureFixture, damage: str
) -> None:
"""A corrupted or externally removed diagnostic note is a cache miss:
-E re-runs and re-derives the warning instead of dropping it silently."""
paths = _make_framework(tmp_path)
result = _ok_result(stderr="warn!")
with patch.object(arduino8266.subprocess, "run", return_value=result):
ld_dir = _run_generate_ld_scripts(paths)
note = ld_dir / ".local.eagle.app.v6.common.ld.stderr"
if damage == "corrupt":
note.write_bytes(b"\xff\xfe")
else:
note.unlink()
caplog.clear()
with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run:
_run_generate_ld_scripts(paths)
assert mock_run.called
assert "Linker-script preprocessor: warn!" in caplog.text
@pytest.mark.parametrize("value", ["0x8000", "0xC000ul", "0x10UL"])
def test_mmu_custom_numeric_sizes_accepted(value: str) -> None:
config = _resolve(
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM",
f"-DMMU_IRAM_SIZE={value}",
"-DMMU_ICACHE_SIZE=0x8000",
)
assert f"MMU_IRAM_SIZE={value}" in config.mmu_defines
@pytest.mark.parametrize(
"flag",
[
"-DMMU_IRAM_SIZE=48K",
# Decimal passes preprocessing but build_surgery's segment parser
# only reads hex, so testing-mode surgery would fail misleadingly
"-DMMU_IRAM_SIZE=32768",
],
)
def test_mmu_custom_malformed_size_raises(flag: str) -> None:
"""A non-hex size would corrupt the preprocessed segment lengths (or
defeat the testing-mode surgery); refuse by name."""
with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE must be a hex"):
_resolve(
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM",
flag,
"-DMMU_ICACHE_SIZE=0x8000",
)
def test_mmu_custom_valueless_switch_accepted_and_others_validated() -> None:
"""Valueless MMU switches (MMU_IRAM_HEAP) pass; every valued MMU_* is
hex-validated, not just the two required sizes."""
config = _resolve(
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM",
"-DMMU_IRAM_SIZE=0x8000",
"-DMMU_ICACHE_SIZE=0x8000",
"-DMMU_IRAM_HEAP",
)
assert "MMU_IRAM_HEAP" in config.mmu_defines
with pytest.raises(EsphomeError, match="MMU_SEC_HEAP_SIZE must be a numeric"):
_resolve(
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM",
"-DMMU_IRAM_SIZE=0x8000",
"-DMMU_ICACHE_SIZE=0x8000",
"-DMMU_SEC_HEAP_SIZE=48K",
)
def test_mmu_custom_accepts_decimal_non_segment_values() -> None:
"""MMU_EXTERNAL_HEAP=128 (the module's own EXTERNAL_128K shape) is a
mmu_iram.h count, not a segment length; decimal is legal there while
the two segment sizes stay hex-only for the surgery parser."""
config = _resolve(
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM",
"-DMMU_IRAM_SIZE=0x8000",
"-DMMU_ICACHE_SIZE=0x8000",
"-DMMU_EXTERNAL_HEAP=128",
)
assert "MMU_EXTERNAL_HEAP=128" in config.mmu_defines
def test_mmu_no_knob_rejects_any_raw_mmu_flag() -> None:
"""The no-knob branch refuses every raw MMU_*, like the knob branch; a
lone switch would win the compile line but not the linker script."""
with pytest.raises(EsphomeError, match="Raw MMU_IRAM_HEAP"):
_resolve("-DMMU_IRAM_HEAP")
def test_raw_nonosdk_define_raises() -> None:
"""A raw NONOSDK* define would split the compile line from the linked
SDK libraries, like the lwIP knob overrides."""
with pytest.raises(EsphomeError, match="NONOSDK305 are set by the"):
_resolve("-DNONOSDK305=1")
def test_write_note_warn_level(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A lost stderr note drops a diagnostic on later cached builds, so it
warns; a lost stamp only costs a cache miss."""
arduino8266._write_note(tmp_path / "missing" / "note", "x", warn=True)
assert "Could not write" in caplog.text
def test_write_note_failure_is_best_effort(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed stamp or stderr-note write costs a cache miss, never the
build."""
caplog.set_level("DEBUG")
arduino8266._write_note(tmp_path / "missing" / "stamp", "x")
assert "Could not write" 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")
@pytest.mark.parametrize("value", ["160 MHz", "\u0661\u0666\u0660"])
def test_defines_flags_invalid_f_cpu_raises(value: str) -> None:
"""A non-numeric board_build.f_cpu is rejected by name; it would land
unquoted on the compile line. Unicode digits count as non-numeric: the
shape checks are ASCII-only."""
CORE.platformio_options = {"board_build.f_cpu": value}
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 = _ok_result(stdout="SECTIONS { no anchor here }")
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 = _ok_result()
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 = _ok_result()
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 = _ok_result(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 = _ok_result()
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_bare_include_and_define_dropped(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An empty-argument -I or -D would make gcc eat the next flag as the
argument; the lex funnel warns and drops both."""
CORE.build_flags = {'-I ""', '-D ""'}
assert arduino8266._lexed_build_flags() == []
assert "Ignoring '-I' with empty argument" in caplog.text
assert "Ignoring '-D' with empty argument" 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 = _ok_result()
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
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_unflagging_a_plain_linker_flag_raises(tmp_path: Path) -> None:
"""build_unflags: -u would strip all seven -u tokens and leave the
operands as ld input files; refuse by name instead."""
paths = _make_framework(tmp_path)
_set_flags()
CORE.build_unflags = {"-u _printf_float"}
with pytest.raises(EsphomeError, match="cannot remove plain linker"):
_write_ninja(paths)
def test_d1_wroom_02_keeps_its_shipped_flash_layout() -> None:
"""The board joined BOARDS late; the flash-size default (2m.ld) would
move _FS_end and the preferences sector on existing devices."""
assert arduino8266._flash_ld_name("d1_wroom_02") == "eagle.flash.2m64.ld"
assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld"
def test_write_project_rejects_spaced_ldscript_override(tmp_path: Path) -> None:
"""A spaced override never reaches the link line: generate_ld_scripts
rejects the name first (the -T _shell_token quoting behind it is
defence-in-depth)."""
CORE.platformio_options = {"board_build.ldscript": "my script.ld"}
paths = _make_framework(tmp_path)
_set_flags()
# The completeness checks run first; give them a src dir to pass
CORE.relative_src_path().mkdir(parents=True, exist_ok=True)
with pytest.raises(EsphomeError, match="Invalid flash linker script name"):
arduino8266.write_project(paths, None)
def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None:
"""Regression: the -include stays relative and the .sum carries no
per-device path, or cross-device ccache sharing breaks."""
paths = _make_framework(tmp_path / "shared")
sums = []
for name in ("dev_a", "dev_b"):
CORE.name = name
CORE.build_path = tmp_path / name
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert (
"srccxxflags = -Winvalid-pch -Werror=invalid-pch "
"-include esphome_pch.h" in content
)
sums.append(
(CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text()
)
assert sums[0] == sums[1]
def test_write_project_pch_sum_tracks_src_flags(tmp_path: Path) -> None:
"""The header compiles with the src flags too, so they are part of what
ccache is told about it."""
paths = _make_framework(tmp_path)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
sum_path = CORE.relative_pioenvs_path(CORE.name) / "esphome_pch.h.gch.sum"
sums = []
for value in ("1", "2"):
CORE.platformio_options["build_src_flags"] = f"-DSRC_ONLY={value}"
_write_ninja(paths)
sums.append(sum_path.read_text())
assert sums[0] != sums[1]