[ci] Add validate.*.yaml for config-only component tests (#16384)

This commit is contained in:
Jesse Hills
2026-05-13 11:37:33 +12:00
committed by GitHub
parent 45a8bd49c3
commit cb2dbcd70d
10 changed files with 589 additions and 22 deletions

View File

@@ -2215,3 +2215,230 @@ def test_should_run_benchmarks_with_branch() -> None:
mock_changed.return_value = []
determine_jobs.should_run_benchmarks("release")
mock_changed.assert_called_with("release")
# ---------------------------------------------------------------------------
# _component_change_is_validate_only
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("component", "changed", "expected"),
[
# Only a base validate file changed.
(
"foo",
["tests/components/foo/validate.esp32-idf.yaml"],
True,
),
# Only a validate variant changed.
(
"foo",
["tests/components/foo/validate-legacy.esp32-idf.yaml"],
True,
),
# Multiple validate files (all validate).
(
"foo",
[
"tests/components/foo/validate.esp32-idf.yaml",
"tests/components/foo/validate-legacy.esp32-idf.yaml",
],
True,
),
# Mixed: validate + regular test must NOT be classified as validate-only.
(
"foo",
[
"tests/components/foo/validate.esp32-idf.yaml",
"tests/components/foo/test.esp32-idf.yaml",
],
False,
),
# Regular test only.
(
"foo",
["tests/components/foo/test.esp32-idf.yaml"],
False,
),
# Source change disqualifies even if a validate file is also touched.
(
"foo",
[
"esphome/components/foo/foo.cpp",
"tests/components/foo/validate.esp32-idf.yaml",
],
False,
),
# No matching files at all.
("foo", ["esphome/core/helpers.cpp"], False),
# Filenames merely starting with "validate" but not following the
# grammar must not match (defensive against accidental classification).
(
"foo",
["tests/components/foo/validatesomething.yaml"],
False,
),
# An unrelated component's validate change doesn't affect this one.
(
"foo",
["tests/components/bar/validate.esp32-idf.yaml"],
False,
),
# common.yaml change in the component dir disqualifies.
(
"foo",
[
"tests/components/foo/common.yaml",
"tests/components/foo/validate.esp32-idf.yaml",
],
False,
),
],
)
def test_component_change_is_validate_only(
component: str, changed: list[str], expected: bool
) -> None:
"""The validate-only classifier rejects anything beyond validate.* edits."""
assert (
determine_jobs._component_change_is_validate_only(component, changed)
is expected
)
def test_main_emits_validate_only_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_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:
"""Directly-changed components whose only edits are validate.*.yaml are
listed in `validate_only_components` so CI can skip their compile stage.
"""
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, [])
# foo: only validate file changed (qualifies)
# bar: test file changed (does not qualify)
mock_changed_files.return_value = [
"tests/components/foo/validate.esp32-idf.yaml",
"tests/components/bar/test.esp32-idf.yaml",
]
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=["foo", "bar"],
),
patch.object(
determine_jobs,
"filter_component_and_test_files",
side_effect=lambda f: f.startswith("tests/components/"),
),
patch.object(
determine_jobs,
"get_components_with_dependencies",
side_effect=lambda files, deps: ["foo", "bar"],
),
patch.object(determine_jobs, "_component_has_tests", return_value=True),
patch.object(
determine_jobs,
"detect_memory_impact_config",
return_value={"should_run": "false"},
),
patch.object(
determine_jobs,
"create_intelligent_batches",
return_value=([["foo", "bar"]], {}),
),
):
determine_jobs.main()
output = json.loads(capsys.readouterr().out)
assert output["validate_only_components"] == ["foo"]
def test_main_validate_only_excludes_transitive_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_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:
"""A component pulled in only as a dependency must NOT be considered
validate-only, even if it has no source changes -- its dependency moved,
so the compile is still required.
"""
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, [])
# Only foo's validate file changed directly. bar is a transitive dep.
mock_changed_files.return_value = [
"tests/components/foo/validate.esp32-idf.yaml",
]
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=["foo", "bar"], # bar pulled in via dependencies
),
patch.object(
determine_jobs,
"filter_component_and_test_files",
side_effect=lambda f: f.startswith("tests/components/"),
),
patch.object(
determine_jobs,
"get_components_with_dependencies",
# deps=False -> directly_changed = [foo]; deps=True -> [foo, bar]
side_effect=lambda files, deps: ["foo", "bar"] if deps else ["foo"],
),
patch.object(determine_jobs, "_component_has_tests", return_value=True),
patch.object(
determine_jobs,
"detect_memory_impact_config",
return_value={"should_run": "false"},
),
patch.object(
determine_jobs,
"create_intelligent_batches",
return_value=([["foo", "bar"]], {}),
),
):
determine_jobs.main()
output = json.loads(capsys.readouterr().out)
# Only foo (directly changed, validate-only). bar is a transitive dep
# and still needs compile despite no source change of its own.
assert output["validate_only_components"] == ["foo"]

View File

@@ -1624,3 +1624,171 @@ def test_split_conflicting_groups_preserves_original_signature_for_first_bucket(
platform, signature = next(iter(extra))
assert platform == "esp32"
assert signature.startswith("i2c__conflict")
# ---------------------------------------------------------------------------
# get_component_test_files / is_validate_only_file
# ---------------------------------------------------------------------------
@pytest.fixture
def fake_component_tests(tmp_path: Path) -> Path:
"""Create a fake tests/components/ tree and return the repo root.
Layout for component "demo":
test.esp32-idf.yaml
test.esp8266-ard.yaml
test-variant.esp32-idf.yaml
validate.esp32-idf.yaml
validate-legacy.esp32-idf.yaml
Layout for component "validate_only":
validate.esp32-idf.yaml (only validate files)
Layout for component "no_tests":
common.yaml (no test/validate files at all)
"""
tests_dir = tmp_path / "tests" / "components"
demo = tests_dir / "demo"
demo.mkdir(parents=True)
(demo / "test.esp32-idf.yaml").write_text("")
(demo / "test.esp8266-ard.yaml").write_text("")
(demo / "test-variant.esp32-idf.yaml").write_text("")
(demo / "validate.esp32-idf.yaml").write_text("")
(demo / "validate-legacy.esp32-idf.yaml").write_text("")
validate_only = tests_dir / "validate_only"
validate_only.mkdir(parents=True)
(validate_only / "validate.esp32-idf.yaml").write_text("")
no_tests = tests_dir / "no_tests"
no_tests.mkdir(parents=True)
(no_tests / "common.yaml").write_text("")
return tmp_path
def _names(paths: list[Path]) -> set[str]:
return {p.name for p in paths}
def test_get_component_test_files_default_excludes_validate(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""Default behaviour: only base test.*.yaml; no variants, no validate."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
files = helpers.get_component_test_files("demo")
assert _names(files) == {"test.esp32-idf.yaml", "test.esp8266-ard.yaml"}
def test_get_component_test_files_all_variants_excludes_validate(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""all_variants=True picks up test variants but still skips validate."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
files = helpers.get_component_test_files("demo", all_variants=True)
assert _names(files) == {
"test.esp32-idf.yaml",
"test.esp8266-ard.yaml",
"test-variant.esp32-idf.yaml",
}
def test_get_component_test_files_include_validate_base_only(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""include_validate=True with base-only adds validate.*.yaml only."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
files = helpers.get_component_test_files("demo", include_validate=True)
assert _names(files) == {
"test.esp32-idf.yaml",
"test.esp8266-ard.yaml",
"validate.esp32-idf.yaml",
}
def test_get_component_test_files_include_validate_all_variants(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""include_validate=True with all_variants adds validate variants too."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
files = helpers.get_component_test_files(
"demo", all_variants=True, include_validate=True
)
assert _names(files) == {
"test.esp32-idf.yaml",
"test.esp8266-ard.yaml",
"test-variant.esp32-idf.yaml",
"validate.esp32-idf.yaml",
"validate-legacy.esp32-idf.yaml",
}
def test_get_component_test_files_validate_only_component(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""A component with only validate files is invisible without the flag."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
assert helpers.get_component_test_files("validate_only") == []
assert helpers.get_component_test_files("validate_only", all_variants=True) == []
files = helpers.get_component_test_files(
"validate_only", all_variants=True, include_validate=True
)
assert _names(files) == {"validate.esp32-idf.yaml"}
def test_get_component_test_files_missing_component(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""Unknown components return an empty list, regardless of flags."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
assert (
helpers.get_component_test_files(
"does_not_exist", all_variants=True, include_validate=True
)
== []
)
def test_get_component_test_files_component_without_tests(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""A component with only common.yaml and no test/validate files returns []."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
assert (
helpers.get_component_test_files(
"no_tests", all_variants=True, include_validate=True
)
== []
)
@pytest.mark.parametrize(
("filename", "expected"),
[
("validate.esp32-idf.yaml", True),
("validate-legacy.esp32-idf.yaml", True),
("validate.host.yaml", True),
("test.esp32-idf.yaml", False),
("test-variant.esp32-idf.yaml", False),
("common.yaml", False),
# Defensive: a hypothetical name starting with "validate" but not
# following the grammar must not be classified as a validate file.
("validatesomething.yaml", False),
],
)
def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> None:
assert helpers.is_validate_only_file(tmp_path / filename) is expected