218 lines
8.4 KiB
Bash
218 lines
8.4 KiB
Bash
|
|
#!/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
|