mirror of
https://github.com/esphome/esphome.git
synced 2026-05-31 17:06:40 +08:00
[core] Extract shared C++ build helpers from cpp_unit_test.py (#14883)
This commit is contained in:
+36
-221
@@ -1,238 +1,53 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from helpers import get_all_components, get_all_dependencies, root_path
|
from helpers import get_all_components, root_path
|
||||||
|
from test_helpers import (
|
||||||
from esphome.__main__ import command_compile, parse_args
|
BASE_CODEGEN_COMPONENTS,
|
||||||
from esphome.config import validate_config
|
PLATFORMIO_GOOGLE_TEST_LIB,
|
||||||
from esphome.const import CONF_PLATFORM
|
USE_TIME_TIMEZONE_FLAG,
|
||||||
from esphome.core import CORE
|
build_and_run,
|
||||||
from esphome.loader import get_component
|
)
|
||||||
from esphome.platformio_api import get_idedata
|
|
||||||
|
|
||||||
# This must coincide with the version in /platformio.ini
|
|
||||||
PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2"
|
|
||||||
|
|
||||||
# Path to /tests/components
|
# Path to /tests/components
|
||||||
COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components"
|
COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components"
|
||||||
|
|
||||||
# Components whose to_code should run during C++ test builds.
|
PLATFORMIO_OPTIONS = {
|
||||||
# Most components don't need code generation for tests; only these
|
"build_type": "debug",
|
||||||
# essential ones (platform setup, logging, core config) are needed.
|
"build_unflags": [
|
||||||
# Note: "core" is the esphome core config module (esphome/core/config.py),
|
"-Os", # remove size-opt flag
|
||||||
# which registers under package name "core" not "esphome".
|
],
|
||||||
CPP_TESTING_CODEGEN_COMPONENTS = {"core", "host", "logger"}
|
"build_flags": [
|
||||||
|
"-Og", # optimize for debug
|
||||||
|
USE_TIME_TIMEZONE_FLAG,
|
||||||
def hash_components(components: list[str]) -> str:
|
"-DESPHOME_DEBUG", # enable debug assertions
|
||||||
key = ",".join(components)
|
# Enable the address and undefined behavior sanitizers
|
||||||
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
"-fsanitize=address",
|
||||||
|
"-fsanitize=undefined",
|
||||||
|
"-fno-omit-frame-pointer",
|
||||||
def filter_components_without_tests(components: list[str]) -> list[str]:
|
],
|
||||||
"""Filter out components that do not have a corresponding test file.
|
"debug_build_flags": [ # only for debug builds
|
||||||
|
"-g3", # max debug info
|
||||||
This is done by checking if the component's directory contains at
|
"-ggdb3",
|
||||||
least a .cpp or .h file.
|
],
|
||||||
"""
|
}
|
||||||
filtered_components: list[str] = []
|
|
||||||
for component in components:
|
|
||||||
test_dir = COMPONENTS_TESTS_DIR / component
|
|
||||||
if test_dir.is_dir() and (
|
|
||||||
any(test_dir.glob("*.cpp")) or any(test_dir.glob("*.h"))
|
|
||||||
):
|
|
||||||
filtered_components.append(component)
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
f"WARNING: No tests found for component '{component}', skipping.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return filtered_components
|
|
||||||
|
|
||||||
|
|
||||||
def create_test_config(config_name: str, includes: list[str]) -> dict:
|
|
||||||
"""Create ESPHome test configuration for C++ unit tests.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_name: Unique name for this test configuration
|
|
||||||
includes: List of include folders for the test build
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configuration dict for ESPHome
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"esphome": {
|
|
||||||
"name": config_name,
|
|
||||||
"friendly_name": "CPP Unit Tests",
|
|
||||||
"libraries": PLATFORMIO_GOOGLE_TEST_LIB,
|
|
||||||
"platformio_options": {
|
|
||||||
"build_type": "debug",
|
|
||||||
"build_unflags": [
|
|
||||||
"-Os", # remove size-opt flag
|
|
||||||
],
|
|
||||||
"build_flags": [
|
|
||||||
"-Og", # optimize for debug
|
|
||||||
"-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing
|
|
||||||
"-DESPHOME_DEBUG", # enable debug assertions
|
|
||||||
# Enable the address and undefined behavior sanitizers
|
|
||||||
"-fsanitize=address",
|
|
||||||
"-fsanitize=undefined",
|
|
||||||
"-fno-omit-frame-pointer",
|
|
||||||
],
|
|
||||||
"debug_build_flags": [ # only for debug builds
|
|
||||||
"-g3", # max debug info
|
|
||||||
"-ggdb3",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"includes": includes,
|
|
||||||
},
|
|
||||||
"host": {},
|
|
||||||
"logger": {"level": "DEBUG"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_platform_components(components: list[str]) -> list[str]:
|
|
||||||
"""Discover platform sub-components referenced by test directory structure.
|
|
||||||
|
|
||||||
For each component being tested, any sub-directory named after a platform
|
|
||||||
domain (e.g. ``sensor``, ``binary_sensor``) is treated as a request to
|
|
||||||
include that ``<domain>.<component>`` platform in the build. The sub-
|
|
||||||
directory must name a valid platform domain; anything else raises an error
|
|
||||||
so that typos are caught early.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of ``"domain.component"`` strings, one per discovered sub-directory.
|
|
||||||
"""
|
|
||||||
platform_components: list[str] = []
|
|
||||||
for component in components:
|
|
||||||
test_dir = COMPONENTS_TESTS_DIR / component
|
|
||||||
if not test_dir.is_dir():
|
|
||||||
continue
|
|
||||||
# Each sub-directory name is expected to be a platform domain
|
|
||||||
# (e.g. tests/components/bthome/sensor/ → sensor.bthome).
|
|
||||||
for domain_dir in test_dir.iterdir():
|
|
||||||
if not domain_dir.is_dir():
|
|
||||||
continue
|
|
||||||
domain = domain_dir.name
|
|
||||||
domain_module = get_component(domain)
|
|
||||||
if domain_module is None or not domain_module.is_platform_component:
|
|
||||||
raise ValueError(
|
|
||||||
f"Component tests for '{component}' reference non-existing or invalid domain '{domain}'"
|
|
||||||
f" in its directory structure. See ({COMPONENTS_TESTS_DIR / component / domain})."
|
|
||||||
)
|
|
||||||
platform_components.append(f"{domain}.{component}")
|
|
||||||
return platform_components
|
|
||||||
|
|
||||||
|
|
||||||
# Exit codes for run_tests
|
|
||||||
EXIT_OK = 0
|
|
||||||
EXIT_SKIPPED = 1
|
|
||||||
EXIT_COMPILE_ERROR = 2
|
|
||||||
EXIT_CONFIG_ERROR = 3
|
|
||||||
EXIT_NO_EXECUTABLE = 4
|
|
||||||
|
|
||||||
|
|
||||||
def run_tests(selected_components: list[str]) -> int:
|
def run_tests(selected_components: list[str]) -> int:
|
||||||
# Skip tests on Windows
|
return build_and_run(
|
||||||
if os.name == "nt":
|
selected_components=selected_components,
|
||||||
print("Skipping esphome tests on Windows", file=sys.stderr)
|
tests_dir=COMPONENTS_TESTS_DIR,
|
||||||
return EXIT_SKIPPED
|
codegen_components=BASE_CODEGEN_COMPONENTS,
|
||||||
|
config_prefix="cpptests",
|
||||||
# Remove components that do not have tests
|
friendly_name="CPP Unit Tests",
|
||||||
components = filter_components_without_tests(selected_components)
|
libraries=PLATFORMIO_GOOGLE_TEST_LIB,
|
||||||
|
platformio_options=PLATFORMIO_OPTIONS,
|
||||||
if len(components) == 0:
|
main_entry="main.cpp",
|
||||||
print(
|
label="unit tests",
|
||||||
"No components specified or no tests found for the specified components.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return EXIT_OK
|
|
||||||
|
|
||||||
components = sorted(components)
|
|
||||||
|
|
||||||
# Build a list of include folders relative to COMPONENTS_TESTS_DIR. These folders will
|
|
||||||
# be added along with their subfolders.
|
|
||||||
# "main.cpp" is a special entry that points to /tests/components/main.cpp,
|
|
||||||
# which provides a custom test runner entry-point replacing the default one.
|
|
||||||
# Each remaining entry is a component folder whose *.cpp files are compiled.
|
|
||||||
includes: list[str] = ["main.cpp"] + components
|
|
||||||
|
|
||||||
# Obtain a list of platform components to be tested:
|
|
||||||
try:
|
|
||||||
platform_components = get_platform_components(components)
|
|
||||||
except ValueError as e:
|
|
||||||
print(f"Error obtaining platform components: {e}")
|
|
||||||
return EXIT_CONFIG_ERROR
|
|
||||||
|
|
||||||
components = sorted(components + platform_components)
|
|
||||||
|
|
||||||
# Create a unique name for this config based on the actual components being tested
|
|
||||||
# to maximize cache during testing
|
|
||||||
config_name: str = "cpptests-" + hash_components(components)
|
|
||||||
|
|
||||||
# Obtain possible dependencies for the requested components.
|
|
||||||
# Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag,
|
|
||||||
# which causes core/time.h to include components/time/posix_tz.h.
|
|
||||||
components_with_dependencies: list[str] = sorted(
|
|
||||||
get_all_dependencies(set(components) | {"time"}, cpp_testing=True)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
config = create_test_config(config_name, includes)
|
|
||||||
|
|
||||||
CORE.config_path = COMPONENTS_TESTS_DIR / "dummy.yaml"
|
|
||||||
CORE.dashboard = None
|
|
||||||
CORE.cpp_testing = True
|
|
||||||
CORE.cpp_testing_codegen = CPP_TESTING_CODEGEN_COMPONENTS
|
|
||||||
|
|
||||||
# Validate config will expand the above with defaults:
|
|
||||||
config = validate_config(config, {})
|
|
||||||
|
|
||||||
# Add all components and dependencies to the base configuration after validation, so their files
|
|
||||||
# are added to the build.
|
|
||||||
for component_name in components_with_dependencies:
|
|
||||||
if "." in component_name:
|
|
||||||
# Format is always "domain.component" (exactly one dot),
|
|
||||||
# as produced by get_platform_components().
|
|
||||||
domain, component = component_name.split(".", maxsplit=1)
|
|
||||||
domain_list = config.setdefault(domain, [])
|
|
||||||
CORE.testing_ensure_platform_registered(domain)
|
|
||||||
domain_list.append({CONF_PLATFORM: component})
|
|
||||||
else:
|
|
||||||
config.setdefault(component_name, [])
|
|
||||||
|
|
||||||
dependencies = set(components_with_dependencies) - set(components)
|
|
||||||
deps_str = ", ".join(dependencies) if dependencies else "None"
|
|
||||||
print(f"Testing components: {', '.join(components)}. Dependencies: {deps_str}")
|
|
||||||
CORE.config = config
|
|
||||||
args = parse_args(["program", "compile", str(CORE.config_path)])
|
|
||||||
try:
|
|
||||||
exit_code: int = command_compile(args, config)
|
|
||||||
|
|
||||||
if exit_code != 0:
|
|
||||||
print(f"Error compiling unit tests for {', '.join(components)}")
|
|
||||||
return exit_code
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"Error compiling unit tests for {', '.join(components)}. Check path. : {e}"
|
|
||||||
)
|
|
||||||
return EXIT_COMPILE_ERROR
|
|
||||||
|
|
||||||
# After a successful compilation, locate the executable and run it:
|
|
||||||
idedata = get_idedata(config)
|
|
||||||
if idedata is None:
|
|
||||||
print("Cannot find executable")
|
|
||||||
return EXIT_NO_EXECUTABLE
|
|
||||||
|
|
||||||
program_path: str = idedata.raw["prog_path"]
|
|
||||||
run_cmd: list[str] = [program_path]
|
|
||||||
run_proc = subprocess.run(run_cmd, check=False)
|
|
||||||
return run_proc.returncode
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""Shared helpers for C++ unit test and benchmark build scripts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from helpers import get_all_dependencies
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from esphome.__main__ import command_compile, parse_args
|
||||||
|
from esphome.config import validate_config
|
||||||
|
from esphome.const import CONF_PLATFORM
|
||||||
|
from esphome.core import CORE
|
||||||
|
from esphome.loader import get_component
|
||||||
|
from esphome.platformio_api import get_idedata
|
||||||
|
|
||||||
|
# This must coincide with the version in /platformio.ini
|
||||||
|
PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2"
|
||||||
|
|
||||||
|
# Google Benchmark library for PlatformIO
|
||||||
|
# Format: name=repository_url (see esphome/core/config.py library parsing)
|
||||||
|
PLATFORMIO_GOOGLE_BENCHMARK_LIB = (
|
||||||
|
"benchmark=https://github.com/google/benchmark.git#v1.9.1"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Key names for the base config sections
|
||||||
|
ESPHOME_KEY = "esphome"
|
||||||
|
HOST_KEY = "host"
|
||||||
|
LOGGER_KEY = "logger"
|
||||||
|
|
||||||
|
# Base config keys that are always present and must not be fully overridden
|
||||||
|
# by component benchmark.yaml files. esphome: allows sub-key merging.
|
||||||
|
BASE_CONFIG_KEYS = frozenset({ESPHOME_KEY, HOST_KEY, LOGGER_KEY})
|
||||||
|
|
||||||
|
# Shared build flag — enables timezone code paths for testing/benchmarking.
|
||||||
|
USE_TIME_TIMEZONE_FLAG = "-DUSE_TIME_TIMEZONE"
|
||||||
|
|
||||||
|
# Components whose to_code should always run during C++ test/benchmark builds.
|
||||||
|
# These are the minimal infrastructure components needed for host compilation.
|
||||||
|
# Note: "core" is the esphome core config module (esphome/core/config.py),
|
||||||
|
# which registers under package name "core" not "esphome".
|
||||||
|
BASE_CODEGEN_COMPONENTS = {"core", "host", "logger"}
|
||||||
|
|
||||||
|
# Exit codes
|
||||||
|
EXIT_OK = 0
|
||||||
|
EXIT_SKIPPED = 1
|
||||||
|
EXIT_COMPILE_ERROR = 2
|
||||||
|
EXIT_CONFIG_ERROR = 3
|
||||||
|
EXIT_NO_EXECUTABLE = 4
|
||||||
|
|
||||||
|
# Name of the per-component YAML config file in benchmark directories
|
||||||
|
BENCHMARK_YAML_FILENAME = "benchmark.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_components(components: list[str]) -> str:
|
||||||
|
"""Create a short hash of component names for unique config naming."""
|
||||||
|
key = ",".join(components)
|
||||||
|
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def filter_components_with_files(components: list[str], tests_dir: Path) -> list[str]:
|
||||||
|
"""Filter out components that do not have .cpp or .h files in the tests dir.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
components: List of component names to check
|
||||||
|
tests_dir: Base directory containing component test/benchmark folders
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list of components that have test files
|
||||||
|
"""
|
||||||
|
filtered_components: list[str] = []
|
||||||
|
for component in components:
|
||||||
|
test_dir = tests_dir / component
|
||||||
|
if test_dir.is_dir() and (
|
||||||
|
any(test_dir.glob("*.cpp")) or any(test_dir.glob("*.h"))
|
||||||
|
):
|
||||||
|
filtered_components.append(component)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"WARNING: No files found for component '{component}' in {test_dir}, skipping.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return filtered_components
|
||||||
|
|
||||||
|
|
||||||
|
def get_platform_components(components: list[str], tests_dir: Path) -> list[str]:
|
||||||
|
"""Discover platform sub-components referenced by test directory structure.
|
||||||
|
|
||||||
|
For each component, any sub-directory named after a platform domain
|
||||||
|
(e.g. ``sensor``, ``binary_sensor``) is treated as a request to include
|
||||||
|
that ``<domain>.<component>`` platform in the build.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
components: List of component names to scan
|
||||||
|
tests_dir: Base directory containing component test/benchmark folders
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ``"domain.component"`` strings
|
||||||
|
"""
|
||||||
|
platform_components: list[str] = []
|
||||||
|
for component in components:
|
||||||
|
test_dir = tests_dir / component
|
||||||
|
if not test_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for domain_dir in test_dir.iterdir():
|
||||||
|
if not domain_dir.is_dir():
|
||||||
|
continue
|
||||||
|
domain = domain_dir.name
|
||||||
|
domain_module = get_component(domain)
|
||||||
|
if domain_module is None or not domain_module.is_platform_component:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{component}' references non-existing or invalid domain '{domain}'"
|
||||||
|
f" in its directory structure. See ({tests_dir / component / domain})."
|
||||||
|
)
|
||||||
|
platform_components.append(f"{domain}.{component}")
|
||||||
|
return platform_components
|
||||||
|
|
||||||
|
|
||||||
|
def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict:
|
||||||
|
"""Load and merge benchmark.yaml files from component directories.
|
||||||
|
|
||||||
|
Each component directory may contain a ``benchmark.yaml`` file that
|
||||||
|
declares additional ESPHome components needed for the build (e.g.
|
||||||
|
``api:``, ``sensor:``). These get merged into the base config before
|
||||||
|
validation so that dependencies are properly resolved with defaults.
|
||||||
|
|
||||||
|
The ``esphome:`` key is special: its sub-keys are merged into the
|
||||||
|
existing esphome config (e.g. to add ``areas:`` or ``devices:``).
|
||||||
|
Other base config keys (``host:``, ``logger:``) are not overridable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
components: List of component directory names
|
||||||
|
tests_dir: Base directory containing component folders
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged dict of component configs to add to the base config
|
||||||
|
"""
|
||||||
|
# Note: components are processed in sorted order. For conflicting keys
|
||||||
|
# (e.g. two benchmark.yaml files both declaring sensor:), the first
|
||||||
|
# component alphabetically wins via setdefault(). This is fine for now
|
||||||
|
# with a single benchmark component (api) but would need a real merge
|
||||||
|
# strategy if multiple components declare overlapping configs.
|
||||||
|
merged: dict = {}
|
||||||
|
for component in components:
|
||||||
|
yaml_path = tests_dir / component / BENCHMARK_YAML_FILENAME
|
||||||
|
if not yaml_path.is_file():
|
||||||
|
continue
|
||||||
|
with open(yaml_path) as f:
|
||||||
|
component_config = yaml.safe_load(f)
|
||||||
|
if component_config and isinstance(component_config, dict):
|
||||||
|
for key, value in component_config.items():
|
||||||
|
if key in BASE_CONFIG_KEYS - {ESPHOME_KEY}:
|
||||||
|
# host: and logger: are not overridable
|
||||||
|
continue
|
||||||
|
if key == ESPHOME_KEY and isinstance(value, dict):
|
||||||
|
# Merge esphome sub-keys rather than replacing
|
||||||
|
esphome_extra = merged.setdefault(ESPHOME_KEY, {})
|
||||||
|
for sub_key, sub_value in value.items():
|
||||||
|
esphome_extra.setdefault(sub_key, sub_value)
|
||||||
|
continue
|
||||||
|
merged.setdefault(key, value)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def create_host_config(
|
||||||
|
config_name: str,
|
||||||
|
friendly_name: str,
|
||||||
|
libraries: str | list[str],
|
||||||
|
includes: list[str],
|
||||||
|
platformio_options: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Create an ESPHome host configuration for C++ builds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_name: Unique name for this configuration
|
||||||
|
friendly_name: Human-readable name
|
||||||
|
libraries: PlatformIO library specification(s)
|
||||||
|
includes: List of include folders for the build
|
||||||
|
platformio_options: Dict of platformio_options to set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configuration dict for ESPHome
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
ESPHOME_KEY: {
|
||||||
|
"name": config_name,
|
||||||
|
"friendly_name": friendly_name,
|
||||||
|
"libraries": libraries,
|
||||||
|
"platformio_options": platformio_options,
|
||||||
|
"includes": includes,
|
||||||
|
},
|
||||||
|
HOST_KEY: {},
|
||||||
|
LOGGER_KEY: {"level": "DEBUG"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compile_and_get_binary(
|
||||||
|
config: dict,
|
||||||
|
components: list[str],
|
||||||
|
codegen_components: set[str],
|
||||||
|
tests_dir: Path,
|
||||||
|
label: str = "build",
|
||||||
|
) -> tuple[int, str | None]:
|
||||||
|
"""Compile an ESPHome configuration and return the binary path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: ESPHome configuration dict (already created via create_host_config)
|
||||||
|
components: List of components to include in the build
|
||||||
|
codegen_components: Set of component names whose to_code should run
|
||||||
|
tests_dir: Base directory for test files (used as config_path base)
|
||||||
|
label: Label for log messages (e.g. "unit tests", "benchmarks")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (exit_code, program_path_or_none)
|
||||||
|
"""
|
||||||
|
# Load any benchmark.yaml files from component directories and merge
|
||||||
|
# them into the config BEFORE dependency resolution and validation.
|
||||||
|
# This allows each benchmark/test dir to declare which ESPHome components
|
||||||
|
# it needs (e.g. api:) so they get proper config defaults.
|
||||||
|
extra_config = load_component_yaml_configs(components, tests_dir)
|
||||||
|
for key, value in extra_config.items():
|
||||||
|
if key == ESPHOME_KEY and isinstance(value, dict):
|
||||||
|
# Merge esphome sub-keys into existing esphome config.
|
||||||
|
# For list values (e.g. libraries), extend rather than replace.
|
||||||
|
for sub_key, sub_value in value.items():
|
||||||
|
existing = config[ESPHOME_KEY].get(sub_key)
|
||||||
|
if existing is not None and isinstance(sub_value, list):
|
||||||
|
# Ensure existing is a list, then extend
|
||||||
|
if not isinstance(existing, list):
|
||||||
|
config[ESPHOME_KEY][sub_key] = [existing]
|
||||||
|
config[ESPHOME_KEY][sub_key].extend(sub_value)
|
||||||
|
else:
|
||||||
|
config[ESPHOME_KEY].setdefault(sub_key, sub_value)
|
||||||
|
else:
|
||||||
|
config.setdefault(key, value)
|
||||||
|
|
||||||
|
# Obtain possible dependencies BEFORE validate_config, because
|
||||||
|
# get_all_dependencies calls CORE.reset() which clears build_path.
|
||||||
|
# Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag,
|
||||||
|
# which causes core/time.h to include components/time/posix_tz.h.
|
||||||
|
components_with_dependencies: list[str] = sorted(
|
||||||
|
get_all_dependencies(set(components) | {"time"}, cpp_testing=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
CORE.config_path = tests_dir / "dummy.yaml"
|
||||||
|
CORE.dashboard = None
|
||||||
|
CORE.cpp_testing = True
|
||||||
|
CORE.cpp_testing_codegen = codegen_components
|
||||||
|
|
||||||
|
# Validate config will expand the above with defaults:
|
||||||
|
config = validate_config(config, {})
|
||||||
|
|
||||||
|
# Add remaining components and dependencies to the configuration after
|
||||||
|
# validation, so their source files are included in the build.
|
||||||
|
for component_name in components_with_dependencies:
|
||||||
|
if "." in component_name:
|
||||||
|
domain, component = component_name.split(".", maxsplit=1)
|
||||||
|
domain_list = config.setdefault(domain, [])
|
||||||
|
CORE.testing_ensure_platform_registered(domain)
|
||||||
|
domain_list.append({CONF_PLATFORM: component})
|
||||||
|
# Skip "core" — it's a pseudo-component handled by the build
|
||||||
|
# system, not a real loadable component (get_component returns None)
|
||||||
|
elif get_component(component_name) is not None:
|
||||||
|
config.setdefault(component_name, [])
|
||||||
|
|
||||||
|
# Register platforms from the extra config (benchmark.yaml) so
|
||||||
|
# USE_SENSOR, USE_LIGHT, etc. defines are emitted without needing
|
||||||
|
# real entity instances.
|
||||||
|
for key in extra_config:
|
||||||
|
if key == ESPHOME_KEY:
|
||||||
|
continue
|
||||||
|
comp = get_component(key)
|
||||||
|
if comp is not None and comp.is_platform_component:
|
||||||
|
CORE.testing_ensure_platform_registered(key)
|
||||||
|
|
||||||
|
dependencies = set(components_with_dependencies) - set(components)
|
||||||
|
deps_str = ", ".join(dependencies) if dependencies else "None"
|
||||||
|
print(f"Building {label}: {', '.join(components)}. Dependencies: {deps_str}")
|
||||||
|
CORE.config = config
|
||||||
|
args = parse_args(["program", "compile", str(CORE.config_path)])
|
||||||
|
try:
|
||||||
|
exit_code: int = command_compile(args, config)
|
||||||
|
|
||||||
|
if exit_code != 0:
|
||||||
|
print(f"Error compiling {label} for {', '.join(components)}")
|
||||||
|
return exit_code, None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error compiling {label} for {', '.join(components)}: {e}")
|
||||||
|
return EXIT_COMPILE_ERROR, None
|
||||||
|
|
||||||
|
# After a successful compilation, locate the executable:
|
||||||
|
idedata = get_idedata(config)
|
||||||
|
if idedata is None:
|
||||||
|
print("Cannot find executable")
|
||||||
|
return EXIT_NO_EXECUTABLE, None
|
||||||
|
|
||||||
|
program_path: str = idedata.raw["prog_path"]
|
||||||
|
return EXIT_OK, program_path
|
||||||
|
|
||||||
|
|
||||||
|
def build_and_run(
|
||||||
|
selected_components: list[str],
|
||||||
|
tests_dir: Path,
|
||||||
|
codegen_components: set[str],
|
||||||
|
config_prefix: str,
|
||||||
|
friendly_name: str,
|
||||||
|
libraries: str | list[str],
|
||||||
|
platformio_options: dict,
|
||||||
|
main_entry: str,
|
||||||
|
label: str = "build",
|
||||||
|
build_only: bool = False,
|
||||||
|
extra_run_args: list[str] | None = None,
|
||||||
|
extra_include_dirs: list[Path] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Build and optionally run a C++ test/benchmark binary.
|
||||||
|
|
||||||
|
This is the main orchestration function shared between unit tests
|
||||||
|
and benchmarks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
selected_components: Components to include (directory names in tests_dir)
|
||||||
|
tests_dir: Directory containing test/benchmark files
|
||||||
|
codegen_components: Components whose to_code should run
|
||||||
|
config_prefix: Prefix for the config name (e.g. "cpptests", "cppbench")
|
||||||
|
friendly_name: Human-readable name for the config
|
||||||
|
libraries: PlatformIO library specification(s)
|
||||||
|
platformio_options: PlatformIO options dict
|
||||||
|
main_entry: Name of the main entry file (e.g. "main.cpp")
|
||||||
|
label: Label for log messages
|
||||||
|
build_only: If True, print binary path and return without running
|
||||||
|
extra_run_args: Extra arguments to pass to the binary
|
||||||
|
extra_include_dirs: Additional directories whose .cpp files
|
||||||
|
should be compiled (resolved relative to tests_dir if possible)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Exit code
|
||||||
|
"""
|
||||||
|
# Skip on Windows
|
||||||
|
if os.name == "nt":
|
||||||
|
print(f"Skipping {label} on Windows", file=sys.stderr)
|
||||||
|
return EXIT_SKIPPED
|
||||||
|
|
||||||
|
# Remove components that do not have files
|
||||||
|
components = filter_components_with_files(selected_components, tests_dir)
|
||||||
|
|
||||||
|
if len(components) == 0:
|
||||||
|
print(
|
||||||
|
f"No components specified or no files found for {label}.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
components = sorted(components)
|
||||||
|
|
||||||
|
# Build include list: main entry point + component folders + extra dirs
|
||||||
|
includes: list[str] = [main_entry] + components
|
||||||
|
if extra_include_dirs:
|
||||||
|
for d in extra_include_dirs:
|
||||||
|
if d.is_dir() and (any(d.glob("*.cpp")) or any(d.glob("*.h"))):
|
||||||
|
# ESPHome includes are relative to the config directory (tests_dir)
|
||||||
|
rel = os.path.relpath(d, tests_dir)
|
||||||
|
includes.append(rel)
|
||||||
|
|
||||||
|
# Discover platform sub-components
|
||||||
|
try:
|
||||||
|
platform_components = get_platform_components(components, tests_dir)
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"Error obtaining platform components: {e}")
|
||||||
|
return EXIT_CONFIG_ERROR
|
||||||
|
|
||||||
|
components = sorted(components + platform_components)
|
||||||
|
|
||||||
|
# Create unique config name
|
||||||
|
config_name: str = f"{config_prefix}-" + hash_components(components)
|
||||||
|
|
||||||
|
config = create_host_config(
|
||||||
|
config_name, friendly_name, libraries, includes, platformio_options
|
||||||
|
)
|
||||||
|
|
||||||
|
exit_code, program_path = compile_and_get_binary(
|
||||||
|
config, components, codegen_components, tests_dir, label
|
||||||
|
)
|
||||||
|
|
||||||
|
if exit_code != EXIT_OK or program_path is None:
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
if build_only:
|
||||||
|
print(f"BUILD_BINARY={program_path}")
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
# Run the binary
|
||||||
|
run_cmd: list[str] = [program_path]
|
||||||
|
if extra_run_args:
|
||||||
|
run_cmd.extend(extra_run_args)
|
||||||
|
run_proc = subprocess.run(run_cmd, check=False)
|
||||||
|
return run_proc.returncode
|
||||||
Reference in New Issue
Block a user