diff --git a/.github/actions/cache-arduino8266/action.yml b/.github/actions/cache-arduino8266/action.yml new file mode 100644 index 0000000000..affe62b5aa --- /dev/null +++ b/.github/actions/cache-arduino8266/action.yml @@ -0,0 +1,39 @@ +name: Cache Arduino ESP8266 +description: > + Resolve the pinned Arduino core and xtensa toolchain versions and cache the + native ESP8266 install (~110 MB framework + toolchain; no ccache store, the + seed job saves before any compile runs). Exports + ESPHOME_ARDUINO8266_PREFIX to the job so every later step installs into + the cached path; the Python venv must already be restored. Mirrors + cache-esp-idf: only dev-branch pushes write the shared cache, everything + else restores. +runs: + using: composite + steps: + - name: Resolve the native toolchain cache key + # Versions are pinned in code, not a hashable file; resolve them so a + # bump changes the cache key. Assignment form so errexit catches a + # resolver failure. + id: version + shell: bash + run: | + # One owner for the install prefix: exported here and referenced by + # the cache steps below via env, so the caller's install and the + # cached path cannot diverge. + echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV" + . venv/bin/activate + key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")') + [ -n "$key" ] || exit 1 + echo "key=$key" >> "$GITHUB_OUTPUT" + - name: Cache the native toolchain (write on dev) + if: github.ref == 'refs/heads/dev' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }} + key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }} + - name: Restore the native toolchain (off dev) + if: github.ref != 'refs/heads/dev' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }} + key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2343f8c5ba..11d073e7e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,8 @@ jobs: device-builder: ${{ steps.determine.outputs.device-builder }} esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }} + esp8266-native: ${{ steps.determine.outputs.esp8266-native }} + esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -165,6 +167,8 @@ jobs: echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT + echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT + echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -183,6 +187,32 @@ jobs: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} + seed-esp8266-native-cache: + name: Seed the esp8266 native toolchain cache + runs-on: ubuntu-24.04 + needs: + - common + # PR-branch cache saves are invisible to other PRs, so dev pushes seed + # the shared entry test-esp8266-native restores. Only dev: the composite + # action saves nowhere else, so a beta/release push would download the + # toolchain and discard it. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + timeout-minutes: 15 + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache the native toolchain + uses: ./.github/actions/cache-arduino8266 + - name: Install the native toolchain + run: | + . venv/bin/activate + python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)" + ci-custom: name: Run script/ci-custom runs-on: ubuntu-24.04 @@ -1237,7 +1267,7 @@ jobs: # compile validates config first, so a separate config pass is # redundant for this smoke test. ESP-IDF framework via PlatformIO: - python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio + python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests echo "" echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..." @@ -1246,6 +1276,40 @@ jobs: # Arduino framework via PlatformIO (only components with an esp32-ard test are built): python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio + test-esp8266-native: + name: Test esp8266 components with the native toolchain + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true' + env: + # Computed by script/determine-jobs.py (ESP8266_NATIVE_TEST_COMPONENTS) + TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp8266-native-components }} + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Cache the native toolchain + uses: ./.github/actions/cache-arduino8266 + + - name: Run native toolchain compile test + run: | + . venv/bin/activate + + echo "Testing components: $TEST_COMPONENTS" + echo "" + + # ESP8266 Arduino built directly (no PlatformIO); compile validates + # config first, so a separate config pass is redundant. + python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests + device-builder: name: Test downstream esphome/device-builder runs-on: ubuntu-24.04 @@ -1608,6 +1672,7 @@ jobs: needs: - common - seed-apt-cache + - seed-esp8266-native-cache - determine-jobs - ci-custom - pylint @@ -1623,6 +1688,7 @@ jobs: - clang-tidy-esp32-variants - test-build-components-split - test-esp32-platformio + - test-esp8266-native - device-builder - memory-impact-target-branch - memory-impact-pr-branch diff --git a/script/determine-jobs.py b/script/determine-jobs.py index f5412af21d..aa93074581 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -50,6 +50,7 @@ from __future__ import annotations import argparse from collections import Counter +from collections.abc import Callable from enum import StrEnum from functools import cache import json @@ -520,48 +521,69 @@ ESP32_PLATFORMIO_TEST_COMPONENTS = frozenset( } ) +# Shared by every toolchain smoke-test job: the base config and the bus +# packages each generated build includes +_SMOKE_HARNESS_TRIGGER_PATH_PREFIXES = ("tests/test_build_components/",) + # Path prefixes whose changes always trigger the PlatformIO compile test: # anything under esphome/platformio/ (the PlatformIO runner / toolchain that # drives every PlatformIO build). The esp32 platform component is already in # ESP32_PLATFORMIO_TEST_COMPONENTS, so its changes are covered by the normal # component-narrowing path. -ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",) +ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ( + "esphome/platformio/", + *_SMOKE_HARNESS_TRIGGER_PATH_PREFIXES, +) # Standalone files that, when changed, trigger the PlatformIO compile test: # - esphome/build_gen/platformio.py -- the PlatformIO build generator # - script/test_build_components.py -- the harness the job invokes # - .github/workflows/ci.yml -- the job's own definition -ESP32_PLATFORMIO_TRIGGER_FILES = frozenset( +# Shared by every toolchain smoke-test job: the harness it invokes and the +# workflow that defines it +_SMOKE_HARNESS_TRIGGER_FILES = frozenset( { - "esphome/build_gen/platformio.py", "script/test_build_components.py", ".github/workflows/ci.yml", } ) +ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | { + "esphome/build_gen/platformio.py", +} + + +def _path_or_file_trigger( + files: list[str], + trigger_files: frozenset[str], + trigger_prefixes: tuple[str, ...], +) -> bool: + """Whether any changed file matches the given infrastructure triggers.""" + return any( + file in trigger_files or file.startswith(trigger_prefixes) for file in files + ) + + +@cache +def _cached_components_closure(files: tuple[str, ...]) -> frozenset[str]: + """Dependency closure of the changed components; cached because the + walk is expensive and every smoke-test job asks for the same list.""" + component_files = [f for f in files if filter_component_and_test_files(f)] + return frozenset(get_components_with_dependencies(component_files, True)) + def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool: """Whether any changed file is a PlatformIO infrastructure / harness trigger.""" - for file in files: - if file in ESP32_PLATFORMIO_TRIGGER_FILES: - return True - if any( - file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES - ): - return True - return False + return _path_or_file_trigger( + files, ESP32_PLATFORMIO_TRIGGER_FILES, ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES + ) def _esp_idf_infra_changed(files: list[str]) -> bool: """Whether any changed file is ESP-IDF build/runner infrastructure.""" - for file in files: - if file in ESP_IDF_INFRA_TRIGGER_FILES: - return True - if any( - file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES - ): - return True - return False + return _path_or_file_trigger( + files, ESP_IDF_INFRA_TRIGGER_FILES, ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES + ) def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: @@ -599,15 +621,23 @@ def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: Returns: Sorted list of component names to compile. """ + return _toolchain_components_to_test( + branch, ESP32_PLATFORMIO_TEST_COMPONENTS, _esp32_platformio_path_or_file_trigger + ) + + +def _toolchain_components_to_test( + branch: str | None, + test_set: frozenset[str], + infra_trigger: Callable[[list[str]], bool], +) -> list[str]: + """The shared narrowing rule for the per-toolchain smoke-test jobs.""" files = changed_files(branch) - if core_changed(files) or _esp32_platformio_path_or_file_trigger(files): - return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) + if core_changed(files) or infra_trigger(files): + return sorted(test_set) - component_files = [f for f in files if filter_component_and_test_files(f)] - changed = get_components_with_dependencies(component_files, True) - - return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed)) + return sorted(test_set & _cached_components_closure(tuple(files))) def should_run_esp32_platformio(branch: str | None = None) -> bool: @@ -628,6 +658,79 @@ def should_run_esp32_platformio(branch: str | None = None) -> bool: return bool(esp32_platformio_components_to_test(branch)) +# The `--toolchain arduino` smoke-test set: covers the core, the bundled and +# converted registry libraries, and the waveform path. +ESP8266_NATIVE_TEST_COMPONENTS = frozenset( + { + "esp8266", + "api", + "web_server", + "captive_portal", + "mqtt", + "esp8266_pwm", + "neopixelbus", + "bme280_i2c", + "uart", + } +) + +# Infrastructure whose changes always trigger the native ESP8266 +# compile test +ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = ( + "esphome/arduino8266/", + "esphome/arduino/", + "esphome/build_helpers/", + *_SMOKE_HARNESS_TRIGGER_PATH_PREFIXES, +) +# Shared library-conversion modules every native build imports; espidf-only +# infra (build_gen/espidf.py) deliberately stays out of the esp8266 set. +_NATIVE_SHARED_TRIGGER_FILES = frozenset( + { + "esphome/framework_helpers.py", + "esphome/platformio/library.py", + "esphome/platformio/extra_script.py", + } +) +# Tripwire: the shared modules must stay in the ESP-IDF trigger set too +# (now defined in clang_tidy_hash), or its smoke test silently skips them +assert _NATIVE_SHARED_TRIGGER_FILES <= ESP_IDF_INFRA_TRIGGER_FILES +ESP8266_NATIVE_TRIGGER_FILES = ( + _NATIVE_SHARED_TRIGGER_FILES + | _SMOKE_HARNESS_TRIGGER_FILES + | { + "esphome/build_gen/arduino8266.py", + "esphome/build_gen/build_tool.py", + "esphome/components/esp8266/build_surgery.py", + "esphome/components/esp8266/boards.py", + "esphome/platformio/registry.py", + # esp8266/__init__.py imports copy_ccache_script from it + "esphome/platformio/toolchain.py", + ".github/actions/cache-arduino8266/action.yml", + } +) + + +def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool: + """Whether any changed file is native-ESP8266 infrastructure / harness.""" + # base_python_changed covers the top-level esphome/*.py modules the + # native backend imports directly (framework_helpers, helpers, writer, + # __main__); without it a change there would silently skip this job. + # base_python_changed is deliberately broad (any top-level esphome/*.py) + # as belt-and-braces while the backend is new; narrow it to the modules + # the backend imports once the toolchain has soaked a few releases + return base_python_changed(files) or _path_or_file_trigger( + files, ESP8266_NATIVE_TRIGGER_FILES, ESP8266_NATIVE_TRIGGER_PATH_PREFIXES + ) + + +def esp8266_native_components_to_test(branch: str | None = None) -> list[str]: + """Subset of ``ESP8266_NATIVE_TEST_COMPONENTS`` the job needs to + compile (same narrowing as ``esp32_platformio_components_to_test``).""" + return _toolchain_components_to_test( + branch, ESP8266_NATIVE_TEST_COMPONENTS, _esp8266_native_path_or_file_trigger + ) + + def determine_cpp_unit_tests( branch: str | None = None, ) -> tuple[bool, list[str]]: @@ -1226,6 +1329,8 @@ def main() -> None: run_device_builder = True esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) run_esp32_platformio = True + esp8266_native_components = sorted(ESP8266_NATIVE_TEST_COMPONENTS) + run_esp8266_native = True else: integration_run_all, integration_test_files = determine_integration_tests( args.branch @@ -1237,6 +1342,8 @@ def main() -> None: run_device_builder = should_run_device_builder(args.branch) esp32_platformio_components = esp32_platformio_components_to_test(args.branch) run_esp32_platformio = bool(esp32_platformio_components) + esp8266_native_components = esp8266_native_components_to_test(args.branch) + run_esp8266_native = bool(esp8266_native_components) run_integration, integration_test_buckets = _compute_integration_test_buckets( integration_run_all, integration_test_files ) @@ -1432,6 +1539,8 @@ def main() -> None: "device_builder": run_device_builder, "esp32_platformio": run_esp32_platformio, "esp32_platformio_components": ",".join(esp32_platformio_components), + "esp8266_native": run_esp8266_native, + "esp8266_native_components": ",".join(esp8266_native_components), "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/script/test_build_components.py b/script/test_build_components.py index ddd8a6a67d..d3dfd36076 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -1027,6 +1027,7 @@ def test_components( isolated_components: set[str] | None = None, base_only: bool = False, toolchain: str | None = None, + fail_on_no_tests: bool = False, ) -> int: """Test components with optional intelligent grouping. @@ -1061,20 +1062,32 @@ def test_components( # toolchain build. include_validate = esphome_command != "compile" - # Find all component tests + # A blank pattern list would slide into the reference-baseline + # fallback and exit green while building nothing + if fail_on_no_tests and not any(component_patterns): + print("No components requested (blank component list)") + return 1 + + # Find all component tests; remember which components each pattern + # (wildcards included) matched, for the deferred no-tests accounting all_tests = {} + pattern_components: dict[str, set[str]] = {} for pattern in component_patterns: # Skip empty patterns (happens when components list is empty string) if not pattern: continue - all_tests.update( - find_component_tests( - tests_dir, pattern, base_only, include_validate=include_validate - ) + found = find_component_tests( + tests_dir, pattern, base_only, include_validate=include_validate ) + pattern_components[pattern] = set(found) + all_tests.update(found) + + if fail_on_no_tests and not all_tests: + # Nothing matched: fail before the synthetic baseline spends a + # compile reporting success on nothing + print(f"No components found matching: {component_patterns}") + return 1 - # If no components found, build a reference configuration for baseline comparison - # Create a synthetic "empty" component test that will build just the base config if not all_tests: print(f"No components found matching: {component_patterns}") print( @@ -1178,6 +1191,23 @@ def test_components( toolchain=toolchain, ) + silent: list[str] = [] + if fail_on_no_tests: + # A green run that built nothing for a requested pattern must not + # pass CI. Per pattern so one silent pattern cannot hide behind + # the others; opt-in because some legs legitimately match nothing; + # deferred past the summary so reproduce commands still print. + built = {c for r in test_results for c in r.components} + # A pattern is silent when it matched no fixture, or when none of + # its matched components produced a build (wildcards included) + silent = [ + p + for p in component_patterns + if p and not (pattern_components.get(p, set()) & built) + ] + if silent: + print(f"No tests ran for requested pattern(s): {', '.join(silent)}") + # Separate results into passed and failed passed_results = [r for r in test_results if r.success] failed_results = [r for r in test_results if not r.success] @@ -1209,7 +1239,7 @@ def test_components( if os.environ.get("GITHUB_STEP_SUMMARY"): write_github_summary(test_results, toolchain=toolchain) - if failed_results: + if failed_results or silent: return 1 return 0 @@ -1264,6 +1294,12 @@ def main() -> int: "--toolchain", help="Select toolchain for compiling.", ) + parser.add_argument( + "--fail-on-no-tests", + action="store_true", + help="Exit non-zero when no test matched (for CI legs whose " + "components must all have fixtures)", + ) args = parser.parse_args() @@ -1282,6 +1318,7 @@ def main() -> int: continue_on_fail=args.continue_on_fail, enable_grouping=not args.no_grouping, isolated_components=isolated_components, + fail_on_no_tests=args.fail_on_no_tests, base_only=args.base_only, toolchain=args.toolchain, ) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 4971821969..4903f1a88a 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -78,6 +78,17 @@ def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_esp8266_native_components_to_test() -> Generator[Mock, None, None]: + """Mock esp8266_native_components_to_test from determine_jobs. + + main() drives both the ``esp8266_native`` boolean output and the + ``esp8266_native_components`` CSV from this one function. + """ + with patch.object(determine_jobs, "esp8266_native_components_to_test") as mock: + yield mock + + @pytest.fixture def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]: """Mock determine_cpp_unit_tests from helpers.""" @@ -106,6 +117,7 @@ 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() + determine_jobs._cached_components_closure.cache_clear() def test_main_all_tests_should_run( @@ -116,6 +128,7 @@ def test_main_all_tests_should_run( mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, mock_esp32_platformio_components_to_test: Mock, + mock_esp8266_native_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -132,6 +145,7 @@ def test_main_all_tests_should_run( mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"] + mock_esp8266_native_components_to_test.return_value = ["api", "logger"] mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -208,6 +222,8 @@ def test_main_all_tests_should_run( assert output["device_builder"] is True assert output["esp32_platformio"] is True assert output["esp32_platformio_components"] == "api,esp32" + assert output["esp8266_native"] is True + assert output["esp8266_native_components"] == "api,logger" 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 @@ -244,6 +260,7 @@ def test_main_no_tests_should_run( mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, mock_esp32_platformio_components_to_test: Mock, + mock_esp8266_native_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -260,6 +277,7 @@ def test_main_no_tests_should_run( mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False mock_esp32_platformio_components_to_test.return_value = [] + mock_esp8266_native_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -302,6 +320,8 @@ def test_main_no_tests_should_run( assert output["device_builder"] is False assert output["esp32_platformio"] is False assert output["esp32_platformio_components"] == "" + assert output["esp8266_native"] is False + assert output["esp8266_native_components"] == "" assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -997,6 +1017,9 @@ _ESP32_PLATFORMIO_FULL_LIST_FILES = [ # Workflow / harness files ["script/test_build_components.py"], [".github/workflows/ci.yml"], + # The base config and bus packages every generated build includes + ["tests/test_build_components/build_components_base.esp32-idf.yaml"], + ["tests/test_build_components/common/uart/esp32-idf.yaml"], ] @@ -3151,6 +3174,81 @@ def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None: assert find_elf_path(build_path) == elf, f"{platform} ELF not found" +@pytest.mark.parametrize( + "changed", + [ + "esphome/arduino8266/framework.py", + "esphome/build_gen/arduino8266.py", + "esphome/components/esp8266/build_surgery.py", + # Shared modules the native build depends on + "esphome/build_helpers/idedata.py", + "esphome/platformio/library.py", + # Top-level esphome/*.py modules the backend imports directly + "esphome/framework_helpers.py", + "esphome/writer.py", + # esp8266/__init__.py imports copy_ccache_script from it + "esphome/platformio/toolchain.py", + # The composite cache action must not ship unexercised + ".github/actions/cache-arduino8266/action.yml", + # The base config and bus packages every generated build includes + "tests/test_build_components/build_components_base.esp8266-ard.yaml", + "tests/test_build_components/common/uart/esp8266-ard.yaml", + ], +) +def test_esp8266_native_components_full_list_on_infra_change(changed: str) -> None: + """Native-ESP8266 infrastructure changes run the full test list.""" + with ( + patch.object(determine_jobs, "changed_files", return_value=[changed]), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=["wifi"], + ), + ): + result = determine_jobs.esp8266_native_components_to_test() + assert result == sorted(determine_jobs.ESP8266_NATIVE_TEST_COMPONENTS) + + +@pytest.mark.parametrize( + ("changed_files", "dependency_closure", "expected"), + [ + # Tested component changed -- narrow to the intersection. + ( + ["esphome/components/mqtt/mqtt_client.cpp"], + ["mqtt", "json"], + ["mqtt"], + ), + # Components outside the test set return an empty list (job skipped). + ( + ["esphome/components/wifi/wifi_component.cpp"], + ["wifi", "network"], + [], + ), + # espidf infrastructure is not an esp8266-native trigger; the + # native backend depends on esphome/build_helpers/ instead. + (["esphome/build_gen/espidf.py"], [], []), + (["esphome/espidf/toolchain.py"], [], []), + (["README.md"], [], []), + ], +) +def test_esp8266_native_components_to_test_narrowing( + changed_files: list[str], + dependency_closure: list[str], + expected: list[str], +) -> None: + """Component changes narrow the native-ESP8266 test list.""" + with ( + patch.object(determine_jobs, "changed_files", return_value=changed_files), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=dependency_closure, + ), + ): + result = determine_jobs.esp8266_native_components_to_test() + assert result == expected + + def test_compute_integration_test_buckets_no_durations_full_fanout() -> None: """Without recorded durations the fan-out stays at the maximum.""" files = [f"tests/integration/test_{i:03d}.py" for i in range(15)] diff --git a/tests/script/test_test_build_components.py b/tests/script/test_test_build_components.py index 74e150380c..1d21e5d943 100644 --- a/tests/script/test_test_build_components.py +++ b/tests/script/test_test_build_components.py @@ -236,3 +236,98 @@ def test_run_grouped_test_closes_group_when_subprocess_raises( ) assert "::endgroup::" in capsys.readouterr().out + + +def test_components_empty_match_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """Under --fail-on-no-tests, a real component filtered to a platform + with no matching test file must not pass CI as a green zero-component + compile.""" + rc = tbc.test_components( + ["logger"], + "zz-none", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out) + + +def test_components_component_with_no_base_file_fails_with_flag( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A component whose fixture matches the platform but whose platform has + no base file builds nothing; under the flag that silent zero fails by + component name instead of hiding behind other components.""" + monkeypatch.setattr(tbc, "get_platform_base_files", lambda base_dir: {}) + rc = tbc.test_components( + ["logger"], + "esp8266-ard", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out) + + +def test_components_blank_list_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """A fully blank component list must not slide into the baseline + fallback and exit green under the flag.""" + rc = tbc.test_components( + [""], "esp8266-ard", "compile", False, fail_on_no_tests=True + ) + assert rc == 1 + assert "blank component list" in capsys.readouterr().out + + +def test_components_wildcard_no_match_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """A wildcard matching nothing must not degrade to the synthetic + baseline build and exit green under the flag.""" + rc = tbc.test_components( + ["zz_no_such*"], + "esp8266-ard", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No components found matching" in capsys.readouterr().out + + +def test_components_empty_match_tolerated_without_flag() -> None: + """The esp32-ard smoke leg deliberately builds only the subset with a + matching fixture; without the flag an empty match stays green.""" + assert ( + tbc.test_components( + ["logger"], "zz-none", "compile", False, enable_grouping=False + ) + == 0 + ) + + +def test_components_unknown_component_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """A renamed smoke-test component must shrink coverage loudly, not fall + into the reference-baseline build.""" + rc = tbc.test_components( + ["no_such_component_xyz"], + "esp8266-ard", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No components found matching" in capsys.readouterr().out