#!/usr/bin/env bash # Offline tests for the "api" adapter (scripts/ha/lib/import_api.py, ha_api.py, # ha_ws.py): normalization of real-shaped HA API responses saved as fixtures, # idempotency, stale-file cleanup, and the "websocket-client missing" error # path. No network access, no HA instance needed — REST calls are replaced # with a fake in-memory client, and the WebSocket client is replaced via # sys.modules injection (see PYEOF below). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HA_LIB_DIR="$SCRIPT_DIR/../lib" FIXTURES_DIR="$SCRIPT_DIR/fixtures" python3 - "$HA_LIB_DIR" "$FIXTURES_DIR" <<'PYEOF' import json import os import sys import tempfile lib_dir, fixtures_dir = sys.argv[1:3] sys.path.insert(0, lib_dir) import import_api # noqa: E402 from normalize import parse_yaml_text # noqa: E402 fail = False def check(condition, message): global fail if condition: print(f"PASS: {message}") else: print(f"FAIL: {message}", file=sys.stderr) fail = True def load_fixture(name): with open(os.path.join(fixtures_dir, name), encoding="utf-8") as f: return json.load(f) class FakeClient: """Stands in for ha_api.Client: canned responses keyed by exact path.""" def __init__(self, responses): self._responses = responses def get(self, path): return self._responses.get(path) # --- automations / scripts / scenes: REST-shaped fixtures, no network --- states = load_fixture("api_states.json") responses = { "/api/config/automation/config/111": load_fixture("api_automation_config_111.json"), "/api/config/script/config/demo_script": load_fixture("api_script_config_demo_script.json"), "/api/config/scene/config/222": load_fixture("api_scene_config_222.json"), } client = FakeClient(responses) with tempfile.TemporaryDirectory() as config_out_dir: report = import_api.new_report() import_api.import_automations(client, states, config_out_dir, report) import_api.import_scripts(client, states, config_out_dir, report) import_api.import_scenes(client, states, config_out_dir, report) check(report["automations_imported"] == 1, "one automation imported (the one with attributes.id)") check( report["automations_skipped_yaml"] == ["automation.legacy_yaml_automation"], "id-less automation entity reported as yaml-skipped, not silently dropped", ) check(report["scripts_imported"] == 1, "one script imported") check(report["scenes_imported"] == 1, "one scene imported") auto_path = os.path.join(config_out_dir, "automations", "111.yaml") script_path = os.path.join(config_out_dir, "scripts", "demo_script.yaml") scene_path = os.path.join(config_out_dir, "scenes", "222.yaml") check(os.path.isfile(auto_path), "automations/111.yaml written") check(os.path.isfile(script_path), "scripts/demo_script.yaml written") check(os.path.isfile(scene_path), "scenes/222.yaml written") with open(auto_path, encoding="utf-8") as f: auto_text = f.read() check("alias: Morning lights" in auto_text, "automation config normalized to canonical (sorted-key) YAML") parsed_back = parse_yaml_text(auto_text) check( parsed_back == load_fixture("api_automation_config_111.json"), "normalized automation YAML round-trips to the original API response", ) # stale-file cleanup: pre-seed an id that no longer exists, re-run, must be gone stale_path = os.path.join(config_out_dir, "automations", "999.yaml") with open(stale_path, "w", encoding="utf-8") as f: f.write("alias: stale\n") report2 = import_api.new_report() import_api.import_automations(client, states, config_out_dir, report2) check(not os.path.isfile(stale_path), "stale automation export removed on re-run (idempotent cleanup)") # idempotency: second run over the same fixture data is byte-identical with open(auto_path, encoding="utf-8") as f: pass1 = f.read() import_api.import_automations(client, states, config_out_dir, report2) with open(auto_path, encoding="utf-8") as f: pass2 = f.read() check(pass1 == pass2, "automations/111.yaml byte-identical across two runs (idempotent)") # --- dashboards / registries / helpers: WebSocket-shaped fixtures --- FAKE_DASHBOARD_LIST = [ {"id": "map", "title": "Map", "url_path": "map", "mode": "storage"}, {"id": "kiosk", "title": "Kiosk", "url_path": "kiosk", "mode": "yaml"}, ] FAKE_WS_RESULTS = { ("lovelace/config", ()): {"title": "Overview", "views": []}, ("lovelace/config", (("url_path", "map"),)): {"title": "Map", "views": []}, ("lovelace/dashboards/list", ()): FAKE_DASHBOARD_LIST, ("lovelace/resources", ()): [{"id": "r1", "type": "module", "url": "/local/x.js"}], ("config/area_registry/list", ()): [{"id": "hall", "name": "Hall"}], ("config/entity_registry/list", ()): [{"entity_id": "light.living_room"}], ("input_boolean/list", ()): [{"id": "on_leave", "name": "On leave"}], } class FakeHaWsError(RuntimeError): pass class FakeHaWsClient: def __init__(self, base_url, token): self.base_url = base_url self.token = token def __enter__(self): return self def __exit__(self, *exc_info): return False def command(self, command_type, **kwargs): key = (command_type, tuple(sorted(kwargs.items()))) if key not in FAKE_WS_RESULTS: raise FakeHaWsError(f"unexpected command in test double: {key!r}") return FAKE_WS_RESULTS[key] fake_ha_ws = type(sys)("ha_ws") fake_ha_ws.HaWsClient = FakeHaWsClient fake_ha_ws.HaWsError = FakeHaWsError with tempfile.TemporaryDirectory() as storage_out_dir: sys.modules["ha_ws"] = fake_ha_ws report = import_api.new_report() import_api.import_dashboards_and_registries( "http://ha.example", "test-token", states, storage_out_dir, report ) del sys.modules["ha_ws"] check(report["websocket_error"] is None, "no websocket error with the fake client installed") check(report["dashboards_imported"] == 2, "default dashboard + one storage-mode dashboard imported") check( report["dashboards_skipped"] == [("kiosk", "mode='yaml' is file-based, not reachable via api")], "yaml-mode dashboard reported as skipped, not silently dropped", ) check(report["helpers_imported"] == {"input_boolean": 1}, "only the present input_* domain was queried") expected_files = { "lovelace.yaml", "lovelace.map.yaml", "lovelace_dashboards.yaml", "lovelace_resources.yaml", "core.area_registry.yaml", "core.entity_registry.yaml", "input_boolean.yaml", } actual_files = set(os.listdir(storage_out_dir)) check(actual_files == expected_files, f"expected storage-export files present, got {sorted(actual_files)}") # stale-file cleanup: a leftover domain export must be removed on re-run stale_path = os.path.join(storage_out_dir, "input_number.yaml") with open(stale_path, "w", encoding="utf-8") as f: f.write("key: input_number\n") sys.modules["ha_ws"] = fake_ha_ws report2 = import_api.new_report() import_api.import_dashboards_and_registries( "http://ha.example", "test-token", states, storage_out_dir, report2 ) del sys.modules["ha_ws"] check(not os.path.isfile(stale_path), "stale storage-export file removed on re-run (idempotent cleanup)") # --- missing dependency path: no traceback, clear actionable message --- with tempfile.TemporaryDirectory() as storage_out_dir: # Force the *real* ha_ws.py to execute (not a fake) and hit its own # `import websocket as _ws` line, so we exercise the actual actionable # ImportError message it raises rather than Python's generic one. sys.modules["websocket"] = None sys.modules.pop("ha_ws", None) report = import_api.new_report() import_api.import_dashboards_and_registries( "http://ha.example", "test-token", states, storage_out_dir, report ) sys.modules.pop("ha_ws", None) del sys.modules["websocket"] check(report["websocket_error"] is not None, "missing websocket-client dependency is reported, not raised") check( "websocket-client" in (report["websocket_error"] or ""), "the reported error names the missing package so an operator knows what to install", ) check(os.listdir(storage_out_dir) == [], "no storage-export files written when the dependency is missing") sys.exit(1 if fail else 0) PYEOF