mirror of
https://github.com/esphome/esphome.git
synced 2026-02-18 15:35:59 -07:00
cover
This commit is contained in:
@@ -353,11 +353,95 @@ def test_extract_bundle_rejects_oversized(
|
|||||||
extract_bundle(bundle_path, tmp_path / "out")
|
extract_bundle(bundle_path, tmp_path / "out")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_bundle_corrupted_tar(tmp_path: Path) -> None:
|
||||||
|
"""Corrupted tar file raises EsphomeError."""
|
||||||
|
bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}"
|
||||||
|
bundle_path.write_bytes(b"not a tar file at all")
|
||||||
|
|
||||||
|
with pytest.raises(EsphomeError, match="Failed to extract bundle"):
|
||||||
|
extract_bundle(bundle_path, tmp_path / "out")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_bundle_malformed_manifest_json(tmp_path: Path) -> None:
|
||||||
|
"""Invalid JSON in manifest.json raises EsphomeError."""
|
||||||
|
bundle_path = tmp_path / f"badjson{BUNDLE_EXTENSION}"
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||||
|
_add_bytes_to_tar(tar, MANIFEST_FILENAME, b"{invalid json")
|
||||||
|
_add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n")
|
||||||
|
bundle_path.write_bytes(buf.getvalue())
|
||||||
|
|
||||||
|
with pytest.raises(EsphomeError, match="malformed manifest.json"):
|
||||||
|
extract_bundle(bundle_path, tmp_path / "out")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_bundle_missing_manifest_version(tmp_path: Path) -> None:
|
||||||
|
"""Manifest without manifest_version raises EsphomeError."""
|
||||||
|
bundle_path = tmp_path / f"nover{BUNDLE_EXTENSION}"
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||||
|
manifest = {ManifestKey.CONFIG_FILENAME: "test.yaml"}
|
||||||
|
_add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode())
|
||||||
|
_add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n")
|
||||||
|
bundle_path.write_bytes(buf.getvalue())
|
||||||
|
|
||||||
|
with pytest.raises(EsphomeError, match="missing 'manifest_version'"):
|
||||||
|
extract_bundle(bundle_path, tmp_path / "out")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_bundle_invalid_manifest_version_type(tmp_path: Path) -> None:
|
||||||
|
"""Non-integer manifest_version raises EsphomeError."""
|
||||||
|
bundle_path = _make_bundle(
|
||||||
|
tmp_path,
|
||||||
|
manifest_overrides={ManifestKey.MANIFEST_VERSION: "not_an_int"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(EsphomeError, match="must be a positive integer"):
|
||||||
|
extract_bundle(bundle_path, tmp_path / "out")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_bundle_manifest_version_zero(tmp_path: Path) -> None:
|
||||||
|
"""manifest_version of 0 is rejected."""
|
||||||
|
bundle_path = _make_bundle(
|
||||||
|
tmp_path,
|
||||||
|
manifest_overrides={ManifestKey.MANIFEST_VERSION: 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(EsphomeError, match="must be a positive integer"):
|
||||||
|
extract_bundle(bundle_path, tmp_path / "out")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_bundle_manifest_not_regular_file(tmp_path: Path) -> None:
|
||||||
|
"""manifest.json that is a directory entry raises EsphomeError."""
|
||||||
|
bundle_path = tmp_path / f"dirmanifest{BUNDLE_EXTENSION}"
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||||
|
# Add manifest.json as a directory instead of a file
|
||||||
|
dir_info = tarfile.TarInfo(name=MANIFEST_FILENAME)
|
||||||
|
dir_info.type = tarfile.DIRTYPE
|
||||||
|
dir_info.size = 0
|
||||||
|
tar.addfile(dir_info)
|
||||||
|
_add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n")
|
||||||
|
bundle_path.write_bytes(buf.getvalue())
|
||||||
|
|
||||||
|
with pytest.raises(EsphomeError, match="not a regular file"):
|
||||||
|
extract_bundle(bundle_path, tmp_path / "out")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# read_bundle_manifest
|
# read_bundle_manifest
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_bundle_manifest_corrupted_tar(tmp_path: Path) -> None:
|
||||||
|
"""Corrupted tar file raises EsphomeError via read_bundle_manifest."""
|
||||||
|
bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}"
|
||||||
|
bundle_path.write_bytes(b"not a tar file")
|
||||||
|
|
||||||
|
with pytest.raises(EsphomeError, match="Failed to read bundle"):
|
||||||
|
read_bundle_manifest(bundle_path)
|
||||||
|
|
||||||
|
|
||||||
def test_read_bundle_manifest(tmp_path: Path) -> None:
|
def test_read_bundle_manifest(tmp_path: Path) -> None:
|
||||||
bundle_path = _make_bundle(
|
bundle_path = _make_bundle(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
@@ -446,6 +530,17 @@ def test_prepare_bundle_missing_file(tmp_path: Path) -> None:
|
|||||||
prepare_bundle_for_compile(missing)
|
prepare_bundle_for_compile(missing)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_bundle_default_target_dir(tmp_path: Path) -> None:
|
||||||
|
"""prepare_bundle_for_compile uses default dir when target_dir is None."""
|
||||||
|
bundle_path = _make_bundle(tmp_path)
|
||||||
|
|
||||||
|
config_path = prepare_bundle_for_compile(bundle_path)
|
||||||
|
|
||||||
|
expected_dir = tmp_path / "device"
|
||||||
|
assert config_path.parent == expected_dir
|
||||||
|
assert config_path.is_file()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ConfigBundleCreator - file discovery
|
# ConfigBundleCreator - file discovery
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -680,6 +775,183 @@ def test_discover_files_idempotent_secrets(tmp_path: Path) -> None:
|
|||||||
assert paths1 == paths2
|
assert paths1 == paths2
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_skips_missing_file(tmp_path: Path) -> None:
|
||||||
|
"""_add_file logs warning for non-existent files via includes."""
|
||||||
|
_setup_config_dir(tmp_path)
|
||||||
|
|
||||||
|
# Include references a file that doesn't exist on disk
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"esphome": {"includes": ["nonexistent.h"]},
|
||||||
|
}
|
||||||
|
creator = ConfigBundleCreator(config)
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
paths = [f.path for f in files]
|
||||||
|
assert "nonexistent.h" not in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_skips_missing_directory(tmp_path: Path) -> None:
|
||||||
|
"""_add_directory logs warning for non-existent directories."""
|
||||||
|
_setup_config_dir(tmp_path)
|
||||||
|
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"external_components": [
|
||||||
|
{"source": {"type": "local", "path": "nonexistent_dir"}}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
creator = ConfigBundleCreator(config)
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
# Only the config file
|
||||||
|
assert len(files) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_yaml_reload_failure(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""YAML reload failure during include discovery is handled gracefully."""
|
||||||
|
_setup_config_dir(tmp_path)
|
||||||
|
|
||||||
|
def _raise_error(*args, **kwargs):
|
||||||
|
raise EsphomeError("parse error")
|
||||||
|
|
||||||
|
monkeypatch.setattr("esphome.yaml_util.load_yaml", _raise_error)
|
||||||
|
|
||||||
|
creator = ConfigBundleCreator({})
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
# Should still have the config file at minimum
|
||||||
|
paths = [f.path for f in files]
|
||||||
|
assert "test.yaml" in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_esphome_includes_c(tmp_path: Path) -> None:
|
||||||
|
"""Paths listed in esphome.includes_c are discovered."""
|
||||||
|
_setup_config_dir(
|
||||||
|
tmp_path,
|
||||||
|
files={"my_code.c": "// c code"},
|
||||||
|
)
|
||||||
|
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"esphome": {"includes_c": ["my_code.c"]},
|
||||||
|
}
|
||||||
|
creator = ConfigBundleCreator(config)
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
paths = [f.path for f in files]
|
||||||
|
assert "my_code.c" in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_external_components_non_local_type(tmp_path: Path) -> None:
|
||||||
|
"""external_components with type != 'local' are skipped."""
|
||||||
|
_setup_config_dir(tmp_path)
|
||||||
|
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"external_components": [
|
||||||
|
{"source": {"type": "git", "url": "https://github.com/user/repo"}}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
creator = ConfigBundleCreator(config)
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
assert len(files) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_external_components_no_path(tmp_path: Path) -> None:
|
||||||
|
"""external_components with local type but missing path are skipped."""
|
||||||
|
_setup_config_dir(tmp_path)
|
||||||
|
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"external_components": [{"source": {"type": "local"}}],
|
||||||
|
}
|
||||||
|
creator = ConfigBundleCreator(config)
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
assert len(files) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_external_components_absolute_path(tmp_path: Path) -> None:
|
||||||
|
"""external_components with absolute path are resolved correctly."""
|
||||||
|
config_dir = _setup_config_dir(
|
||||||
|
tmp_path,
|
||||||
|
files={"ext/comp/__init__.py": "# comp"},
|
||||||
|
)
|
||||||
|
|
||||||
|
abs_path = str(config_dir / "ext")
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"external_components": [{"source": {"type": "local", "path": abs_path}}],
|
||||||
|
}
|
||||||
|
creator = ConfigBundleCreator(config)
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
paths = [f.path for f in files]
|
||||||
|
assert "ext/comp/__init__.py" in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_relative_string_with_known_extension(tmp_path: Path) -> None:
|
||||||
|
"""Relative strings with known extensions are resolved and warned."""
|
||||||
|
_setup_config_dir(
|
||||||
|
tmp_path,
|
||||||
|
files={"my_cert.pem": "cert data"},
|
||||||
|
)
|
||||||
|
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"component": {"cert": "my_cert.pem"},
|
||||||
|
}
|
||||||
|
creator = ConfigBundleCreator(config)
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
paths = [f.path for f in files]
|
||||||
|
assert "my_cert.pem" in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_relative_string_missing_file(tmp_path: Path) -> None:
|
||||||
|
"""Relative strings with known extensions that don't exist are skipped."""
|
||||||
|
_setup_config_dir(tmp_path)
|
||||||
|
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"component": {"cert": "nonexistent.pem"},
|
||||||
|
}
|
||||||
|
creator = ConfigBundleCreator(config)
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
assert len(files) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_esphome_includes_absolute_path(tmp_path: Path) -> None:
|
||||||
|
"""esphome.includes with absolute path is handled."""
|
||||||
|
config_dir = _setup_config_dir(
|
||||||
|
tmp_path,
|
||||||
|
files={"my_code.h": "#pragma once"},
|
||||||
|
)
|
||||||
|
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"esphome": {"includes": [str(config_dir / "my_code.h")]},
|
||||||
|
}
|
||||||
|
creator = ConfigBundleCreator(config)
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
paths = [f.path for f in files]
|
||||||
|
assert "my_code.h" in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_files_walk_tuple_values(tmp_path: Path) -> None:
|
||||||
|
"""Tuples in config are walked like lists."""
|
||||||
|
config_dir = _setup_config_dir(
|
||||||
|
tmp_path,
|
||||||
|
files={"a.pem": "cert"},
|
||||||
|
)
|
||||||
|
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"items": (config_dir / "a.pem",),
|
||||||
|
}
|
||||||
|
creator = ConfigBundleCreator(config)
|
||||||
|
files = creator.discover_files()
|
||||||
|
|
||||||
|
paths = [f.path for f in files]
|
||||||
|
assert "a.pem" in paths
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ConfigBundleCreator - create_bundle
|
# ConfigBundleCreator - create_bundle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -750,6 +1022,57 @@ def test_create_bundle_no_secrets(tmp_path: Path) -> None:
|
|||||||
assert result.manifest[ManifestKey.HAS_SECRETS] is False
|
assert result.manifest[ManifestKey.HAS_SECRETS] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_bundle_secrets_load_failure(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Secrets file that fails to load during filtering is skipped gracefully."""
|
||||||
|
config_dir = _setup_config_dir(tmp_path)
|
||||||
|
(config_dir / "secrets.yaml").write_text("k: v\n")
|
||||||
|
(config_dir / "test.yaml").write_text("a: !secret k\n")
|
||||||
|
|
||||||
|
from esphome import yaml_util as yu
|
||||||
|
|
||||||
|
original_load = yu.load_yaml
|
||||||
|
|
||||||
|
def _failing_on_filter(fname, *args, clear_secrets=True, **kwargs):
|
||||||
|
# Fail only when _build_filtered_secrets calls with clear_secrets=False
|
||||||
|
if not clear_secrets and "secrets" in str(fname):
|
||||||
|
raise EsphomeError("corrupt secrets")
|
||||||
|
return original_load(fname, *args, clear_secrets=clear_secrets, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(yu, "load_yaml", _failing_on_filter)
|
||||||
|
|
||||||
|
creator = ConfigBundleCreator({})
|
||||||
|
result = creator.create_bundle()
|
||||||
|
|
||||||
|
# Should succeed without secrets since the filtered load failed
|
||||||
|
assert result.manifest[ManifestKey.HAS_SECRETS] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_bundle_secrets_non_dict(tmp_path: Path) -> None:
|
||||||
|
"""Secrets file that parses to non-dict is skipped."""
|
||||||
|
config_dir = _setup_config_dir(tmp_path)
|
||||||
|
(config_dir / "secrets.yaml").write_text("- item1\n- item2\n")
|
||||||
|
(config_dir / "test.yaml").write_text("a: !secret k\n")
|
||||||
|
|
||||||
|
creator = ConfigBundleCreator({})
|
||||||
|
result = creator.create_bundle()
|
||||||
|
|
||||||
|
assert result.manifest[ManifestKey.HAS_SECRETS] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_bundle_secrets_no_matching_keys(tmp_path: Path) -> None:
|
||||||
|
"""Secrets with no matching keys produces empty filtered result."""
|
||||||
|
config_dir = _setup_config_dir(tmp_path)
|
||||||
|
(config_dir / "secrets.yaml").write_text("other_key: value\n")
|
||||||
|
(config_dir / "test.yaml").write_text("a: !secret nonexistent\n")
|
||||||
|
|
||||||
|
creator = ConfigBundleCreator({})
|
||||||
|
result = creator.create_bundle()
|
||||||
|
|
||||||
|
assert result.manifest[ManifestKey.HAS_SECRETS] is False
|
||||||
|
|
||||||
|
|
||||||
def test_create_bundle_deterministic_order(tmp_path: Path) -> None:
|
def test_create_bundle_deterministic_order(tmp_path: Path) -> None:
|
||||||
"""Files are added in sorted order for reproducibility."""
|
"""Files are added in sorted order for reproducibility."""
|
||||||
_setup_config_dir(
|
_setup_config_dir(
|
||||||
|
|||||||
Reference in New Issue
Block a user