"""Tests for esphome/platformio/pch.py.script against a fake SCons env.""" from __future__ import annotations from collections.abc import Callable import os from pathlib import Path import stat import subprocess import sys from types import SimpleNamespace from unittest.mock import patch import pytest from esphome.build_helpers import pch from esphome.platformio import toolchain pytestmark = pytest.mark.skipif( os.name == "nt", reason="the fake compiler is a POSIX shell script" ) _SCRIPT = Path(toolchain.__file__).parent / "pch.py.script" class _FakePlatform: name = "fake" packages = {"framework-x": {}, "toolchain-y": {}} def get_package_version(self, name: str) -> str | None: # None for an optional package that is not installed return None if name == "toolchain-y" else "1.2.3" class _FakeSConsEnv(dict): """Just enough of a SCons construction environment for pch.py.""" def __init__( self, proj_dir: Path, src_dir: Path, cxx: str, flags: list[str], platform_cls: type[_FakePlatform] = _FakePlatform, ): super().__init__(ENV={}) self._subst = { "$PROJECT_DIR": str(proj_dir), "$PROJECT_SRC_DIR": str(src_dir), "$CXX": cxx, } self._flags = flags self._platform_cls = platform_cls self.prepended: list[str] = [] def subst(self, expr: str) -> str: # noqa: N802 return self._subst[expr] def subst_list(self, expr: str) -> list[list[str]]: # noqa: N802 return [self._flags] def PioPlatform(self) -> _FakePlatform: # noqa: N802 return self._platform_cls() def Prepend(self, CXXFLAGS: list[str]) -> None: # noqa: N802, N803 self.prepended = CXXFLAGS def Flatten(self, nodes: list) -> list: # noqa: N802 return nodes def GetProjectOption(self, name: str, default: list) -> list: # noqa: N802 return self.get(f"option:{name}", default) def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: """A compiler stand-in that records its argv and writes the -o target.""" cxx = tmp_path / "fake-gxx" # As GCC: the program's path when it exists next to the driver, else its # name; the version from FAKE_GCC_VERSION body = ( 'case "$1" in -print-prog-name=*) n=${1#*=};' ' p="$(dirname "$0")/../libexec/gcc/arm-none-eabi/10.3.1/$n";' ' [ -x "$p" ] && echo "$p" || echo "$n"; exit 0;;' ' -dumpversion) echo "${FAKE_GCC_VERSION:-10.3.1}"; exit 0;; esac\n' 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' ) if fail: body += "echo boom >&2\nexit 1\n" else: body += 'out=""; prev=""\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done\n' body += '[ -n "$out" ] && echo gch > "$out"\n' cxx.write_text("#!/bin/sh\n" + body) cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC) return cxx def _run_script( tmp_path: Path, flags: list[str] | None = None, fail: bool = False, env_vars: dict[str, str] | None = None, name: str = "dev", platform_cls: type[_FakePlatform] = _FakePlatform, build_files: Callable[[tuple], list] | None = None, cxx: Path | None = None, ) -> _FakeSConsEnv: proj = tmp_path / name src = proj / "src" (src / "esphome" / "core").mkdir(parents=True, exist_ok=True) (src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n") (src / "esphome" / "core" / "pch_prefix.h").write_text( '#include "esphome/core/defines.h"\n' ) if cxx is None: cxx = _fake_cxx(tmp_path, fail=fail) args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls) # Distinct objects: the -include flags must land on projenv only global_env = _FakeSConsEnv(*args) projenv = _FakeSConsEnv(*args) projenv.global_env = global_env if build_files is not None: global_env["PIOBUILDFILES"] = build_files(args) source = _SCRIPT.read_text() with patch.dict(os.environ, env_vars or {}, clear=True): exec( # noqa: S102 compile(source, "pch.py", "exec"), { "Import": lambda *_names: None, "env": global_env, "projenv": projenv, "COMMAND_LINE_TARGETS": [], }, ) return projenv def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None: scons_env = _run_script(tmp_path) proj = tmp_path / "dev" assert (proj / "esphome_pch_src.h").read_text() == pch.pch_header_text( pch.PCH_DEFAULT_HEADERS ) assert (proj / "esphome_pch.h").read_text() == pch.PCH_GUARD_TEXT assert (proj / "esphome_pch.h.gch").is_file() assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64 assert scons_env.prepended == pch.pch_consumer_flags() # The -include flags are scoped to projenv (src compiles) assert scons_env.global_env.prepended == [] class _LibreTinyPlatform(_FakePlatform): name = "libretiny" def _run_on_host( tmp_path: Path, host: str, machine: str, platform_cls: type[_FakePlatform], cxx: Path | None = None, gcc_version: str = "10.3.1", ) -> _FakeSConsEnv: with ( patch.object(sys, "platform", host), patch("platform.machine", return_value=machine), ): return _run_script( tmp_path, platform_cls=platform_cls, cxx=cxx, env_vars={"FAKE_GCC_VERSION": gcc_version}, ) def _fake_toolchain(tmp_path: Path) -> tuple[Path, Path]: """A driver in bin/ and a cc1plus that records its argv in libexec/.""" toolchain = tmp_path / "toolchain" (toolchain / "bin").mkdir(parents=True) cxx = _fake_cxx(toolchain / "bin") real = toolchain / "libexec" / "gcc" / "arm-none-eabi" / "10.3.1" / "cc1plus" real.parent.mkdir(parents=True) real.write_text('#!/bin/sh\nprintf \'%s\\n\' "$@" > "$0.argv"\n') real.chmod(0o755) return cxx, real def test_pch_script_gcc10_wrapper_on_apple_silicon(tmp_path: Path) -> None: """The LibreTiny .gch compile and the consumers get a -B directory holding a cc1plus that starts the real one.""" cxx, real = _fake_toolchain(tmp_path) scons_env = _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx) wrapper = tmp_path / "dev" / pch.PCH_CC1_DIR / "cc1plus" assert wrapper.stat().st_mode & stat.S_IXUSR assert repr(str(real)) in wrapper.read_text(encoding="utf-8") argv = Path(f"{cxx}.argv").read_text(encoding="utf-8").split("\n") assert f"-B{pch.PCH_CC1_DIR}/" in argv assert scons_env.prepended == [f"-B{pch.PCH_CC1_DIR}/", *pch.pch_consumer_flags()] @pytest.mark.skipif(sys.platform != "darwin", reason="the wrapper is macOS only") def test_pch_script_gcc10_wrapper_starts_the_real_cc1plus(tmp_path: Path) -> None: cxx, real = _fake_toolchain(tmp_path) _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx) wrapper = tmp_path / "dev" / pch.PCH_CC1_DIR / "cc1plus" result = subprocess.run( [str(wrapper), "-quiet", "x.cpp"], capture_output=True, text=True, check=False ) assert result.returncode == 0, result.stderr assert Path(f"{real}.argv").read_text(encoding="utf-8") == "-quiet\nx.cpp\n" def test_pch_script_gcc10_without_cc1plus_builds_plainly( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: """A driver without a cc1plus of its own gets no wrapper and no header.""" scons_env = _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform) assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists() assert scons_env.prepended == [] assert "compiling without it" in capsys.readouterr().out @pytest.mark.parametrize( ("host", "machine"), [("darwin", "x86_64"), ("win32", "AMD64")] ) def test_pch_script_gcc10_skipped_elsewhere( tmp_path: Path, host: str, machine: str, capsys: pytest.CaptureFixture[str] ) -> None: """Where the GCC 10 .gch cannot load, the build runs without it.""" scons_env = _run_on_host(tmp_path, host, machine, _LibreTinyPlatform) assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists() assert scons_env.prepended == [] assert "compiling without it" in capsys.readouterr().out @pytest.mark.parametrize( ("host", "machine", "platform_cls", "gcc_version"), [ ("darwin", "arm64", _FakePlatform, "10.3.1"), ("linux", "aarch64", _LibreTinyPlatform, "10.3.1"), # From GCC 12 the .gch loads at any address, so the rule retires itself ("darwin", "arm64", _LibreTinyPlatform, "12.2.0"), ("win32", "AMD64", _LibreTinyPlatform, "14.2.0"), ], ) def test_pch_script_no_wrapper_where_the_gch_loads( tmp_path: Path, host: str, machine: str, platform_cls: type[_FakePlatform], gcc_version: str, ) -> None: scons_env = _run_on_host( tmp_path, host, machine, platform_cls, gcc_version=gcc_version ) assert not (tmp_path / "dev" / pch.PCH_CC1_DIR).exists() assert scons_env.prepended == pch.pch_consumer_flags() def test_pch_script_names_match_the_python_side(tmp_path: Path) -> None: """The script cannot import esphome, so its copies are pinned.""" namespace: dict[str, object] = { "Import": lambda *_names: None, "env": _FakeSConsEnv(tmp_path, tmp_path, "g++", []), "projenv": None, "COMMAND_LINE_TARGETS": ["nobuild"], } exec(compile(_SCRIPT.read_text(), "pch.py", "exec"), namespace) # noqa: S102 assert namespace["_HEADER_NAME"] == pch.PCH_HEADER_NAME assert namespace["_SOURCE_NAME"] == pch.PCH_SOURCE_NAME assert namespace["_DEFAULT_HEADERS"] == pch.PCH_DEFAULT_HEADERS assert namespace["_CONSUMER_FLAGS"] == pch.pch_consumer_flags() assert namespace["_GUARD_TEXT"] == pch.PCH_GUARD_TEXT assert namespace["_INCLUDE_RE"].pattern == pch._INCLUDE_RE.pattern assert namespace["_CC1_DIR"] == pch.PCH_CC1_DIR def test_pch_script_compile_failure_stops_the_build(tmp_path: Path) -> None: """The pch holds only ESPHome's own headers: a failure is a defect.""" with pytest.raises(RuntimeError, match="could not compile") as raised: _run_script(tmp_path, fail=True) assert "boom" in str(raised.value) assert not (tmp_path / "dev" / "esphome_pch.h.gch.sum").exists() def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None: """One SCons element stays one compiler argv; -include pairs are stripped from the .gch compile.""" spaced = tmp_path / "My Configs" spaced.mkdir() (tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True) (tmp_path / "dev" / "src" / "other.h").write_text("") flags = ['-DUSB_PRODUCT=\\"Pico 2W\\"', "-I", str(spaced), "-include", "other.h"] _run_script(tmp_path, flags=flags) calls = (tmp_path / "fake-gxx.argv").read_text().split("---call---\n") gch_call = next(c for c in calls if "c++-header" in c).splitlines() assert '-DUSB_PRODUCT="Pico 2W"' in gch_call assert str(spaced) in gch_call assert "-include" not in gch_call # The stripped src-resolvable -include is folded into the prefix header pch = (tmp_path / "dev" / "esphome_pch_src.h").read_text() assert pch.splitlines()[0] == '#include "other.h"' def test_pch_script_folds_joined_force_include_spelling(tmp_path: Path) -> None: """-includefoo.h folds like the separated form, matching the native path.""" (tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True) (tmp_path / "dev" / "src" / "other.h").write_text("") _run_script(tmp_path, flags=["-DX=1", "-includeother.h"]) pch = (tmp_path / "dev" / "esphome_pch_src.h").read_text() assert pch.splitlines()[0] == '#include "other.h"' def test_pch_script_sum_is_device_independent(tmp_path: Path) -> None: """Regression: identical configs in different dirs share cache keys.""" sums = [] for name in ("dev_a", "dev_b"): proj = tmp_path / name _run_script( tmp_path, flags=["-DX=1", "-I", str(proj / "include")], env_vars={"CCACHE_BASEDIR": str(proj)}, name=name, ) sums.append((proj / "esphome_pch.h.gch.sum").read_text()) (tmp_path / "fake-gxx").unlink() (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) assert sums[0] == sums[1] def test_pch_script_uses_the_envs_of_existing_src_objects(tmp_path: Path) -> None: """The ESP-IDF builder creates the src objects from its own environments before this script runs; flags on projenv would never reach them.""" made: list[_FakeSConsEnv] = [] def build_files(args: tuple) -> list: src = args[1] made.extend(_FakeSConsEnv(*args) for _ in range(3)) return [ SimpleNamespace(env=made[0], sources=[src / "main.cpp"]), SimpleNamespace(env=made[0], sources=[src / "esphome" / "a.cpp"]), # C and framework objects keep their environments untouched SimpleNamespace(env=made[1], sources=[src / "esphome" / "b.c"]), SimpleNamespace(env=made[2], sources=[src.parent / "lib" / "c.cpp"]), ] projenv = _run_script(tmp_path, build_files=build_files) assert made[0].prepended == pch.pch_consumer_flags() assert made[1].prepended == made[2].prepended == projenv.prepended == [] def test_copy_pch_script(tmp_path: Path) -> None: from esphome.core import CORE CORE.build_path = tmp_path toolchain.copy_pch_script() assert (tmp_path / "pch.py").read_text() == _SCRIPT.read_text() def _run_without_projenv(tmp_path: Path, targets: list[str]) -> Path: proj = tmp_path / "dev" (proj / "src").mkdir(parents=True) def strict_import(*names: str) -> None: if "projenv" in names: raise RuntimeError("Import of non-existent variable 'projenv'") env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"]) exec( # noqa: S102 compile(_SCRIPT.read_text(), "pch.py", "exec"), {"Import": strict_import, "env": env, "COMMAND_LINE_TARGETS": targets}, ) return proj def test_pch_script_nobuild_without_projenv_is_noop(tmp_path: Path) -> None: """-t nobuild never exports projenv; the script must not abort.""" proj = _run_without_projenv(tmp_path, ["nobuild"]) assert not (proj / "esphome_pch.h").exists() def test_pch_script_build_without_projenv_stops(tmp_path: Path) -> None: with pytest.raises(RuntimeError, match="no project environment"): _run_without_projenv(tmp_path, []) def test_pch_script_stops_when_no_source_object_matches(tmp_path: Path) -> None: """Flags on projenv would reach no compile while the log says the precompiled header is in use.""" def build_files(args: tuple) -> list: lib = args[1].parent / "lib" / "c.cpp" return [SimpleNamespace(env=_FakeSConsEnv(*args), sources=[lib])] with pytest.raises(RuntimeError, match="no C\\+\\+ source takes"): _run_script(tmp_path, build_files=build_files) def test_pch_script_ignores_library_trees_and_non_headers(tmp_path: Path) -> None: """.piolibdeps and non-header files must not enter the digest (or be read at all); package versions already cover library identity.""" proj = tmp_path / "dev" libdeps = proj / ".piolibdeps" / "lib" / "src" libdeps.mkdir(parents=True) (libdeps / "lib.h").write_text("#define A 1\n") override = proj / "lwip_override" override.mkdir(parents=True) (override / "lwipopts.h").write_text("#define TCP_MSS 1460\n") (override / "notes.txt").write_text("v1\n") flags = ["-DX=1", "-I", str(libdeps), "-I", str(override)] _run_script(tmp_path, flags=flags) first = (proj / "esphome_pch.h.gch.sum").read_text() (libdeps / "lib.h").write_text("#define A 2\n") (override / "notes.txt").write_text("v2\n") (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) _run_script(tmp_path, flags=flags) assert (proj / "esphome_pch.h.gch.sum").read_text() == first def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None: """Generated headers in project-local -I dirs (e.g. rp2's lwip_override) must invalidate the checksum when they change.""" proj = tmp_path / "dev" override = proj / "lwip_override" override.mkdir(parents=True) (override / "lwipopts.h").write_text("#define TCP_MSS 1460\n") flags = ["-DX=1", "-I", str(override)] _run_script(tmp_path, flags=flags) first = (proj / "esphome_pch.h.gch.sum").read_text() (override / "lwipopts.h").write_text("#define TCP_MSS 536\n") (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) _run_script(tmp_path, flags=flags) assert (proj / "esphome_pch.h.gch.sum").read_text() != first def test_pch_script_folds_force_includes_found_on_the_include_path( tmp_path: Path, ) -> None: """A framework force-include (Arduino.h) is read before the core headers, as it was without the precompiled header.""" _run_script(tmp_path, flags=["-DX=1", "-include", "Arduino.h"]) source = (tmp_path / "dev" / "esphome_pch_src.h").read_text() assert source.splitlines() == [ '#include "Arduino.h"', '#include "esphome/core/pch_prefix.h"', ] def test_pch_script_skipped_for_an_absolute_force_include( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: outside = tmp_path / "outside.h" outside.write_text("") scons_env = _run_script(tmp_path, flags=["-DX=1", "-include", str(outside)]) assert scons_env.prepended == [] assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists() assert "prevents the precompiled header" in capsys.readouterr().out def _sum_after(tmp_path: Path, flags: list[str]) -> str: (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) _run_script(tmp_path, flags=flags) return (tmp_path / "dev" / "esphome_pch.h.gch.sum").read_text() @pytest.mark.parametrize( "option", [["-Iinclude"], ["-isystem", "include"], ["-iquoteinclude"]] ) def test_pch_script_hashes_relative_include_dirs( tmp_path: Path, option: list[str] ) -> None: """Compiles run in the project root, so -Iinclude is a project dir.""" include = tmp_path / "dev" / "include" include.mkdir(parents=True) (include / "user.h").write_text("#define A 1\n") flags = ["-DX=1", *option, "-I."] first = _sum_after(tmp_path, flags) (include / "user.h").write_text("#define A 2\n") assert _sum_after(tmp_path, flags) != first def test_pch_script_hashes_the_sdkconfig(tmp_path: Path) -> None: """sdkconfig.h is build output; the configuration it comes from is not.""" proj = tmp_path / "dev" proj.mkdir() (proj / "sdkconfig.dev").write_text("CONFIG_X=y\n") first = _sum_after(tmp_path, ["-DX=1"]) (proj / "sdkconfig.dev").write_text("CONFIG_X=n\n") assert _sum_after(tmp_path, ["-DX=1"]) != first def test_pch_script_hashes_the_library_versions(tmp_path: Path) -> None: sums = [] for version in ("7.4.1", "7.4.2"): (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) with patch.object( _FakeSConsEnv, "GetProjectOption", lambda self, name, default, version=version: [f"ArduinoJson@{version}"], ): _run_script(tmp_path) sums.append((tmp_path / "dev" / "esphome_pch.h.gch.sum").read_text()) assert sums[0] != sums[1]