mirror of
https://github.com/esphome/esphome.git
synced 2026-09-29 16:00:23 +00:00
Merge branch 'dev' into proxy-subscribe-acks
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user