[host] Build natively with ninja instead of PlatformIO (#19214)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Jesse Hills
2026-09-28 11:29:59 +02:00
committed by GitHub
co-authored by J. Nick Koston
parent bb7af14012
commit 32e11c4f4a
47 changed files with 2532 additions and 630 deletions
+55 -71
View File
@@ -16,7 +16,6 @@ import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
@@ -35,7 +34,7 @@ from esphome.helpers import (
write_file,
write_file_if_changed,
)
from esphome.platformio.toolchain import get_idedata
from esphome.host.toolchain import get_elf_path
from .const import (
API_CONNECTION_TIMEOUT,
@@ -82,16 +81,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
"""Get environment variables for PlatformIO with shared cache."""
def _get_build_env() -> dict[str, str]:
"""Environment for an ``esphome compile`` subprocess."""
env = os.environ.copy()
env["PLATFORMIO_CORE_DIR"] = str(cache_dir)
env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache")
# libdeps is keyed only by env name (the device name), and fixtures share
# names; two xdist workers first-compiling the same name race pio pkg
# install in the same directory. Keep libdeps per worker.
worker = os.environ.get("PYTEST_XDIST_WORKER", "master")
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker)
# Prevent cache cleaning during integration tests
env["ESPHOME_SKIP_CLEAN_BUILD"] = "1"
# Cap each compile's -j so several xdist workers do not each spawn a
@@ -110,53 +102,41 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
return env
# Registry libraries (noise-c, lvgl, ...) download into <data_dir>/pio_components,
# and every integration test gets its own data dir. Share one download cache
# per xdist worker instead: the converter has no cross-process lock, so
# workers must never write the same dir, while a worker's later tests (and
# later runs) reuse what it fetched.
LIBRARY_CACHE_ROOT = INTEGRATION_TESTS_ROOT / "pio_components"
@pytest.fixture(scope="session")
def shared_platformio_cache() -> Generator[Path]:
"""Initialize a shared PlatformIO cache for all integration tests."""
# Use a dedicated directory for integration tests to avoid conflicts.
test_cache_dir = INTEGRATION_TESTS_ROOT
cache_dir = test_cache_dir / "platformio"
def shared_library_cache() -> Path:
"""This worker's shared registry-library download cache."""
worker = os.environ.get("PYTEST_XDIST_WORKER", "master")
# Keyed by checkout too: two sessions on one machine share worker ids
cache_dir = LIBRARY_CACHE_ROOT / _REPO_KEY / worker
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
# Use a lock file in the home directory to ensure only one process initializes the cache
# This is needed when running with pytest-xdist
# The lock file must be in a directory that already exists to avoid race conditions
lock_file = Path.home() / ".esphome-integration-tests-init.lock"
# Always acquire the lock to ensure cache is ready before proceeding
with lock_file.open("w") as lock_fd:
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
# Check if the native platform is installed (the actual indicator of a populated cache)
native_platform = cache_dir / "platforms" / "native"
if not native_platform.exists():
# Create the test cache directory if it doesn't exist
test_cache_dir.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory() as tmpdir:
# Use the cache_init fixture for initialization
init_dir = Path(tmpdir)
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
config_path = init_dir / "cache_init.yaml"
config_path.write_text(
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
)
# Run compilation to populate the cache
# We must succeed here to avoid race conditions where multiple
# tests try to populate the same cache directory simultaneously
env = _get_platformio_env(cache_dir)
subprocess.run(
[sys.executable, "-m", "esphome", "compile", str(config_path)],
check=True,
cwd=init_dir,
env=env,
close_fds=False,
)
# Lock is held until here, ensuring cache is fully populated before any test proceeds
yield cache_dir
def _link_library_cache(config_dir: Path, cache_dir: Path) -> None:
"""Point a config dir's library download cache at the shared one
(blocking, run in executor)."""
data_dir = config_dir / ".esphome"
data_dir.mkdir(exist_ok=True)
link = data_dir / "pio_components"
if link.is_symlink():
if link.resolve() == cache_dir.resolve():
return
# A shared build dir was linked by another worker; writing through
# it would put two workers in one cache
link.unlink()
elif link.exists():
# A real dir from a run predating the shared cache; nothing in it is
# worth more than a re-download
rmtree(link)
link.symlink_to(cache_dir, target_is_directory=True)
@pytest.fixture(scope="module", autouse=True)
@@ -238,18 +218,17 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
content = content.replace("api:", f"api:\n port: {unused_tcp_port}")
# Add debug build flags for integration tests to enable assertions
if "esphome:" in content and "platformio_options:" not in content:
# Add platformio_options with debug flags after esphome:
if "esphome:" in content and "build_flags:" not in content:
# Add build_flags with debug flags after esphome:
content = content.replace(
"esphome:",
"esphome:\n"
" # Enable assertions for integration tests\n"
" platformio_options:\n"
" build_flags:\n"
' - "-DDEBUG" # Enable assert() statements\n'
' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n'
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
' - "-g" # Add debug symbols',
" build_flags:\n"
' - "-DDEBUG" # Enable assert() statements\n'
' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n'
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
' - "-g" # Add debug symbols',
)
# Replace external component path placeholder if present
@@ -283,8 +262,8 @@ async def write_yaml_config(
yield _write_config
# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale
# dirs for a fixture are pruned when its content hash changes.
# Deliberately not CI-cached; stale dirs for a fixture are pruned when its
# content hash changes.
SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds"
# In the dir name (not just the hash) so pruning stays inside this checkout
@@ -470,8 +449,7 @@ def _resolve_compiled_binary(config_path: Path) -> Path:
)
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
idedata = get_idedata(config)
binary_path = Path(idedata.firmware_elf_path)
binary_path = get_elf_path()
if not binary_path.exists():
raise RuntimeError(f"Compiled binary not found at {binary_path}")
return binary_path
@@ -480,19 +458,22 @@ def _resolve_compiled_binary(config_path: Path) -> Path:
@pytest_asyncio.fixture
async def compile_esphome(
integration_test_dir: Path,
shared_platformio_cache: Path,
shared_library_cache: Path,
request: pytest.FixtureRequest,
) -> AsyncGenerator[CompileFunction]:
"""Compile an ESPHome configuration and return the binary path."""
async def _compile(config_path: Path) -> Path:
# Use the shared PlatformIO cache for faster compilation
# This avoids re-downloading dependencies for each test
env = _get_platformio_env(shared_platformio_cache)
env = _get_build_env()
loop = asyncio.get_running_loop()
name = _shared_yaml_name(request)
if name is None:
# Share the library download cache so a test never re-fetches
# what an earlier one already pulled from the registry
await loop.run_in_executor(
None, _link_library_cache, integration_test_dir, shared_library_cache
)
await _run_esphome_compile(config_path, integration_test_dir, env)
return await loop.run_in_executor(
None, _resolve_compiled_binary, config_path
@@ -568,6 +549,9 @@ async def compile_esphome(
await loop.run_in_executor(
None, write_file_if_changed, shared_config, content
)
await loop.run_in_executor(
None, _link_library_cache, shared_dir, shared_library_cache
)
await _run_esphome_compile(shared_config, shared_dir, env)
if built is None or not built.exists():
built = await loop.run_in_executor(
@@ -1,9 +1,8 @@
esphome:
name: api-action-timeout-test
# Use a short timeout for testing (500ms instead of 30s)
platformio_options:
build_flags:
- "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500"
build_flags:
- "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500"
host:
@@ -1,10 +0,0 @@
esphome:
name: cache-init
host:
api:
encryption:
key: "IIevImVI42I0FGos5nLqFK91jrJehrgidI0ArwMLr8w="
logger:
@@ -1,8 +1,7 @@
esphome:
name: fnv1-hash-object-id-test
platformio_options:
build_flags:
- "-DDEBUG"
build_flags:
- "-DDEBUG"
on_boot:
- lambda: |-
using esphome::fnv1_hash_object_id;
+2 -3
View File
@@ -1,8 +1,7 @@
esphome:
name: fnv1a-hash-test
platformio_options:
build_flags:
- "-DDEBUG"
build_flags:
- "-DDEBUG"
on_boot:
- lambda: |-
using esphome::fnv1a_hash;
@@ -1,8 +1,7 @@
esphome:
name: micros-to-millis-test
platformio_options:
build_flags:
- "-DDEBUG"
build_flags:
- "-DDEBUG"
on_boot:
- lambda: |-
using esphome::micros_to_millis;
+1 -4
View File
@@ -2143,10 +2143,7 @@ def test_no_orphan_integration_fixtures() -> None:
fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve()
fixtures = list(fixtures_dir.glob("*.yaml"))
assert fixtures, f"no fixtures found under {fixtures_dir}"
# cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead
orphans = [
f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping
]
orphans = [f.stem for f in fixtures if f.stem not in mapping]
assert not orphans, f"fixtures invisible to CI test selection: {orphans}"
@@ -604,7 +604,7 @@ def test_write_project_libraries_and_variant(
)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS")
with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.arduino8266"):
with caplog.at_level(logging.DEBUG, logger="esphome.build_helpers.ninja_gen"):
content = _write_ninja(
paths, libraries=[library, headers_only], ccache="/cc/ccache"
)
@@ -616,6 +616,9 @@ def test_write_project_libraries_and_variant(
assert "libHeadersOnly.a" not in content
assert "Library HeadersOnly has no source files" in caplog.text
assert " flags = -DMYLIB=1" 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
+368
View File
@@ -0,0 +1,368 @@
"""Tests for esphome.build_gen.host (the host ninja generator)."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.arduino.library import ArduinoLibrary
from esphome.build_gen import host as build_gen
from esphome.build_helpers.ninja import escape as _e, quote_path as _q
from esphome.build_helpers.ninja_gen import Flag
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_HOST
from esphome.core import CORE, EsphomeError, Library
from esphome.host.toolchain import PROGRAM_NAME, HostCompilers
COMPILERS = HostCompilers(cc=("/usr/bin/gcc", "-m32"), cxx=("/usr/bin/g++",))
@pytest.fixture(autouse=True)
def _core(tmp_path: Path) -> None:
CORE.build_path = tmp_path
CORE.name = "dev"
CORE.cpp_standard = "gnu++20"
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST}
def _make_src(tmp_path: Path, *names: str) -> Path:
src = tmp_path / "src"
for name in names:
path = src / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
return src
def _render(ccache: str | None = None) -> tuple[bool, str]:
changed = build_gen.write_project(COMPILERS, ccache)
ninja = CORE.build_path / ".pioenvs" / "dev" / "build.ninja"
return changed, ninja.read_text()
@pytest.mark.parametrize(
("entries", "compile_flags", "link_flags"),
[
(
["-DUSE_HOST", "-Iinc", "-Ufoo", "-Wall"],
[("-DUSE_HOST",), ("-Iinc",), ("-Ufoo",), ("-Wall",)],
[],
),
(["-std=gnu++20"], [("-std=gnu++20",)], []),
(
["-lssl", "-L/opt/lib", "-Wl,--gc-sections"],
[],
[("-lssl",), ("-L/opt/lib",), ("-Wl,--gc-sections",)],
),
# A link-only flag travels with its argument
(
["-Xlinker --wrap=malloc", "-z noexecstack", "-framework Cocoa"],
[],
[
("-Xlinker", "--wrap=malloc"),
("-z", "noexecstack"),
("-framework", "Cocoa"),
],
),
# Both lines, as SCons routes unclassified flags
(
["-g -O2", "-fsanitize=address", "-pthread", "--coverage"],
[
("-g",),
("-O2",),
("-fsanitize=address",),
("-pthread",),
("--coverage",),
],
[
("-g",),
("-O2",),
("-fsanitize=address",),
("-pthread",),
("--coverage",),
],
),
# A flag with a path argument travels with it, glued on or not
(
["-include pre.h", "-isystem /x", "-isystem/y", "-iquotez"],
[("-include", "pre.h"), ("-isystem", "/x"), ("-isystem/y",), ("-iquotez",)],
[],
),
],
)
def test_split_flags(
entries: list[str], compile_flags: list[Flag], link_flags: list[Flag]
) -> None:
flags = build_gen.parse_flags(entries, "esphome")
assert build_gen.split_flags(flags) == (compile_flags, link_flags)
@pytest.mark.parametrize(
"entries",
[
# The flag is the last token of its entry
["-g -include"],
# Entries are a set: the next entry is not this flag's argument
["-include", "pre.h"],
["-framework", "-pthread"],
# A path never starts with "-"
["-include -pthread"],
],
)
def test_parse_flags_refuses_a_flag_without_its_argument(entries: list[str]) -> None:
with pytest.raises(EsphomeError, match="with no argument; write the flag"):
build_gen.parse_flags(entries, "esphome")
def test_parse_flags_accepts_a_linker_argument_that_is_a_flag() -> None:
assert build_gen.parse_flags(["-Xlinker --wrap=malloc"], "esphome") == [
("-Xlinker", "--wrap=malloc")
]
def test_flag_lists_route_the_standard() -> None:
"""cpp_standard wins for C++; C never sees a C++ standard."""
CORE.build_flags = {
"-DUSE_HOST",
"-Iinc",
"-Wl,-x",
"-g",
"-lssl",
"-std=c++17",
"-std=gnu17",
}
CORE.cxx_build_flags = {"-Wno-volatile"}
cflags, cxxflags, link_flags = build_gen._flag_lists()
# The relative include is anchored at the build path
inc = f"-I{CORE.build_path / 'inc'}"
assert cflags == ["-DUSE_HOST", inc, "-g", "-std=gnu17"]
assert cxxflags == ["-std=gnu++20", "-DUSE_HOST", inc, "-g", "-Wno-volatile"]
assert link_flags == ["-Wl,-x", "-g", "-lssl"]
def test_flag_lists_without_cpp_standard_keeps_user_std() -> None:
CORE.cpp_standard = None
CORE.build_flags = {"-std=c++17"}
cflags, cxxflags, _link = build_gen._flag_lists()
assert cflags == []
assert cxxflags == ["-std=c++17"]
def test_flag_lists_apply_unflags(caplog: pytest.LogCaptureFixture) -> None:
CORE.build_flags = {"-g", "-DUSE_HOST"}
CORE.build_unflags = {"-g", "-Onope"}
with caplog.at_level(logging.WARNING):
cflags, cxxflags, link_flags = build_gen._flag_lists()
assert "-g" not in cflags + cxxflags + link_flags
assert "-DUSE_HOST" in cflags
assert "build_unflags entries matched no build flag: -Onope" in caplog.text
def test_resolve_host_libraries_without_libraries() -> None:
with patch("esphome.arduino.library.resolve_libraries") as resolve:
assert build_gen._resolve_host_libraries() == []
resolve.assert_not_called()
def test_resolve_host_libraries_is_framework_less() -> None:
CORE.add_library(Library(name="lvgl/lvgl", version="9.5.0"))
lib = ArduinoLibrary(name="lvgl")
with patch(
"esphome.arduino.library.resolve_libraries", return_value=[lib]
) as resolve:
assert build_gen._resolve_host_libraries() == [lib]
resolve.assert_called_once_with(
None,
pio_platform="native",
board_mcu="host",
cache_key="host",
framework=None,
manifest_optional=True,
)
def test_write_project_requires_generated_sources(tmp_path: Path) -> None:
with pytest.raises(EsphomeError, match="Generated source directory .* is missing"):
build_gen.write_project(COMPILERS, None)
_make_src(tmp_path, "esphome.h")
with pytest.raises(EsphomeError, match="No source files found"):
build_gen.write_project(COMPILERS, None)
def test_write_project_emits_every_source_kind(tmp_path: Path) -> None:
src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S", "y.s", "h.h")
CORE.build_flags = {"-DUSE_HOST", "-g"}
changed, ninja = _render(ccache="/usr/bin/ccache")
assert changed is True
# A compiler override's arguments follow the program
assert f"cc = {_q('/usr/bin/gcc')} -m32\n" in ninja
assert f"cxx = {_q('/usr/bin/g++')}" in ninja
assert f"ccache = {_q('/usr/bin/ccache')}" in ninja
assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}" in ninja
assert (
f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}" in ninja
)
assert "build obj/src/x.S.o: aspp " in ninja
assert "build obj/src/y.s.o: asm " in ninja
assert "h.h" not in ninja
# __FILE__ stays relative to the build path, as it was under PlatformIO,
# whether the source arrives absolute or rewritten by ccache
sep = os.sep
maps = (
f"{_q(f'-fmacro-prefix-map={tmp_path}{sep}=')} "
f"{_q(f'-fmacro-prefix-map=..{sep}..{sep}=')}"
)
assert f"cflags = -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja
assert f"cxxflags = -std=gnu++20 -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja
# Assembly gets the defines and includes only
assert f"asflags = -DUSE_HOST -I{_q(src)} {maps}\n" in ninja
assert "linkflags = -g\n" in ninja
assert "libdirflags = \n" in ninja
assert "libflags = \n" in ninja
assert "rule ar" not in ninja
assert (
f"build {PROGRAM_NAME}: link obj/src/esphome/core/a.c.o obj/src/main.cpp.o "
f"obj/src/x.S.o obj/src/y.s.o | \n archives = \ndefault {PROGRAM_NAME}\n"
) in ninja
# Unchanged content reports no change so the compile DB can be reused
changed, _ = _render(ccache="/usr/bin/ccache")
assert changed is False
def test_write_project_without_ccache(tmp_path: Path) -> None:
_make_src(tmp_path, "main.cpp")
_changed, ninja = _render()
assert "ccache = \n" in ninja
def test_write_project_routes_user_link_flags(tmp_path: Path) -> None:
_make_src(tmp_path, "main.cpp")
lib_dir = tmp_path / "opt" / "lib"
# Forward slashes: build flags are lexed like a POSIX shell line, which
# reads a backslash as an escape
CORE.build_flags = {
f"-L{lib_dir.as_posix()}",
"-lcrypto",
"-Wl,-framework,Security",
}
_changed, ninja = _render()
assert "linkflags = -Wl,-framework,Security\n" in ninja
assert f"libdirflags = -L{_q(lib_dir)}\n" in ninja
assert "libflags = -lcrypto\n" in ninja
def _libraries(tmp_path: Path) -> list[ArduinoLibrary]:
lib_dir = tmp_path / "libs"
for name in ("foo/src/a.cpp", "foo/src/sub/b.c", "bare/x.cpp"):
path = lib_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
archived = ArduinoLibrary(
name="foo",
sources=[lib_dir / "foo/src/a.cpp", lib_dir / "foo/src/sub/b.c"],
include_dirs=[lib_dir / "foo/src"],
flags=["-DFOO=1"],
link_dirs=[lib_dir / "foo/lib"],
link_libs=["bar"],
link_flags=["-Wl,--gc-sections"],
)
direct = ArduinoLibrary(
name="bare", sources=[lib_dir / "bare/x.cpp"], lib_archive=False
)
header_only = ArduinoLibrary(name="hdr", include_dirs=[lib_dir / "hdr"])
return [archived, direct, header_only]
def test_write_project_with_libraries(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
src = _make_src(tmp_path, "main.cpp")
libs = _libraries(tmp_path)
with (
patch.object(build_gen, "_resolve_host_libraries", return_value=libs),
patch.object(build_gen, "find_tool", return_value="/usr/bin/ar") as find_tool,
patch.object(build_gen.sys, "platform", "linux"),
caplog.at_level(logging.DEBUG),
):
_changed, ninja = _render()
find_tool.assert_called_once_with("AR", ("ar",))
assert (
f"rule ar\n command = $python $buildtool ar {_q('/usr/bin/ar')} $out $out.rsp"
in ninja
)
lib_dir = tmp_path / "libs"
# Every library's include dir joins the global include path
assert f"-I{_q(src)} -I{_q(lib_dir / 'foo/src')} -I{_q(lib_dir / 'hdr')}" in ninja
assert "linkflags = -Wl,--gc-sections\n" in ninja
assert f"libdirflags = -L{_q(lib_dir / 'foo/lib')}\n" in ninja
assert "libflags = -lbar\n" in ninja
# Library sources compile with the library's own flags, rooted at their
# common parent; its own include dirs lead the line so another library's
# header of the same name cannot shadow them
own = f"-I{_q(lib_dir / 'foo/src')}"
assert "$own_includes $cxxflags $flags" in ninja
assert (
f"build obj/lib/foo/a.cpp.o: cxx {_e(lib_dir / 'foo/src/a.cpp')}\n"
f" own_includes = {own}\n flags = -DFOO=1\n" in ninja
)
assert (
f"build obj/lib/foo/sub/b.c.o: c {_e(lib_dir / 'foo/src/sub/b.c')}\n"
f" own_includes = {own}\n flags = -DFOO=1\n" in ninja
)
assert "build libfoo.a: ar obj/lib/foo/a.cpp.o obj/lib/foo/sub/b.c.o\n" in ninja
# libArchive: false objects link directly; the archive is an order-only
# input wrapped in a group for GNU ld
assert (
f"build {PROGRAM_NAME}: link obj/src/main.cpp.o obj/lib/bare/x.cpp.o "
"| libfoo.a\n"
" archives = -Wl,--start-group libfoo.a -Wl,--end-group\n"
) in ninja
assert "Library hdr has no source files" in caplog.text
def test_write_project_darwin_links_archives_bare(tmp_path: Path) -> None:
_make_src(tmp_path, "main.cpp")
libs = _libraries(tmp_path)
with (
patch.object(build_gen, "_resolve_host_libraries", return_value=libs),
patch.object(build_gen, "find_tool", return_value="/usr/bin/ar"),
patch.object(build_gen.sys, "platform", "darwin"),
):
_changed, ninja = _render()
assert " archives = libfoo.a\n" in ninja
assert "--start-group" not in ninja
def test_build_unflags_remove_a_flag_with_its_argument(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Only the named pair goes; the same path under another flag stays."""
CORE.build_flags = {"-iquote inc", "-isystem inc", "-Xlinker -dead_strip", "-g"}
CORE.build_unflags = {"-isystem inc", "-Xlinker -dead_strip"}
with caplog.at_level(logging.WARNING):
cflags, _cxxflags, link_flags = build_gen._flag_lists()
assert cflags == ["-g", "-iquote", str(CORE.build_path / "inc")]
assert link_flags == ["-g"]
assert "matched no build flag" not in caplog.text
def test_build_unflags_warn_when_nothing_matches(
caplog: pytest.LogCaptureFixture,
) -> None:
CORE.build_flags = {"-g"}
CORE.build_unflags = {"-isystem inc", "-DNOPE"}
with caplog.at_level(logging.WARNING):
build_gen._flag_lists()
assert "matched no build flag: -DNOPE, -isystem " in caplog.text
def test_build_unflags_match_anchored_paths(caplog: pytest.LogCaptureFixture) -> None:
CORE.build_flags = {"-Iinc", "-g"}
CORE.build_unflags = {"-Iinc"}
with caplog.at_level(logging.WARNING):
cflags, _cxxflags, _link = build_gen._flag_lists()
assert cflags == ["-g"]
assert "matched no build flag" not in caplog.text
@@ -120,3 +120,48 @@ def test_parse_enable_env_spelling_tables(
"""cv.boolean's spelling tables plus the 1/0 env convention."""
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw)
assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected
def test_resolve_absolute_ccache_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Anchor the path: ninja runs from the build directory."""
monkeypatch.chdir(tmp_path)
with patch.object(ccache, "resolve_ccache_path", return_value="bin/ccache"):
resolved = ccache.resolve_absolute_ccache_path()
assert Path(resolved) == tmp_path / "bin" / "ccache"
with patch.object(ccache, "resolve_ccache_path", return_value=None):
assert ccache.resolve_absolute_ccache_path() is None
def test_ccache_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from esphome.core import CORE
CORE.build_path = tmp_path / "build"
spec = ("ESPHOME_TEST_PREFIX", "test")
monkeypatch.setenv("ESPHOME_TEST_PREFIX", str(tmp_path / "cache"))
monkeypatch.setenv("CCACHE_NOHASHDIR", "false")
for key in ("CCACHE_DIR", "CCACHE_BASEDIR", "CCACHE_DEPEND"):
monkeypatch.delenv(key, raising=False)
# None means resolved and disabled
assert ccache.ccache_env(None, spec) == {}
env = ccache.ccache_env("/usr/bin/ccache", spec)
# User-set values are respected; the rest get defaults
assert "CCACHE_NOHASHDIR" not in env
assert env["CCACHE_DEPEND"] == "1"
assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache")
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
def test_ccache_defaults_env_escapes_a_dollar(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A literal $ is doubled, since ccache expands $VAR in its settings."""
from esphome.core import CORE
CORE.build_path = tmp_path / "a$b"
for key in ("CCACHE_DIR", "CCACHE_BASEDIR"):
monkeypatch.delenv(key, raising=False)
env = ccache.ccache_defaults_env(tmp_path / "c$d")
assert env["CCACHE_DIR"].endswith("c$$d")
assert env["CCACHE_BASEDIR"].endswith("a$$b")
@@ -676,3 +676,36 @@ def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None:
)
mock_build.assert_not_called()
assert data["cached"] is True
def test_warn_if_idedata_missing_is_silent_on_success(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
idedata.warn_if_idedata_missing(lambda: {"cc_path": "gcc"})
assert "idedata" not in caplog.text
def test_warn_if_idedata_missing_warns_on_none(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
idedata.warn_if_idedata_missing(lambda: None)
assert "No idedata was generated for this build" in caplog.text
@pytest.mark.parametrize(
"error", [EsphomeError("db unusable"), ValueError("bad"), OSError("gone")]
)
def test_warn_if_idedata_missing_downgrades_failures(
caplog: pytest.LogCaptureFixture, error: Exception
) -> None:
"""The firmware already built; a broken idedata must not fail the build."""
def broken() -> dict | None:
raise error
with caplog.at_level(logging.DEBUG):
idedata.warn_if_idedata_missing(broken)
assert f"Could not generate idedata: {error}" in caplog.text
assert "Idedata failure detail" in caplog.text
@@ -0,0 +1,34 @@
"""Tests for esphome.build_helpers.native."""
from __future__ import annotations
import logging
import pytest
from esphome.build_helpers import native
from esphome.const import Toolchain
from esphome.core import CORE
def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None:
"""Options a native build drops are warned by name; consumed ones stay quiet."""
CORE.toolchain = Toolchain.ARDUINO
CORE.platformio_options = {
"lib_ignore": ["x"],
"board_build.filesystem": "littlefs",
}
with caplog.at_level(logging.WARNING):
native.warn_ignored_platformio_options({"lib_ignore"})
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
assert "native 'arduino' toolchain" in caplog.text
assert "lib_ignore" not in caplog.text
def test_warn_ignored_platformio_options_without_options(
caplog: pytest.LogCaptureFixture,
) -> None:
CORE.platformio_options = None
with caplog.at_level(logging.WARNING):
native.warn_ignored_platformio_options(())
assert caplog.text == ""
@@ -141,3 +141,126 @@ def test_shell_token_windows_branch_uses_argv_rule() -> None:
with patch.object(os, "name", "nt"):
assert ninja_helper.shell_token("a b") == '"a b"'
assert ninja_helper.shell_token("", force=True) == '""'
@pytest.mark.parametrize("char", ["|", "\n", "\r"])
def test_escape_rejects_what_ninja_cannot_express(char: str) -> None:
with pytest.raises(EsphomeError, match="a ninja build file cannot express"):
ninja_helper.escape(f"src/a{char}b.cpp")
def test_escape_keeps_a_hash() -> None:
"""A # is only a comment at the start of a ninja line."""
assert ninja_helper.escape("src/a#b c.cpp") == "src/a#b$ c.cpp"
def test_absolute_tool_anchors_a_relative_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Anchor the path: ninja runs from the build directory."""
monkeypatch.chdir(tmp_path)
assert Path(ninja_helper.absolute_tool("bin/ninja")) == tmp_path / "bin" / "ninja"
def test_find_ninja_returns_an_absolute_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with (
patch("shutil.which", return_value="bin/ninja"),
patch.object(ninja_helper, "_ninja_runs", return_value=True),
):
assert ninja_helper.find_ninja() == tmp_path / "bin" / "ninja"
@pytest.fixture
def compdb_dir(tmp_path: Path) -> Path:
build_dir = tmp_path / "build"
build_dir.mkdir()
(build_dir / "build.ninja").write_text("rule x\n")
return build_dir
def test_refresh_compile_commands_regenerates_when_stale(compdb_dir: Path) -> None:
ninja = Path("ninja")
compdb = compdb_dir / "compile_commands.json"
stamp = compdb_dir / ".compile_commands.stamp"
with patch.object(ninja_helper, "write_compile_commands") as write:
# A rewritten manifest always regenerates
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, True)
assert write.call_count == 1
assert stamp.is_file()
# No compile DB yet
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 2
compdb.write_text("[]")
# Fresh stamp: nothing to do
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 2
# A manifest newer than the stamp (interrupted previous run)
os.utime(stamp, (1, 1))
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 3
# A missing stamp regenerates too
stamp.unlink()
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 4
def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> MagicMock:
return MagicMock(returncode=rc, stdout=stdout, stderr=stderr)
def test_write_compile_commands_success(compdb_dir: Path) -> None:
entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]'
with patch("subprocess.run", return_value=_completed(stdout=entries)) as run:
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {"A": "1"})
assert (compdb_dir / "compile_commands.json").read_text() == entries
# Every compile rule, so preprocessed assembly reaches the database too
assert run.call_args.args[0] == [
"ninja",
"-C",
str(compdb_dir),
"-t",
"compdb",
"c",
"cxx",
"aspp",
"asm",
]
assert run.call_args.kwargs["env"] == {"A": "1"}
@pytest.mark.parametrize(
("result", "message"),
[
(_completed(rc=1, stderr="boom"), "Could not generate compile_commands.json"),
# A parse failure names its cause, not the rule-name story
(_completed(stdout="not json"), "unparsable compile database.*not json"),
(_completed(stdout="[]"), "empty compile database"),
],
)
def test_write_compile_commands_failures_drop_stale_db(
compdb_dir: Path, result: MagicMock, message: str
) -> None:
compdb = compdb_dir / "compile_commands.json"
compdb.write_text("[stale]")
with (
patch("subprocess.run", return_value=result),
pytest.raises(EsphomeError, match=message),
):
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {})
assert not compdb.exists()
def test_write_compile_commands_keeps_the_mtime_of_an_unchanged_db(
compdb_dir: Path,
) -> None:
"""The idedata cache is keyed on the DB's mtime."""
entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]'
compdb = compdb_dir / "compile_commands.json"
compdb.write_text(entries)
os.utime(compdb, (1000, 1000))
with patch("subprocess.run", return_value=_completed(stdout=entries)):
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {})
assert compdb.stat().st_mtime == 1000
@@ -0,0 +1,57 @@
"""Tests for esphome.build_helpers.ninja_gen."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from esphome.build_helpers import ninja_gen
from esphome.build_helpers.ninja_gen import Flag
@pytest.mark.parametrize(
("flag", "anchored"),
[
(("-Iinc",), ("-I{base}/inc",)),
(("-Llib",), ("-L{base}/lib",)),
(("-include", "pre.h"), ("-include", "{base}/pre.h")),
(("-isystem", "sys"), ("-isystem", "{base}/sys")),
# The glued spelling of a flag that takes a path
(("-isystemsys",), ("-isystem{base}/sys",)),
(("-includepre.h",), ("-include{base}/pre.h",)),
# An absolute operand is never changed
(("-I{base}/abs",), ("-I{base}/abs",)),
(("-include", "{base}/abs.h"), ("-include", "{base}/abs.h")),
# Not a path
(("-DUSE_HOST",), ("-DUSE_HOST",)),
(("-lssl",), ("-lssl",)),
(("-framework", "Cocoa"), ("-framework", "Cocoa")),
(("-I",), ("-I",)),
],
)
def test_anchor_path_flag(tmp_path: Path, flag: Flag, anchored: Flag) -> None:
"""Relative operands resolve from the build path, as under PlatformIO."""
def fill(tokens: Flag) -> Flag:
return tuple(
str(Path(tok.replace("{base}", str(tmp_path))))
if "{base}" in tok and not tok.startswith("-")
else tok.replace("{base}/", f"{tmp_path}{os.sep}")
for tok in tokens
)
assert ninja_gen.anchor_path_flag(fill(flag), tmp_path) == fill(anchored)
def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None:
for name in ("b.cpp", "a.c", "skip.cpp", "notes.txt", "sub/c.S"):
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
assert ninja_gen.collect_sources(tmp_path, {"skip.cpp"}) == [
tmp_path / "a.c",
tmp_path / "b.cpp",
tmp_path / "sub" / "c.S",
]
@@ -0,0 +1,86 @@
"""Tests for the host platform's native-toolchain wiring."""
from __future__ import annotations
import asyncio
from unittest.mock import patch
import pytest
from esphome.components import host
import esphome.config_validation as cv
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_HOST,
Toolchain,
)
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def _core_data() -> None:
CORE.data[KEY_CORE] = {}
def test_schema_resolves_the_host_toolchain() -> None:
"""The host serves exactly one toolchain; it is picked without config."""
config = host.CONFIG_SCHEMA({})
assert CORE.toolchain is Toolchain.HOST
assert CORE.using_native_toolchain
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == PLATFORM_HOST
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "host"
assert "mac_address" in config
def test_schema_rejects_the_platformio_toolchain() -> None:
"""There is no PlatformIO backend left to honor --toolchain platformio."""
CORE.toolchain = Toolchain.PLATFORMIO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'platformio' for host"):
host.CONFIG_SCHEMA({})
def test_to_code_configures_the_native_build() -> None:
config = host.CONFIG_SCHEMA({"mac_address": "AA:BB:CC:DD:EE:FF"})
asyncio.run(host.to_code(config))
assert "-DUSE_HOST" in CORE.build_flags
# The standard travels through cpp_standard, not a -std= build flag,
# so the generator can keep it off the C compile line
assert CORE.cpp_standard == "gnu++20"
assert not any(flag.startswith("-std=") for flag in CORE.build_flags)
assert not CORE.platformio_options
define_names = {define.name for define in CORE.defines}
assert {"ESPHOME_BOARD", "ESPHOME_VARIANT", "USE_ESPHOME_HOST_MAC_ADDRESS"} <= (
define_names
)
def test_run_compile_hook_claims_the_build() -> None:
config = {"esphome": {}}
with (
patch.object(host.sys, "platform", "linux"),
patch("esphome.host.toolchain.run_compile", return_value=0) as run,
):
assert host.run_compile(object(), config) is True
run.assert_called_once_with(config, CORE.verbose)
def test_run_compile_hook_raises_on_failure() -> None:
with (
patch.object(host.sys, "platform", "linux"),
patch("esphome.host.toolchain.run_compile", return_value=2),
pytest.raises(EsphomeError, match="Host build failed"),
):
host.run_compile(object(), {"esphome": {}})
def test_run_compile_hook_refuses_windows() -> None:
"""The host sources use POSIX interfaces; fail before building, by name."""
with (
patch.object(host.sys, "platform", "win32"),
patch("esphome.host.toolchain.run_compile") as run,
pytest.raises(EsphomeError, match="does not build on Windows"),
):
host.run_compile(object(), {"esphome": {}})
run.assert_not_called()
+11 -20
View File
@@ -112,17 +112,6 @@ def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None:
assert env["CCACHE_DIR"] == "x"
def test_ccache_env(tmp_path: Path) -> None:
assert framework.ccache_env(None) == {}
with patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True):
env = framework.ccache_env("/usr/bin/ccache")
# User-set values are respected; the rest get defaults
assert "CCACHE_NOHASHDIR" not in env
assert env["CCACHE_DEPEND"] == "1"
assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve())
assert env["CCACHE_DIR"].endswith("ccache")
def test_check_and_install_rejects_old_core(tmp_path: Path) -> None:
"""Calling the installer below the floor fails before any download."""
with pytest.raises(EsphomeError, match=">= 3.1.1"):
@@ -148,18 +137,20 @@ def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None:
assert env["PATH"].split(os.pathsep) == [str(tmp_path / "bin"), "/usr/bin", "/bin"]
def test_ccache_env_accepts_a_preresolved_path() -> None:
"""The caller resolves ccache once and threads it through; None means
resolved-and-disabled."""
with patch.dict(os.environ, {}, clear=True):
assert framework.ccache_env(None) == {}
env = framework.ccache_env("/usr/bin/ccache")
assert env["CCACHE_DIR"].endswith("ccache")
def test_toolchain_tool_layout(tmp_path: Path) -> None:
"""One owner for the bin/xtensa-lx106-elf-<name> layout."""
tool = framework.toolchain_tool(tmp_path, "addr2line")
assert tool.parent == tmp_path / "bin"
assert tool.name.startswith("xtensa-lx106-elf-addr2line")
assert (tool.suffix == ".exe") is (os.name == "nt")
def test_get_build_env_uses_the_arduino8266_ccache_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ESPHOME_ARDUINO8266_PREFIX", str(tmp_path / "cache"))
monkeypatch.delenv("CCACHE_DIR", raising=False)
env = framework.get_build_env(tmp_path / "toolchain", "/usr/bin/ccache")
assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache")
# None means resolved and disabled
assert "CCACHE_DIR" not in framework.get_build_env(tmp_path / "toolchain", None)
+56 -152
View File
@@ -16,6 +16,7 @@ from esphome.const import (
CONF_ESPHOME,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
Toolchain,
)
from esphome.core import CORE, EsphomeError
@@ -80,7 +81,7 @@ def test_run_compile_build_failure(tmp_path: Path) -> None:
patch.object(
toolchain.subprocess, "run", return_value=MagicMock(returncode=2)
) as mock_run,
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "refresh_compile_commands") as mock_compdb,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2
cmd = mock_run.call_args[0][0]
@@ -100,7 +101,7 @@ def test_run_compile_success(tmp_path: Path) -> None:
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
) as mock_run,
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "refresh_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary") as mock_size,
patch.object(toolchain, "get_idedata") as mock_idedata,
):
@@ -116,39 +117,12 @@ def test_run_compile_success(tmp_path: Path) -> None:
# cwd, not -C, so ninja prints no "Entering directory" banner
assert "-C" not in cmd
assert call[1]["cwd"] is not None
mock_compdb.assert_called_once()
# An unchanged manifest is passed on, so the shared refresh can skip
assert mock_compdb.call_args.args[3] is False
mock_size.assert_called_once()
mock_idedata.assert_called_once()
def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None:
"""An interrupted run can leave build.ninja newer than the compile DB;
mere existence must not skip regeneration."""
build_dir = toolchain.get_build_dir()
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "build.ninja").write_text("")
compdb = build_dir / "compile_commands.json"
compdb.write_text("[]")
os.utime(compdb, ((build_dir / "build.ninja").stat().st_mtime - 5,) * 2)
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(
returncode=0, stdout="ninja: no work to do.\n", stderr=""
),
),
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata"),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
mock_compdb.assert_called_once()
def test_run_compile_missing_artifact_fails(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
@@ -164,7 +138,7 @@ def test_run_compile_missing_artifact_fails(
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "refresh_compile_commands"),
patch.object(toolchain, "_print_size_summary") as mock_size,
patch.object(toolchain, "get_idedata"),
):
@@ -188,7 +162,7 @@ def test_run_compile_warns_when_idedata_fails(
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "refresh_compile_commands"),
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata", return_value=None),
):
@@ -196,63 +170,6 @@ def test_run_compile_warns_when_idedata_fails(
assert "No idedata was generated for this build" in caplog.text
def test_write_compile_commands(tmp_path: Path) -> None:
build_dir = tmp_path / "build"
build_dir.mkdir()
entries = '[{"file": "a.cpp", "command": "cc"}]\n'
with patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=entries),
):
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
assert (build_dir / "compile_commands.json").read_text() == entries
@pytest.mark.parametrize(
("stdout", "match"),
[
("[]\n", "empty compile database"),
# A parse failure names its cause, not the rule-name story
("not json", "unparsable compile database.*not json"),
],
)
def test_write_compile_commands_bad_db_raises(
tmp_path: Path, stdout: str, match: str
) -> None:
"""An empty or unparsable compile database fails the build with its
actual cause and drops any stale database."""
build_dir = tmp_path / "build"
build_dir.mkdir()
(build_dir / "compile_commands.json").write_text("[stale]")
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=stdout),
),
pytest.raises(EsphomeError, match=match),
):
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
assert not (build_dir / "compile_commands.json").exists()
def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None:
"""A failed compdb run must not leave a stale database behind."""
stale = tmp_path / "compile_commands.json"
stale.write_text("[]")
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=1, stderr="boom"),
),
pytest.raises(EsphomeError, match="compile_commands"),
):
toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {})
assert not stale.exists()
def test_parse_app_size(tmp_path: Path) -> None:
ld = tmp_path / "eagle.flash.4m.ld"
ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n")
@@ -342,7 +259,9 @@ def test_get_idedata_delegates(tmp_path: Path) -> None:
"esphome.build_helpers.idedata.load_or_build_idedata",
return_value={"cc_path": "x"},
) as mock_load,
patch.object(toolchain, "resolve_ccache_path", return_value="/cc/ccache"),
patch.object(
toolchain, "resolve_absolute_ccache_path", return_value="/cc/ccache"
),
):
assert toolchain.get_idedata() == {"cc_path": "x"}
compile_commands, elf, cache = mock_load.call_args[0]
@@ -359,43 +278,12 @@ def test_get_idedata_no_ccache(tmp_path: Path) -> None:
patch(
"esphome.build_helpers.idedata.load_or_build_idedata", return_value={}
) as mock_load,
patch.object(toolchain, "resolve_ccache_path", return_value=None),
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None),
):
toolchain.get_idedata()
assert mock_load.call_args.kwargs["launcher"] is None
def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None:
"""An unchanged build.ninja means the compile DB is already current."""
build_dir = toolchain.get_build_dir()
build_dir.mkdir(parents=True, exist_ok=True)
# write_project (stubbed below) always leaves a build.ninja behind
(build_dir / "build.ninja").write_text("# manifest")
def run(regenerate_expected: bool) -> None:
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata"),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert mock_compdb.called == regenerate_expected
# Missing compile DB: regenerated even though build.ninja is unchanged
run(regenerate_expected=True)
# Present compile DB + unchanged build.ninja: skipped
(build_dir / "compile_commands.json").write_text("[]")
run(regenerate_expected=False)
def test_print_size_summary_unparsable_section(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
@@ -447,32 +335,6 @@ def test_print_size_summary_missing_section_skips_summary(
assert "missing section(s) .bss" in caplog.text
def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None:
"""Component-added options the native build drops are warned by name;
the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags,
flash_mode) stay quiet."""
CORE.platformio_options = {
"board_build.ldscript": "eagle.flash.4m2m.ld",
"board_build.f_cpu": "160000000L",
"board_build.filesystem": "littlefs",
"board_build.flash_mode": "dio",
"build_src_flags": "-include throw_stubs.h",
"lib_ignore": ["Updater"],
"upload_speed": "460800",
}
toolchain._warn_ignored_platformio_options()
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
assert "native 'arduino' toolchain" in caplog.text
assert "board_build.ldscript is ignored" not in caplog.text
assert "board_build.f_cpu is ignored" not in caplog.text
assert "lib_ignore" not in caplog.text
assert "build_src_flags" not in caplog.text
assert "flash_mode" not in caplog.text
# Component-added upload_speed never gets read under the native
# toolchain, so it must warn
assert "upload_speed" in caplog.text
def test_run_compile_idedata_error_does_not_fail_build(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
@@ -486,7 +348,7 @@ def test_run_compile_idedata_error_does_not_fail_build(
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "refresh_compile_commands"),
patch.object(toolchain, "_print_size_summary"),
patch.object(
toolchain,
@@ -512,7 +374,7 @@ def test_run_compile_skipped_size_summary_names_consequence(
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "refresh_compile_commands"),
patch.object(toolchain, "_print_size_summary", return_value=False),
patch.object(toolchain, "get_idedata", return_value=None),
):
@@ -541,8 +403,50 @@ def test_get_idedata_accepts_preresolved_ccache() -> None:
"esphome.build_helpers.idedata.load_or_build_idedata",
return_value={"ok": True},
) as mock_build,
patch.object(toolchain, "resolve_ccache_path") as mock_resolve,
patch.object(toolchain, "resolve_absolute_ccache_path") as mock_resolve,
):
assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True}
mock_resolve.assert_not_called()
assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache"
def test_run_compile_warns_about_dropped_platformio_options(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Component-added options the native build drops are warned by name;
the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags,
flash_mode) stay quiet."""
CORE.toolchain = Toolchain.ARDUINO
CORE.platformio_options = {
"board_build.ldscript": "eagle.flash.4m2m.ld",
"board_build.f_cpu": "160000000L",
"board_build.filesystem": "littlefs",
"board_build.flash_mode": "dio",
"build_src_flags": "-include throw_stubs.h",
"lib_ignore": ["Updater"],
"upload_speed": "460800",
}
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project", return_value=True),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "refresh_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata"),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
assert "platformio_options->upload_speed is ignored" in caplog.text
assert "native 'arduino' toolchain" in caplog.text
assert "board_build.ldscript is ignored" not in caplog.text
assert "board_build.f_cpu is ignored" not in caplog.text
assert "lib_ignore" not in caplog.text
assert "build_src_flags" not in caplog.text
assert "flash_mode" not in caplog.text
# A rewritten manifest is passed on, so the compile DB is regenerated
assert mock_compdb.call_args.args[3] is True
+26
View File
@@ -1160,3 +1160,29 @@ def test_versionless_dependency_with_provider_stays_quiet(
libs = _resolve(framework)
assert "Wire" in [lib.name for lib in libs]
assert "has no version to resolve" not in caplog.text
def test_resolve_libraries_without_framework_is_all_external() -> None:
"""The host build has no core tree: nothing is bundled, the framework
check is skipped, and every name reaches the converter."""
_add_library("Wire", None)
_add_library("lvgl/lvgl", "9.5.0")
def fake_convert(libraries: list, backend: LibraryBackend) -> list:
assert backend.platform == "native"
assert backend.framework is None
assert backend.cache_key == "host"
assert backend.provides("Wire") is False
return []
with patch.object(component, "convert_libraries", side_effect=fake_convert) as mock:
libs = component.resolve_libraries(
None,
pio_platform="native",
board_mcu="host",
cache_key="host",
framework=None,
)
assert libs == []
(libraries, _backend), _ = mock.call_args
assert [lib.name for lib in libraries] == ["Wire", "lvgl/lvgl"]
+8
View File
@@ -976,6 +976,14 @@ class TestEsphomeCore:
target.toolchain = const.Toolchain.SDK_NRF
assert target.using_native_toolchain is False
def test_using_toolchain_host(self, target):
"""The host's only toolchain: the system compiler driven by ninja."""
target.toolchain = const.Toolchain.HOST
assert target.using_toolchain_host is True
assert target.using_native_toolchain is True
target.toolchain = const.Toolchain.PLATFORMIO
assert target.using_toolchain_host is False
def test_add_library__extracts_short_name_from_path(self, target):
"""Test add_library extracts short name from library paths like owner/lib."""
target.data[const.KEY_CORE] = {
+329
View File
@@ -0,0 +1,329 @@
"""Tests for esphome.host.toolchain (the native host build driver)."""
from __future__ import annotations
from collections.abc import Generator
import logging
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_HOST,
Toolchain,
)
from esphome.core import CORE, EsphomeError
from esphome.host import toolchain
from esphome.host.toolchain import PROGRAM_NAME
@pytest.fixture(autouse=True)
def _core(tmp_path: Path) -> None:
CORE.config_path = tmp_path / "dev.yaml"
CORE.build_path = tmp_path
CORE.name = "dev"
CORE.toolchain = Toolchain.HOST
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST}
def _abs(path: str) -> str:
"""What the toolchain makes of a tool path (a drive is added on Windows)."""
return str(Path(path).absolute())
def test_find_command_prefers_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CXX", "/opt/clang++")
with patch("shutil.which", side_effect={"/opt/clang++": "/opt/clang++"}.get):
assert toolchain.find_command("CXX", ("g++",)) == (_abs("/opt/clang++"),)
def test_find_command_keeps_the_override_arguments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``CC="gcc -m32"`` is a command, as make and CMake read it."""
monkeypatch.setenv("CC", "gcc -m32 '--sysroot=/opt/my sdk'")
with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get):
assert toolchain.find_command("CC", ("cc",)) == (
_abs("/usr/bin/gcc"),
"-m32",
"--sysroot=/opt/my sdk",
)
@pytest.mark.parametrize(
("override", "expected_args"),
[("ccache gcc", ()), ("/opt/bin/ccache gcc -m32", ("-m32",))],
)
def test_find_command_drops_a_ccache_prefix(
monkeypatch: pytest.MonkeyPatch, override: str, expected_args: tuple[str, ...]
) -> None:
"""The build adds ccache itself; the compiler is the word after it."""
monkeypatch.setenv("CC", override)
with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get):
assert toolchain.find_command("CC", ("cc",)) == (
_abs("/usr/bin/gcc"),
*expected_args,
)
def test_find_command_accepts_a_compiler_named_ccache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Alone, the word is the program: ccache's compiler links work that way."""
monkeypatch.setenv("CC", "ccache")
with patch("shutil.which", side_effect={"ccache": "/usr/bin/ccache"}.get):
assert toolchain.find_command("CC", ("cc",)) == (_abs("/usr/bin/ccache"),)
def test_find_command_env_override_must_run(monkeypatch: pytest.MonkeyPatch) -> None:
"""A broken override fails by name instead of silently using another compiler."""
monkeypatch.setenv("CXX", "nope++")
with (
patch("shutil.which", return_value=None),
pytest.raises(EsphomeError, match="CXX='nope\\+\\+' does not name"),
):
toolchain.find_command("CXX", ("g++",))
def test_find_command_blank_override_is_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CC", " ")
with patch("shutil.which", side_effect={"clang": "/usr/bin/clang"}.get):
assert toolchain.find_command("CC", ("gcc", "clang")) == (
_abs("/usr/bin/clang"),
)
def test_find_command_first_candidate_wins(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CC", raising=False)
table = {"gcc": "/usr/bin/gcc", "clang": "/usr/bin/clang"}
with patch("shutil.which", side_effect=table.get):
assert toolchain.find_command("CC", ("gcc", "clang")) == (_abs("/usr/bin/gcc"),)
def test_find_command_none_found(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CC", raising=False)
with (
patch("shutil.which", return_value=None),
pytest.raises(
EsphomeError,
match=r"gcc not found on PATH \(tried gcc, clang\); install it or set CC",
),
):
toolchain.find_command("CC", ("gcc", "clang"))
def test_find_command_makes_a_relative_hit_absolute(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Anchor the path: ninja runs from the build directory."""
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("CC", "./toolchain/gcc")
with patch("shutil.which", return_value="./toolchain/gcc"):
(found,) = toolchain.find_command("CC", ("gcc",))
assert Path(found) == Path("toolchain/gcc").absolute()
def test_find_tool_refuses_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AR", "ar --plugin x")
with (
patch("shutil.which", return_value="/usr/bin/ar"),
pytest.raises(EsphomeError, match="AR must name a program without arguments"),
):
toolchain.find_tool("AR", ("ar",))
def test_find_compilers(monkeypatch: pytest.MonkeyPatch) -> None:
for var in ("CC", "CXX"):
monkeypatch.delenv(var, raising=False)
table = {"gcc": "/usr/bin/gcc", "g++": "/usr/bin/g++"}
with patch("shutil.which", side_effect=table.get):
assert toolchain.find_compilers() == toolchain.HostCompilers(
cc=(_abs("/usr/bin/gcc"),), cxx=(_abs("/usr/bin/g++"),)
)
def test_build_paths(tmp_path: Path) -> None:
"""The PlatformIO layout is kept: CORE.firmware_bin resolves the same file."""
assert toolchain.get_build_dir() == tmp_path / ".pioenvs" / "dev"
assert toolchain.get_elf_path() == CORE.firmware_bin
def test_binutils_paths(monkeypatch: pytest.MonkeyPatch) -> None:
for var in ("OBJDUMP", "READELF"):
monkeypatch.delenv(var, raising=False)
table = {"objdump": "/usr/bin/objdump", "readelf": "/usr/bin/readelf"}
with patch("shutil.which", side_effect=table.get):
assert toolchain.get_objdump_path() == Path(_abs("/usr/bin/objdump"))
assert toolchain.get_readelf_path() == Path(_abs("/usr/bin/readelf"))
def test_get_build_env_merges_without_leaking(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ESPHOME_HOST_PREFIX", str(tmp_path / "cache"))
monkeypatch.delenv("CCACHE_DIR", raising=False)
monkeypatch.setenv("KEEP_ME", "1")
env = toolchain.get_build_env("/usr/bin/ccache")
assert env["KEEP_ME"] == "1"
assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache")
assert "CCACHE_DIR" not in os.environ
def test_get_build_env_without_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CCACHE_DIR", raising=False)
assert "CCACHE_DIR" not in toolchain.get_build_env(None)
@pytest.fixture
def compile_env(tmp_path: Path) -> Generator[SimpleNamespace]:
"""Stub everything run_compile resolves; the build dir holds a manifest."""
build_dir = tmp_path / ".pioenvs" / "dev"
build_dir.mkdir(parents=True)
(build_dir / "build.ninja").write_text("rule x\n")
compilers = toolchain.HostCompilers(("gcc",), ("g++",))
with (
patch.object(toolchain, "find_ninja", return_value=Path("/usr/bin/ninja")),
patch.object(toolchain, "find_compilers", return_value=compilers),
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None),
patch("esphome.build_gen.host.write_project", return_value=True) as project,
patch.object(toolchain, "refresh_compile_commands") as refresh,
patch.object(
toolchain, "_load_idedata", return_value={"cc_path": "gcc"}
) as ide,
patch("subprocess.run") as run,
):
yield SimpleNamespace(
build_dir=build_dir,
compilers=compilers,
write_project=project,
refresh=refresh,
idedata=ide,
run=run,
)
def _completed(rc: int = 0) -> SimpleNamespace:
return SimpleNamespace(returncode=rc, stdout="", stderr="")
def test_run_compile_builds_and_reports_success(compile_env: SimpleNamespace) -> None:
elf = compile_env.build_dir / PROGRAM_NAME
def build(cmd: list[str], **kwargs: object) -> SimpleNamespace:
elf.write_text("")
return _completed()
compile_env.run.side_effect = build
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}
assert toolchain.run_compile(config, verbose=True) == 0
compile_env.write_project.assert_called_once_with(compile_env.compilers, None)
compile_env.refresh.assert_called_once()
assert compile_env.refresh.call_args.args[3] is True
compile_env.run.assert_called_once()
assert compile_env.run.call_args.args[0] == [
str(Path("/usr/bin/ninja")),
"-v",
"-j",
"4",
PROGRAM_NAME,
]
assert compile_env.run.call_args.kwargs["cwd"] == compile_env.build_dir
compile_env.idedata.assert_called_once_with(None)
def test_run_compile_defaults(compile_env: SimpleNamespace) -> None:
"""No verbosity and no process limit: just the ninja target."""
(compile_env.build_dir / PROGRAM_NAME).write_text("")
compile_env.run.return_value = _completed()
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert compile_env.run.call_args.args[0] == [
str(Path("/usr/bin/ninja")),
PROGRAM_NAME,
]
# The build inherits stdio and never captures: progress must stream
kwargs = compile_env.run.call_args.kwargs
assert kwargs["check"] is False
assert kwargs["close_fds"] is False
assert "capture_output" not in kwargs
def test_run_compile_warns_about_dropped_platformio_options(
compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture
) -> None:
(compile_env.build_dir / PROGRAM_NAME).write_text("")
compile_env.run.return_value = _completed()
CORE.platformio_options = {"lib_ignore": ["x"], "board_build.f_cpu": "1"}
with caplog.at_level(logging.WARNING):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert "platformio_options->board_build.f_cpu is ignored" in caplog.text
assert "native 'host' toolchain" in caplog.text
assert "lib_ignore" not in caplog.text
def test_run_compile_passes_the_resolved_ccache(compile_env: SimpleNamespace) -> None:
(compile_env.build_dir / PROGRAM_NAME).write_text("")
compile_env.run.return_value = _completed()
with patch.object(
toolchain, "resolve_absolute_ccache_path", return_value="/usr/bin/ccache"
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
compile_env.write_project.assert_called_once_with(
compile_env.compilers, "/usr/bin/ccache"
)
compile_env.idedata.assert_called_once_with("/usr/bin/ccache")
def test_run_compile_build_failure_returns_code(compile_env: SimpleNamespace) -> None:
compile_env.run.return_value = _completed(rc=3)
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 3
compile_env.idedata.assert_not_called()
def test_run_compile_missing_program_fails(
compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture
) -> None:
"""A green ninja run that produced no program fails by name."""
compile_env.run.return_value = _completed()
with caplog.at_level(logging.ERROR):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 1
assert "Build produced no" in caplog.text
compile_env.idedata.assert_not_called()
@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None])
def test_get_idedata_resolves_ccache(tmp_path: Path, ccache: str | None) -> None:
with (
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=ccache),
patch(
"esphome.build_helpers.idedata.load_or_build_idedata", return_value={"x": 1}
) as load,
):
assert toolchain.get_idedata() == {"x": 1}
load.assert_called_once_with(
tmp_path / ".pioenvs" / "dev" / "compile_commands.json",
tmp_path / ".pioenvs" / "dev" / PROGRAM_NAME,
CORE.relative_internal_path("idedata", "dev.json"),
launcher=ccache,
)
@pytest.mark.parametrize("platform", ["darwin", "win32"])
def test_check_analysis_supported_refuses_non_elf(platform: str) -> None:
with (
patch.object(toolchain.sys, "platform", platform),
pytest.raises(EsphomeError, match=f"the host build on {platform}"),
):
toolchain.check_analysis_supported()
def test_check_analysis_supported_accepts_linux() -> None:
with patch.object(toolchain.sys, "platform", "linux"):
toolchain.check_analysis_supported()
+183 -29
View File
@@ -7805,52 +7805,206 @@ async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
assert second.index("a: 2") < second.index("z: 1")
def test_host_program_path_platformio_toolchain() -> None:
"""Host + PlatformIO toolchain reads the memoized idedata path."""
setup_core(platform=PLATFORM_HOST)
idedata = SimpleNamespace(firmware_elf_path="/build/x/.pioenvs/x/program")
with patch(
"esphome.platformio.toolchain.get_idedata", return_value=idedata
) as mock_get:
assert main._host_program_path({}) == "/build/x/.pioenvs/x/program"
mock_get.assert_called_once_with({})
def test_host_program_path_esp_idf_toolchain() -> None:
"""Host + native ESP-IDF toolchain asks the espidf toolchain for the ELF."""
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.ESP_IDF
with patch(
"esphome.espidf.toolchain.get_elf_path", return_value=Path("/b/app.elf")
):
assert main._host_program_path({}) == str(Path("/b/app.elf"))
def test_command_compile_host_logs_program_path(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""command_compile on host logs the compiled program path."""
setup_core(platform=PLATFORM_HOST)
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
with (
patch.object(main, "write_cpp", return_value=0),
patch.object(main, "compile_program", return_value=0),
patch.object(main, "_host_program_path", return_value="/b/program"),
caplog.at_level(logging.INFO),
):
assert main.command_compile(SimpleNamespace(only_generate=False), {}) == 0
assert "Successfully compiled program to path '/b/program'" in caplog.text
assert f"Successfully compiled program to path '{CORE.firmware_bin}'" in caplog.text
def test_command_run_host_executes_program(caplog: pytest.LogCaptureFixture) -> None:
def test_command_run_host_executes_program(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""command_run on host logs and executes the compiled program directly."""
setup_core(platform=PLATFORM_HOST)
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
with (
patch.object(main, "write_cpp", return_value=0),
patch.object(main, "compile_program", return_value=0),
patch.object(main, "_host_program_path", return_value="/b/program"),
patch.object(main, "run_external_process", return_value=0) as mock_run,
caplog.at_level(logging.INFO),
):
assert main.command_run(SimpleNamespace(), {}) == 0
mock_run.assert_called_with("/b/program")
assert "Running program from path '/b/program'" in caplog.text
program = str(CORE.firmware_bin)
mock_run.assert_called_with(program)
assert f"Running program from path '{program}'" in caplog.text
def test_write_cpp_file_project_generation_follows_host_toolchain() -> None:
"""Only PlatformIO gets a platformio.ini; ESP-IDF writes its CMake project
here, and the other native builds generate theirs at compile time."""
setup_core(platform=PLATFORM_HOST)
with (
patch("esphome.writer.write_cpp"),
patch("esphome.build_gen.platformio.write_project") as mock_pio,
patch("esphome.build_gen.espidf.write_project") as mock_idf,
):
CORE.toolchain = Toolchain.HOST
assert main.write_cpp_file() == 0
mock_pio.assert_not_called()
mock_idf.assert_not_called()
CORE.toolchain = Toolchain.PLATFORMIO
assert main.write_cpp_file() == 0
mock_pio.assert_called_once()
CORE.toolchain = Toolchain.ESP_IDF
assert main.write_cpp_file() == 0
mock_idf.assert_called_once()
def test_compile_program_host_uses_the_platform_hook() -> None:
"""The host component's run_compile claims the build."""
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.HOST
with (
patch("esphome.components.host.run_compile", return_value=True) as hook,
patch("esphome.__main__._check_and_emit_build_info") as build_info,
):
assert compile_program(MagicMock(), {}) == 0
hook.assert_called_once()
build_info.assert_called_once()
def test_compile_program_native_toolchain_needs_a_backend() -> None:
"""A native toolchain no hook claims must not fall through to PlatformIO."""
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.HOST
with (
patch("esphome.platformio.toolchain.run_compile") as mock_pio,
# A platform package without a run_compile hook
patch.dict(sys.modules, {"esphome.components.host": SimpleNamespace()}),
pytest.raises(EsphomeError, match="no platform backend claimed the build"),
):
compile_program(MagicMock(), {})
mock_pio.assert_not_called()
def test_native_backend_resolves_host() -> None:
from esphome.build_helpers.native import native_backend
from esphome.host import toolchain as host_toolchain
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.PLATFORMIO
assert native_backend() is None
CORE.toolchain = Toolchain.HOST
assert native_backend() is host_toolchain
# A native toolchain without a backend for the platform must not degrade
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(
EsphomeError, match="has no native build backend module for platform host"
):
native_backend()
def test_command_idedata_host_prints_json(capsys: CaptureFixture) -> None:
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.HOST
data = {"cxx_path": "g++", "prog_path": "/build/program"}
with patch("esphome.host.toolchain.get_idedata", return_value=data) as mock_get:
assert command_idedata(MagicMock(), CORE.config) == 0
mock_get.assert_called_once_with()
assert json.loads(capsys.readouterr().out) == data
def _native_host_tools(tmp_path: Path) -> tuple[Path, Path, Path]:
objdump, readelf, elf = (tmp_path / n for n in ("objdump", "readelf", "program"))
for tool in (objdump, readelf, elf):
tool.write_text("")
return objdump, readelf, elf
def test_command_analyze_memory_native_toolchain(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
mock_ram_strings_analyzer: Mock,
) -> None:
"""A native backend supplies its own binutils and ELF, with no idedata."""
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
CORE.toolchain = Toolchain.HOST
objdump, readelf, elf = _native_host_tools(tmp_path)
config = {CONF_ESPHOME: {CONF_NAME: "dev"}}
with (
patch("esphome.host.toolchain.get_objdump_path", return_value=objdump),
patch("esphome.host.toolchain.get_readelf_path", return_value=readelf),
patch("esphome.host.toolchain.get_elf_path", return_value=elf),
patch("esphome.host.toolchain.check_analysis_supported"),
):
assert command_analyze_memory(MockArgs(), config) == 0
mock_memory_analyzer_cli.assert_called_once_with(
str(elf), str(objdump), str(readelf), set(), idedata=None
)
mock_ram_strings_analyzer.assert_called_once_with(
str(elf), objdump_path=str(objdump), platform="host"
)
assert "Mock Memory Report" in capfd.readouterr().out
def test_command_analyze_memory_native_missing_tool_fails(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
mock_write_cpp: Mock,
mock_compile_program: Mock,
) -> None:
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
CORE.toolchain = Toolchain.HOST
objdump, readelf, elf = _native_host_tools(tmp_path)
readelf.unlink()
with (
patch("esphome.host.toolchain.get_objdump_path", return_value=objdump),
patch("esphome.host.toolchain.get_readelf_path", return_value=readelf),
patch("esphome.host.toolchain.get_elf_path", return_value=elf),
patch("esphome.host.toolchain.check_analysis_supported"),
caplog.at_level(logging.ERROR),
):
assert command_analyze_memory(MockArgs(), {}) == 1
assert f"{readelf} is missing; the toolchain install may be incomplete" in (
caplog.text
)
def test_command_analyze_memory_host_missing_elf_fails(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
mock_write_cpp: Mock,
mock_compile_program: Mock,
) -> None:
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
CORE.toolchain = Toolchain.HOST
objdump, readelf, elf = _native_host_tools(tmp_path)
elf.unlink()
with (
patch("esphome.host.toolchain.get_objdump_path", return_value=objdump),
patch("esphome.host.toolchain.get_readelf_path", return_value=readelf),
patch("esphome.host.toolchain.get_elf_path", return_value=elf),
patch("esphome.host.toolchain.check_analysis_supported"),
caplog.at_level(logging.ERROR),
):
assert command_analyze_memory(MockArgs(), {}) == 1
assert f"{elf} is missing; compile the configuration first" in caplog.text
def test_command_analyze_memory_host_refuses_before_compiling(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
) -> None:
"""A machine whose host program is not ELF fails before the compile."""
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
CORE.toolchain = Toolchain.HOST
with (
patch("esphome.host.toolchain.sys.platform", "darwin"),
pytest.raises(EsphomeError, match="analyze-memory reads ELF files"),
):
command_analyze_memory(MockArgs(), {})
mock_write_cpp.assert_not_called()
mock_compile_program.assert_not_called()
@@ -407,6 +407,28 @@ def test_convert_libraries_redownloads_when_manifest_missing(
assert top[0].data["name"] == "A"
def test_convert_libraries_manifest_optional_uses_default_layout(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A backend accepting manifest-less libraries (the host build, like
PlatformIO's native platform) gets a default manifest instead of a
re-download that would repeat every build."""
calls = _patch_download_without_manifest(
monkeypatch, tmp_path, manifest_on_force=True
)
emitted: list[ConvertedLibrary] = []
backend = _backend(emit=emitted.append)
backend.manifest_optional = True
with caplog.at_level(logging.DEBUG, logger="esphome.platformio.library"):
top = convert_libraries([Library("esphome/A", "1.0.0", None)], backend)
assert calls == [False]
assert top[0].data == {"name": "esphome/A"}
assert emitted == top
assert "has no manifest; using PlatformIO's default layout" in caplog.text
def test_convert_libraries_raises_when_manifest_missing_after_retry(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -1251,3 +1273,50 @@ def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
_backend(),
)
assert "has no version to resolve" not in caplog.text
def test_convert_libraries_symlink_url_resolves_as_local(setup_core: Path) -> None:
"""symlink:// is PlatformIO's other spelling for a local library folder."""
src = setup_core / "lib_dev"
(src / "src").mkdir(parents=True)
(src / "library.json").write_text(json.dumps({"name": "benchmark"}))
url = src.as_uri().replace("file://", "symlink://", 1)
# Both the name=URL form and an explicit repository take the scheme
for library in (
Library(f"benchmark={url}", None, None),
Library("benchmark", None, url),
):
top = convert_libraries([library], _backend())
assert isinstance(top[0].source, LocalSource)
assert top[0].source_path == src
def test_convert_libraries_incompatible_names_the_platform_without_a_framework(
setup_core: Path,
) -> None:
"""The host has no framework; the error must not read 'compatible with None'."""
src = setup_core / "lib_dev"
src.mkdir()
(src / "library.json").write_text(
json.dumps({"name": "Only32", "platforms": "espressif32"})
)
backend = _backend()
backend.platform = "native"
backend.framework = None
with pytest.raises(RuntimeError, match="Only32 is not compatible with native"):
convert_libraries([Library("Only32", None, src.as_uri())], backend)
@pytest.mark.parametrize(
("url", "message"),
[
("symlink://host/lib", "Unsupported host in symlink:// library URL"),
("symlink:lib_dev", "symlink:// library URL .* must be an absolute"),
],
)
def test_convert_libraries_symlink_url_errors_name_the_scheme(
setup_core: Path, url: str, message: str
) -> None:
with pytest.raises(RuntimeError, match=message):
convert_libraries([Library("benchmark", None, url)], _backend())