mirror of
https://github.com/esphome/esphome.git
synced 2026-05-24 18:06:27 +08:00
[ci] Only run integration tests for changed components (#14776)
This commit is contained in:
+67
-37
@@ -6,6 +6,8 @@ what files have changed. It outputs JSON with the following structure:
|
||||
|
||||
{
|
||||
"integration_tests": true/false,
|
||||
"integration_tests_run_all": true/false,
|
||||
"integration_test_files": ["tests/integration/test_foo.py", ...],
|
||||
"clang_tidy": true/false,
|
||||
"clang_format": true/false,
|
||||
"python_linters": true/false,
|
||||
@@ -56,13 +58,13 @@ from helpers import (
|
||||
core_changed,
|
||||
filter_component_and_test_cpp_files,
|
||||
filter_component_and_test_files,
|
||||
get_all_dependencies,
|
||||
get_changed_components,
|
||||
get_component_from_path,
|
||||
get_component_test_files,
|
||||
get_components_from_integration_fixtures,
|
||||
get_components_with_dependencies,
|
||||
get_cpp_changed_components,
|
||||
get_fixture_to_test_files,
|
||||
get_integration_test_files_for_components,
|
||||
get_target_branch,
|
||||
git_ls_files,
|
||||
parse_test_filename,
|
||||
@@ -143,65 +145,88 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [
|
||||
]
|
||||
|
||||
|
||||
def should_run_integration_tests(branch: str | None = None) -> bool:
|
||||
"""Determine if integration tests should run based on changed files.
|
||||
def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[str]]:
|
||||
"""Determine which integration tests should run based on changed files.
|
||||
|
||||
This function is used by the CI workflow to intelligently skip integration tests when they're
|
||||
not needed, saving significant CI time and resources.
|
||||
This function is used by the CI workflow to intelligently skip or filter
|
||||
integration tests, saving significant CI time and resources.
|
||||
|
||||
Integration tests will run when ANY of the following conditions are met:
|
||||
Returns (run_all=True, []) when ANY of the following conditions are met:
|
||||
|
||||
1. Core C++ files changed (esphome/core/*)
|
||||
- Any .cpp, .h, .tcc files in the core directory
|
||||
- These files contain fundamental functionality used throughout ESPHome
|
||||
- Examples: esphome/core/component.cpp, esphome/core/application.h
|
||||
|
||||
2. Core Python files changed (esphome/core/*.py)
|
||||
- Only .py files in the esphome/core/ directory
|
||||
- These are core Python files that affect the entire system
|
||||
- Examples: esphome/core/config.py, esphome/core/__init__.py
|
||||
- NOT included: esphome/*.py, esphome/dashboard/*.py, esphome/components/*/*.py
|
||||
|
||||
3. Integration test files changed
|
||||
- Any file in tests/integration/ directory
|
||||
- This includes test files themselves and fixture YAML files
|
||||
- Examples: tests/integration/test_api.py, tests/integration/fixtures/api.yaml
|
||||
3. Integration test infrastructure files changed
|
||||
- conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc.
|
||||
|
||||
4. Components used by integration tests (or their dependencies) changed
|
||||
- The function parses all YAML files in tests/integration/fixtures/
|
||||
- Extracts which components are used in integration tests
|
||||
- Recursively finds all dependencies of those components
|
||||
- If any of these components have changes, tests must run
|
||||
- Example: If api.yaml uses 'sensor' and 'api' components, and 'api' depends on 'socket',
|
||||
then changes to sensor/, api/, or socket/ components trigger tests
|
||||
Returns (run_all=False, [test_files...]) when:
|
||||
|
||||
4. Specific integration test files changed
|
||||
- Only those specific test files are returned
|
||||
|
||||
5. Components used by integration tests (or their dependencies) changed
|
||||
- Only test files whose fixtures use the changed components are returned
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against. If None, uses default.
|
||||
|
||||
Returns:
|
||||
True if integration tests should run, False otherwise.
|
||||
Tuple of (run_all, test_files) where:
|
||||
- run_all: True if all integration tests should run
|
||||
- test_files: List of specific test file paths to run (empty if run_all
|
||||
is True, or if no tests need to run)
|
||||
"""
|
||||
files = changed_files(branch)
|
||||
|
||||
if core_changed(files):
|
||||
# If any core files changed, run integration tests
|
||||
return True
|
||||
# If any core files changed, run all integration tests
|
||||
return (True, [])
|
||||
|
||||
# Check if any integration test files changed
|
||||
if any("tests/integration" in file for file in files):
|
||||
return True
|
||||
# If infrastructure Python files changed (conftest, utils, etc.), run all tests
|
||||
# Excludes test files (test_*.py), fixtures, and non-Python files (README.md)
|
||||
if any(
|
||||
f.startswith("tests/integration/")
|
||||
and f.endswith(".py")
|
||||
and not f.startswith("tests/integration/test_")
|
||||
and "/fixtures/" not in f
|
||||
for f in files
|
||||
):
|
||||
return (True, [])
|
||||
|
||||
# Get all components used in integration tests and their dependencies
|
||||
fixture_components = get_components_from_integration_fixtures()
|
||||
all_required_components = get_all_dependencies(fixture_components)
|
||||
# Collect specific test files that need to run
|
||||
test_files: set[str] = set()
|
||||
fixture_to_test_files = get_fixture_to_test_files()
|
||||
|
||||
# Check if any required components changed
|
||||
for file in files:
|
||||
component = get_component_from_path(file)
|
||||
if component and component in all_required_components:
|
||||
return True
|
||||
for f in files:
|
||||
if f.startswith("tests/integration/test_") and f.endswith(".py"):
|
||||
test_files.add(f)
|
||||
elif f.startswith("tests/integration/fixtures/"):
|
||||
if f.endswith(".yaml"):
|
||||
# Fixture YAML changed - add corresponding test file(s)
|
||||
test_files.update(fixture_to_test_files.get(Path(f).stem, ()))
|
||||
else:
|
||||
# Non-YAML fixture file changed (e.g., external_components/)
|
||||
# Run all tests since we can't determine which tests are affected
|
||||
return (True, [])
|
||||
|
||||
return False
|
||||
# Find test files whose fixtures use any of the changed components
|
||||
changed_component_set = {
|
||||
component for file in files if (component := get_component_from_path(file))
|
||||
}
|
||||
if changed_component_set:
|
||||
test_files.update(
|
||||
get_integration_test_files_for_components(changed_component_set)
|
||||
)
|
||||
|
||||
if test_files:
|
||||
return (False, sorted(test_files))
|
||||
|
||||
return (False, [])
|
||||
|
||||
|
||||
@cache
|
||||
@@ -682,7 +707,10 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine what should run
|
||||
run_integration = should_run_integration_tests(args.branch)
|
||||
integration_run_all, integration_test_files = determine_integration_tests(
|
||||
args.branch
|
||||
)
|
||||
run_integration = integration_run_all or bool(integration_test_files)
|
||||
run_clang_tidy = should_run_clang_tidy(args.branch)
|
||||
run_clang_format = should_run_clang_format(args.branch)
|
||||
run_python_linters = should_run_python_linters(args.branch)
|
||||
@@ -810,6 +838,8 @@ def main() -> None:
|
||||
|
||||
output: dict[str, Any] = {
|
||||
"integration_tests": run_integration,
|
||||
"integration_tests_run_all": integration_run_all,
|
||||
"integration_test_files": integration_test_files,
|
||||
"clang_tidy": run_clang_tidy,
|
||||
"clang_tidy_mode": clang_tidy_mode,
|
||||
"clang_format": run_clang_format,
|
||||
|
||||
+118
-14
@@ -700,37 +700,141 @@ def get_all_dependencies(
|
||||
return all_components
|
||||
|
||||
|
||||
def _extract_components_from_yaml(config: dict) -> set[str]:
|
||||
"""Extract component names from a parsed YAML config.
|
||||
|
||||
Args:
|
||||
config: Parsed YAML configuration dictionary
|
||||
|
||||
Returns:
|
||||
Set of component names found in the config
|
||||
"""
|
||||
components: set[str] = set()
|
||||
|
||||
# Add all top-level component keys (skip YAML anchor keys starting with '.')
|
||||
components.update(k for k in config if isinstance(k, str) and not k.startswith("."))
|
||||
|
||||
# Add platform values from list entries (e.g., sensor -> platform: template adds "template")
|
||||
for value in config.values():
|
||||
if isinstance(value, list):
|
||||
components.update(
|
||||
item["platform"]
|
||||
for item in value
|
||||
if isinstance(item, dict) and "platform" in item
|
||||
)
|
||||
|
||||
return components
|
||||
|
||||
|
||||
def get_components_from_integration_fixtures() -> set[str]:
|
||||
"""Extract all components used in integration test fixtures.
|
||||
|
||||
Returns:
|
||||
Set of component names used in integration test fixtures
|
||||
"""
|
||||
return {
|
||||
comp
|
||||
for components in get_components_per_integration_fixture().values()
|
||||
for comp in components
|
||||
}
|
||||
|
||||
|
||||
@cache
|
||||
def get_components_per_integration_fixture() -> dict[str, set[str]]:
|
||||
"""Extract components used in each integration test fixture.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping fixture name (stem) to set of component names
|
||||
"""
|
||||
from esphome import yaml_util
|
||||
|
||||
components: set[str] = set()
|
||||
result: dict[str, set[str]] = {}
|
||||
fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures"
|
||||
|
||||
for yaml_file in fixtures_dir.glob("*.yaml"):
|
||||
config: dict[str, any] | None = yaml_util.load_yaml(yaml_file)
|
||||
config: dict[str, Any] | None = yaml_util.load_yaml(yaml_file)
|
||||
if not config:
|
||||
continue
|
||||
|
||||
# Add all top-level component keys (skip YAML anchor keys starting with '.')
|
||||
components.update(
|
||||
k for k in config if isinstance(k, str) and not k.startswith(".")
|
||||
)
|
||||
result[yaml_file.stem] = _extract_components_from_yaml(config)
|
||||
|
||||
# Add platform components (e.g., output.template)
|
||||
for value in config.values():
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
return result
|
||||
|
||||
for item in value:
|
||||
if isinstance(item, dict) and "platform" in item:
|
||||
components.add(item["platform"])
|
||||
|
||||
return components
|
||||
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
|
||||
|
||||
|
||||
@cache
|
||||
def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
|
||||
"""Map integration test fixture names to the test files that use them.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping fixture name to frozenset of test file paths
|
||||
(relative to repo root)
|
||||
"""
|
||||
integration_dir = Path(__file__).parent.parent / "tests" / "integration"
|
||||
result: dict[str, set[str]] = {}
|
||||
|
||||
for test_file in integration_dir.glob("test_*.py"):
|
||||
content = test_file.read_text(encoding="utf-8")
|
||||
rel_path = test_file.relative_to(Path(__file__).parent.parent).as_posix()
|
||||
for func in _TEST_FUNC_RE.findall(content):
|
||||
base_name = func.replace("test_", "").partition("[")[0]
|
||||
result.setdefault(base_name, set()).add(rel_path)
|
||||
|
||||
return {k: frozenset(v) for k, v in result.items()}
|
||||
|
||||
|
||||
@cache
|
||||
def _get_component_to_integration_test_files() -> dict[str, frozenset[str]]:
|
||||
"""Build index mapping each component to the test files that depend on it.
|
||||
|
||||
Resolves full dependency trees once per fixture, then inverts the mapping
|
||||
so lookups are O(1) per component.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping component name to frozenset of test file paths
|
||||
"""
|
||||
fixture_components = get_components_per_integration_fixture()
|
||||
fixture_to_test_files = get_fixture_to_test_files()
|
||||
|
||||
result: dict[str, set[str]] = {}
|
||||
for fixture_name, components in fixture_components.items():
|
||||
test_files = fixture_to_test_files.get(fixture_name)
|
||||
if not test_files:
|
||||
continue
|
||||
# Get full dependency tree for this fixture's components
|
||||
all_deps = get_all_dependencies(components)
|
||||
for dep in all_deps:
|
||||
result.setdefault(dep, set()).update(test_files)
|
||||
|
||||
return {k: frozenset(v) for k, v in result.items()}
|
||||
|
||||
|
||||
def get_integration_test_files_for_components(
|
||||
changed_components: set[str],
|
||||
) -> list[str]:
|
||||
"""Get integration test file paths that use any of the given components.
|
||||
|
||||
Uses a precomputed component → test files index for O(C) lookup
|
||||
where C is the number of changed components.
|
||||
|
||||
Args:
|
||||
changed_components: Set of component names that have changed
|
||||
|
||||
Returns:
|
||||
Sorted list of test file paths relative to repo root
|
||||
(e.g., ["tests/integration/test_api.py", ...])
|
||||
"""
|
||||
component_to_tests = _get_component_to_integration_test_files()
|
||||
|
||||
return sorted(
|
||||
{
|
||||
test_file
|
||||
for component in changed_components
|
||||
for test_file in component_to_tests.get(component, ())
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def filter_component_and_test_files(file_path: str) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user