#!/usr/bin/env python3 import argparse from functools import partial import os from pathlib import Path import subprocess import sys import tempfile from build_helpers import ( PLATFORMIO_GOOGLE_TEST_LIB, build_and_run, load_test_manifest_overrides, ) from helpers import get_all_components, root_path from esphome.core import EsphomeError from esphome.host.toolchain import find_compilers # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" BUILD_FLAGS = [ "-Og", # optimize for debug "-g3", # max debug info "-ggdb3", "-DESPHOME_DEBUG", # enable debug assertions "-fno-omit-frame-pointer", ] SANITIZER_FLAGS = ["-fsanitize=address", "-fsanitize=undefined"] def sanitizer_flags() -> list[str]: """The sanitizer flags to build with: all of them, except on macOS the ones whose runtime does not start. Apple clang 15's AddressSanitizer aborts before main() on macOS 26, which would fail every test for a toolchain problem, so each flag is tried on an empty program first. A compiler that cannot build the empty program is an error, not a dropped flag. CI keeps every flag. """ if sys.platform != "darwin": return SANITIZER_FLAGS try: cxx = find_compilers().cxx except EsphomeError as err: sys.exit(str(err)) flags: list[str] = [] with tempfile.TemporaryDirectory() as tmp: program = Path(tmp) / "probe" for flag in SANITIZER_FLAGS: compiled = subprocess.run( [*cxx, flag, "-x", "c++", "-", "-o", program], check=False, input=b"int main() { return 0; }", capture_output=True, ) if compiled.returncode != 0: sys.exit( f"{cxx[0]} cannot build an empty program with {flag}:\n" f"{compiled.stderr.decode(errors='replace').strip()}" ) ran = subprocess.run([program], check=False, capture_output=True) if ran.returncode == 0: flags.append(flag) continue lines = [ line for line in ran.stderr.decode(errors="replace").splitlines() if line.strip() ] print( f"{flag} aborts at start with {cxx[0]}: " f"{lines[0] if lines else f'exit code {ran.returncode}'}\n" " building the tests without it; set CXX to a compiler whose runtime works to keep it", file=sys.stderr, ) return flags def run_tests(selected_components: list[str]) -> int: # allocator_may_return_null: an oversized request must come back empty, not abort the run os.environ["ASAN_OPTIONS"] = "detect_leaks=0:allocator_may_return_null=1" return build_and_run( selected_components=selected_components, tests_dir=COMPONENTS_TESTS_DIR, manifest_override_loader=partial( load_test_manifest_overrides, tests_dir=COMPONENTS_TESTS_DIR ), config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, build_flags=BUILD_FLAGS + sanitizer_flags(), main_entry="main.cpp", label="unit tests", ) def main() -> None: parser = argparse.ArgumentParser( description="Run C++ unit tests for ESPHome components." ) parser.add_argument( "components", nargs="*", help="List of components to test. Use --all to test all known components.", ) parser.add_argument("--all", action="store_true", help="Test all known components.") args = parser.parse_args() if args.all: components: list[str] = get_all_components() else: components: list[str] = args.components sys.exit(run_tests(components)) if __name__ == "__main__": main()