diff --git a/script/determine-jobs.py b/script/determine-jobs.py index cf098f92c9..94a78e8423 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -70,6 +70,7 @@ from helpers import ( get_changed_components, get_component_from_path, get_component_test_files, + get_component_test_platforms, get_components_with_dependencies, get_cpp_changed_components, get_fixture_to_test_files, @@ -77,7 +78,6 @@ from helpers import ( get_target_branch, git_ls_files, is_validate_only_file, - parse_test_filename, root_path, ) from split_components_for_ci import create_intelligent_batches @@ -169,24 +169,6 @@ MEMORY_IMPACT_FALLBACK_COMPONENT = "api" # Representative component for core ch MEMORY_IMPACT_FALLBACK_PLATFORM = Platform.ESP32_IDF # Most representative platform MEMORY_IMPACT_MAX_COMPONENTS = 40 # Max components before results become nonsensical -# Platform-specific components that can only be built on their respective platforms -# These components contain platform-specific code and cannot be cross-compiled -# Regular components (wifi, logger, api, etc.) are cross-platform and not listed here -PLATFORM_SPECIFIC_COMPONENTS = frozenset( - { - "esp32", # ESP32 platform implementation - "esp8266", # ESP8266 platform implementation - "rp2040", # Raspberry Pi Pico / RP2040 platform implementation - "libretiny", # LibreTiny base platform implementation - "bk72xx", # Beken BK72xx platform implementation (uses LibreTiny) - "rtl87xx", # Realtek RTL87xx platform implementation (uses LibreTiny) - "ln882x", # Winner Micro LN882x platform implementation (uses LibreTiny) - "host", # Host platform (for testing on development machine) - "nrf52", # Nordic nRF52 platform implementation (uses Zephyr) - "zephyr", # Zephyr RTOS platform implementation - } -) - # Platform preference order for memory impact analysis # This order is used when no platform-specific hints are detected from filenames # Priority rationale: @@ -1006,23 +988,24 @@ def detect_memory_impact_config( ] = {} # Track which platforms each component supports for component in sorted(changed_component_set): - # Look for test files on preferred platforms - test_files = get_component_test_files(component, all_variants=True) - if not test_files: - continue - - # Check if component has tests for any preferred platform - available_platforms = [ - platform - for test_file in test_files - if (platform := parse_test_filename(test_file)[1]) != "all" - and platform in MEMORY_IMPACT_PLATFORM_PREFERENCE - ] + # Discover the platforms this component has BASE tests for, using the + # same logic as the build runner (get_component_test_platforms wraps the + # shared get_component_test_files + parse_test_filename helpers). Base + # tests only: the memory impact CI build runs test_build_components.py + # with --base-only, which compiles base test..yaml files but + # never variant test-..yaml files. Counting + # variant-only platforms here would let us select a platform the build + # then has nothing to compile for, producing no memory output. + available_platforms = { + Platform(platform) + for platform in get_component_test_platforms(component) + if platform in MEMORY_IMPACT_PLATFORM_PREFERENCE + } if not available_platforms: continue - component_platforms_map[component] = set(available_platforms) + component_platforms_map[component] = available_platforms components_with_tests.append(component) # If no components have tests, don't run memory impact @@ -1084,20 +1067,57 @@ def detect_memory_impact_config( ) platform = _select_platform_by_count(platform_counts) - # Filter out platform-specific components that are incompatible with selected platform - # Platform components (esp32, esp8266, rp2040, etc.) can only build on their own platform - # Other components (wifi, logger, etc.) are cross-platform and can build anywhere - compatible_components = [ - component - for component in components_with_tests - if component not in PLATFORM_SPECIFIC_COMPONENTS - or platform in component_platforms_map.get(component, set()) - ] + # Keep only components that have a base test on the selected platform. + # The merged build runs test_build_components.py -t --base-only, + # so a component without a base test..yaml compiles nothing and + # contributes no memory output. This also covers platform-specific + # components (esp32, esp8266, etc.), which only have tests on their own + # platform. When components don't share a common platform we build the + # largest subset that does, dropping the rest. + def components_supporting(target: Platform) -> list[str]: + return [ + component + for component in components_with_tests + if target in component_platforms_map.get(component, set()) + ] - # If no components are compatible with the selected platform, don't run + compatible_components = components_supporting(platform) + + # A platform hint (or no-common-platform fallback) can pick a platform that + # no changed component actually has a base test for, leaving nothing to + # build. In that case fall back to the platform supported by the most + # components. component_platforms_map is non-empty (guarded above) and every + # value is a non-empty platform set (components with no supported platform + # are skipped at discovery), so this always yields a buildable platform with + # at least one compatible component. + if not compatible_components: + platform = _select_platform_by_count( + Counter( + p for platforms in component_platforms_map.values() for p in platforms + ) + ) + compatible_components = components_supporting(platform) + + # Defensive backstop: unreachable given the invariant above, but guards + # against a future regression in platform selection silently passing an + # empty component list to the build. if not compatible_components: return {"should_run": "false"} + # Log components dropped because they lack a base test on the selected + # platform so partial-subset builds are visible in CI logs. + dropped_components = [ + component + for component in components_with_tests + if component not in compatible_components + ] + if dropped_components: + print( + f"Memory impact: Dropping components without a base test on " + f"{platform}: {dropped_components}", + file=sys.stderr, + ) + # Debug output print("Memory impact analysis:", file=sys.stderr) print(f" Changed components: {sorted(changed_component_set)}", file=sys.stderr) diff --git a/script/helpers.py b/script/helpers.py index 9839e766e2..1ebfe405a7 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -149,6 +149,31 @@ def get_component_test_files( return files +def get_component_test_platforms(component: str, *, base_only: bool = True) -> set[str]: + """Return the set of platforms a component has compilable test files for. + + Uses the same discovery as ``test_build_components.py`` (``get_component_test_files`` + + ``parse_test_filename``) so callers agree with what the build runner would + actually compile. With ``base_only=True`` (the default, matching the + memory-impact build's ``--base-only``), only base ``test..yaml`` + files are considered; variant ``test-..yaml`` files are + excluded. The ``"all"`` platform sentinel is excluded. + + Args: + component: Component name (e.g. "wifi") + base_only: If True, only consider base test files (default). + + Returns: + Set of platform identifiers (e.g. {"esp32-idf", "esp8266-ard"}). + """ + platforms: set[str] = set() + for test_file in get_component_test_files(component, all_variants=not base_only): + platform = parse_test_filename(test_file)[1] + if platform != "all": + platforms.add(platform) + return platforms + + def is_validate_only_file(test_file: Path) -> bool: """Return True if the given path is a config-only validate file. diff --git a/script/test_build_components.py b/script/test_build_components.py index 767b55c94b..651268609e 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -42,6 +42,7 @@ from script.analyze_component_buses import ( from script.helpers import ( get_component_test_files, is_validate_only_file, + parse_test_filename, split_conflicting_groups, ) from script.merge_component_configs import merge_component_configs @@ -122,21 +123,6 @@ def find_component_tests( return dict(component_tests) -def parse_test_filename(test_file: Path) -> tuple[str, str]: - """Parse test filename to extract test name and platform. - - Args: - test_file: Path to test file - - Returns: - Tuple of (test_name, platform) - """ - parts = test_file.stem.split(".") - if len(parts) == 2: - return parts[0], parts[1] # test, platform - return parts[0], "all" - - def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]: """Get all platform base files. diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index ac3c6424bf..acc268fa68 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1426,7 +1426,15 @@ def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) -> @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.""" + """Test memory impact detection when components have no common platform. + + The merged build runs with --base-only on a single platform, so components + without a base test on the selected platform cannot be built and must be + dropped. We build the largest subset that shares the selected platform + rather than handing the runner components it has nothing to compile for + (which previously produced "0 passed, 0 failed" and a failed memory + extraction). + """ # Create test directory structure tests_dir = tmp_path / "tests" / "components" @@ -1453,12 +1461,70 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: result = determine_jobs.detect_memory_impact_config() - # Should pick the most frequently supported platform + # No common platform: pick the most preferred platform among those supported + # (esp8266-ard outranks esp32-idf in the preference list) and build only the + # components that have a base test on it. wifi (esp32-idf only) is dropped. 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["platform"] == "esp8266-ard" + assert result["components"] == ["logger"] + assert result["use_merged_config"] == "true" + + +def test_detect_memory_impact_config_variant_only_platform_excluded( + tmp_path: Path, +) -> None: + """Regression test for the const + shelly_dimmer memory-impact failure. + + Reproduces https://github.com/esphome/esphome/actions/runs/26746938473 + where a platform hint selected esp32-idf even though neither changed + component had a base test.esp32-idf.yaml. The merged --base-only build then + found nothing to compile ("0 passed, 0 failed") and memory extraction + failed. Also covers a component whose only esp32-idf test is a *variant* + (test-*.esp32-idf.yaml): --base-only never compiles variants, so it must + not count toward platform availability. + """ + tests_dir = tmp_path / "tests" / "components" + + # const: base test only on esp32-s3-idf + const_dir = tests_dir / "const" + const_dir.mkdir(parents=True) + (const_dir / "test.esp32-s3-idf.yaml").write_text("test: const") + + # shelly_dimmer: base test only on esp8266-ard + shelly_dir = tests_dir / "shelly_dimmer" + shelly_dir.mkdir(parents=True) + (shelly_dir / "test.esp8266-ard.yaml").write_text("test: shelly_dimmer") + + # mdns: only a VARIANT test on esp32-idf (no base test.esp32-idf.yaml). + # --base-only would never build it, so it must be excluded entirely. + mdns_dir = tests_dir / "mdns" + mdns_dir.mkdir(parents=True) + (mdns_dir / "test-min.esp32-idf.yaml").write_text("test: mdns") + + 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, + ): + # The "_esp32" filename yields an esp32-idf platform hint, reproducing + # the original bug where the hint picked a platform no component could + # build as a base test. + mock_changed_files.return_value = [ + "esphome/components/const/const.cpp", + "esphome/components/shelly_dimmer/shelly_dimmer_esp32.cpp", + "esphome/components/mdns/mdns.cpp", + ] + + result = determine_jobs.detect_memory_impact_config() + + # The esp32-idf hint is unbuildable (no base test), so we fall back to the + # platform supported by the most components, broken by preference order: + # esp8266-ard (shelly_dimmer) outranks esp32-s3-idf (const). Only the + # component with a base test on the selected platform is returned; the + # variant-only mdns is excluded entirely. + assert result["should_run"] == "true" + assert result["platform"] == "esp8266-ard" + assert result["components"] == ["shelly_dimmer"] assert result["use_merged_config"] == "true" @@ -1545,12 +1611,16 @@ def test_detect_memory_impact_config_includes_base_bus_components( @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. +def test_detect_memory_impact_config_variant_only_components_skipped( + tmp_path: Path, +) -> None: + """Components with only variant tests are skipped for memory impact. - 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). + Components like improv_serial and ethernet only have variant test files + (test-*.yaml), no base test..yaml. The memory-impact build runs + test_build_components.py with --base-only, which never compiles variants, so + these components have nothing buildable and must not be selected. Selecting + them previously produced "0 passed, 0 failed" and a failed memory extraction. """ # Create test directory structure tests_dir = tmp_path / "tests" / "components" @@ -1581,12 +1651,8 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: 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" + # Neither component has a base test, so nothing is buildable under --base-only + assert result["should_run"] == "false" # Tests for clang-tidy split mode logic