Files
EOS/tests/test_homeassistant.py
T
Bobby NoelteandGitHub 886c93c92b fix: default server settings prevent env var config (#1234)
Change configuration source priorities to:

- cli
- environment vars
- dotenv settings
- config file settings
- init settings

By this the environment vars supersede any configuration var
provided by the configuration file or by the initialisation
with pydantic.

The test_config.py::test_computed_path was fixed to to not
use the defaul env var overwrite defined by conftest.py.
This seemed to indicate non working env vars, but in fact
was a test setupt fault.

Besides this fix there are other fixes and changes added:

* fix: exclude computed fields when merging settings

  Pydantic may overwrite settings by values given for computed
  fields and use these values instead of re-computing the field.
  Avoid computed fields in merging settings.

* chore: improve Windows compatability of development setup

  Improve scripts to better run also on Windows. When doing path
  checks keep compatibility also to Windows pathes. A lot of
  changes to avoid the famous Windows CRLF handling and keep
  line endings to LF.

* chore: add development hint for Windows

  Windows developers should set core.autocrlf to false.

* chore: update version

Signed-off-by: b0661 <b0661n0e17e@gmail.com>
2026-08-16 13:56:25 +02:00

344 lines
13 KiB
Python

import os
import subprocess
from pathlib import Path
from typing import Optional
import pytest
import yaml
from pydantic import ValidationError
class TestHomeAssistantAddon:
"""Tests to ensure the repository root is a valid Home Assistant add-on.
Simulates the Home Assistant Supervisor's expectations.
"""
@property
def root(self):
"""Repository root (repo == addon)."""
return Path(__file__).resolve().parent.parent
def test_config_yaml_exists(self):
"""Ensure config.yaml exists in the repo root."""
cfg_path = self.root / "config.yaml"
assert cfg_path.is_file(), "config.yaml must exist in repository root."
def test_config_yaml_loadable(self):
"""Verify that config.yaml parses and contains required fields."""
cfg_path = self.root / "config.yaml"
with open(cfg_path) as f:
cfg = yaml.safe_load(f)
required_fields = ["name", "version", "slug", "description", "arch"]
for field in required_fields:
assert field in cfg, f"Missing required field '{field}' in config.yaml."
# Additional validation
assert isinstance(cfg["arch"], list), "arch must be a list"
assert len(cfg["arch"]) > 0, "arch list cannot be empty"
print(f"✓ config.yaml valid:")
print(f" Name: {cfg['name']}")
print(f" Version: {cfg['version']}")
print(f" Slug: {cfg['slug']}")
print(f" Architectures: {', '.join(cfg['arch'])}")
def test_readme_exists(self):
"""Ensure README.md exists and is not empty."""
readme_path = self.root / "README.md"
assert readme_path.is_file(), "README.md must exist in the repository root."
content = readme_path.read_text(encoding="utf-8")
assert len(content.strip()) > 0, "README.md is empty"
print(f"✓ README.md exists ({len(content)} bytes)")
def test_docs_md_exists(self):
"""Ensure DOCS.md exists in the repo root (for Home Assistant add-on documentation)."""
docs_path = self.root / "DOCS.md"
assert docs_path.is_file(), "DOCS.md must exist in the repository root for add-on documentation."
content = docs_path.read_text(encoding="utf-8")
assert len(content.strip()) > 0, "DOCS.md is empty"
print(f"✓ DOCS.md exists ({len(content)} bytes)")
@pytest.mark.docker
def test_dockerfile_exists(self):
"""Ensure Dockerfile exists in the repo root and has basic structure."""
dockerfile = self.root / "Dockerfile"
assert dockerfile.is_file(), "Dockerfile must exist in repository root."
content = dockerfile.read_text()
# Check for FROM statement
assert "FROM" in content, "Dockerfile must contain FROM statement"
# Check for common add-on patterns
if "ARG BUILD_FROM" in content:
print("✓ Dockerfile uses Home Assistant build args")
print("✓ Dockerfile exists and has valid structure")
@pytest.mark.docker
def test_docker_build_context_valid(self):
"""Runs a Docker build using the root of the repo as Home Assistant supervisor would.
Fails if the build context is invalid or Dockerfile has syntax errors.
"""
# Check if Docker is available
try:
subprocess.run(
["docker", "--version"],
capture_output=True,
check=True
)
except (FileNotFoundError, subprocess.CalledProcessError):
pytest.skip("Docker not found or not running")
cmd = [
"docker", "build",
"-t", "ha-addon-test:latest",
str(self.root),
]
print(f"\nBuilding Docker image from: {self.root}")
try:
result = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
cwd=str(self.root)
)
print("✓ Docker build successful")
if result.stdout:
print("\nBuild output (last 20 lines):")
print('\n'.join(result.stdout.splitlines()[-20:]))
except subprocess.CalledProcessError as e:
print("\n✗ Docker build failed")
print("\nSTDOUT:")
print(e.stdout)
print("\nSTDERR:")
print(e.stderr)
pytest.fail(
f"Docker build failed with exit code {e.returncode}. "
"This simulates a Supervisor build failure."
)
@pytest.mark.docker
def test_addon_builder_validation(self, is_finalize: bool):
"""Validate add-on can be built using Home Assistant's builder tool.
This is the closest to what Supervisor does when installing an add-on.
"""
if not is_finalize:
pytest.skip("Skipping add-on builder validation test — not full run")
# Check if Docker is available
try:
subprocess.run(
["docker", "--version"],
capture_output=True,
check=True
)
except (FileNotFoundError, subprocess.CalledProcessError):
pytest.skip("Docker not found or not running")
print(f"\nValidating add-on with builder: {self.root}")
# Read config to get architecture info
cfg_path = self.root / "config.yaml"
with open(cfg_path) as f:
cfg = yaml.safe_load(f)
# Detect host architecture
import platform
machine = platform.machine().lower()
# Map Python's platform names to Home Assistant architectures
arch_map = {
"x86_64": "amd64",
"amd64": "amd64",
"aarch64": "aarch64",
"arm64": "aarch64",
"armv7l": "armv7",
"armv7": "armv7",
}
host_arch = arch_map.get(machine, "amd64")
# Check if config supports this architecture
if host_arch not in cfg["arch"]:
pytest.skip(
f"Add-on doesn't support host architecture {host_arch}. "
f"Supported: {', '.join(cfg['arch'])}"
)
print(f"Using builder for architecture: {host_arch}")
# The builder expects specific arguments for building
builder_image = f"ghcr.io/home-assistant/{host_arch}-builder:latest"
result = subprocess.run(
[
"docker", "run", "--rm", "--privileged",
"-v", f"{self.root}:/data",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
builder_image,
"--generic", cfg["version"],
"--target", "/data",
f"--{host_arch}",
"--test"
],
capture_output=True,
text=True,
cwd=str(self.root),
check=False,
timeout=600
)
# Print output for debugging
if result.stdout:
print("\nBuilder stdout:")
print(result.stdout)
if result.stderr:
print("\nBuilder stderr:")
print(result.stderr)
# Check result
if result.returncode != 0:
# Check if it's just because the builder tool is unavailable
if "exec format error" in result.stderr or "not found" in result.stderr:
pytest.fail(
"Builder tool not compatible with this system."
)
pytest.fail(
f"Add-on builder validation failed with exit code {result.returncode}"
)
print("✓ Add-on builder validation passed")
def test_build_yaml_if_exists(self):
"""If build.yaml exists, validate its structure."""
build_path = self.root / "build.yaml"
if not build_path.exists():
pytest.skip("build.yaml not present (optional)")
with open(build_path) as f:
build_cfg = yaml.safe_load(f)
assert "build_from" in build_cfg, "build.yaml must contain 'build_from'"
assert isinstance(build_cfg["build_from"], dict), "'build_from' must be a dictionary"
print("✓ build.yaml structure valid")
print(f" Architectures defined: {', '.join(build_cfg['build_from'].keys())}")
def test_addon_configuration_complete(self):
"""Comprehensive validation of add-on configuration.
Checks all required fields and common configuration issues.
"""
cfg_path = self.root / "config.yaml"
with open(cfg_path) as f:
cfg = yaml.safe_load(f)
# Required top-level fields
required_fields = ["name", "version", "slug", "description", "arch"]
for field in required_fields:
assert field in cfg, f"Missing required field: {field}"
# Validate specific fields
assert isinstance(cfg["arch"], list), "arch must be a list"
assert len(cfg["arch"]) > 0, "arch list cannot be empty"
valid_archs = ["aarch64", "amd64", "armhf", "armv7", "i386"]
for arch in cfg["arch"]:
assert arch in valid_archs, f"Invalid architecture: {arch}"
# Validate version format (should be semantic versioning)
version = cfg["version"]
assert isinstance(version, str), "version must be a string"
# Validate slug (lowercase, no special chars except dash)
slug = cfg["slug"]
assert slug.islower() or "-" in slug, "slug should be lowercase"
assert slug.replace("-", "").replace("_", "").isalnum(), \
"slug should only contain alphanumeric characters, dash, or underscore"
# Optional but common fields
if "startup" in cfg:
valid_startup = ["initialize", "system", "services", "application", "once"]
assert cfg["startup"] in valid_startup, \
f"Invalid startup value: {cfg['startup']}"
if "boot" in cfg:
valid_boot = ["auto", "manual"]
assert cfg["boot"] in valid_boot, f"Invalid boot value: {cfg['boot']}"
# Validate ingress configuration
if cfg.get("ingress"):
assert "ingress_port" in cfg, "ingress_port required when ingress is enabled"
ingress_port = cfg["ingress_port"]
assert isinstance(ingress_port, int), "ingress_port must be an integer"
assert 1 <= ingress_port <= 65535, "ingress_port must be a valid port number"
# Ingress port should NOT be in ports section
ports = cfg.get("ports", {})
port_key = f"{ingress_port}/tcp"
assert port_key not in ports, \
f"Port {ingress_port} is used for ingress and should not be in 'ports' section"
# Validate URL if present
if "url" in cfg:
url = cfg["url"]
assert url.startswith("http://") or url.startswith("https://"), \
"URL must start with http:// or https://"
# Validate map directories if present
if "map" in cfg:
assert isinstance(cfg["map"], list), "map must be a list"
valid_mappings = ["config", "ssl", "addons", "backup", "share", "media"]
for mapping in cfg["map"]:
# Handle both "config:rw" and "config" formats
base_mapping = mapping.split(":")[0]
assert base_mapping in valid_mappings, \
f"Invalid map directory: {base_mapping}"
print("✓ Add-on configuration validation passed")
print(f" Name: {cfg['name']}")
print(f" Version: {cfg['version']}")
print(f" Slug: {cfg['slug']}")
print(f" Architectures: {', '.join(cfg['arch'])}")
if "startup" in cfg:
print(f" Startup: {cfg['startup']}")
if cfg.get("ingress"):
print(f" Ingress: enabled on port {cfg['ingress_port']}")
def test_ingress_configuration_consistent(self):
"""If ingress is enabled, ensure port configuration is correct."""
cfg_path = self.root / "config.yaml"
with open(cfg_path) as f:
cfg = yaml.safe_load(f)
if not cfg.get("ingress"):
pytest.skip("Ingress not enabled")
# If ingress is enabled, check configuration
assert "ingress_port" in cfg, "ingress_port must be specified when ingress is enabled"
ingress_port = cfg["ingress_port"]
# The ingress port should NOT be in the ports section
ports = cfg.get("ports", {})
port_key = f"{ingress_port}/tcp"
if port_key in ports:
pytest.fail(
f"Port {ingress_port} is used for ingress but also listed in 'ports' section. "
f"Remove it from 'ports' to avoid conflicts."
)
print(f"✓ Ingress configuration valid (port {ingress_port})")