mirror of
https://github.com/esphome/esphome.git
synced 2026-10-01 08:24:13 +08:00
The guard validates arguments, so it reads better next to the other early exit than after the loop that already skips blank patterns.
1329 lines
49 KiB
Python
Executable File
1329 lines
49 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Test ESPHome component builds with intelligent grouping.
|
|
|
|
This script replaces the bash test_build_components script with Python,
|
|
adding support for intelligent component grouping based on shared bus
|
|
configurations to reduce CI build time.
|
|
|
|
Features:
|
|
- Analyzes components for shared common bus configs
|
|
- Groups compatible components together
|
|
- Merges configs for grouped components
|
|
- Uses --testing-mode for grouped tests
|
|
- Maintains backward compatibility with single component testing
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
# Add esphome to path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
# pylint: disable=wrong-import-position
|
|
from script.analyze_component_buses import (
|
|
BASE_BUS_COMPONENTS,
|
|
ISOLATED_COMPONENTS,
|
|
NO_BUSES_SIGNATURE,
|
|
analyze_all_components,
|
|
create_grouping_signature,
|
|
is_platform_component,
|
|
merge_compatible_bus_groups,
|
|
uses_local_file_references,
|
|
)
|
|
from script.helpers import (
|
|
components_conflict,
|
|
get_component_test_files,
|
|
is_validate_only_file,
|
|
parse_test_filename,
|
|
split_conflicting_groups,
|
|
)
|
|
from script.merge_component_configs import merge_component_configs
|
|
|
|
|
|
@dataclass
|
|
class TestResult:
|
|
"""Store information about a single test run."""
|
|
|
|
test_id: str
|
|
components: list[str]
|
|
platform: str
|
|
success: bool
|
|
duration: float
|
|
command: str = ""
|
|
test_type: str = "compile" # "config" or "compile"
|
|
|
|
|
|
def show_disk_space_if_ci(esphome_command: str) -> None:
|
|
"""Show disk space usage if running in CI during compile.
|
|
|
|
Only shows output during compilation (not config validation) since
|
|
disk space is only relevant when actually building firmware.
|
|
|
|
Args:
|
|
esphome_command: The esphome command being run (config/compile/clean)
|
|
"""
|
|
# Only show disk space during compilation in CI
|
|
# Config validation doesn't build anything so disk space isn't relevant
|
|
if not os.environ.get("GITHUB_ACTIONS"):
|
|
return
|
|
if esphome_command != "compile":
|
|
return
|
|
|
|
print("\n" + "=" * 80)
|
|
print("Disk Space After Build:")
|
|
print("=" * 80)
|
|
# Use sys.stdout.flush() to ensure output appears immediately
|
|
sys.stdout.flush()
|
|
subprocess.run(["df", "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr)
|
|
print("=" * 80 + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def start_log_group(title: str) -> None:
|
|
"""Begin a collapsible log group in the GitHub Actions log viewer.
|
|
|
|
Everything printed until the matching :func:`end_log_group` is folded away
|
|
by default, so the full ``esphome config``/``compile`` dump for one
|
|
configuration no longer pushes the pass/fail result thousands of lines down
|
|
the log. Outside CI this is a no-op so local runs stay plain.
|
|
|
|
Args:
|
|
title: Text shown on the (collapsed) group header line.
|
|
"""
|
|
if not os.environ.get("GITHUB_ACTIONS"):
|
|
return
|
|
# Flush so the marker is ordered correctly relative to the child process
|
|
# output that follows (the subprocess writes straight to our stdout).
|
|
sys.stdout.flush()
|
|
print(f"::group::{title}")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def end_log_group() -> None:
|
|
"""Close the collapsible log group opened by :func:`start_log_group`.
|
|
|
|
Outside CI this is a no-op.
|
|
"""
|
|
if not os.environ.get("GITHUB_ACTIONS"):
|
|
return
|
|
sys.stdout.flush()
|
|
print("::endgroup::")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def find_component_tests(
|
|
components_dir: Path,
|
|
component_pattern: str = "*",
|
|
base_only: bool = False,
|
|
include_validate: bool = False,
|
|
) -> dict[str, list[Path]]:
|
|
"""Find all component test files.
|
|
|
|
Args:
|
|
components_dir: Path to tests/components directory
|
|
component_pattern: Glob pattern for component names
|
|
base_only: If True, only find base test files (test.*.yaml), not variant files (test-*.yaml)
|
|
include_validate: If True, also include config-only files (validate.*.yaml).
|
|
These are run with `esphome config` only and never compiled.
|
|
|
|
Returns:
|
|
Dictionary mapping component name to list of test files
|
|
"""
|
|
component_tests = defaultdict(list)
|
|
|
|
for comp_dir in components_dir.glob(component_pattern):
|
|
if not comp_dir.is_dir():
|
|
continue
|
|
|
|
# Get test files using helper function
|
|
test_files = get_component_test_files(
|
|
comp_dir.name,
|
|
all_variants=not base_only,
|
|
include_validate=include_validate,
|
|
)
|
|
if test_files:
|
|
component_tests[comp_dir.name] = test_files
|
|
|
|
return dict(component_tests)
|
|
|
|
|
|
def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]:
|
|
"""Get all platform base files.
|
|
|
|
Args:
|
|
base_dir: Path to test_build_components directory
|
|
|
|
Returns:
|
|
Dictionary mapping platform to list of base files (for version variants)
|
|
"""
|
|
platform_files = defaultdict(list)
|
|
|
|
for base_file in base_dir.glob("build_components_base.*.yaml"):
|
|
# Extract platform from filename
|
|
# e.g., build_components_base.esp32-idf.yaml -> esp32-idf
|
|
# or build_components_base.esp32-idf-50.yaml -> esp32-idf
|
|
filename = base_file.stem
|
|
parts = filename.replace("build_components_base.", "").split("-")
|
|
|
|
# Platform is everything before version number (if present)
|
|
# Check if last part is a number (version)
|
|
platform = "-".join(parts[:-1]) if parts[-1].isdigit() else "-".join(parts)
|
|
|
|
platform_files[platform].append(base_file)
|
|
|
|
return dict(platform_files)
|
|
|
|
|
|
def group_components_by_platform(
|
|
failed_results: list[TestResult],
|
|
) -> dict[tuple[str, str], list[str]]:
|
|
"""Group failed components by platform and test type for simplified reproduction commands.
|
|
|
|
Args:
|
|
failed_results: List of failed test results
|
|
|
|
Returns:
|
|
Dictionary mapping (platform, test_type) to list of component names
|
|
"""
|
|
platform_components: dict[tuple[str, str], list[str]] = {}
|
|
for result in failed_results:
|
|
key = (result.platform, result.test_type)
|
|
if key not in platform_components:
|
|
platform_components[key] = []
|
|
platform_components[key].extend(result.components)
|
|
|
|
# Remove duplicates and sort for each platform
|
|
return {
|
|
key: sorted(set(components)) for key, components in platform_components.items()
|
|
}
|
|
|
|
|
|
def format_github_summary(test_results: list[TestResult], toolchain=None) -> str:
|
|
"""Format test results as GitHub Actions job summary markdown.
|
|
|
|
Args:
|
|
test_results: List of all test results
|
|
|
|
Returns:
|
|
Markdown formatted summary string
|
|
"""
|
|
# Separate results into passed and failed
|
|
passed_results = [r for r in test_results if r.success]
|
|
failed_results = [r for r in test_results if not r.success]
|
|
|
|
lines = []
|
|
|
|
# Header with emoji based on success/failure
|
|
if failed_results:
|
|
lines.append("## :x: Component Tests Failed\n")
|
|
else:
|
|
lines.append("## :white_check_mark: Component Tests Passed\n")
|
|
|
|
# Summary statistics
|
|
total_time = sum(r.duration for r in test_results)
|
|
# Determine test type from results (all should be the same)
|
|
test_type = test_results[0].test_type if test_results else "unknown"
|
|
lines.append(
|
|
f"**Results:** {len(passed_results)} passed, {len(failed_results)} failed\n"
|
|
)
|
|
lines.append(f"**Total time:** {total_time:.1f}s\n")
|
|
lines.append(f"**Test type:** `{test_type}`\n")
|
|
|
|
# Show failed tests if any
|
|
if failed_results:
|
|
lines.append("### Failed Tests\n")
|
|
lines.append("| Test | Components | Platform | Duration |\n")
|
|
lines.append("|------|-----------|----------|----------|\n")
|
|
for result in failed_results:
|
|
components_str = ", ".join(result.components)
|
|
lines.append(
|
|
f"| `{result.test_id}` | {components_str} | {result.platform} | {result.duration:.1f}s |\n"
|
|
)
|
|
lines.append("\n")
|
|
|
|
# Show simplified commands to reproduce failures
|
|
# Group all failed components by platform for a single command per platform
|
|
lines.append("<details>\n")
|
|
lines.append("<summary>Commands to reproduce failures</summary>\n\n")
|
|
lines.append("```bash\n")
|
|
|
|
# Generate one command per platform and test type
|
|
extra_arguments = f" --toolchain {toolchain}" if toolchain else ""
|
|
platform_components = group_components_by_platform(failed_results)
|
|
for platform, test_type in sorted(platform_components.keys()):
|
|
components_csv = ",".join(platform_components[(platform, test_type)])
|
|
lines.append(
|
|
f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}{extra_arguments}\n"
|
|
)
|
|
|
|
lines.append("```\n")
|
|
lines.append("</details>\n")
|
|
|
|
# Show passed tests
|
|
if passed_results:
|
|
lines.append("### Passed Tests\n\n")
|
|
lines.append(f"{len(passed_results)} tests passed successfully\n")
|
|
|
|
# Separate grouped and individual tests
|
|
grouped_results = [r for r in passed_results if len(r.components) > 1]
|
|
individual_results = [r for r in passed_results if len(r.components) == 1]
|
|
|
|
if grouped_results:
|
|
lines.append("#### Grouped Tests\n")
|
|
lines.append("| Components | Platform | Count | Duration |\n")
|
|
lines.append("|-----------|----------|-------|----------|\n")
|
|
for result in grouped_results:
|
|
components_str = ", ".join(result.components)
|
|
lines.append(
|
|
f"| {components_str} | {result.platform} | {len(result.components)} | {result.duration:.1f}s |\n"
|
|
)
|
|
lines.append("\n")
|
|
|
|
if individual_results:
|
|
lines.append("#### Individual Tests\n")
|
|
# Show first 10 individual tests with timing
|
|
if len(individual_results) <= 10:
|
|
lines.extend(
|
|
f"- `{result.test_id}` - {result.duration:.1f}s\n"
|
|
for result in individual_results
|
|
)
|
|
else:
|
|
lines.extend(
|
|
f"- `{result.test_id}` - {result.duration:.1f}s\n"
|
|
for result in individual_results[:10]
|
|
)
|
|
lines.append(f"\n...and {len(individual_results) - 10} more\n")
|
|
lines.append("\n")
|
|
|
|
return "".join(lines)
|
|
|
|
|
|
def write_github_summary(
|
|
test_results: list[TestResult], toolchain: str | None = None
|
|
) -> None:
|
|
"""Write GitHub Actions job summary with test results and timing.
|
|
|
|
Args:
|
|
test_results: List of all test results
|
|
"""
|
|
summary_content = format_github_summary(test_results, toolchain)
|
|
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as f:
|
|
f.write(summary_content)
|
|
|
|
|
|
def extract_platform_with_version(base_file: Path) -> str:
|
|
"""Extract platform with version from base filename.
|
|
|
|
Args:
|
|
base_file: Path to base file
|
|
|
|
Returns:
|
|
Platform with version (e.g., "esp32-idf-50" or "esp32-idf")
|
|
"""
|
|
# Remove "build_components_base." prefix and ".yaml" suffix
|
|
return base_file.stem.replace("build_components_base.", "")
|
|
|
|
|
|
def run_esphome_test(
|
|
component: str,
|
|
test_file: Path,
|
|
platform: str,
|
|
platform_with_version: str,
|
|
base_file: Path,
|
|
build_dir: Path,
|
|
esphome_command: str,
|
|
continue_on_fail: bool,
|
|
use_testing_mode: bool = False,
|
|
toolchain: str | None = None,
|
|
) -> TestResult:
|
|
"""Run esphome test for a single component.
|
|
|
|
Args:
|
|
component: Component name
|
|
test_file: Path to component test file
|
|
platform: Platform name (e.g., "esp32-idf")
|
|
platform_with_version: Platform with version (e.g., "esp32-idf-50")
|
|
base_file: Path to platform base file
|
|
build_dir: Path to build directory
|
|
esphome_command: ESPHome command (config/compile)
|
|
continue_on_fail: Whether to continue on failure
|
|
use_testing_mode: Whether to use --testing-mode flag
|
|
|
|
Returns:
|
|
TestResult object with test details and timing
|
|
"""
|
|
test_name = test_file.stem.split(".")[0]
|
|
|
|
# Create dynamic test file in build directory
|
|
output_file = build_dir / f"{component}.{test_name}.{platform_with_version}.yaml"
|
|
|
|
# Copy base file and substitute component test file reference
|
|
base_content = base_file.read_text(encoding="utf-8")
|
|
# Get relative path from build dir to test file
|
|
repo_root = Path(__file__).parent.parent
|
|
component_test_ref = f"../../{test_file.relative_to(repo_root / 'tests')}"
|
|
output_content = base_content.replace("$component_test_file", component_test_ref)
|
|
output_file.write_text(output_content)
|
|
|
|
# Build esphome command
|
|
cmd = [
|
|
sys.executable,
|
|
"-m",
|
|
"esphome",
|
|
]
|
|
|
|
# Add --testing-mode if needed (must be before subcommand)
|
|
if use_testing_mode:
|
|
cmd.append("--testing-mode")
|
|
|
|
# Add substitutions
|
|
cmd.extend(
|
|
[
|
|
"-s",
|
|
"component_name",
|
|
component,
|
|
"-s",
|
|
"component_dir",
|
|
f"../../components/{component}",
|
|
"-s",
|
|
"test_name",
|
|
test_name,
|
|
"-s",
|
|
"target_platform",
|
|
platform,
|
|
]
|
|
)
|
|
|
|
if toolchain:
|
|
cmd.extend(["--toolchain", toolchain])
|
|
|
|
# Add command
|
|
cmd.append(esphome_command)
|
|
|
|
# Add config file
|
|
cmd.append(str(output_file))
|
|
|
|
# Build command string for display/logging
|
|
cmd_str = " ".join(cmd)
|
|
|
|
# Run command inside a collapsible CI log group so the full esphome output
|
|
# for this configuration can be folded away by default.
|
|
group_title = f"[{component}] [{test_name}] [{platform_with_version}]"
|
|
start_log_group(group_title)
|
|
print(f"> {group_title}")
|
|
if use_testing_mode:
|
|
print(" (using --testing-mode)")
|
|
|
|
start_time = time.time()
|
|
test_id = f"{component}.{test_name}.{platform_with_version}"
|
|
|
|
# Always close the group, even if the subprocess or disk-space reporting
|
|
# raises, so later output is never folded into the wrong CI log section.
|
|
try:
|
|
result = subprocess.run(cmd, check=False)
|
|
# Show disk space after build in CI during compile
|
|
show_disk_space_if_ci(esphome_command)
|
|
finally:
|
|
end_log_group()
|
|
|
|
success = result.returncode == 0
|
|
duration = time.time() - start_time
|
|
|
|
if not success and not continue_on_fail:
|
|
# Print command immediately for failed tests. The group is already
|
|
# closed, so the failure and reproduce command stay visible.
|
|
print(f"\n{'=' * 80}")
|
|
print("FAILED - Command to reproduce:")
|
|
print(f"{'=' * 80}")
|
|
print(cmd_str)
|
|
print()
|
|
raise subprocess.CalledProcessError(result.returncode, cmd)
|
|
|
|
return TestResult(
|
|
test_id=test_id,
|
|
components=[component],
|
|
platform=platform_with_version,
|
|
success=success,
|
|
duration=duration,
|
|
command=cmd_str,
|
|
test_type=esphome_command,
|
|
)
|
|
|
|
|
|
def run_grouped_test(
|
|
components: list[str],
|
|
platform: str,
|
|
platform_with_version: str,
|
|
base_file: Path,
|
|
build_dir: Path,
|
|
tests_dir: Path,
|
|
esphome_command: str,
|
|
continue_on_fail: bool,
|
|
toolchain: str | None = None,
|
|
) -> TestResult:
|
|
"""Run esphome test for a group of components with shared bus configs.
|
|
|
|
Args:
|
|
components: List of component names to test together
|
|
platform: Platform name (e.g., "esp32-idf")
|
|
platform_with_version: Platform with version (e.g., "esp32-idf-50")
|
|
base_file: Path to platform base file
|
|
build_dir: Path to build directory
|
|
tests_dir: Path to tests/components directory
|
|
esphome_command: ESPHome command (config/compile)
|
|
continue_on_fail: Whether to continue on failure
|
|
|
|
Returns:
|
|
TestResult object with test details and timing
|
|
"""
|
|
# Create merged config
|
|
group_name = "_".join(components[:3]) # Use first 3 components for name
|
|
if len(components) > 3:
|
|
group_name += f"_plus_{len(components) - 3}"
|
|
|
|
# Create unique device name by hashing sorted component list + platform
|
|
# This prevents conflicts when different component groups are tested
|
|
sorted_components = sorted(components)
|
|
hash_input = "_".join(sorted_components) + "_" + platform
|
|
group_hash = hashlib.md5(hash_input.encode()).hexdigest()[:8]
|
|
device_name = f"comptest{platform.replace('-', '')}{group_hash}"
|
|
|
|
merged_config_file = build_dir / f"merged_{group_name}.{platform_with_version}.yaml"
|
|
|
|
try:
|
|
merge_component_configs(
|
|
component_names=components,
|
|
platform=platform_with_version,
|
|
tests_dir=tests_dir,
|
|
output_file=merged_config_file,
|
|
)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
print(f"Error merging configs for {components}: {e}")
|
|
if not continue_on_fail:
|
|
raise
|
|
# Return TestResult for merge failure
|
|
test_id = f"GROUPED[{','.join(components)}].{platform_with_version}"
|
|
return TestResult(
|
|
test_id=test_id,
|
|
components=components,
|
|
platform=platform_with_version,
|
|
success=False,
|
|
duration=0.0,
|
|
command=f"# Failed during config merge: {e}",
|
|
test_type=esphome_command,
|
|
)
|
|
|
|
# Create test file that includes merged config
|
|
output_file = build_dir / f"test_{group_name}.{platform_with_version}.yaml"
|
|
base_content = base_file.read_text(encoding="utf-8")
|
|
merged_ref = merged_config_file.name
|
|
output_content = base_content.replace("$component_test_file", merged_ref)
|
|
output_file.write_text(output_content)
|
|
|
|
# Build esphome command with --testing-mode
|
|
cmd = [
|
|
sys.executable,
|
|
"-m",
|
|
"esphome",
|
|
"--testing-mode", # Required for grouped tests
|
|
"-s",
|
|
"component_name",
|
|
device_name, # Use unique hash-based device name
|
|
"-s",
|
|
"component_dir",
|
|
"../../components",
|
|
"-s",
|
|
"test_name",
|
|
"merged",
|
|
"-s",
|
|
"target_platform",
|
|
platform,
|
|
]
|
|
|
|
if toolchain:
|
|
cmd.extend(["--toolchain", toolchain])
|
|
|
|
# Add command
|
|
cmd.append(esphome_command)
|
|
|
|
cmd.append(str(output_file))
|
|
|
|
# Build command string for display/logging
|
|
cmd_str = " ".join(cmd)
|
|
|
|
# Run command inside a collapsible CI log group so the full esphome output
|
|
# for this grouped configuration can be folded away by default.
|
|
components_str = ", ".join(components)
|
|
group_title = f"[GROUPED: {components_str}] [{platform_with_version}]"
|
|
start_log_group(group_title)
|
|
print(f"> {group_title}")
|
|
print(" (using --testing-mode)")
|
|
|
|
start_time = time.time()
|
|
test_id = f"GROUPED[{','.join(components)}].{platform_with_version}"
|
|
|
|
# Always close the group, even if the subprocess or disk-space reporting
|
|
# raises, so later output is never folded into the wrong CI log section.
|
|
try:
|
|
result = subprocess.run(cmd, check=False)
|
|
# Show disk space after build in CI during compile
|
|
show_disk_space_if_ci(esphome_command)
|
|
finally:
|
|
end_log_group()
|
|
|
|
success = result.returncode == 0
|
|
duration = time.time() - start_time
|
|
|
|
if not success and not continue_on_fail:
|
|
# Print command immediately for failed tests. The group is already
|
|
# closed, so the failure and reproduce command stay visible.
|
|
print(f"\n{'=' * 80}")
|
|
print("FAILED - Command to reproduce:")
|
|
print(f"{'=' * 80}")
|
|
print(cmd_str)
|
|
print()
|
|
raise subprocess.CalledProcessError(result.returncode, cmd)
|
|
|
|
return TestResult(
|
|
test_id=test_id,
|
|
components=components,
|
|
platform=platform_with_version,
|
|
success=success,
|
|
duration=duration,
|
|
command=cmd_str,
|
|
test_type=esphome_command,
|
|
)
|
|
|
|
|
|
def run_grouped_component_tests(
|
|
all_tests: dict[str, list[Path]],
|
|
platform_filter: str | None,
|
|
platform_bases: dict[str, list[Path]],
|
|
tests_dir: Path,
|
|
build_dir: Path,
|
|
esphome_command: str,
|
|
continue_on_fail: bool,
|
|
additional_isolated: set[str] | None = None,
|
|
toolchain: str | None = None,
|
|
) -> tuple[set[tuple[str, str]], list[TestResult]]:
|
|
"""Run grouped component tests.
|
|
|
|
Args:
|
|
all_tests: Dictionary mapping component names to test files
|
|
platform_filter: Optional platform to filter by
|
|
platform_bases: Platform base files mapping
|
|
tests_dir: Path to tests/components directory
|
|
build_dir: Path to build directory
|
|
esphome_command: ESPHome command (config/compile)
|
|
continue_on_fail: Whether to continue on failure
|
|
additional_isolated: Additional components to treat as isolated (not grouped)
|
|
|
|
Returns:
|
|
Tuple of (tested_components, test_results)
|
|
"""
|
|
tested_components = set()
|
|
test_results = []
|
|
|
|
# Group components by platform and bus signature
|
|
grouped_components: dict[tuple[str, str], list[str]] = defaultdict(list)
|
|
print("\n" + "=" * 80)
|
|
print("Analyzing components for intelligent grouping...")
|
|
print("=" * 80)
|
|
component_buses, non_groupable, direct_bus_components = analyze_all_components(
|
|
tests_dir
|
|
)
|
|
|
|
# Track why components can't be grouped (for detailed output)
|
|
non_groupable_reasons = {}
|
|
|
|
# Merge additional isolated components with predefined ones
|
|
# ISOLATED COMPONENTS are tested individually WITHOUT --testing-mode
|
|
# This is critical because:
|
|
# - Grouped tests use --testing-mode which disables pin conflict checks and other validation
|
|
# - These checks are disabled to allow config merging (multiple components in one build)
|
|
# - For directly changed components (via --isolate), we need full validation to catch issues
|
|
# - Dependencies are safe to group since they weren't modified in the PR
|
|
all_isolated = set(ISOLATED_COMPONENTS.keys())
|
|
if additional_isolated:
|
|
all_isolated.update(additional_isolated)
|
|
|
|
# Group by (platform, bus_signature)
|
|
for component, platforms in component_buses.items():
|
|
if component not in all_tests:
|
|
continue
|
|
|
|
# Skip components that must be tested in isolation
|
|
# These are shown separately and should not be in non_groupable_reasons
|
|
if component in all_isolated:
|
|
continue
|
|
|
|
# Skip base bus components (these test the bus platforms themselves)
|
|
if component in BASE_BUS_COMPONENTS:
|
|
continue
|
|
|
|
# Skip components that use local file references or direct bus configs
|
|
if component in non_groupable:
|
|
# Track the reason (using pre-calculated results to avoid expensive re-analysis)
|
|
if component not in non_groupable_reasons:
|
|
if component in direct_bus_components:
|
|
non_groupable_reasons[component] = (
|
|
"Defines buses directly (not via packages) - NEEDS MIGRATION"
|
|
)
|
|
elif uses_local_file_references(tests_dir / component):
|
|
non_groupable_reasons[component] = (
|
|
"Uses local file references ($component_dir)"
|
|
)
|
|
elif is_platform_component(tests_dir / component):
|
|
non_groupable_reasons[component] = (
|
|
"Platform component (abstract base class)"
|
|
)
|
|
else:
|
|
non_groupable_reasons[component] = (
|
|
"Uses !extend or !remove directives"
|
|
)
|
|
continue
|
|
|
|
for platform, buses in platforms.items():
|
|
# Skip if platform doesn't match filter
|
|
if platform_filter and not platform.startswith(platform_filter):
|
|
continue
|
|
|
|
# Create signature for this component's bus configuration
|
|
# Components with no buses get NO_BUSES_SIGNATURE so they can be grouped together
|
|
if buses:
|
|
signature = create_grouping_signature({platform: buses}, platform)
|
|
else:
|
|
signature = NO_BUSES_SIGNATURE
|
|
|
|
# Add to grouped components (including those with no buses)
|
|
if signature:
|
|
grouped_components[(platform, signature)].append(component)
|
|
|
|
# Merge groups with compatible buses (cross-bus grouping optimization)
|
|
# This allows mixing components with different buses (e.g., ble + uart)
|
|
# as long as they don't have conflicting configurations for the same bus type
|
|
grouped_components = merge_compatible_bus_groups(grouped_components)
|
|
|
|
# Split groups that contain components declaring CONFLICTS_WITH each other.
|
|
# The bus-level merge above only considers shared bus configs; components
|
|
# with the same bus signature (e.g. both I2C) can still be mutually
|
|
# incompatible (e.g. bme680_bsec vs. bme68x_bsec2_i2c which auto-loads
|
|
# bme68x_bsec2). Those must end up in separate builds.
|
|
grouped_components = split_conflicting_groups(grouped_components)
|
|
|
|
# Print detailed grouping plan
|
|
print("\nGrouping Plan:")
|
|
print("-" * 80)
|
|
|
|
# Show isolated components (must test individually due to known issues or direct changes)
|
|
isolated_in_tests = [c for c in all_isolated if c in all_tests]
|
|
if isolated_in_tests:
|
|
predefined_isolated = [c for c in isolated_in_tests if c in ISOLATED_COMPONENTS]
|
|
additional_in_tests = [
|
|
c for c in isolated_in_tests if c in (additional_isolated or set())
|
|
]
|
|
|
|
if predefined_isolated:
|
|
print(
|
|
f"\n⚠ {len(predefined_isolated)} components must be tested in isolation (known build issues):"
|
|
)
|
|
for comp in sorted(predefined_isolated):
|
|
reason = ISOLATED_COMPONENTS[comp]
|
|
print(f" - {comp}: {reason}")
|
|
|
|
if additional_in_tests:
|
|
print(
|
|
f"\n✓ {len(additional_in_tests)} components tested in isolation (directly changed in PR):"
|
|
)
|
|
for comp in sorted(additional_in_tests):
|
|
print(f" - {comp}")
|
|
|
|
# Show base bus components (test the bus platform implementations)
|
|
base_bus_in_tests = [c for c in BASE_BUS_COMPONENTS if c in all_tests]
|
|
if base_bus_in_tests:
|
|
print(
|
|
f"\n○ {len(base_bus_in_tests)} base bus platform components (tested individually):"
|
|
)
|
|
for comp in sorted(base_bus_in_tests):
|
|
print(f" - {comp}")
|
|
|
|
# Show excluded components with detailed reasons
|
|
if non_groupable_reasons:
|
|
excluded_in_tests = [c for c in non_groupable_reasons if c in all_tests]
|
|
if excluded_in_tests:
|
|
print(
|
|
f"\n⚠ {len(excluded_in_tests)} components excluded from grouping (each needs individual build):"
|
|
)
|
|
# Group by reason to show summary
|
|
direct_bus = [
|
|
c
|
|
for c in excluded_in_tests
|
|
if "NEEDS MIGRATION" in non_groupable_reasons.get(c, "")
|
|
]
|
|
if direct_bus:
|
|
print(
|
|
f"\n ⚠⚠⚠ {len(direct_bus)} DEFINE BUSES DIRECTLY - NEED MIGRATION TO PACKAGES:"
|
|
)
|
|
for comp in sorted(direct_bus):
|
|
print(f" - {comp}")
|
|
|
|
other_reasons = [
|
|
c
|
|
for c in excluded_in_tests
|
|
if "NEEDS MIGRATION" not in non_groupable_reasons.get(c, "")
|
|
]
|
|
if other_reasons and len(other_reasons) <= 10:
|
|
print("\n Other non-groupable components:")
|
|
for comp in sorted(other_reasons):
|
|
reason = non_groupable_reasons[comp]
|
|
print(f" - {comp}: {reason}")
|
|
elif other_reasons:
|
|
print(
|
|
f"\n Other non-groupable components: {len(other_reasons)} components"
|
|
)
|
|
|
|
# Distribute no_buses components into other groups to maximize efficiency
|
|
# Components with no buses can merge with any bus group since they have no conflicting requirements
|
|
no_buses_by_platform: dict[str, list[str]] = {}
|
|
for (platform, signature), components in list(grouped_components.items()):
|
|
if signature == NO_BUSES_SIGNATURE:
|
|
no_buses_by_platform[platform] = components
|
|
# Remove from grouped_components - we'll distribute them
|
|
del grouped_components[(platform, signature)]
|
|
|
|
# Distribute no_buses components into existing groups for each platform
|
|
for platform, no_buses_comps in no_buses_by_platform.items():
|
|
# Find all non-empty groups for this platform (excluding no_buses)
|
|
platform_groups = [
|
|
(sig, comps)
|
|
for (plat, sig), comps in grouped_components.items()
|
|
if plat == platform and sig != NO_BUSES_SIGNATURE
|
|
]
|
|
|
|
# Distribute no_buses components round-robin across existing groups,
|
|
# but never place a component into a group it conflicts with. Conflict
|
|
# splitting (split_conflicting_groups) may have created sibling groups
|
|
# like "no_buses__conflict1" precisely to keep incompatible components
|
|
# apart (e.g. on nRF52, network pulls in openthread which zigbee
|
|
# conflicts with); redistribution must not silently undo that split.
|
|
leftover: list[str] = []
|
|
for i, comp in enumerate(no_buses_comps):
|
|
placed = False
|
|
# Try groups starting at the round-robin offset to keep the spread.
|
|
for offset in range(len(platform_groups)):
|
|
sig, comps = platform_groups[(i + offset) % len(platform_groups)]
|
|
if any(components_conflict(comp, other, platform) for other in comps):
|
|
continue
|
|
# comps is the same list object stored in grouped_components, so
|
|
# this also extends the group in grouped_components.
|
|
comps.append(comp)
|
|
placed = True
|
|
break
|
|
if not placed:
|
|
leftover.append(comp)
|
|
|
|
if leftover:
|
|
# Components that conflict with every existing group stay together in
|
|
# their own no_buses group (they were grouped before, so they don't
|
|
# conflict with each other).
|
|
grouped_components.setdefault((platform, NO_BUSES_SIGNATURE), []).extend(
|
|
leftover
|
|
)
|
|
|
|
groups_to_test = []
|
|
individual_tests = set() # Use set to avoid duplicates
|
|
|
|
for (platform, signature), components in sorted(grouped_components.items()):
|
|
if len(components) > 1:
|
|
groups_to_test.append((platform, signature, components))
|
|
# Note: Don't add single-component groups to individual_tests here
|
|
# They'll be added below when we check for ungrouped components
|
|
|
|
# Add components that weren't grouped on any platform
|
|
for component in all_tests:
|
|
if component not in [c for _, _, comps in groups_to_test for c in comps]:
|
|
individual_tests.add(component)
|
|
|
|
if groups_to_test:
|
|
print(f"\n✓ {len(groups_to_test)} groups will be tested together:")
|
|
for platform, signature, components in groups_to_test:
|
|
component_list = ", ".join(sorted(components))
|
|
print(f" [{platform}] [{signature}]: {component_list}")
|
|
print(
|
|
f" → {len(components)} components in 1 build (saves {len(components) - 1} builds)"
|
|
)
|
|
|
|
if individual_tests:
|
|
print(f"\n○ {len(individual_tests)} components will be tested individually:")
|
|
sorted_individual = sorted(individual_tests)
|
|
for comp in sorted_individual[:10]:
|
|
print(f" - {comp}")
|
|
if len(individual_tests) > 10:
|
|
print(f" ... and {len(individual_tests) - 10} more")
|
|
|
|
# Calculate actual build counts based on test files, not component counts
|
|
# Without grouping: every test file would be built separately
|
|
total_test_files = sum(len(test_files) for test_files in all_tests.values())
|
|
|
|
# With grouping:
|
|
# - 1 build per group (regardless of how many components)
|
|
# - Individual components still need all their platform builds
|
|
# - Validate files of grouped components still run individually
|
|
# (they're config-only and bypass the grouped compile, see
|
|
# run_individual_component_test), so each adds one more invocation.
|
|
individual_test_file_count = sum(
|
|
len(all_tests[comp]) for comp in individual_tests if comp in all_tests
|
|
)
|
|
|
|
grouped_component_set = {c for _, _, comps in groups_to_test for c in comps}
|
|
grouped_validate_file_count = sum(
|
|
1
|
|
for comp in grouped_component_set
|
|
for test_file in all_tests.get(comp, [])
|
|
if is_validate_only_file(test_file)
|
|
)
|
|
|
|
total_grouped_components = sum(len(comps) for _, _, comps in groups_to_test)
|
|
total_builds_with_grouping = (
|
|
len(groups_to_test) + individual_test_file_count + grouped_validate_file_count
|
|
)
|
|
builds_saved = total_test_files - total_builds_with_grouping
|
|
|
|
print(f"\n{'=' * 80}")
|
|
print(
|
|
f"Summary: {total_builds_with_grouping} builds total (vs {total_test_files} without grouping)"
|
|
)
|
|
print(
|
|
f" • {len(groups_to_test)} grouped builds ({total_grouped_components} components)"
|
|
)
|
|
print(
|
|
f" • {individual_test_file_count} individual builds ({len(individual_tests)} components)"
|
|
)
|
|
if grouped_validate_file_count:
|
|
print(
|
|
f" • {grouped_validate_file_count} validate-only invocations for grouped components"
|
|
)
|
|
if total_test_files > 0:
|
|
reduction_pct = (builds_saved / total_test_files) * 100
|
|
print(f" • Saves {builds_saved} builds ({reduction_pct:.1f}% reduction)")
|
|
print("=" * 80 + "\n")
|
|
|
|
# Execute grouped tests
|
|
for (platform, _signature), components in grouped_components.items():
|
|
# Only group if we have multiple components with same signature
|
|
if len(components) <= 1:
|
|
continue
|
|
|
|
# Filter out components not in our test list
|
|
components_to_group = [c for c in components if c in all_tests]
|
|
if len(components_to_group) <= 1:
|
|
continue
|
|
|
|
# Get platform base files
|
|
if platform not in platform_bases:
|
|
continue
|
|
|
|
for base_file in platform_bases[platform]:
|
|
platform_with_version = extract_platform_with_version(base_file)
|
|
|
|
# Skip if platform filter doesn't match
|
|
if platform_filter and platform != platform_filter:
|
|
continue
|
|
if (
|
|
platform_filter
|
|
and platform_with_version != platform_filter
|
|
and not platform_with_version.startswith(f"{platform_filter}-")
|
|
):
|
|
continue
|
|
|
|
# Run grouped test
|
|
test_result = run_grouped_test(
|
|
components=components_to_group,
|
|
platform=platform,
|
|
platform_with_version=platform_with_version,
|
|
base_file=base_file,
|
|
build_dir=build_dir,
|
|
tests_dir=tests_dir,
|
|
esphome_command=esphome_command,
|
|
continue_on_fail=continue_on_fail,
|
|
toolchain=toolchain,
|
|
)
|
|
|
|
# Mark all components as tested
|
|
for comp in components_to_group:
|
|
tested_components.add((comp, platform_with_version))
|
|
|
|
# Store test result
|
|
test_results.append(test_result)
|
|
|
|
return tested_components, test_results
|
|
|
|
|
|
def run_individual_component_test(
|
|
component: str,
|
|
test_file: Path,
|
|
platform: str,
|
|
platform_with_version: str,
|
|
base_file: Path,
|
|
build_dir: Path,
|
|
esphome_command: str,
|
|
continue_on_fail: bool,
|
|
tested_components: set[tuple[str, str]],
|
|
test_results: list[TestResult],
|
|
toolchain: str | None = None,
|
|
) -> None:
|
|
"""Run an individual component test if not already tested in a group.
|
|
|
|
Args:
|
|
component: Component name
|
|
test_file: Test file path
|
|
platform: Platform name
|
|
platform_with_version: Platform with version
|
|
base_file: Base file for platform
|
|
build_dir: Build directory
|
|
esphome_command: ESPHome command
|
|
continue_on_fail: Whether to continue on failure
|
|
tested_components: Set of already tested components
|
|
test_results: List to append test results
|
|
"""
|
|
# Validate files (validate.*.yaml) are config-only and never participate
|
|
# in compile-time bus grouping, so always run them individually even when
|
|
# the (component, platform) pair was covered by a group test.
|
|
if (
|
|
not is_validate_only_file(test_file)
|
|
and (component, platform_with_version) in tested_components
|
|
):
|
|
return
|
|
|
|
test_result = run_esphome_test(
|
|
component=component,
|
|
test_file=test_file,
|
|
platform=platform,
|
|
platform_with_version=platform_with_version,
|
|
base_file=base_file,
|
|
build_dir=build_dir,
|
|
esphome_command=esphome_command,
|
|
continue_on_fail=continue_on_fail,
|
|
toolchain=toolchain,
|
|
)
|
|
test_results.append(test_result)
|
|
|
|
|
|
def test_components(
|
|
component_patterns: list[str],
|
|
platform_filter: str | None,
|
|
esphome_command: str,
|
|
continue_on_fail: bool,
|
|
enable_grouping: bool = True,
|
|
isolated_components: set[str] | None = None,
|
|
base_only: bool = False,
|
|
toolchain: str | None = None,
|
|
fail_on_no_tests: bool = False,
|
|
) -> int:
|
|
"""Test components with optional intelligent grouping.
|
|
|
|
Args:
|
|
component_patterns: List of component name patterns
|
|
platform_filter: Optional platform to filter by
|
|
esphome_command: ESPHome command (config/compile)
|
|
continue_on_fail: Whether to continue on failure
|
|
enable_grouping: Whether to enable component grouping
|
|
isolated_components: Set of component names to test in isolation (not grouped).
|
|
These are tested WITHOUT --testing-mode to enable full validation
|
|
(pin conflicts, etc). This is used in CI for directly changed components
|
|
to catch issues that would be missed with --testing-mode.
|
|
base_only: If True, only test base test files (test.*.yaml), not variant files (test-*.yaml)
|
|
|
|
Returns:
|
|
Exit code (0 for success, 1 for failure)
|
|
"""
|
|
# Setup paths
|
|
repo_root = Path(__file__).parent.parent
|
|
tests_dir = repo_root / "tests" / "components"
|
|
build_components_dir = repo_root / "tests" / "test_build_components"
|
|
build_dir = build_components_dir / "build"
|
|
build_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Get platform base files
|
|
platform_bases = get_platform_base_files(build_components_dir)
|
|
|
|
# Validate files (validate.*.yaml) are config-only -- they exercise
|
|
# schema/validation paths but are never compiled. Include them when running
|
|
# `config` or `clean`; exclude them under `compile` so they never reach a
|
|
# toolchain build.
|
|
include_validate = esphome_command != "compile"
|
|
|
|
# A blank pattern list would slide into the reference-baseline
|
|
# fallback and exit green while building nothing
|
|
if fail_on_no_tests and not any(component_patterns):
|
|
print("No components requested (blank component list)")
|
|
return 1
|
|
|
|
# Find all component tests; remember which components each pattern
|
|
# (wildcards included) matched, for the deferred no-tests accounting
|
|
all_tests = {}
|
|
pattern_components: dict[str, set[str]] = {}
|
|
for pattern in component_patterns:
|
|
# Skip empty patterns (happens when components list is empty string)
|
|
if not pattern:
|
|
continue
|
|
found = find_component_tests(
|
|
tests_dir, pattern, base_only, include_validate=include_validate
|
|
)
|
|
pattern_components[pattern] = set(found)
|
|
all_tests.update(found)
|
|
|
|
if fail_on_no_tests and not all_tests:
|
|
# Nothing matched: fail before the synthetic baseline spends a
|
|
# compile reporting success on nothing
|
|
print(f"No components found matching: {component_patterns}")
|
|
return 1
|
|
|
|
if not all_tests:
|
|
print(f"No components found matching: {component_patterns}")
|
|
print(
|
|
"Building reference configuration with no components for baseline comparison..."
|
|
)
|
|
|
|
# Create empty test files for each platform (or filtered platform)
|
|
reference_tests: list[Path] = []
|
|
for platform_name in platform_bases:
|
|
if platform_filter and not platform_name.startswith(platform_filter):
|
|
continue
|
|
# Create an empty test file named to match the platform
|
|
empty_test_file = build_dir / f"reference.{platform_name}.yaml"
|
|
empty_test_file.write_text(
|
|
"# Empty component test for baseline reference\n"
|
|
)
|
|
reference_tests.append(empty_test_file)
|
|
|
|
# Add to all_tests dict with component name "reference"
|
|
all_tests["reference"] = reference_tests
|
|
|
|
print(f"Found {len(all_tests)} components to test")
|
|
|
|
# Run tests
|
|
test_results = []
|
|
tested_components = set() # Track which components were tested in groups
|
|
|
|
# First, run grouped tests if grouping is enabled
|
|
if enable_grouping:
|
|
tested_components, grouped_results = run_grouped_component_tests(
|
|
all_tests=all_tests,
|
|
platform_filter=platform_filter,
|
|
platform_bases=platform_bases,
|
|
tests_dir=tests_dir,
|
|
build_dir=build_dir,
|
|
esphome_command=esphome_command,
|
|
continue_on_fail=continue_on_fail,
|
|
additional_isolated=isolated_components,
|
|
toolchain=toolchain,
|
|
)
|
|
test_results.extend(grouped_results)
|
|
|
|
# Then run individual tests for components not in groups
|
|
for component, test_files in sorted(all_tests.items()):
|
|
for test_file in test_files:
|
|
test_name, platform = parse_test_filename(test_file)
|
|
|
|
# Handle "all" platform tests
|
|
if platform == "all":
|
|
# Run for all platforms
|
|
for plat, base_files in platform_bases.items():
|
|
if platform_filter and plat != platform_filter:
|
|
continue
|
|
|
|
for base_file in base_files:
|
|
platform_with_version = extract_platform_with_version(base_file)
|
|
run_individual_component_test(
|
|
component=component,
|
|
test_file=test_file,
|
|
platform=plat,
|
|
platform_with_version=platform_with_version,
|
|
base_file=base_file,
|
|
build_dir=build_dir,
|
|
esphome_command=esphome_command,
|
|
continue_on_fail=continue_on_fail,
|
|
tested_components=tested_components,
|
|
test_results=test_results,
|
|
toolchain=toolchain,
|
|
)
|
|
else:
|
|
# Platform-specific test
|
|
if platform_filter and platform != platform_filter:
|
|
continue
|
|
|
|
if platform not in platform_bases:
|
|
print(f"No base file for platform: {platform}")
|
|
continue
|
|
|
|
for base_file in platform_bases[platform]:
|
|
platform_with_version = extract_platform_with_version(base_file)
|
|
|
|
# Skip if requested platform doesn't match
|
|
if (
|
|
platform_filter
|
|
and platform_with_version != platform_filter
|
|
and not platform_with_version.startswith(f"{platform_filter}-")
|
|
):
|
|
continue
|
|
|
|
run_individual_component_test(
|
|
component=component,
|
|
test_file=test_file,
|
|
platform=platform,
|
|
platform_with_version=platform_with_version,
|
|
base_file=base_file,
|
|
build_dir=build_dir,
|
|
esphome_command=esphome_command,
|
|
continue_on_fail=continue_on_fail,
|
|
tested_components=tested_components,
|
|
test_results=test_results,
|
|
toolchain=toolchain,
|
|
)
|
|
|
|
silent: list[str] = []
|
|
if fail_on_no_tests:
|
|
# A green run that built nothing for a requested pattern must not
|
|
# pass CI. Per pattern so one silent pattern cannot hide behind
|
|
# the others; opt-in because some legs legitimately match nothing;
|
|
# deferred past the summary so reproduce commands still print.
|
|
built = {c for r in test_results for c in r.components}
|
|
# A pattern is silent when it matched no fixture, or when none of
|
|
# its matched components produced a build (wildcards included)
|
|
silent = [
|
|
p
|
|
for p in component_patterns
|
|
if p and not (pattern_components.get(p, set()) & built)
|
|
]
|
|
if silent:
|
|
print(f"No tests ran for requested pattern(s): {', '.join(silent)}")
|
|
|
|
# Separate results into passed and failed
|
|
passed_results = [r for r in test_results if r.success]
|
|
failed_results = [r for r in test_results if not r.success]
|
|
|
|
# Print summary
|
|
print("\n" + "=" * 80)
|
|
print(f"Test Summary: {len(passed_results)} passed, {len(failed_results)} failed")
|
|
print("=" * 80)
|
|
|
|
if failed_results:
|
|
print("\nFailed tests:")
|
|
for result in failed_results:
|
|
print(f" - {result.test_id}")
|
|
|
|
# Print simplified commands grouped by platform and test type for easy copy-paste
|
|
print("\n" + "=" * 80)
|
|
print("Commands to reproduce failures (copy-paste to reproduce locally):")
|
|
print("=" * 80)
|
|
extra_arguments = f" --toolchain {toolchain}" if toolchain else ""
|
|
platform_components = group_components_by_platform(failed_results)
|
|
for platform, test_type in sorted(platform_components.keys()):
|
|
components_csv = ",".join(platform_components[(platform, test_type)])
|
|
print(
|
|
f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}{extra_arguments}"
|
|
)
|
|
print()
|
|
|
|
# Write GitHub Actions job summary if in CI
|
|
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
|
write_github_summary(test_results, toolchain=toolchain)
|
|
|
|
if failed_results or silent:
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
"""Main entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Test ESPHome component builds with intelligent grouping"
|
|
)
|
|
parser.add_argument(
|
|
"-e",
|
|
"--esphome-command",
|
|
default="compile",
|
|
choices=["config", "compile", "clean"],
|
|
help="ESPHome command to run (default: compile)",
|
|
)
|
|
parser.add_argument(
|
|
"-c",
|
|
"--components",
|
|
default="*",
|
|
help="Component pattern(s) to test (default: *). Comma-separated.",
|
|
)
|
|
parser.add_argument(
|
|
"-t",
|
|
"--target",
|
|
help="Target platform to test (e.g., esp32-idf)",
|
|
)
|
|
parser.add_argument(
|
|
"-f",
|
|
"--continue-on-fail",
|
|
action="store_true",
|
|
help="Continue testing even if a test fails",
|
|
)
|
|
parser.add_argument(
|
|
"--no-grouping",
|
|
action="store_true",
|
|
help="Disable component grouping (test each component individually)",
|
|
)
|
|
parser.add_argument(
|
|
"--isolate",
|
|
help="Comma-separated list of components to test in isolation (not grouped with others). "
|
|
"These are tested WITHOUT --testing-mode to enable full validation. "
|
|
"Used in CI for directly changed components to catch pin conflicts and other issues.",
|
|
)
|
|
parser.add_argument(
|
|
"--base-only",
|
|
action="store_true",
|
|
help="Only test base test files (test.*.yaml), not variant files (test-*.yaml)",
|
|
)
|
|
parser.add_argument(
|
|
"--toolchain",
|
|
help="Select toolchain for compiling.",
|
|
)
|
|
parser.add_argument(
|
|
"--fail-on-no-tests",
|
|
action="store_true",
|
|
help="Exit non-zero when no test matched (for CI legs whose "
|
|
"components must all have fixtures)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Parse component patterns
|
|
component_patterns = [p.strip() for p in args.components.split(",")]
|
|
|
|
# Parse isolated components
|
|
isolated_components = None
|
|
if args.isolate:
|
|
isolated_components = {c.strip() for c in args.isolate.split(",") if c.strip()}
|
|
|
|
return test_components(
|
|
component_patterns=component_patterns,
|
|
platform_filter=args.target,
|
|
esphome_command=args.esphome_command,
|
|
continue_on_fail=args.continue_on_fail,
|
|
enable_grouping=not args.no_grouping,
|
|
isolated_components=isolated_components,
|
|
fail_on_no_tests=args.fail_on_no_tests,
|
|
base_only=args.base_only,
|
|
toolchain=args.toolchain,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|