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() 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() 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})")