mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 11:07:33 +00:00
[ci] Replace clang-tidy hash with direct config-file diff check (#17019)
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
"""Unit tests for script/clang_tidy_hash.py module."""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -11,76 +9,45 @@ import pytest
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
|
||||
|
||||
import clang_tidy_hash # noqa: E402
|
||||
from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_content", "expected"),
|
||||
[
|
||||
(
|
||||
"clang-tidy==18.1.5 # via -r requirements_dev.in\n",
|
||||
"clang-tidy==18.1.5 # via -r requirements_dev.in",
|
||||
),
|
||||
(
|
||||
"other-package==1.0\nclang-tidy==17.0.0\nmore-packages==2.0\n",
|
||||
"clang-tidy==17.0.0",
|
||||
),
|
||||
(
|
||||
"# comment\nclang-tidy==16.0.0 # some comment\n",
|
||||
"clang-tidy==16.0.0 # some comment",
|
||||
),
|
||||
("no-clang-tidy-here==1.0\n", "clang-tidy version not found"),
|
||||
],
|
||||
)
|
||||
def test_get_clang_tidy_version_from_requirements(
|
||||
file_content: str, expected: str
|
||||
def _populate(repo_root: Path) -> None:
|
||||
"""Create every clang-tidy global file plus a base sdkconfig.defaults."""
|
||||
for name in CLANG_TIDY_GLOBAL_FILES:
|
||||
path = repo_root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(f"contents of {name}\n")
|
||||
(repo_root / "sdkconfig.defaults").write_text("CONFIG_BASE=y\n")
|
||||
|
||||
|
||||
def test_calculate_clang_tidy_hash_is_deterministic(tmp_path: Path) -> None:
|
||||
"""Same inputs must produce the same hash."""
|
||||
_populate(tmp_path)
|
||||
assert clang_tidy_hash.calculate_clang_tidy_hash(
|
||||
repo_root=tmp_path
|
||||
) == clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", CLANG_TIDY_GLOBAL_FILES)
|
||||
def test_calculate_clang_tidy_hash_changes_with_each_global_file(
|
||||
tmp_path: Path, filename: str
|
||||
) -> None:
|
||||
"""Test extracting clang-tidy version from various file formats."""
|
||||
# Mock read_file_lines to return our test content
|
||||
with patch("clang_tidy_hash.read_file_lines") as mock_read:
|
||||
mock_read.return_value = file_content.splitlines(keepends=True)
|
||||
"""Editing any global file must change the hash."""
|
||||
_populate(tmp_path)
|
||||
before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path)
|
||||
|
||||
result = clang_tidy_hash.get_clang_tidy_version_from_requirements()
|
||||
(tmp_path / filename).write_text("changed\n")
|
||||
after = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None:
|
||||
"""Test calculating hash from all configuration sources including sdkconfig.defaults."""
|
||||
clang_tidy_content = b"Checks: '-*,readability-*'\n"
|
||||
requirements_version = "clang-tidy==18.1.5"
|
||||
platformio_content = b"[env:esp32]\nplatform = espressif32\n"
|
||||
sdkconfig_content = b""
|
||||
requirements_content = "clang-tidy==18.1.5\n"
|
||||
|
||||
# Create temporary files
|
||||
(tmp_path / ".clang-tidy").write_bytes(clang_tidy_content)
|
||||
(tmp_path / "platformio.ini").write_bytes(platformio_content)
|
||||
(tmp_path / "sdkconfig.defaults").write_bytes(sdkconfig_content)
|
||||
(tmp_path / "requirements_dev.txt").write_text(requirements_content)
|
||||
|
||||
# Expected hash calculation
|
||||
expected_hasher = hashlib.sha256()
|
||||
expected_hasher.update(clang_tidy_content)
|
||||
expected_hasher.update(requirements_version.encode())
|
||||
expected_hasher.update(platformio_content)
|
||||
expected_hasher.update(b"sdkconfig.defaults")
|
||||
expected_hasher.update(sdkconfig_content)
|
||||
expected_hash = expected_hasher.hexdigest()
|
||||
|
||||
result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path)
|
||||
|
||||
assert result == expected_hash
|
||||
assert after != before
|
||||
|
||||
|
||||
def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Per-target sdkconfig.defaults.<target> files must be part of the hash."""
|
||||
(tmp_path / ".clang-tidy").write_bytes(b"Checks: '-*'\n")
|
||||
(tmp_path / "platformio.ini").write_bytes(b"[env:esp32]\n")
|
||||
(tmp_path / "requirements_dev.txt").write_text("clang-tidy==18.1.5\n")
|
||||
(tmp_path / "sdkconfig.defaults").write_bytes(b"CONFIG_BASE=y\n")
|
||||
|
||||
_populate(tmp_path)
|
||||
before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path)
|
||||
|
||||
# Adding a per-target file must change the hash.
|
||||
@@ -95,230 +62,14 @@ def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig(
|
||||
assert after_edit != after_add
|
||||
|
||||
|
||||
def test_calculate_clang_tidy_hash_without_sdkconfig(tmp_path: Path) -> None:
|
||||
"""Test calculating hash without sdkconfig.defaults file."""
|
||||
clang_tidy_content = b"Checks: '-*,readability-*'\n"
|
||||
requirements_version = "clang-tidy==18.1.5"
|
||||
platformio_content = b"[env:esp32]\nplatform = espressif32\n"
|
||||
requirements_content = "clang-tidy==18.1.5\n"
|
||||
|
||||
# Create temporary files (without sdkconfig.defaults)
|
||||
(tmp_path / ".clang-tidy").write_bytes(clang_tidy_content)
|
||||
(tmp_path / "platformio.ini").write_bytes(platformio_content)
|
||||
(tmp_path / "requirements_dev.txt").write_text(requirements_content)
|
||||
|
||||
# Expected hash calculation (no sdkconfig)
|
||||
expected_hasher = hashlib.sha256()
|
||||
expected_hasher.update(clang_tidy_content)
|
||||
expected_hasher.update(requirements_version.encode())
|
||||
expected_hasher.update(platformio_content)
|
||||
expected_hash = expected_hasher.hexdigest()
|
||||
|
||||
def test_calculate_clang_tidy_hash_handles_missing_optional_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Hash calculation must not fail when files are absent."""
|
||||
# Only .clang-tidy present; everything else missing.
|
||||
(tmp_path / ".clang-tidy").write_text("Checks: '-*'\n")
|
||||
result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path)
|
||||
|
||||
assert result == expected_hash
|
||||
|
||||
|
||||
def test_read_stored_hash_exists(tmp_path: Path) -> None:
|
||||
"""Test reading hash when file exists."""
|
||||
stored_hash = "abc123def456"
|
||||
hash_file = tmp_path / ".clang-tidy.hash"
|
||||
hash_file.write_text(f"{stored_hash}\n")
|
||||
|
||||
result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path)
|
||||
|
||||
assert result == stored_hash
|
||||
|
||||
|
||||
def test_read_stored_hash_not_exists(tmp_path: Path) -> None:
|
||||
"""Test reading hash when file doesn't exist."""
|
||||
result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_write_hash(tmp_path: Path) -> None:
|
||||
"""Test writing hash to file."""
|
||||
hash_value = "abc123def456"
|
||||
hash_file = tmp_path / ".clang-tidy.hash"
|
||||
|
||||
clang_tidy_hash.write_hash(hash_value, repo_root=tmp_path)
|
||||
|
||||
assert hash_file.exists()
|
||||
assert hash_file.read_text() == hash_value.strip() + "\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "current_hash", "stored_hash", "hash_file_in_changed", "expected_exit"),
|
||||
[
|
||||
(["--check"], "abc123", "abc123", False, 1), # Hashes match, no scan needed
|
||||
(["--check"], "abc123", "def456", False, 0), # Hashes differ, scan needed
|
||||
(["--check"], "abc123", None, False, 0), # No stored hash, scan needed
|
||||
(
|
||||
["--check"],
|
||||
"abc123",
|
||||
"abc123",
|
||||
True,
|
||||
0,
|
||||
), # Hash file updated in PR, scan needed
|
||||
],
|
||||
)
|
||||
def test_main_check_mode(
|
||||
args: list[str],
|
||||
current_hash: str,
|
||||
stored_hash: str | None,
|
||||
hash_file_in_changed: bool,
|
||||
expected_exit: int,
|
||||
) -> None:
|
||||
"""Test main function in check mode."""
|
||||
changed = [".clang-tidy.hash"] if hash_file_in_changed else []
|
||||
|
||||
# Create a mock module that can be imported
|
||||
mock_helpers = Mock()
|
||||
mock_helpers.changed_files = Mock(return_value=changed)
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py"] + args),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
patch.dict("sys.modules", {"helpers": mock_helpers}),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
assert exc_info.value.code == expected_exit
|
||||
|
||||
|
||||
def test_main_update_mode(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test main function in update mode."""
|
||||
current_hash = "abc123"
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py", "--update"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.write_hash") as mock_write,
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
mock_write.assert_called_once_with(current_hash)
|
||||
captured = capsys.readouterr()
|
||||
assert f"Hash updated: {current_hash}" in captured.out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_hash", "stored_hash"),
|
||||
[
|
||||
("abc123", "def456"), # Hash changed, should update
|
||||
("abc123", None), # No stored hash, should update
|
||||
],
|
||||
)
|
||||
def test_main_update_if_changed_mode_update(
|
||||
current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test main function in update-if-changed mode when update is needed."""
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
patch("clang_tidy_hash.write_hash") as mock_write,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
mock_write.assert_called_once_with(current_hash)
|
||||
captured = capsys.readouterr()
|
||||
assert "Clang-tidy hash updated" in captured.out
|
||||
|
||||
|
||||
def test_main_update_if_changed_mode_no_update(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Test main function in update-if-changed mode when no update is needed."""
|
||||
current_hash = "abc123"
|
||||
stored_hash = "abc123"
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
patch("clang_tidy_hash.write_hash") as mock_write,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
mock_write.assert_not_called()
|
||||
captured = capsys.readouterr()
|
||||
assert "Clang-tidy hash unchanged" in captured.out
|
||||
|
||||
|
||||
def test_main_verify_mode_success(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test main function in verify mode when verification passes."""
|
||||
current_hash = "abc123"
|
||||
stored_hash = "abc123"
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py", "--verify"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
captured = capsys.readouterr()
|
||||
assert "Hash verification passed" in captured.out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_hash", "stored_hash"),
|
||||
[
|
||||
("abc123", "def456"), # Hashes differ, verification fails
|
||||
("abc123", None), # No stored hash, verification fails
|
||||
],
|
||||
)
|
||||
def test_main_verify_mode_failure(
|
||||
current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test main function in verify mode when verification fails."""
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py", "--verify"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "ERROR: Clang-tidy configuration has changed" in captured.out
|
||||
|
||||
|
||||
def test_main_default_mode(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test main function in default mode (no arguments)."""
|
||||
current_hash = "abc123"
|
||||
stored_hash = "def456"
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert f"Current hash: {current_hash}" in captured.out
|
||||
assert f"Stored hash: {stored_hash}" in captured.out
|
||||
assert "Match: False" in captured.out
|
||||
|
||||
|
||||
def test_read_file_lines(tmp_path: Path) -> None:
|
||||
"""Test read_file_lines helper function."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_content = "line1\nline2\nline3\n"
|
||||
test_file.write_text(test_content)
|
||||
|
||||
result = clang_tidy_hash.read_file_lines(test_file)
|
||||
|
||||
assert result == ["line1\n", "line2\n", "line3\n"]
|
||||
assert len(result) == 64 # sha256 hexdigest length
|
||||
|
||||
|
||||
def test_read_file_bytes(tmp_path: Path) -> None:
|
||||
@@ -330,35 +81,3 @@ def test_read_file_bytes(tmp_path: Path) -> None:
|
||||
result = clang_tidy_hash.read_file_bytes(test_file)
|
||||
|
||||
assert result == test_content
|
||||
|
||||
|
||||
def test_write_file_content(tmp_path: Path) -> None:
|
||||
"""Test write_file_content helper function."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_content = "test content"
|
||||
|
||||
clang_tidy_hash.write_file_content(test_file, test_content)
|
||||
|
||||
assert test_file.read_text() == test_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "expected"),
|
||||
[
|
||||
("clang-tidy==18.1.5", ("clang-tidy", "clang-tidy==18.1.5")),
|
||||
(
|
||||
"clang-tidy==18.1.5 # comment",
|
||||
("clang-tidy", "clang-tidy==18.1.5 # comment"),
|
||||
),
|
||||
("some-package>=1.0,<2.0", ("some-package", "some-package>=1.0,<2.0")),
|
||||
("pkg_with-dashes==1.0", ("pkg_with-dashes", "pkg_with-dashes==1.0")),
|
||||
("# just a comment", None),
|
||||
("", None),
|
||||
(" ", None),
|
||||
("invalid line without version", None),
|
||||
],
|
||||
)
|
||||
def test_parse_requirement_line(line: str, expected: tuple[str, str] | None) -> None:
|
||||
"""Test parsing individual requirement lines."""
|
||||
result = clang_tidy_hash.parse_requirement_line(line)
|
||||
assert result == expected
|
||||
|
||||
@@ -5,7 +5,7 @@ import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import Mock, call, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -653,52 +653,38 @@ def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("check_returncode", "changed_files", "expected_result"),
|
||||
("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
|
||||
([], False), # Nothing changed
|
||||
(["esphome/core.cpp"], True), # C++ file changed
|
||||
(["README.md"], False), # No C++ files changed
|
||||
([".clang-tidy"], True), # clang-tidy config changed - full scan
|
||||
(["platformio.ini"], True), # build config changed - full scan
|
||||
(["requirements_dev.txt"], True), # clang-tidy version source changed
|
||||
(["sdkconfig.defaults"], True), # sdkconfig changed - full scan
|
||||
(["sdkconfig.defaults.esp32c6"], True), # per-target sdkconfig changed
|
||||
(["esphome/idf_component.yml"], True), # idf managed deps changed
|
||||
(["platformio.ini", "README.md"], True), # config + non-C++
|
||||
],
|
||||
)
|
||||
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)
|
||||
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
|
||||
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")])
|
||||
determine_jobs.should_run_clang_tidy("release")
|
||||
# changed_files is queried against the given branch by both the
|
||||
# config-file full-scan check and the C++ extension check.
|
||||
mock_changed.assert_called_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
Reference in New Issue
Block a user