"""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 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: 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" body = ( '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, ) -> _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' ) 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 == [] 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 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]