Merge branch 'dev' into proxy-subscribe-acks

This commit is contained in:
Keith Burzinski
2026-08-17 16:55:06 -05:00
committed by GitHub
106 changed files with 448 additions and 211 deletions
@@ -19,7 +19,7 @@ def test_final_validate_accepts_supported_idf(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
assert _final_validate({}) == {}
_final_validate({})
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"])
+9 -8
View File
@@ -371,27 +371,28 @@ def test_migrate_returns_none_for_invalid_legacy_shapes(
def test_validate_image_final_defaults_to_little_endian() -> None:
out = validate_image_final({CONF_FILE: "x.png"})
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
config = {CONF_FILE: "x.png"}
validate_image_final(config)
assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
def test_validate_image_final_keeps_little_endian(
caplog: pytest.LogCaptureFixture,
) -> None:
config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"}
with caplog.at_level(logging.WARNING):
out = validate_image_final(
{CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"}
)
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
validate_image_final(config)
assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
assert "big-endian" not in caplog.text
def test_validate_image_final_warns_on_big_endian(
caplog: pytest.LogCaptureFixture,
) -> None:
config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}
with caplog.at_level(logging.WARNING):
out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"})
assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN"
validate_image_final(config)
assert config[CONF_BYTE_ORDER] == "BIG_ENDIAN"
assert "big-endian" in caplog.text
@@ -37,7 +37,7 @@ def test_provisioning_accepts_a_registered_source(
set_core_config(PlatformFramework.ESP32_IDF)
register_source("network")
# Should not raise.
assert FINAL_VALIDATE_SCHEMA({}) == {}
FINAL_VALIDATE_SCHEMA({})
def test_provisioning_warns_on_hardcoded_credentials(
@@ -49,7 +49,7 @@ def test_provisioning_warns_on_hardcoded_credentials(
register_source("network")
report_hardcoded_credentials("wifi")
with caplog.at_level(logging.WARNING):
assert FINAL_VALIDATE_SCHEMA({}) == {}
FINAL_VALIDATE_SCHEMA({})
assert "wifi" in caplog.text
assert "credentials" in caplog.text
@@ -62,7 +62,7 @@ def test_provisioning_no_warning_without_hardcoded_credentials(
set_core_config(PlatformFramework.ESP32_IDF)
register_source("network")
with caplog.at_level(logging.WARNING):
assert FINAL_VALIDATE_SCHEMA({}) == {}
FINAL_VALIDATE_SCHEMA({})
assert "credentials" not in caplog.text
+67
View File
@@ -1,6 +1,7 @@
#include <gtest/gtest.h>
#include <cstring>
#include "esphome/core/alloc_helpers.h"
#include "esphome/core/helpers.h"
namespace esphome::core::testing {
@@ -213,4 +214,70 @@ TEST(BufAppendSepStr, Truncation) {
EXPECT_EQ(end - buf, 7);
}
// --- base64 encode/decode ---
static const char BASE64_ALPHABET[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// Pack 6-bit indices 0..63 into 48 bytes so encoding yields the full alphabet in order
TEST(Base64, EncodeProducesCanonicalAlphabet) {
uint8_t bytes[48];
size_t n = 0;
for (uint8_t i = 0; i < 64; i += 4) {
bytes[n++] = (i << 2) | ((i + 1) >> 4);
bytes[n++] = ((i + 1) & 0x0F) << 4 | ((i + 2) >> 2);
bytes[n++] = ((i + 2) & 0x03) << 6 | (i + 3);
}
std::string encoded = base64_encode(bytes, sizeof(bytes)); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(encoded, BASE64_ALPHABET);
}
// Decode the alphabet then re-encode: locks the encode and decode mappings together
TEST(Base64, DecodeCanonicalAlphabetRoundTrip) {
uint8_t buf[48];
size_t len = base64_decode(std::string(BASE64_ALPHABET), buf, sizeof(buf));
EXPECT_EQ(len, 48u);
std::string reencoded = base64_encode(buf, len); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(reencoded, BASE64_ALPHABET);
}
TEST(Base64, DecodeBase64UrlMatchesStandard) {
std::string url = BASE64_ALPHABET;
for (char &c : url) {
if (c == '+')
c = '-';
if (c == '/')
c = '_';
}
uint8_t standard[48], urlsafe[48];
size_t len_standard = base64_decode(std::string(BASE64_ALPHABET), standard, sizeof(standard));
size_t len_url = base64_decode(url, urlsafe, sizeof(urlsafe));
EXPECT_EQ(len_standard, len_url);
EXPECT_EQ(memcmp(standard, urlsafe, len_standard), 0);
}
// RFC 4648 vectors cover both padding cases (len % 3 == 1 and len % 3 == 2)
TEST(Base64, Rfc4648Vectors) {
const struct {
const char *plain;
const char *encoded;
} vectors[] = {
{"", ""},
{"f", "Zg=="},
{"fo", "Zm8="},
{"foo", "Zm9v"},
{"foob", "Zm9vYg=="},
{"fooba", "Zm9vYmE="},
{"foobar", "Zm9vYmFy"},
};
for (const auto &v : vectors) {
const auto *plain = reinterpret_cast<const uint8_t *>(v.plain);
std::string encoded = base64_encode(plain, strlen(v.plain)); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(encoded, v.encoded);
uint8_t buf[8];
size_t len = base64_decode(reinterpret_cast<const uint8_t *>(v.encoded), strlen(v.encoded), buf, sizeof(buf));
EXPECT_EQ(len, strlen(v.plain));
EXPECT_EQ(memcmp(buf, v.plain, len), 0);
}
}
} // namespace esphome::core::testing
+121
View File
@@ -20,6 +20,7 @@ changed_files = helpers.changed_files
filter_changed = helpers.filter_changed
get_changed_components = helpers.get_changed_components
_get_changed_files_from_command = helpers._get_changed_files_from_command
run_gh_command = helpers.run_gh_command
_get_pr_number_from_github_env = helpers._get_pr_number_from_github_env
_get_changed_files_github_actions = helpers._get_changed_files_github_actions
_filter_changed_ci = helpers._filter_changed_ci
@@ -1872,3 +1873,123 @@ def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) ->
def test_base_python_changed(files: list[str], expected: bool) -> None:
"""Only Python modules directly in esphome/ count as base Python changes."""
assert helpers.base_python_changed(files) is expected
def _gh_error(stderr: str) -> subprocess.CalledProcessError:
return subprocess.CalledProcessError(1, ["gh"], output="", stderr=stderr)
def _gh_success(stdout: str = "ok\n") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(["gh"], 0, stdout=stdout, stderr="")
def test_run_gh_command_success() -> None:
"""A successful command returns without retrying."""
with patch("helpers.subprocess.run", return_value=_gh_success()) as mock_run:
result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"])
assert result.stdout == "ok\n"
mock_run.assert_called_once()
@pytest.mark.parametrize(
"second_error",
[
(
'Post "https://api.github.com/graphql": tls: failed to verify'
" certificate: x509: certificate is not valid for any names,"
" but wanted to match api.github.com"
),
'Post "https://api.github.com/graphql": EOF',
(
"error connecting to api.github.com\n"
"check your internet connection or https://githubstatus.com"
),
],
)
def test_run_gh_command_retries_transient_error(second_error: str) -> None:
"""Transient server errors are retried with 2s/4s backoff."""
with (
patch(
"helpers.subprocess.run",
side_effect=[
_gh_error("HTTP 502: 502 Bad Gateway (https://api.github.com/graphql)"),
_gh_error(second_error),
_gh_success(),
],
) as mock_run,
patch("helpers.time.sleep") as mock_sleep,
):
result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"])
assert result.stdout == "ok\n"
assert mock_run.call_count == 3
assert [call.args[0] for call in mock_sleep.call_args_list] == [2, 4]
def test_run_gh_command_gives_up_after_max_attempts() -> None:
"""A persistent transient error raises after the third attempt."""
with (
patch(
"helpers.subprocess.run",
side_effect=_gh_error("HTTP 503: Service Unavailable"),
) as mock_run,
patch("helpers.time.sleep") as mock_sleep,
pytest.raises(subprocess.CalledProcessError),
):
run_gh_command(["gh", "pr", "diff", "123", "--name-only"])
assert mock_run.call_count == 3
assert mock_sleep.call_count == 2
@pytest.mark.parametrize(
"stderr",
[
"HTTP 404: Not Found (https://api.github.com/repos/x)",
"HTTP 401: Bad credentials",
"HTTP 403: API rate limit exceeded for installation ID 123.",
"diff exceeded the maximum number of changed files (300)",
(
"GraphQL: Could not resolve to a PullRequest with the number of 999999."
" (repository.pullRequest)"
),
],
)
def test_run_gh_command_permanent_error_not_retried(stderr: str) -> None:
"""Permanent failures raise immediately without any retry."""
with (
patch("helpers.subprocess.run", side_effect=_gh_error(stderr)) as mock_run,
patch("helpers.time.sleep") as mock_sleep,
pytest.raises(subprocess.CalledProcessError),
):
run_gh_command(["gh", "pr", "diff", "123", "--name-only"])
mock_run.assert_called_once()
mock_sleep.assert_not_called()
def test_run_gh_command_no_retry_for_non_idempotent_commands() -> None:
"""retry=False fails on the first error even when it looks transient."""
with (
patch(
"helpers.subprocess.run",
side_effect=_gh_error("HTTP 502: 502 Bad Gateway"),
) as mock_run,
patch("helpers.time.sleep") as mock_sleep,
pytest.raises(subprocess.CalledProcessError),
):
run_gh_command(["gh", "pr", "comment", "123", "--body", "x"], retry=False)
mock_run.assert_called_once()
mock_sleep.assert_not_called()
def test_get_changed_files_from_command_gh_failure_keeps_stderr() -> None:
"""Failures from gh surface stderr so callers can detect the 300-file limit."""
stderr = "diff exceeded the maximum number of changed files (300)"
with (
patch("helpers.subprocess.run", side_effect=_gh_error(stderr)),
pytest.raises(Exception, match="maximum number of changed files"),
):
_get_changed_files_from_command(["gh", "pr", "diff", "123", "--name-only"])