mirror of
https://github.com/esphome/esphome.git
synced 2026-05-19 03:01:49 +08:00
520371c4a2
- Skip the device-builder downstream job on beta/release target branches. Those branches lag behind device-builder@main, so a newer device-builder API requirement would falsely fail the run without reflecting any problem in the PR itself. Mirrors the same skip detect_memory_impact_config already does. - Broaden the trigger to any non-C++ file under esphome/. The package ships data files via include-package-data = true (e.g. esphome/idf_component.yml, dashboard templates, JSON), so a Python-only filter under-fires for changes that still affect what device-builder installs. Tests cover both: per-file behavior (with the skip mocked off) and the beta/release skip itself short-circuiting before changed_files is even consulted.
2218 lines
84 KiB
Python
2218 lines
84 KiB
Python
"""Unit tests for script/determine-jobs.py module."""
|
|
|
|
from collections.abc import Generator
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
from unittest.mock import Mock, call, patch
|
|
|
|
import pytest
|
|
|
|
# Add the script directory to Python path so we can import the module
|
|
script_dir = os.path.abspath(
|
|
os.path.join(os.path.dirname(__file__), "..", "..", "script")
|
|
)
|
|
sys.path.insert(0, script_dir)
|
|
|
|
# Import helpers module for patching
|
|
import helpers # noqa: E402
|
|
|
|
import script.helpers # noqa: E402
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"determine_jobs", os.path.join(script_dir, "determine-jobs.py")
|
|
)
|
|
determine_jobs = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(determine_jobs)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_determine_integration_tests() -> Generator[Mock, None, None]:
|
|
"""Mock determine_integration_tests."""
|
|
with patch.object(determine_jobs, "determine_integration_tests") as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_should_run_clang_tidy() -> Generator[Mock, None, None]:
|
|
"""Mock should_run_clang_tidy from helpers."""
|
|
with patch.object(determine_jobs, "should_run_clang_tidy") as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_should_run_clang_format() -> Generator[Mock, None, None]:
|
|
"""Mock should_run_clang_format from helpers."""
|
|
with patch.object(determine_jobs, "should_run_clang_format") as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_should_run_python_linters() -> Generator[Mock, None, None]:
|
|
"""Mock should_run_python_linters from helpers."""
|
|
with patch.object(determine_jobs, "should_run_python_linters") as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_should_run_import_time() -> Generator[Mock, None, None]:
|
|
"""Mock should_run_import_time from determine_jobs."""
|
|
with patch.object(determine_jobs, "should_run_import_time") as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_should_run_device_builder() -> Generator[Mock, None, None]:
|
|
"""Mock should_run_device_builder from determine_jobs."""
|
|
with patch.object(determine_jobs, "should_run_device_builder") as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
|
|
"""Mock determine_cpp_unit_tests from helpers."""
|
|
with patch.object(determine_jobs, "determine_cpp_unit_tests") as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_changed_files() -> Generator[Mock, None, None]:
|
|
"""Mock changed_files for memory impact detection."""
|
|
with patch.object(determine_jobs, "changed_files") as mock:
|
|
# Default to empty list
|
|
mock.return_value = []
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_target_branch_dev() -> Generator[Mock, None, None]:
|
|
"""Mock get_target_branch to return 'dev' for memory impact tests."""
|
|
with patch.object(determine_jobs, "get_target_branch", return_value="dev") as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_determine_jobs_caches() -> None:
|
|
"""Clear all cached functions before each test."""
|
|
determine_jobs._is_clang_tidy_full_scan.cache_clear()
|
|
determine_jobs._component_has_tests.cache_clear()
|
|
|
|
|
|
def test_main_all_tests_should_run(
|
|
mock_determine_integration_tests: Mock,
|
|
mock_should_run_clang_tidy: Mock,
|
|
mock_should_run_clang_format: Mock,
|
|
mock_should_run_python_linters: Mock,
|
|
mock_should_run_import_time: Mock,
|
|
mock_should_run_device_builder: Mock,
|
|
mock_changed_files: Mock,
|
|
mock_determine_cpp_unit_tests: Mock,
|
|
capsys: pytest.CaptureFixture[str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Test when all tests should run."""
|
|
# Ensure we're not in GITHUB_ACTIONS mode for this test
|
|
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
|
|
|
mock_determine_integration_tests.return_value = (True, [])
|
|
mock_should_run_clang_tidy.return_value = True
|
|
mock_should_run_clang_format.return_value = True
|
|
mock_should_run_python_linters.return_value = True
|
|
mock_should_run_import_time.return_value = True
|
|
mock_should_run_device_builder.return_value = True
|
|
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
|
|
|
|
# Mock changed_files to return non-component files (to avoid memory impact)
|
|
# Memory impact only runs when component C++ files change
|
|
mock_changed_files.return_value = [
|
|
"esphome/config.py",
|
|
"esphome/helpers.py",
|
|
]
|
|
|
|
# Stable, deterministic stand-in for the tests/integration/ glob so the
|
|
# bucket assertions don't drift with the real test count.
|
|
fake_test_files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]
|
|
|
|
# Run main function with mocked argv
|
|
with (
|
|
patch("sys.argv", ["determine-jobs.py"]),
|
|
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
|
patch.object(
|
|
determine_jobs,
|
|
"_all_integration_test_files",
|
|
return_value=fake_test_files,
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_changed_components",
|
|
return_value=["wifi", "api", "sensor"],
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"filter_component_and_test_files",
|
|
side_effect=lambda f: f.startswith("esphome/components/"),
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_components_with_dependencies",
|
|
side_effect=lambda files, deps: (
|
|
["wifi", "api"] if not deps else ["wifi", "api", "sensor"]
|
|
),
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"detect_memory_impact_config",
|
|
return_value={"should_run": "false"},
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"create_intelligent_batches",
|
|
return_value=([["wifi", "api", "sensor"]], {}),
|
|
),
|
|
):
|
|
determine_jobs.main()
|
|
|
|
# Check output
|
|
captured = capsys.readouterr()
|
|
output = json.loads(captured.out)
|
|
|
|
assert output["integration_tests"] is True
|
|
# run_all=True expands to the full glob and pre-buckets into 3 parts.
|
|
# Each bucket's `tests` is a JSON list of file paths.
|
|
assert isinstance(output["integration_test_buckets"], list)
|
|
assert len(output["integration_test_buckets"]) == 3
|
|
assert [b["name"] for b in output["integration_test_buckets"]] == [
|
|
"1/3",
|
|
"2/3",
|
|
"3/3",
|
|
]
|
|
for bucket in output["integration_test_buckets"]:
|
|
assert isinstance(bucket["tests"], list)
|
|
for path in bucket["tests"]:
|
|
assert isinstance(path, str)
|
|
bucket_files = [f for b in output["integration_test_buckets"] for f in b["tests"]]
|
|
assert bucket_files == fake_test_files
|
|
# Bucket sizes are balanced (max-min difference at most 1).
|
|
sizes = [len(b["tests"]) for b in output["integration_test_buckets"]]
|
|
assert max(sizes) - min(sizes) <= 1
|
|
assert output["clang_tidy"] is True
|
|
assert output["clang_tidy_mode"] in ["nosplit", "split"]
|
|
assert output["clang_format"] is True
|
|
assert output["python_linters"] is True
|
|
assert output["import_time"] is True
|
|
assert output["device_builder"] is True
|
|
assert output["changed_components"] == ["wifi", "api", "sensor"]
|
|
# changed_components_with_tests will only include components that actually have test files
|
|
assert "changed_components_with_tests" in output
|
|
assert isinstance(output["changed_components_with_tests"], list)
|
|
# component_test_count matches number of components with tests
|
|
assert output["component_test_count"] == len(
|
|
output["changed_components_with_tests"]
|
|
)
|
|
# changed_cpp_file_count should be present
|
|
assert "changed_cpp_file_count" in output
|
|
assert isinstance(output["changed_cpp_file_count"], int)
|
|
# memory_impact should be false (no component C++ files changed)
|
|
assert "memory_impact" in output
|
|
assert output["memory_impact"]["should_run"] == "false"
|
|
assert output["cpp_unit_tests_run_all"] is False
|
|
assert output["cpp_unit_tests_components"] == ["wifi", "api", "sensor"]
|
|
# component_test_batches should be present and be a list of space-separated strings
|
|
assert "component_test_batches" in output
|
|
assert isinstance(output["component_test_batches"], list)
|
|
# Each batch should be a space-separated string of component names
|
|
for batch in output["component_test_batches"]:
|
|
assert isinstance(batch, str)
|
|
# Should contain at least one component (no empty batches)
|
|
assert len(batch) > 0
|
|
|
|
|
|
def test_main_no_tests_should_run(
|
|
mock_determine_integration_tests: Mock,
|
|
mock_should_run_clang_tidy: Mock,
|
|
mock_should_run_clang_format: Mock,
|
|
mock_should_run_python_linters: Mock,
|
|
mock_should_run_import_time: Mock,
|
|
mock_should_run_device_builder: Mock,
|
|
mock_changed_files: Mock,
|
|
mock_determine_cpp_unit_tests: Mock,
|
|
capsys: pytest.CaptureFixture[str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Test when no tests should run."""
|
|
# Ensure we're not in GITHUB_ACTIONS mode for this test
|
|
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
|
|
|
mock_determine_integration_tests.return_value = (False, [])
|
|
mock_should_run_clang_tidy.return_value = False
|
|
mock_should_run_clang_format.return_value = False
|
|
mock_should_run_python_linters.return_value = False
|
|
mock_should_run_import_time.return_value = False
|
|
mock_should_run_device_builder.return_value = False
|
|
mock_determine_cpp_unit_tests.return_value = (False, [])
|
|
|
|
# Mock changed_files to return no component files
|
|
mock_changed_files.return_value = []
|
|
|
|
# Run main function with mocked argv
|
|
with (
|
|
patch("sys.argv", ["determine-jobs.py"]),
|
|
patch.object(determine_jobs, "get_changed_components", return_value=[]),
|
|
patch.object(
|
|
determine_jobs, "filter_component_and_test_files", return_value=False
|
|
),
|
|
patch.object(
|
|
determine_jobs, "get_components_with_dependencies", return_value=[]
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"detect_memory_impact_config",
|
|
return_value={"should_run": "false"},
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"create_intelligent_batches",
|
|
return_value=([], {}),
|
|
),
|
|
):
|
|
determine_jobs.main()
|
|
|
|
# Check output
|
|
captured = capsys.readouterr()
|
|
output = json.loads(captured.out)
|
|
|
|
assert output["integration_tests"] is False
|
|
assert output["integration_test_buckets"] == []
|
|
assert output["clang_tidy"] is False
|
|
assert output["clang_tidy_mode"] == "disabled"
|
|
assert output["clang_format"] is False
|
|
assert output["python_linters"] is False
|
|
assert output["import_time"] is False
|
|
assert output["device_builder"] is False
|
|
assert output["changed_components"] == []
|
|
assert output["changed_components_with_tests"] == []
|
|
assert output["component_test_count"] == 0
|
|
# changed_cpp_file_count should be 0
|
|
assert output["changed_cpp_file_count"] == 0
|
|
# memory_impact should be present
|
|
assert "memory_impact" in output
|
|
assert output["memory_impact"]["should_run"] == "false"
|
|
assert output["cpp_unit_tests_run_all"] is False
|
|
assert output["cpp_unit_tests_components"] == []
|
|
# component_test_batches should be empty list
|
|
assert "component_test_batches" in output
|
|
assert output["component_test_batches"] == []
|
|
|
|
|
|
def test_main_with_branch_argument(
|
|
mock_determine_integration_tests: Mock,
|
|
mock_should_run_clang_tidy: Mock,
|
|
mock_should_run_clang_format: Mock,
|
|
mock_should_run_python_linters: Mock,
|
|
mock_should_run_import_time: Mock,
|
|
mock_should_run_device_builder: Mock,
|
|
mock_changed_files: Mock,
|
|
mock_determine_cpp_unit_tests: Mock,
|
|
capsys: pytest.CaptureFixture[str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Test with branch argument."""
|
|
# Ensure we're not in GITHUB_ACTIONS mode for this test
|
|
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
|
|
|
mock_determine_integration_tests.return_value = (False, [])
|
|
mock_should_run_clang_tidy.return_value = True
|
|
mock_should_run_clang_format.return_value = False
|
|
mock_should_run_python_linters.return_value = True
|
|
mock_should_run_import_time.return_value = True
|
|
mock_should_run_device_builder.return_value = True
|
|
mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"])
|
|
|
|
# Mock changed_files to return non-component files (to avoid memory impact)
|
|
# Memory impact only runs when component C++ files change
|
|
mock_changed_files.return_value = ["esphome/config.py"]
|
|
|
|
with (
|
|
patch("sys.argv", ["script.py", "-b", "main"]),
|
|
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
|
patch.object(determine_jobs, "get_changed_components", return_value=["mqtt"]),
|
|
patch.object(
|
|
determine_jobs,
|
|
"filter_component_and_test_files",
|
|
side_effect=lambda f: f.startswith("esphome/components/"),
|
|
),
|
|
patch.object(
|
|
determine_jobs, "get_components_with_dependencies", return_value=["mqtt"]
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"detect_memory_impact_config",
|
|
return_value={"should_run": "false"},
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"create_intelligent_batches",
|
|
return_value=([["mqtt"]], {}),
|
|
),
|
|
):
|
|
determine_jobs.main()
|
|
|
|
# Check that functions were called with branch
|
|
mock_determine_integration_tests.assert_called_once_with("main")
|
|
mock_should_run_clang_tidy.assert_called_once_with("main")
|
|
mock_should_run_clang_format.assert_called_once_with("main")
|
|
mock_should_run_python_linters.assert_called_once_with("main")
|
|
mock_should_run_import_time.assert_called_once_with("main")
|
|
mock_should_run_device_builder.assert_called_once_with("main")
|
|
|
|
# Check output
|
|
captured = capsys.readouterr()
|
|
output = json.loads(captured.out)
|
|
|
|
assert output["integration_tests"] is False
|
|
assert output["integration_test_buckets"] == []
|
|
assert output["clang_tidy"] is True
|
|
assert output["clang_tidy_mode"] in ["nosplit", "split"]
|
|
assert output["clang_format"] is False
|
|
assert output["python_linters"] is True
|
|
assert output["import_time"] is True
|
|
assert output["device_builder"] is True
|
|
assert output["changed_components"] == ["mqtt"]
|
|
# changed_components_with_tests will only include components that actually have test files
|
|
assert "changed_components_with_tests" in output
|
|
assert isinstance(output["changed_components_with_tests"], list)
|
|
# component_test_count matches number of components with tests
|
|
assert output["component_test_count"] == len(
|
|
output["changed_components_with_tests"]
|
|
)
|
|
# changed_cpp_file_count should be present
|
|
assert "changed_cpp_file_count" in output
|
|
assert isinstance(output["changed_cpp_file_count"], int)
|
|
# memory_impact should be false (no component C++ files changed)
|
|
assert "memory_impact" in output
|
|
assert output["memory_impact"]["should_run"] == "false"
|
|
assert output["cpp_unit_tests_run_all"] is False
|
|
assert output["cpp_unit_tests_components"] == ["mqtt"]
|
|
|
|
|
|
def test_compute_integration_test_buckets_empty() -> None:
|
|
"""No integration tests scheduled => (False, [])."""
|
|
run, buckets = determine_jobs._compute_integration_test_buckets(False, [])
|
|
assert run is False
|
|
assert buckets == []
|
|
|
|
|
|
def test_compute_integration_test_buckets_below_threshold() -> None:
|
|
"""A small explicit list (<= threshold) => single 1/1 bucket with that list."""
|
|
files = [f"tests/integration/test_{name}.py" for name in ("c", "a", "b")]
|
|
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
|
|
assert run is True
|
|
assert buckets == [{"name": "1/1", "tests": sorted(files)}]
|
|
|
|
|
|
def test_compute_integration_test_buckets_at_threshold_stays_single() -> None:
|
|
"""Exactly INTEGRATION_TESTS_SPLIT_THRESHOLD files => still one bucket
|
|
(the split kicks in only when count is strictly greater than threshold)."""
|
|
files = [
|
|
f"tests/integration/test_{i:02d}.py"
|
|
for i in range(determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD)
|
|
]
|
|
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
|
|
assert run is True
|
|
assert len(buckets) == 1
|
|
assert buckets[0]["name"] == "1/1"
|
|
assert buckets[0]["tests"] == sorted(files)
|
|
|
|
|
|
def test_compute_integration_test_buckets_just_over_threshold_splits() -> None:
|
|
"""One file over the threshold triggers the 3-bucket fan-out, balanced."""
|
|
n = determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD + 1
|
|
files = [f"tests/integration/test_{i:02d}.py" for i in range(n)]
|
|
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
|
|
assert run is True
|
|
assert [b["name"] for b in buckets] == ["1/3", "2/3", "3/3"]
|
|
union = [path for b in buckets for path in b["tests"]]
|
|
assert union == sorted(files)
|
|
sizes = [len(b["tests"]) for b in buckets]
|
|
assert max(sizes) - min(sizes) <= 1
|
|
|
|
|
|
def test_compute_integration_test_buckets_run_all_with_empty_glob_disables_run() -> (
|
|
None
|
|
):
|
|
"""run_all=True but glob returns no files => run suppressed (otherwise
|
|
pytest would collect tests outside tests/integration/)."""
|
|
with patch.object(determine_jobs, "_all_integration_test_files", return_value=[]):
|
|
run, buckets = determine_jobs._compute_integration_test_buckets(True, [])
|
|
assert run is False
|
|
assert buckets == []
|
|
|
|
|
|
def test_determine_integration_tests(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Test determine_integration_tests function."""
|
|
# Core C++ files trigger run_all
|
|
with patch.object(
|
|
determine_jobs, "changed_files", return_value=["esphome/core/component.cpp"]
|
|
):
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is True
|
|
assert test_files == []
|
|
|
|
# Core Python files trigger run_all
|
|
with patch.object(
|
|
determine_jobs, "changed_files", return_value=["esphome/core/config.py"]
|
|
):
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is True
|
|
assert test_files == []
|
|
|
|
# Python files directly in esphome/ do NOT trigger tests
|
|
with patch.object(
|
|
determine_jobs, "changed_files", return_value=["esphome/config.py"]
|
|
):
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is False
|
|
assert test_files == []
|
|
|
|
# Python files in subdirectories (not core) do NOT trigger tests
|
|
with patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["esphome/dashboard/web_server.py"],
|
|
):
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is False
|
|
assert test_files == []
|
|
|
|
|
|
def test_determine_integration_tests_with_branch() -> None:
|
|
"""Test determine_integration_tests with branch argument."""
|
|
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
|
mock_changed.return_value = []
|
|
run_all, test_files = determine_jobs.determine_integration_tests("release")
|
|
mock_changed.assert_called_once_with("release")
|
|
assert run_all is False
|
|
assert test_files == []
|
|
|
|
|
|
def test_determine_integration_tests_component_dependency() -> None:
|
|
"""Test that integration tests return specific test files when components used in fixtures change."""
|
|
with (
|
|
patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["esphome/components/api/api.cpp"],
|
|
),
|
|
patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map,
|
|
patch.object(
|
|
determine_jobs, "get_integration_test_files_for_components"
|
|
) as mock_test_files,
|
|
):
|
|
mock_fixture_map.return_value = {}
|
|
mock_test_files.return_value = [
|
|
"tests/integration/test_api.py",
|
|
"tests/integration/test_sensor.py",
|
|
]
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is False
|
|
assert test_files == [
|
|
"tests/integration/test_api.py",
|
|
"tests/integration/test_sensor.py",
|
|
]
|
|
|
|
|
|
def test_determine_integration_tests_component_only_affected_tests() -> None:
|
|
"""Test that only tests using the changed component are returned."""
|
|
with (
|
|
patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["esphome/components/modbus/modbus.cpp"],
|
|
),
|
|
patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}),
|
|
patch.object(
|
|
determine_jobs, "get_integration_test_files_for_components"
|
|
) as mock_test_files,
|
|
):
|
|
mock_test_files.return_value = [
|
|
"tests/integration/test_uart_mock_modbus.py",
|
|
]
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is False
|
|
assert test_files == ["tests/integration/test_uart_mock_modbus.py"]
|
|
# Verify it was called with the right component
|
|
mock_test_files.assert_called_once_with({"modbus"})
|
|
|
|
|
|
def test_determine_integration_tests_infra_file_runs_all() -> None:
|
|
"""Test that changing infrastructure files (conftest.py, etc.) runs all tests."""
|
|
with patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["tests/integration/conftest.py"],
|
|
):
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is True
|
|
assert test_files == []
|
|
|
|
|
|
def test_determine_integration_tests_readme_does_not_run_all() -> None:
|
|
"""Test that changing README.md does not trigger integration tests."""
|
|
with patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["tests/integration/README.md"],
|
|
):
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is False
|
|
assert test_files == []
|
|
|
|
|
|
def test_determine_integration_tests_changed_test_file() -> None:
|
|
"""Test that changing a specific test file only runs that test."""
|
|
with (
|
|
patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["tests/integration/test_syslog.py"],
|
|
),
|
|
patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}),
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_integration_test_files_for_components",
|
|
return_value=[],
|
|
),
|
|
):
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is False
|
|
assert test_files == ["tests/integration/test_syslog.py"]
|
|
|
|
|
|
def test_determine_integration_tests_changed_fixture_yaml() -> None:
|
|
"""Test that changing a fixture YAML runs the corresponding test file."""
|
|
with (
|
|
patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["tests/integration/fixtures/uart_mock_modbus.yaml"],
|
|
),
|
|
patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map,
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_integration_test_files_for_components",
|
|
return_value=[],
|
|
),
|
|
):
|
|
mock_fixture_map.return_value = {
|
|
"uart_mock_modbus": frozenset(
|
|
{"tests/integration/test_uart_mock_modbus.py"}
|
|
),
|
|
}
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is False
|
|
assert test_files == ["tests/integration/test_uart_mock_modbus.py"]
|
|
|
|
|
|
def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None:
|
|
"""Test that non-YAML changes under fixtures/ (e.g., external_components) run all tests."""
|
|
with patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=[
|
|
"tests/integration/fixtures/external_components/test_component/__init__.py"
|
|
],
|
|
):
|
|
run_all, test_files = determine_jobs.determine_integration_tests()
|
|
assert run_all is True
|
|
assert test_files == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("check_returncode", "changed_files", "expected_result"),
|
|
[
|
|
(0, [], True), # Hash changed - need full scan
|
|
(1, ["esphome/core.cpp"], True), # C++ file changed
|
|
(1, ["README.md"], False), # No C++ files changed
|
|
(1, [".clang-tidy.hash"], True), # Hash file itself changed
|
|
(1, ["platformio.ini", ".clang-tidy.hash"], True), # Config + hash changed
|
|
],
|
|
)
|
|
def test_should_run_clang_tidy(
|
|
check_returncode: int,
|
|
changed_files: list[str],
|
|
expected_result: bool,
|
|
) -> None:
|
|
"""Test should_run_clang_tidy function."""
|
|
with (
|
|
patch.object(determine_jobs, "changed_files", return_value=changed_files),
|
|
patch("subprocess.run") as mock_run,
|
|
):
|
|
# Test with hash check returning specific code
|
|
mock_run.return_value = Mock(returncode=check_returncode)
|
|
result = determine_jobs.should_run_clang_tidy()
|
|
assert result == expected_result
|
|
|
|
|
|
def test_should_run_clang_tidy_hash_check_exception() -> None:
|
|
"""Test should_run_clang_tidy when hash check fails with exception."""
|
|
# When hash check fails, clang-tidy should run as a safety measure
|
|
with (
|
|
patch.object(determine_jobs, "changed_files", return_value=["README.md"]),
|
|
patch("subprocess.run", side_effect=Exception("Hash check failed")),
|
|
):
|
|
result = determine_jobs.should_run_clang_tidy()
|
|
assert result is True # Fail safe - run clang-tidy
|
|
|
|
|
|
def test_should_run_clang_tidy_with_branch() -> None:
|
|
"""Test should_run_clang_tidy with branch argument."""
|
|
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
|
mock_changed.return_value = []
|
|
with patch("subprocess.run") as mock_run:
|
|
mock_run.return_value = Mock(returncode=1) # Hash unchanged
|
|
determine_jobs.should_run_clang_tidy("release")
|
|
# Changed files is called twice now - once for hash check, once for .clang-tidy.hash check
|
|
assert mock_changed.call_count == 2
|
|
mock_changed.assert_has_calls([call("release"), call("release")])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("changed_files", "expected_result"),
|
|
[
|
|
(["esphome/core.py"], True),
|
|
(["script/test.py"], True),
|
|
(["esphome/test.pyi"], True), # .pyi files should trigger
|
|
(["README.md"], False),
|
|
([], False),
|
|
],
|
|
)
|
|
def test_should_run_python_linters(
|
|
changed_files: list[str], expected_result: bool
|
|
) -> None:
|
|
"""Test should_run_python_linters function."""
|
|
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
|
|
result = determine_jobs.should_run_python_linters()
|
|
assert result == expected_result
|
|
|
|
|
|
def test_should_run_python_linters_with_branch() -> None:
|
|
"""Test should_run_python_linters with branch argument."""
|
|
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
|
mock_changed.return_value = []
|
|
determine_jobs.should_run_python_linters("release")
|
|
mock_changed.assert_called_once_with("release")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("changed_files", "expected_result"),
|
|
[
|
|
# esphome Python files trigger the check
|
|
(["esphome/__main__.py"], True),
|
|
(["esphome/components/wifi/__init__.py"], True),
|
|
(["esphome/core/config.py"], True),
|
|
(["esphome/types.pyi"], True),
|
|
# Dependency declarations and the check's own files trigger
|
|
(["requirements.txt"], True),
|
|
(["requirements_dev.txt"], True),
|
|
(["requirements_test.txt"], True),
|
|
(["pyproject.toml"], True),
|
|
(["script/check_import_time.py"], True),
|
|
(["script/import_time_budget.json"], True),
|
|
# Mixed: any triggering file is enough
|
|
(["docs/README.md", "esphome/config.py"], True),
|
|
# Python files outside esphome/ don't trigger
|
|
(["script/some_other_script.py"], False),
|
|
(["tests/script/test_determine_jobs.py"], False),
|
|
# Non-Python changes don't trigger
|
|
(["esphome/core/component.cpp"], False),
|
|
(["tests/components/wifi/test.esp32-idf.yaml"], False),
|
|
(["README.md"], False),
|
|
([], False),
|
|
],
|
|
)
|
|
def test_should_run_import_time(
|
|
changed_files: list[str], expected_result: bool
|
|
) -> None:
|
|
"""Test should_run_import_time function."""
|
|
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
|
|
result = determine_jobs.should_run_import_time()
|
|
assert result == expected_result
|
|
|
|
|
|
def test_should_run_import_time_with_branch() -> None:
|
|
"""Test should_run_import_time with branch argument."""
|
|
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
|
mock_changed.return_value = []
|
|
determine_jobs.should_run_import_time("release")
|
|
mock_changed.assert_called_once_with("release")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("changed_files", "expected_result"),
|
|
[
|
|
# esphome Python files trigger downstream device-builder tests
|
|
(["esphome/__main__.py"], True),
|
|
(["esphome/components/wifi/__init__.py"], True),
|
|
(["esphome/core/config.py"], True),
|
|
(["esphome/types.pyi"], True),
|
|
# Runtime dependency changes trigger
|
|
(["requirements.txt"], True),
|
|
(["pyproject.toml"], True),
|
|
# Non-C++ files packaged with esphome trigger -- device-builder
|
|
# picks them up because esphome's pyproject sets
|
|
# include-package-data = true.
|
|
(["esphome/idf_component.yml"], True),
|
|
(["esphome/dashboard/templates/index.html"], True),
|
|
(["esphome/components/api/api_pb2_service.json"], True),
|
|
# Mixed: any triggering file is enough
|
|
(["docs/README.md", "esphome/config.py"], True),
|
|
# Dev/test-only dependency changes don't trigger device-builder
|
|
# (they don't affect the importable surface device-builder uses)
|
|
(["requirements_dev.txt"], False),
|
|
(["requirements_test.txt"], False),
|
|
# Files outside esphome/ don't trigger
|
|
(["script/some_other_script.py"], False),
|
|
(["tests/script/test_determine_jobs.py"], False),
|
|
# C++ files under esphome/ don't trigger -- they only affect
|
|
# compiled firmware, not the Python install device-builder pulls in.
|
|
(["esphome/core/component.cpp"], False),
|
|
(["esphome/core/component.h"], False),
|
|
(["esphome/components/wifi/wifi_component.cpp"], False),
|
|
# Files outside esphome/ entirely
|
|
(["tests/components/wifi/test.esp32-idf.yaml"], False),
|
|
(["README.md"], False),
|
|
([], False),
|
|
],
|
|
)
|
|
def test_should_run_device_builder(
|
|
changed_files: list[str], expected_result: bool
|
|
) -> None:
|
|
"""Test should_run_device_builder function (non-beta/release target)."""
|
|
with (
|
|
patch.object(determine_jobs, "changed_files", return_value=changed_files),
|
|
# Mock target branch to "dev" so the beta/release skip is bypassed
|
|
# for these per-file behavior checks.
|
|
patch.object(determine_jobs, "get_target_branch", return_value="dev"),
|
|
):
|
|
result = determine_jobs.should_run_device_builder()
|
|
assert result == expected_result
|
|
|
|
|
|
def test_should_run_device_builder_with_branch() -> None:
|
|
"""Test should_run_device_builder with branch argument."""
|
|
with (
|
|
patch.object(determine_jobs, "changed_files") as mock_changed,
|
|
patch.object(determine_jobs, "get_target_branch", return_value="dev"),
|
|
):
|
|
mock_changed.return_value = []
|
|
determine_jobs.should_run_device_builder("release")
|
|
mock_changed.assert_called_once_with("release")
|
|
|
|
|
|
@pytest.mark.parametrize("target_branch", ["beta", "release", "release-2026.5"])
|
|
def test_should_run_device_builder_skips_beta_release(target_branch: str) -> None:
|
|
"""Beta/release target branches skip device-builder (lag behind device-builder@main)."""
|
|
with (
|
|
patch.object(determine_jobs, "get_target_branch", return_value=target_branch),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed,
|
|
):
|
|
# Even with a triggering file present, the target-branch guard wins.
|
|
mock_changed.return_value = ["esphome/__main__.py"]
|
|
assert determine_jobs.should_run_device_builder() is False
|
|
# changed_files shouldn't even be consulted -- the guard short-circuits.
|
|
mock_changed.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("changed_files", "expected_result"),
|
|
[
|
|
(["esphome/core.cpp"], True),
|
|
(["esphome/core.h"], True),
|
|
(["test.hpp"], True),
|
|
(["test.cc"], True),
|
|
(["test.cxx"], True),
|
|
(["test.c"], True),
|
|
(["test.tcc"], True),
|
|
(["README.md"], False),
|
|
([], False),
|
|
],
|
|
)
|
|
def test_should_run_clang_format(
|
|
changed_files: list[str], expected_result: bool
|
|
) -> None:
|
|
"""Test should_run_clang_format function."""
|
|
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
|
|
result = determine_jobs.should_run_clang_format()
|
|
assert result == expected_result
|
|
|
|
|
|
def test_should_run_clang_format_with_branch() -> None:
|
|
"""Test should_run_clang_format with branch argument."""
|
|
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
|
mock_changed.return_value = []
|
|
determine_jobs.should_run_clang_format("release")
|
|
mock_changed.assert_called_once_with("release")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("changed_files", "expected_count"),
|
|
[
|
|
(["esphome/core.cpp"], 1),
|
|
(["esphome/core.h"], 1),
|
|
(["test.hpp"], 1),
|
|
(["test.cc"], 1),
|
|
(["test.cxx"], 1),
|
|
(["test.c"], 1),
|
|
(["test.tcc"], 1),
|
|
(["esphome/core.cpp", "esphome/core.h"], 2),
|
|
(["esphome/core.cpp", "esphome/core.h", "test.cc"], 3),
|
|
(["README.md"], 0),
|
|
(["esphome/config.py"], 0),
|
|
(["README.md", "esphome/config.py"], 0),
|
|
(["esphome/core.cpp", "README.md", "esphome/config.py"], 1),
|
|
([], 0),
|
|
],
|
|
)
|
|
def test_count_changed_cpp_files(changed_files: list[str], expected_count: int) -> None:
|
|
"""Test count_changed_cpp_files function."""
|
|
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
|
|
result = determine_jobs.count_changed_cpp_files()
|
|
assert result == expected_count
|
|
|
|
|
|
def test_count_changed_cpp_files_with_branch() -> None:
|
|
"""Test count_changed_cpp_files with branch argument."""
|
|
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
|
mock_changed.return_value = []
|
|
determine_jobs.count_changed_cpp_files("release")
|
|
mock_changed.assert_called_once_with("release")
|
|
|
|
|
|
def test_main_filters_components_without_tests(
|
|
mock_determine_integration_tests: Mock,
|
|
mock_should_run_clang_tidy: Mock,
|
|
mock_should_run_clang_format: Mock,
|
|
mock_should_run_python_linters: Mock,
|
|
mock_changed_files: Mock,
|
|
capsys: pytest.CaptureFixture[str],
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Test that components without test files are filtered out."""
|
|
# Ensure we're not in GITHUB_ACTIONS mode for this test
|
|
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
|
|
|
mock_determine_integration_tests.return_value = (False, [])
|
|
mock_should_run_clang_tidy.return_value = False
|
|
mock_should_run_clang_format.return_value = False
|
|
mock_should_run_python_linters.return_value = False
|
|
|
|
# Mock changed_files to return component files
|
|
mock_changed_files.return_value = [
|
|
"esphome/components/wifi/wifi.cpp",
|
|
"esphome/components/sensor/sensor.h",
|
|
]
|
|
|
|
# Create test directory structure
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# wifi has tests
|
|
wifi_dir = tests_dir / "wifi"
|
|
wifi_dir.mkdir(parents=True)
|
|
(wifi_dir / "test.esp32.yaml").write_text("test: config")
|
|
|
|
# sensor has tests
|
|
sensor_dir = tests_dir / "sensor"
|
|
sensor_dir.mkdir(parents=True)
|
|
(sensor_dir / "test.esp8266.yaml").write_text("test: config")
|
|
|
|
# airthings_ble exists but has no test files
|
|
airthings_dir = tests_dir / "airthings_ble"
|
|
airthings_dir.mkdir(parents=True)
|
|
|
|
# Mock root_path to use tmp_path (need to patch both determine_jobs and helpers)
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "create_components_graph", return_value={}),
|
|
patch("sys.argv", ["determine-jobs.py"]),
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_changed_components",
|
|
return_value=["wifi", "sensor", "airthings_ble"],
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"filter_component_and_test_files",
|
|
side_effect=lambda f: f.startswith("esphome/components/"),
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_components_with_dependencies",
|
|
side_effect=lambda files, deps: (
|
|
["wifi", "sensor"] if not deps else ["wifi", "sensor", "airthings_ble"]
|
|
),
|
|
),
|
|
patch.object(determine_jobs, "changed_files", return_value=[]),
|
|
patch.object(
|
|
determine_jobs,
|
|
"detect_memory_impact_config",
|
|
return_value={"should_run": "false"},
|
|
),
|
|
):
|
|
# Clear the cache since we're mocking root_path
|
|
determine_jobs.main()
|
|
|
|
# Check output
|
|
captured = capsys.readouterr()
|
|
output = json.loads(captured.out)
|
|
|
|
# changed_components should have all components
|
|
assert set(output["changed_components"]) == {"wifi", "sensor", "airthings_ble"}
|
|
# changed_components_with_tests should only have components with test files
|
|
assert set(output["changed_components_with_tests"]) == {"wifi", "sensor"}
|
|
# component_test_count should be based on components with tests
|
|
assert output["component_test_count"] == 2
|
|
# changed_cpp_file_count should be present
|
|
assert "changed_cpp_file_count" in output
|
|
assert isinstance(output["changed_cpp_file_count"], int)
|
|
# memory_impact should be present
|
|
assert "memory_impact" in output
|
|
assert output["memory_impact"]["should_run"] == "false"
|
|
|
|
|
|
def test_main_detects_components_with_variant_tests(
|
|
mock_determine_integration_tests: Mock,
|
|
mock_should_run_clang_tidy: Mock,
|
|
mock_should_run_clang_format: Mock,
|
|
mock_should_run_python_linters: Mock,
|
|
mock_changed_files: Mock,
|
|
capsys: pytest.CaptureFixture[str],
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Test that components with only variant test files (test-*.yaml) are detected.
|
|
|
|
This test verifies the fix for components like improv_serial, ethernet, mdns,
|
|
improv_base, and safe_mode which only have variant test files (test-*.yaml)
|
|
instead of base test files (test.*.yaml).
|
|
"""
|
|
# Ensure we're not in GITHUB_ACTIONS mode for this test
|
|
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
|
|
|
mock_determine_integration_tests.return_value = (False, [])
|
|
mock_should_run_clang_tidy.return_value = False
|
|
mock_should_run_clang_format.return_value = False
|
|
mock_should_run_python_linters.return_value = False
|
|
|
|
# Mock changed_files to return component files
|
|
mock_changed_files.return_value = [
|
|
"esphome/components/improv_serial/improv_serial.cpp",
|
|
"esphome/components/ethernet/ethernet.cpp",
|
|
"esphome/components/no_tests/component.cpp",
|
|
]
|
|
|
|
# Create test directory structure
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# improv_serial has only variant tests (like the real component)
|
|
improv_serial_dir = tests_dir / "improv_serial"
|
|
improv_serial_dir.mkdir(parents=True)
|
|
(improv_serial_dir / "test-uart0.esp32-idf.yaml").write_text("test: config")
|
|
(improv_serial_dir / "test-uart0.esp8266-ard.yaml").write_text("test: config")
|
|
(improv_serial_dir / "test-usb_cdc.esp32-s2-idf.yaml").write_text("test: config")
|
|
|
|
# ethernet also has only variant tests
|
|
ethernet_dir = tests_dir / "ethernet"
|
|
ethernet_dir.mkdir(parents=True)
|
|
(ethernet_dir / "test-manual_ip.esp32-idf.yaml").write_text("test: config")
|
|
(ethernet_dir / "test-dhcp.esp32-idf.yaml").write_text("test: config")
|
|
|
|
# no_tests component has no test files at all
|
|
no_tests_dir = tests_dir / "no_tests"
|
|
no_tests_dir.mkdir(parents=True)
|
|
|
|
# Mock root_path to use tmp_path (need to patch both determine_jobs and helpers)
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "create_components_graph", return_value={}),
|
|
patch("sys.argv", ["determine-jobs.py"]),
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_changed_components",
|
|
return_value=["improv_serial", "ethernet", "no_tests"],
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"filter_component_and_test_files",
|
|
side_effect=lambda f: f.startswith("esphome/components/"),
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_components_with_dependencies",
|
|
side_effect=lambda files, deps: (
|
|
["improv_serial", "ethernet"]
|
|
if not deps
|
|
else ["improv_serial", "ethernet", "no_tests"]
|
|
),
|
|
),
|
|
patch.object(determine_jobs, "changed_files", return_value=[]),
|
|
patch.object(
|
|
determine_jobs,
|
|
"detect_memory_impact_config",
|
|
return_value={"should_run": "false"},
|
|
),
|
|
):
|
|
# Clear the cache since we're mocking root_path
|
|
determine_jobs.main()
|
|
|
|
# Check output
|
|
captured = capsys.readouterr()
|
|
output = json.loads(captured.out)
|
|
|
|
# changed_components should have all components
|
|
assert set(output["changed_components"]) == {
|
|
"improv_serial",
|
|
"ethernet",
|
|
"no_tests",
|
|
}
|
|
# changed_components_with_tests should include components with variant tests
|
|
assert set(output["changed_components_with_tests"]) == {"improv_serial", "ethernet"}
|
|
# component_test_count should be 2 (improv_serial and ethernet)
|
|
assert output["component_test_count"] == 2
|
|
# no_tests should be excluded since it has no test files
|
|
assert "no_tests" not in output["changed_components_with_tests"]
|
|
|
|
|
|
# Tests for detect_memory_impact_config function
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_target_branch_dev")
|
|
def test_detect_memory_impact_config_with_common_platform(tmp_path: Path) -> None:
|
|
"""Test memory impact detection when components share a common platform."""
|
|
# Create test directory structure
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# wifi component with esp32-idf test
|
|
wifi_dir = tests_dir / "wifi"
|
|
wifi_dir.mkdir(parents=True)
|
|
(wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")
|
|
|
|
# api component with esp32-idf test
|
|
api_dir = tests_dir / "api"
|
|
api_dir.mkdir(parents=True)
|
|
(api_dir / "test.esp32-idf.yaml").write_text("test: api")
|
|
|
|
# Mock changed_files to return wifi and api component changes
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
):
|
|
mock_changed_files.return_value = [
|
|
"esphome/components/wifi/wifi.cpp",
|
|
"esphome/components/api/api.cpp",
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
assert result["should_run"] == "true"
|
|
assert set(result["components"]) == {"wifi", "api"}
|
|
assert result["platform"] == "esp32-idf" # Common platform
|
|
assert result["use_merged_config"] == "true"
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_target_branch_dev")
|
|
def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
|
|
"""Test memory impact detection with core C++ changes (no component changes)."""
|
|
# Create test directory structure with fallback component
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# api component (fallback component) with esp32-idf test
|
|
api_dir = tests_dir / "api"
|
|
api_dir.mkdir(parents=True)
|
|
(api_dir / "test.esp32-idf.yaml").write_text("test: api")
|
|
|
|
# Mock changed_files to return only core C++ files (no component files)
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
):
|
|
mock_changed_files.return_value = [
|
|
"esphome/core/application.cpp",
|
|
"esphome/core/component.h",
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
assert result["should_run"] == "true"
|
|
assert result["components"] == ["api"] # Fallback component
|
|
assert result["platform"] == "esp32-idf" # Fallback platform
|
|
assert result["use_merged_config"] == "true"
|
|
|
|
|
|
def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) -> None:
|
|
"""Test that Python-only core changes don't trigger memory impact analysis."""
|
|
# Create test directory structure with fallback component
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# api component (fallback component) with esp32-idf test
|
|
api_dir = tests_dir / "api"
|
|
api_dir.mkdir(parents=True)
|
|
(api_dir / "test.esp32-idf.yaml").write_text("test: api")
|
|
|
|
# Mock changed_files to return only core Python files (no C++ files)
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
):
|
|
mock_changed_files.return_value = [
|
|
"esphome/__main__.py",
|
|
"esphome/config.py",
|
|
"esphome/core/config.py",
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Python-only changes should NOT trigger memory impact analysis
|
|
assert result["should_run"] == "false"
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_target_branch_dev")
|
|
def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
|
|
"""Test memory impact detection when components have no common platform."""
|
|
# Create test directory structure
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# wifi component only has esp32-idf test
|
|
wifi_dir = tests_dir / "wifi"
|
|
wifi_dir.mkdir(parents=True)
|
|
(wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")
|
|
|
|
# logger component only has esp8266-ard test
|
|
logger_dir = tests_dir / "logger"
|
|
logger_dir.mkdir(parents=True)
|
|
(logger_dir / "test.esp8266-ard.yaml").write_text("test: logger")
|
|
|
|
# Mock changed_files to return both components
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
):
|
|
mock_changed_files.return_value = [
|
|
"esphome/components/wifi/wifi.cpp",
|
|
"esphome/components/logger/logger.cpp",
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Should pick the most frequently supported platform
|
|
assert result["should_run"] == "true"
|
|
assert set(result["components"]) == {"wifi", "logger"}
|
|
# When no common platform, picks most commonly supported
|
|
# esp8266-ard is preferred over esp32-idf in the preference list
|
|
assert result["platform"] in ["esp32-idf", "esp8266-ard"]
|
|
assert result["use_merged_config"] == "true"
|
|
|
|
|
|
def test_detect_memory_impact_config_no_changes(tmp_path: Path) -> None:
|
|
"""Test memory impact detection when no files changed."""
|
|
# Mock changed_files to return empty list
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
):
|
|
mock_changed_files.return_value = []
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
assert result["should_run"] == "false"
|
|
|
|
|
|
def test_detect_memory_impact_config_no_components_with_tests(tmp_path: Path) -> None:
|
|
"""Test memory impact detection when changed components have no tests."""
|
|
# Create test directory structure
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# Create component directory but no test files
|
|
custom_component_dir = tests_dir / "my_custom_component"
|
|
custom_component_dir.mkdir(parents=True)
|
|
|
|
# Mock changed_files to return component without tests
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
):
|
|
mock_changed_files.return_value = [
|
|
"esphome/components/my_custom_component/component.cpp",
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
assert result["should_run"] == "false"
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_target_branch_dev")
|
|
def test_detect_memory_impact_config_includes_base_bus_components(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Test that base bus components (i2c, spi, uart) are included when directly changed.
|
|
|
|
Base bus components should be analyzed for memory impact when they are directly
|
|
changed, even though they are often used as dependencies. This ensures that
|
|
optimizations to base components (like using move semantics or initializer_list)
|
|
are properly measured.
|
|
"""
|
|
# Create test directory structure
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# uart component (base bus component that should be included)
|
|
uart_dir = tests_dir / "uart"
|
|
uart_dir.mkdir(parents=True)
|
|
(uart_dir / "test.esp32-idf.yaml").write_text("test: uart")
|
|
|
|
# wifi component (regular component)
|
|
wifi_dir = tests_dir / "wifi"
|
|
wifi_dir.mkdir(parents=True)
|
|
(wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")
|
|
|
|
# Mock changed_files to return both uart and wifi
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
):
|
|
mock_changed_files.return_value = [
|
|
"esphome/components/uart/automation.h", # Header file with inline code
|
|
"esphome/components/wifi/wifi.cpp",
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Should include both uart and wifi
|
|
assert result["should_run"] == "true"
|
|
assert set(result["components"]) == {"uart", "wifi"}
|
|
assert result["platform"] == "esp32-idf" # Common platform
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_target_branch_dev")
|
|
def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None:
|
|
"""Test memory impact detection for components with only variant test files.
|
|
|
|
This verifies that memory impact analysis works correctly for components like
|
|
improv_serial, ethernet, mdns, etc. which only have variant test files
|
|
(test-*.yaml) instead of base test files (test.*.yaml).
|
|
"""
|
|
# Create test directory structure
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# improv_serial with only variant tests
|
|
improv_serial_dir = tests_dir / "improv_serial"
|
|
improv_serial_dir.mkdir(parents=True)
|
|
(improv_serial_dir / "test-uart0.esp32-idf.yaml").write_text("test: improv")
|
|
(improv_serial_dir / "test-uart0.esp8266-ard.yaml").write_text("test: improv")
|
|
(improv_serial_dir / "test-usb_cdc.esp32-s2-idf.yaml").write_text("test: improv")
|
|
|
|
# ethernet with only variant tests
|
|
ethernet_dir = tests_dir / "ethernet"
|
|
ethernet_dir.mkdir(parents=True)
|
|
(ethernet_dir / "test-manual_ip.esp32-idf.yaml").write_text("test: ethernet")
|
|
(ethernet_dir / "test-dhcp.esp32-c3-idf.yaml").write_text("test: ethernet")
|
|
|
|
# Mock changed_files to return both components
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
):
|
|
mock_changed_files.return_value = [
|
|
"esphome/components/improv_serial/improv_serial.cpp",
|
|
"esphome/components/ethernet/ethernet.cpp",
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Should detect both components even though they only have variant tests
|
|
assert result["should_run"] == "true"
|
|
assert set(result["components"]) == {"improv_serial", "ethernet"}
|
|
# Both components support esp32-idf
|
|
assert result["platform"] == "esp32-idf"
|
|
assert result["use_merged_config"] == "true"
|
|
|
|
|
|
# Tests for clang-tidy split mode logic
|
|
|
|
|
|
def test_clang_tidy_mode_full_scan(
|
|
mock_determine_integration_tests: Mock,
|
|
mock_should_run_clang_tidy: Mock,
|
|
mock_should_run_clang_format: Mock,
|
|
mock_should_run_python_linters: Mock,
|
|
mock_changed_files: Mock,
|
|
capsys: pytest.CaptureFixture[str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Test that full scan (hash changed) always uses split mode."""
|
|
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
|
|
|
mock_determine_integration_tests.return_value = (False, [])
|
|
mock_should_run_clang_tidy.return_value = True
|
|
mock_should_run_clang_format.return_value = False
|
|
mock_should_run_python_linters.return_value = False
|
|
|
|
# Mock changed_files to return no component files
|
|
mock_changed_files.return_value = []
|
|
|
|
# Mock full scan (hash changed)
|
|
with (
|
|
patch("sys.argv", ["determine-jobs.py"]),
|
|
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=True),
|
|
patch.object(determine_jobs, "get_changed_components", return_value=[]),
|
|
patch.object(
|
|
determine_jobs, "filter_component_and_test_files", return_value=False
|
|
),
|
|
patch.object(
|
|
determine_jobs, "get_components_with_dependencies", return_value=[]
|
|
),
|
|
):
|
|
determine_jobs.main()
|
|
|
|
captured = capsys.readouterr()
|
|
output = json.loads(captured.out)
|
|
|
|
# Full scan should always use split mode
|
|
assert output["clang_tidy_mode"] == "split"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("component_count", "files_per_component", "expected_mode"),
|
|
[
|
|
# Small PR: 5 files in 1 component -> nosplit
|
|
(1, 5, "nosplit"),
|
|
# Medium PR: 30 files in 2 components -> nosplit
|
|
(2, 15, "nosplit"),
|
|
# Medium PR: 64 files total -> nosplit (just under threshold)
|
|
(2, 32, "nosplit"),
|
|
# Large PR: 65 files total -> split (at threshold)
|
|
(2, 33, "split"), # 2 * 33 = 66 files
|
|
# Large PR: 100 files in 10 components -> split
|
|
(10, 10, "split"),
|
|
],
|
|
ids=[
|
|
"1_comp_5_files_nosplit",
|
|
"2_comp_30_files_nosplit",
|
|
"2_comp_64_files_nosplit_under_threshold",
|
|
"2_comp_66_files_split_at_threshold",
|
|
"10_comp_100_files_split",
|
|
],
|
|
)
|
|
def test_clang_tidy_mode_targeted_scan(
|
|
component_count: int,
|
|
files_per_component: int,
|
|
expected_mode: str,
|
|
mock_determine_integration_tests: Mock,
|
|
mock_should_run_clang_tidy: Mock,
|
|
mock_should_run_clang_format: Mock,
|
|
mock_should_run_python_linters: Mock,
|
|
mock_changed_files: Mock,
|
|
capsys: pytest.CaptureFixture[str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Test clang-tidy mode selection based on files_to_check count."""
|
|
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
|
|
|
mock_determine_integration_tests.return_value = (False, [])
|
|
mock_should_run_clang_tidy.return_value = True
|
|
mock_should_run_clang_format.return_value = False
|
|
mock_should_run_python_linters.return_value = False
|
|
|
|
# Create component names
|
|
components = [f"comp{i}" for i in range(component_count)]
|
|
|
|
# Mock changed_files to return component files
|
|
mock_changed_files.return_value = [
|
|
f"esphome/components/{comp}/file.cpp" for comp in components
|
|
]
|
|
|
|
# Mock git_ls_files to return files for each component
|
|
cpp_files = {
|
|
f"esphome/components/{comp}/file{i}.cpp": 0
|
|
for comp in components
|
|
for i in range(files_per_component)
|
|
}
|
|
|
|
# Create a mock that returns the cpp_files dict for any call
|
|
def mock_git_ls_files(patterns=None):
|
|
return cpp_files
|
|
|
|
with (
|
|
patch("sys.argv", ["determine-jobs.py"]),
|
|
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
|
patch.object(determine_jobs, "git_ls_files", side_effect=mock_git_ls_files),
|
|
patch.object(determine_jobs, "get_changed_components", return_value=components),
|
|
patch.object(
|
|
determine_jobs,
|
|
"filter_component_and_test_files",
|
|
side_effect=lambda f: f.startswith("esphome/components/"),
|
|
),
|
|
patch.object(
|
|
determine_jobs, "get_components_with_dependencies", return_value=components
|
|
),
|
|
):
|
|
determine_jobs.main()
|
|
|
|
captured = capsys.readouterr()
|
|
output = json.loads(captured.out)
|
|
|
|
assert output["clang_tidy_mode"] == expected_mode
|
|
|
|
|
|
def test_main_core_files_changed_still_detects_components(
|
|
mock_determine_integration_tests: Mock,
|
|
mock_should_run_clang_tidy: Mock,
|
|
mock_should_run_clang_format: Mock,
|
|
mock_should_run_python_linters: Mock,
|
|
mock_changed_files: Mock,
|
|
mock_determine_cpp_unit_tests: Mock,
|
|
capsys: pytest.CaptureFixture[str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Test that component changes are detected even when core files change."""
|
|
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
|
|
|
mock_determine_integration_tests.return_value = (True, [])
|
|
mock_should_run_clang_tidy.return_value = True
|
|
mock_should_run_clang_format.return_value = True
|
|
mock_should_run_python_linters.return_value = True
|
|
mock_determine_cpp_unit_tests.return_value = (True, [])
|
|
|
|
mock_changed_files.return_value = [
|
|
"esphome/core/helpers.h",
|
|
"esphome/components/select/select_traits.h",
|
|
"esphome/components/select/select_traits.cpp",
|
|
"esphome/components/api/api.proto",
|
|
]
|
|
|
|
with (
|
|
patch("sys.argv", ["determine-jobs.py"]),
|
|
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
|
patch.object(determine_jobs, "get_changed_components", return_value=None),
|
|
patch.object(
|
|
determine_jobs,
|
|
"filter_component_and_test_files",
|
|
side_effect=lambda f: f.startswith("esphome/components/"),
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_components_with_dependencies",
|
|
side_effect=lambda files, deps: (
|
|
["select", "api"]
|
|
if not deps
|
|
else ["select", "api", "bluetooth_proxy", "logger"]
|
|
),
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"detect_memory_impact_config",
|
|
return_value={"should_run": "false"},
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"create_intelligent_batches",
|
|
return_value=([["select", "api", "bluetooth_proxy", "logger"]], {}),
|
|
),
|
|
):
|
|
determine_jobs.main()
|
|
|
|
captured = capsys.readouterr()
|
|
output = json.loads(captured.out)
|
|
|
|
assert output["clang_tidy"] is True
|
|
assert output["clang_tidy_mode"] == "split"
|
|
assert "select" in output["changed_components"]
|
|
assert "api" in output["changed_components"]
|
|
assert len(output["changed_components"]) > 0
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_target_branch_dev")
|
|
def test_detect_memory_impact_config_filters_incompatible_esp32_on_esp8266(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Test that ESP32 components are filtered out when ESP8266 platform is selected.
|
|
|
|
This test verifies the fix for the issue where ESP32 components were being included
|
|
when ESP8266 was selected as the platform, causing build failures in PR 10387.
|
|
"""
|
|
# Create test directory structure
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# esp32 component only has esp32-idf tests (NOT compatible with esp8266)
|
|
esp32_dir = tests_dir / "esp32"
|
|
esp32_dir.mkdir(parents=True)
|
|
(esp32_dir / "test.esp32-idf.yaml").write_text("test: esp32")
|
|
(esp32_dir / "test.esp32-s3-idf.yaml").write_text("test: esp32")
|
|
|
|
# esp8266 component only has esp8266-ard test (NOT compatible with esp32)
|
|
esp8266_dir = tests_dir / "esp8266"
|
|
esp8266_dir.mkdir(parents=True)
|
|
(esp8266_dir / "test.esp8266-ard.yaml").write_text("test: esp8266")
|
|
|
|
# Mock changed_files to return both esp32 and esp8266 component changes
|
|
# Include esp8266-specific filename to trigger esp8266 platform hint
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
):
|
|
mock_changed_files.return_value = [
|
|
"tests/components/esp32/common.yaml",
|
|
"tests/components/esp8266/test.esp8266-ard.yaml",
|
|
"esphome/core/helpers_esp8266.h", # ESP8266-specific file to hint platform
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Memory impact should run
|
|
assert result["should_run"] == "true"
|
|
|
|
# Platform should be esp8266-ard (due to ESP8266 filename hint)
|
|
assert result["platform"] == "esp8266-ard"
|
|
|
|
# CRITICAL: Only esp8266 component should be included, not esp32
|
|
# This prevents trying to build ESP32 components on ESP8266 platform
|
|
assert result["components"] == ["esp8266"], (
|
|
"When esp8266-ard platform is selected, only esp8266 component should be included, "
|
|
"not esp32. This prevents trying to build ESP32 components on ESP8266 platform."
|
|
)
|
|
|
|
assert result["use_merged_config"] == "true"
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_target_branch_dev")
|
|
def test_detect_memory_impact_config_filters_incompatible_esp8266_on_esp32(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Test that ESP8266 components are filtered out when ESP32 platform is selected.
|
|
|
|
This is the inverse of the ESP8266 test - ensures filtering works both ways.
|
|
"""
|
|
# Create test directory structure
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
# esp32 component only has esp32-idf tests (NOT compatible with esp8266)
|
|
esp32_dir = tests_dir / "esp32"
|
|
esp32_dir.mkdir(parents=True)
|
|
(esp32_dir / "test.esp32-idf.yaml").write_text("test: esp32")
|
|
(esp32_dir / "test.esp32-s3-idf.yaml").write_text("test: esp32")
|
|
|
|
# esp8266 component only has esp8266-ard test (NOT compatible with esp32)
|
|
esp8266_dir = tests_dir / "esp8266"
|
|
esp8266_dir.mkdir(parents=True)
|
|
(esp8266_dir / "test.esp8266-ard.yaml").write_text("test: esp8266")
|
|
|
|
# Mock changed_files to return both esp32 and esp8266 component changes
|
|
# Include MORE esp32-specific filenames to ensure esp32-idf wins the hint count
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
):
|
|
mock_changed_files.return_value = [
|
|
"tests/components/esp32/common.yaml",
|
|
"tests/components/esp8266/test.esp8266-ard.yaml",
|
|
"esphome/components/wifi/wifi_component_esp_idf.cpp", # ESP-IDF hint
|
|
"esphome/components/ethernet/ethernet_esp32.cpp", # ESP32 hint
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Memory impact should run
|
|
assert result["should_run"] == "true"
|
|
|
|
# Platform should be esp32-idf (due to more ESP32-IDF hints)
|
|
assert result["platform"] == "esp32-idf"
|
|
|
|
# CRITICAL: Only esp32 component should be included, not esp8266
|
|
# This prevents trying to build ESP8266 components on ESP32 platform
|
|
assert result["components"] == ["esp32"], (
|
|
"When esp32-idf platform is selected, only esp32 component should be included, "
|
|
"not esp8266. This prevents trying to build ESP8266 components on ESP32 platform."
|
|
)
|
|
|
|
assert result["use_merged_config"] == "true"
|
|
|
|
|
|
def test_detect_memory_impact_config_skips_release_branch(tmp_path: Path) -> None:
|
|
"""Test that memory impact analysis is skipped for release* branches."""
|
|
# Create test directory structure with components that have tests
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
wifi_dir = tests_dir / "wifi"
|
|
wifi_dir.mkdir(parents=True)
|
|
(wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")
|
|
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
patch.object(determine_jobs, "get_target_branch", return_value="release"),
|
|
):
|
|
mock_changed_files.return_value = ["esphome/components/wifi/wifi.cpp"]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Memory impact should be skipped for release branch
|
|
assert result["should_run"] == "false"
|
|
|
|
|
|
def test_detect_memory_impact_config_skips_beta_branch(tmp_path: Path) -> None:
|
|
"""Test that memory impact analysis is skipped for beta* branches."""
|
|
# Create test directory structure with components that have tests
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
wifi_dir = tests_dir / "wifi"
|
|
wifi_dir.mkdir(parents=True)
|
|
(wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")
|
|
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
patch.object(determine_jobs, "get_target_branch", return_value="beta"),
|
|
):
|
|
mock_changed_files.return_value = ["esphome/components/wifi/wifi.cpp"]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Memory impact should be skipped for beta branch
|
|
assert result["should_run"] == "false"
|
|
|
|
|
|
def test_detect_memory_impact_config_runs_for_dev_branch(tmp_path: Path) -> None:
|
|
"""Test that memory impact analysis runs for dev branch."""
|
|
# Create test directory structure with components that have tests
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
wifi_dir = tests_dir / "wifi"
|
|
wifi_dir.mkdir(parents=True)
|
|
(wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")
|
|
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
patch.object(determine_jobs, "get_target_branch", return_value="dev"),
|
|
):
|
|
mock_changed_files.return_value = ["esphome/components/wifi/wifi.cpp"]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Memory impact should run for dev branch
|
|
assert result["should_run"] == "true"
|
|
assert result["components"] == ["wifi"]
|
|
|
|
|
|
def test_detect_memory_impact_config_skips_too_many_components(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Test that memory impact analysis is skipped when more than 40 components changed."""
|
|
# Create test directory structure with 41 components
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
component_names = [f"component_{i}" for i in range(41)]
|
|
|
|
for component_name in component_names:
|
|
comp_dir = tests_dir / component_name
|
|
comp_dir.mkdir(parents=True)
|
|
(comp_dir / "test.esp32-idf.yaml").write_text(f"test: {component_name}")
|
|
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
patch.object(determine_jobs, "get_target_branch", return_value="dev"),
|
|
):
|
|
mock_changed_files.return_value = [
|
|
f"esphome/components/{name}/{name}.cpp" for name in component_names
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Memory impact should be skipped for too many components (41 > 40)
|
|
assert result["should_run"] == "false"
|
|
|
|
|
|
def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> None:
|
|
"""Test that memory impact analysis runs with exactly 40 components (at limit)."""
|
|
# Create test directory structure with exactly 40 components
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
component_names = [f"component_{i}" for i in range(40)]
|
|
|
|
for component_name in component_names:
|
|
comp_dir = tests_dir / component_name
|
|
comp_dir.mkdir(parents=True)
|
|
(comp_dir / "test.esp32-idf.yaml").write_text(f"test: {component_name}")
|
|
|
|
with (
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
|
patch.object(determine_jobs, "get_target_branch", return_value="dev"),
|
|
):
|
|
mock_changed_files.return_value = [
|
|
f"esphome/components/{name}/{name}.cpp" for name in component_names
|
|
]
|
|
|
|
result = determine_jobs.detect_memory_impact_config()
|
|
|
|
# Memory impact should run at exactly 40 components (at limit but not over)
|
|
assert result["should_run"] == "true"
|
|
assert len(result["components"]) == 40
|
|
|
|
|
|
# Tests for _detect_platform_hint_from_filename function
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("filename", "expected_platform"),
|
|
[
|
|
# ESP-IDF platform detection
|
|
("esphome/components/wifi/wifi_esp_idf.cpp", determine_jobs.Platform.ESP32_IDF),
|
|
(
|
|
"esphome/components/wifi/wifi_component_esp_idf.cpp",
|
|
determine_jobs.Platform.ESP32_IDF,
|
|
),
|
|
(
|
|
"esphome/components/ethernet/ethernet_idf.cpp",
|
|
determine_jobs.Platform.ESP32_IDF,
|
|
),
|
|
# ESP32 variant detection with IDF suffix
|
|
(
|
|
"esphome/components/ble/esp32c3_idf.cpp",
|
|
determine_jobs.Platform.ESP32_C3_IDF,
|
|
),
|
|
(
|
|
"esphome/components/ble/esp32c6_idf.cpp",
|
|
determine_jobs.Platform.ESP32_C6_IDF,
|
|
),
|
|
(
|
|
"esphome/components/ble/esp32s2_idf.cpp",
|
|
determine_jobs.Platform.ESP32_S2_IDF,
|
|
),
|
|
(
|
|
"esphome/components/ble/esp32s3_idf.cpp",
|
|
determine_jobs.Platform.ESP32_S3_IDF,
|
|
),
|
|
# ESP8266 detection
|
|
(
|
|
"esphome/components/wifi/wifi_esp8266.cpp",
|
|
determine_jobs.Platform.ESP8266_ARD,
|
|
),
|
|
("esphome/core/helpers_esp8266.h", determine_jobs.Platform.ESP8266_ARD),
|
|
# Generic ESP32 detection (without IDF suffix)
|
|
("esphome/components/wifi/wifi_esp32.cpp", determine_jobs.Platform.ESP32_IDF),
|
|
(
|
|
"esphome/components/ethernet/ethernet_esp32.cpp",
|
|
determine_jobs.Platform.ESP32_IDF,
|
|
),
|
|
# LibreTiny / BK72xx detection
|
|
(
|
|
"esphome/components/wifi/wifi_libretiny.cpp",
|
|
determine_jobs.Platform.BK72XX_ARD,
|
|
),
|
|
("esphome/components/ble/ble_bk72xx.cpp", determine_jobs.Platform.BK72XX_ARD),
|
|
# RTL87xx (LibreTiny Realtek) detection
|
|
(
|
|
"tests/components/logger/test.rtl87xx-ard.yaml",
|
|
determine_jobs.Platform.RTL87XX_ARD,
|
|
),
|
|
(
|
|
"esphome/components/libretiny/wifi_rtl87xx.cpp",
|
|
determine_jobs.Platform.RTL87XX_ARD,
|
|
),
|
|
# LN882x (LibreTiny Lightning) detection
|
|
(
|
|
"tests/components/logger/test.ln882x-ard.yaml",
|
|
determine_jobs.Platform.LN882X_ARD,
|
|
),
|
|
(
|
|
"esphome/components/libretiny/wifi_ln882x.cpp",
|
|
determine_jobs.Platform.LN882X_ARD,
|
|
),
|
|
# RP2040 / Raspberry Pi Pico detection
|
|
("esphome/components/gpio/gpio_rp2040.cpp", determine_jobs.Platform.RP2040_ARD),
|
|
("esphome/components/wifi/wifi_rp2040.cpp", determine_jobs.Platform.RP2040_ARD),
|
|
("esphome/components/i2c/i2c_pico.cpp", determine_jobs.Platform.RP2040_ARD),
|
|
("esphome/components/spi/spi_pico.cpp", determine_jobs.Platform.RP2040_ARD),
|
|
(
|
|
"tests/components/rp2040/test.rp2040-ard.yaml",
|
|
determine_jobs.Platform.RP2040_ARD,
|
|
),
|
|
# nRF52 / Zephyr detection
|
|
(
|
|
"tests/components/logger/test.nrf52-adafruit.yaml",
|
|
determine_jobs.Platform.NRF52_ZEPHYR,
|
|
),
|
|
(
|
|
"esphome/components/nrf52/gpio.cpp",
|
|
determine_jobs.Platform.NRF52_ZEPHYR,
|
|
),
|
|
(
|
|
"esphome/components/zephyr/core.cpp",
|
|
determine_jobs.Platform.NRF52_ZEPHYR,
|
|
),
|
|
(
|
|
"esphome/components/zephyr_ble_server/ble_server.cpp",
|
|
determine_jobs.Platform.NRF52_ZEPHYR,
|
|
),
|
|
# No platform hint (generic files)
|
|
("esphome/components/wifi/wifi.cpp", None),
|
|
("esphome/components/sensor/sensor.h", None),
|
|
("esphome/core/helpers.h", None),
|
|
("README.md", None),
|
|
],
|
|
ids=[
|
|
"esp_idf_suffix",
|
|
"esp_idf_component_suffix",
|
|
"idf_suffix",
|
|
"esp32c3_idf",
|
|
"esp32c6_idf",
|
|
"esp32s2_idf",
|
|
"esp32s3_idf",
|
|
"esp8266_suffix",
|
|
"esp8266_core_header",
|
|
"generic_esp32",
|
|
"esp32_in_name",
|
|
"libretiny",
|
|
"bk72xx",
|
|
"rtl87xx_test_yaml",
|
|
"rtl87xx_wifi",
|
|
"ln882x_test_yaml",
|
|
"ln882x_wifi",
|
|
"rp2040_gpio",
|
|
"rp2040_wifi",
|
|
"pico_i2c",
|
|
"pico_spi",
|
|
"rp2040_test_yaml",
|
|
"nrf52_test_yaml",
|
|
"nrf52_gpio",
|
|
"zephyr_core",
|
|
"zephyr_ble_server",
|
|
"generic_wifi_no_hint",
|
|
"generic_sensor_no_hint",
|
|
"core_helpers_no_hint",
|
|
"readme_no_hint",
|
|
],
|
|
)
|
|
def test_detect_platform_hint_from_filename(
|
|
filename: str, expected_platform: determine_jobs.Platform | None
|
|
) -> None:
|
|
"""Test _detect_platform_hint_from_filename correctly detects platform hints."""
|
|
result = determine_jobs._detect_platform_hint_from_filename(filename)
|
|
assert result == expected_platform
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("filename", "expected_platform"),
|
|
[
|
|
# RP2040/Pico with different cases
|
|
("file_RP2040.cpp", determine_jobs.Platform.RP2040_ARD),
|
|
("file_Rp2040.cpp", determine_jobs.Platform.RP2040_ARD),
|
|
("file_PICO.cpp", determine_jobs.Platform.RP2040_ARD),
|
|
("file_Pico.cpp", determine_jobs.Platform.RP2040_ARD),
|
|
# ESP8266 with different cases
|
|
("file_ESP8266.cpp", determine_jobs.Platform.ESP8266_ARD),
|
|
# ESP32 with different cases
|
|
("file_ESP32.cpp", determine_jobs.Platform.ESP32_IDF),
|
|
# nRF52/Zephyr with different cases
|
|
("file_NRF52.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
|
|
("file_Nrf52.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
|
|
("file_ZEPHYR.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
|
|
("file_Zephyr.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
|
|
],
|
|
ids=[
|
|
"rp2040_uppercase",
|
|
"rp2040_mixedcase",
|
|
"pico_uppercase",
|
|
"pico_titlecase",
|
|
"esp8266_uppercase",
|
|
"esp32_uppercase",
|
|
"nrf52_uppercase",
|
|
"nrf52_mixedcase",
|
|
"zephyr_uppercase",
|
|
"zephyr_titlecase",
|
|
],
|
|
)
|
|
def test_detect_platform_hint_from_filename_case_insensitive(
|
|
filename: str, expected_platform: determine_jobs.Platform
|
|
) -> None:
|
|
"""Test that platform detection is case-insensitive."""
|
|
result = determine_jobs._detect_platform_hint_from_filename(filename)
|
|
assert result == expected_platform
|
|
|
|
|
|
def test_component_batching_beta_branch_40_per_batch(
|
|
tmp_path: Path,
|
|
mock_determine_integration_tests: Mock,
|
|
mock_should_run_clang_tidy: Mock,
|
|
mock_should_run_clang_format: Mock,
|
|
mock_should_run_python_linters: Mock,
|
|
mock_changed_files: Mock,
|
|
mock_determine_cpp_unit_tests: Mock,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""Test that beta/release branches create batches with 40 actual components each.
|
|
|
|
For beta/release branches, all components should be groupable (not isolated),
|
|
and each batch should contain 40 actual components with weight 1 each.
|
|
This matches the original behavior before consolidation.
|
|
"""
|
|
# Create 120 test components with test files
|
|
component_names = [f"comp_{i:03d}" for i in range(120)]
|
|
tests_dir = tmp_path / "tests" / "components"
|
|
|
|
for comp in component_names:
|
|
comp_dir = tests_dir / comp
|
|
comp_dir.mkdir(parents=True)
|
|
(comp_dir / "test.esp32-idf.yaml").write_text(f"# Test for {comp}")
|
|
|
|
# Setup mocks
|
|
mock_determine_integration_tests.return_value = (False, [])
|
|
mock_should_run_clang_tidy.return_value = False
|
|
mock_should_run_clang_format.return_value = False
|
|
mock_should_run_python_linters.return_value = False
|
|
mock_determine_cpp_unit_tests.return_value = (False, [])
|
|
|
|
# Mock changed_files to return all component files
|
|
changed_files = [
|
|
f"esphome/components/{comp}/{comp}.cpp" for comp in component_names
|
|
]
|
|
mock_changed_files.return_value = changed_files
|
|
|
|
# Run main function with beta branch
|
|
# Don't mock create_intelligent_batches - that's what we're testing!
|
|
with (
|
|
patch("sys.argv", ["determine-jobs.py", "--branch", "beta"]),
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(helpers, "root_path", str(tmp_path)),
|
|
patch.object(script.helpers, "root_path", str(tmp_path)),
|
|
patch.object(determine_jobs, "get_target_branch", return_value="beta"),
|
|
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_changed_components",
|
|
return_value=component_names,
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"filter_component_and_test_files",
|
|
side_effect=lambda f: f.startswith("esphome/components/"),
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"get_components_with_dependencies",
|
|
side_effect=lambda files, deps: component_names,
|
|
),
|
|
patch.object(
|
|
determine_jobs,
|
|
"detect_memory_impact_config",
|
|
return_value={"should_run": "false"},
|
|
),
|
|
):
|
|
determine_jobs.main()
|
|
|
|
# Check output
|
|
captured = capsys.readouterr()
|
|
output = json.loads(captured.out)
|
|
|
|
# Verify batches are present and properly sized
|
|
assert "component_test_batches" in output
|
|
batches = output["component_test_batches"]
|
|
|
|
# Should have 3 batches (120 components / 40 per batch = 3)
|
|
assert len(batches) == 3, f"Expected 3 batches, got {len(batches)}"
|
|
|
|
# Each batch should have approximately 40 components (all weight=1, groupable)
|
|
for i, batch_str in enumerate(batches):
|
|
batch_components = batch_str.split()
|
|
assert len(batch_components) == 40, (
|
|
f"Batch {i} should have 40 components, got {len(batch_components)}"
|
|
)
|
|
|
|
# Verify all 120 components are in batches
|
|
all_components = []
|
|
for batch_str in batches:
|
|
all_components.extend(batch_str.split())
|
|
assert len(all_components) == 120
|
|
assert set(all_components) == set(component_names)
|
|
|
|
|
|
# --- should_run_benchmarks tests ---
|
|
|
|
|
|
def test_should_run_benchmarks_core_change() -> None:
|
|
"""Test benchmarks trigger on core C++ file changes."""
|
|
with patch.object(
|
|
determine_jobs, "changed_files", return_value=["esphome/core/scheduler.cpp"]
|
|
):
|
|
assert determine_jobs.should_run_benchmarks() is True
|
|
|
|
|
|
def test_should_run_benchmarks_core_header_change() -> None:
|
|
"""Test benchmarks trigger on core header changes."""
|
|
with patch.object(
|
|
determine_jobs, "changed_files", return_value=["esphome/core/helpers.h"]
|
|
):
|
|
assert determine_jobs.should_run_benchmarks() is True
|
|
|
|
|
|
def test_should_run_benchmarks_host_platform_change() -> None:
|
|
"""Test benchmarks trigger on host platform changes.
|
|
|
|
Benchmarks build and run on the host platform, so changes to its
|
|
millis()/micros()/etc. implementations affect every benchmark.
|
|
"""
|
|
for host_file in [
|
|
"esphome/components/host/core.cpp",
|
|
"esphome/components/host/__init__.py",
|
|
]:
|
|
with patch.object(determine_jobs, "changed_files", return_value=[host_file]):
|
|
assert determine_jobs.should_run_benchmarks() is True, (
|
|
f"Expected benchmarks to run for {host_file}"
|
|
)
|
|
|
|
|
|
def test_should_run_benchmarks_benchmark_infra_change() -> None:
|
|
"""Test benchmarks trigger on benchmark infrastructure changes."""
|
|
for infra_file in [
|
|
"script/cpp_benchmark.py",
|
|
"script/build_helpers.py",
|
|
"script/setup_codspeed_lib.py",
|
|
]:
|
|
with patch.object(determine_jobs, "changed_files", return_value=[infra_file]):
|
|
assert determine_jobs.should_run_benchmarks() is True, (
|
|
f"Expected benchmarks to run for {infra_file}"
|
|
)
|
|
|
|
|
|
def test_should_run_benchmarks_benchmark_file_change() -> None:
|
|
"""Test benchmarks trigger on benchmark file changes."""
|
|
with patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["tests/benchmarks/components/api/bench_proto_encode.cpp"],
|
|
):
|
|
assert determine_jobs.should_run_benchmarks() is True
|
|
|
|
|
|
def test_should_run_benchmarks_core_benchmark_file_change() -> None:
|
|
"""Test benchmarks trigger on core benchmark file changes."""
|
|
with patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["tests/benchmarks/core/bench_scheduler.cpp"],
|
|
):
|
|
assert determine_jobs.should_run_benchmarks() is True
|
|
|
|
|
|
def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None:
|
|
"""Test benchmarks trigger when a benchmarked component changes."""
|
|
# Create a fake benchmarks directory with an 'api' component
|
|
benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
|
|
benchmarks_dir.mkdir(parents=True)
|
|
(benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")
|
|
|
|
with (
|
|
patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["esphome/components/api/proto.h"],
|
|
),
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(
|
|
determine_jobs,
|
|
"BENCHMARKS_COMPONENTS_PATH",
|
|
"tests/benchmarks/components",
|
|
),
|
|
):
|
|
assert determine_jobs.should_run_benchmarks() is True
|
|
|
|
|
|
def test_should_run_benchmarks_non_benchmarked_component_change(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Test benchmarks do NOT trigger for non-benchmarked component changes."""
|
|
# Create a fake benchmarks directory with only 'api'
|
|
benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
|
|
benchmarks_dir.mkdir(parents=True)
|
|
(benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")
|
|
|
|
with (
|
|
patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
return_value=["esphome/components/sensor/__init__.py"],
|
|
),
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(
|
|
determine_jobs,
|
|
"BENCHMARKS_COMPONENTS_PATH",
|
|
"tests/benchmarks/components",
|
|
),
|
|
):
|
|
assert determine_jobs.should_run_benchmarks() is False
|
|
|
|
|
|
def test_should_run_benchmarks_no_dependency_expansion(tmp_path: Path) -> None:
|
|
"""Test benchmarks do NOT expand to dependent components.
|
|
|
|
Changing 'sensor' should not trigger 'api' benchmarks even if api
|
|
depends on sensor. This is intentional — benchmark runs should be
|
|
targeted to directly changed components only.
|
|
"""
|
|
benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
|
|
benchmarks_dir.mkdir(parents=True)
|
|
(benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")
|
|
|
|
with (
|
|
patch.object(
|
|
determine_jobs,
|
|
"changed_files",
|
|
# sensor is a dependency of api, but benchmarks don't expand
|
|
return_value=["esphome/components/sensor/sensor.cpp"],
|
|
),
|
|
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
|
patch.object(
|
|
determine_jobs,
|
|
"BENCHMARKS_COMPONENTS_PATH",
|
|
"tests/benchmarks/components",
|
|
),
|
|
):
|
|
assert determine_jobs.should_run_benchmarks() is False
|
|
|
|
|
|
def test_should_run_benchmarks_unrelated_change() -> None:
|
|
"""Test benchmarks do NOT trigger for unrelated changes."""
|
|
with patch.object(determine_jobs, "changed_files", return_value=["README.md"]):
|
|
assert determine_jobs.should_run_benchmarks() is False
|
|
|
|
|
|
def test_should_run_benchmarks_no_changes() -> None:
|
|
"""Test benchmarks do NOT trigger with no changes."""
|
|
with patch.object(determine_jobs, "changed_files", return_value=[]):
|
|
assert determine_jobs.should_run_benchmarks() is False
|
|
|
|
|
|
def test_should_run_benchmarks_with_branch() -> None:
|
|
"""Test should_run_benchmarks passes branch to changed_files."""
|
|
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
|
mock_changed.return_value = []
|
|
determine_jobs.should_run_benchmarks("release")
|
|
mock_changed.assert_called_with("release")
|