ha-testcontainer 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ha_testcontainer/__init__.py +23 -0
- ha_testcontainer/container.py +475 -0
- ha_testcontainer/visual.py +199 -0
- ha_testcontainer-1.0.1.dist-info/METADATA +432 -0
- ha_testcontainer-1.0.1.dist-info/RECORD +8 -0
- ha_testcontainer-1.0.1.dist-info/WHEEL +5 -0
- ha_testcontainer-1.0.1.dist-info/licenses/LICENSE +21 -0
- ha_testcontainer-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""ha_testcontainer – Test container for Home Assistant.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from ha_testcontainer import HATestContainer, HAVersion
|
|
6
|
+
|
|
7
|
+
with HATestContainer(version=HAVersion.STABLE, config_path="ha-config") as ha:
|
|
8
|
+
print(ha.get_url())
|
|
9
|
+
resp = ha.api("GET", "states")
|
|
10
|
+
print(resp.json())
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .container import HATestContainer, HAVersion
|
|
14
|
+
from .visual import PAGE_LOAD_TIMEOUT, HA_SETTLE_MS, inject_ha_token, assert_snapshot
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"HATestContainer",
|
|
18
|
+
"HAVersion",
|
|
19
|
+
"PAGE_LOAD_TIMEOUT",
|
|
20
|
+
"HA_SETTLE_MS",
|
|
21
|
+
"inject_ha_token",
|
|
22
|
+
"assert_snapshot",
|
|
23
|
+
]
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Home Assistant test container.
|
|
2
|
+
|
|
3
|
+
Provides :class:`HATestContainer`, a :class:`~testcontainers.core.container.DockerContainer`
|
|
4
|
+
subclass that:
|
|
5
|
+
|
|
6
|
+
* Starts the official Home Assistant Docker image (stable / beta / dev / pinned version).
|
|
7
|
+
* Performs the HA onboarding flow programmatically so tests don't need to touch the UI.
|
|
8
|
+
* Creates and exposes a long-lived API token for REST and WebSocket access.
|
|
9
|
+
* Optionally mounts a custom config directory and/or a ``custom_components`` directory.
|
|
10
|
+
* Works as a context manager or with explicit ``start()`` / ``stop()`` calls.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import urlencode
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
import websocket
|
|
24
|
+
from testcontainers.core.container import DockerContainer
|
|
25
|
+
from testcontainers.core.waiting_utils import wait_for_logs
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Constants
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
#: Registry prefix for the official Home Assistant image.
|
|
32
|
+
HA_IMAGE = "ghcr.io/home-assistant/home-assistant"
|
|
33
|
+
|
|
34
|
+
#: Port that Home Assistant listens on inside the container.
|
|
35
|
+
HA_PORT = 8123
|
|
36
|
+
|
|
37
|
+
#: How long (seconds) to wait for the web-server to become reachable.
|
|
38
|
+
STARTUP_TIMEOUT = 120
|
|
39
|
+
|
|
40
|
+
#: Default credentials used for the programmatic onboarding step.
|
|
41
|
+
DEFAULT_USERNAME = "testadmin"
|
|
42
|
+
DEFAULT_PASSWORD = "testpassword123" # noqa: S105 - test-only credential
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HAVersion:
|
|
46
|
+
"""Convenience constants for well-known HA image tags."""
|
|
47
|
+
|
|
48
|
+
STABLE = "stable"
|
|
49
|
+
BETA = "beta"
|
|
50
|
+
DEV = "dev"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Main class
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HATestContainer(DockerContainer):
|
|
59
|
+
"""Disposable Home Assistant container for automated tests.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
version:
|
|
64
|
+
Docker image tag. Use :class:`HAVersion` constants or pass a
|
|
65
|
+
specific release string such as ``"2024.6.0"``.
|
|
66
|
+
config_path:
|
|
67
|
+
Host path to mount as ``/config`` inside the container. When
|
|
68
|
+
omitted HA creates a minimal default configuration at start-up.
|
|
69
|
+
custom_components_path:
|
|
70
|
+
Host path to mount as ``/config/custom_components``. Ignored when
|
|
71
|
+
*config_path* is given and already contains a ``custom_components``
|
|
72
|
+
sub-directory (HA will pick it up automatically from the config
|
|
73
|
+
volume).
|
|
74
|
+
username:
|
|
75
|
+
Admin username created during programmatic onboarding.
|
|
76
|
+
password:
|
|
77
|
+
Admin password created during programmatic onboarding.
|
|
78
|
+
port:
|
|
79
|
+
Container port to expose (default: 8123).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
version: str = HAVersion.STABLE,
|
|
85
|
+
config_path: str | Path | None = None,
|
|
86
|
+
custom_components_path: str | Path | None = None,
|
|
87
|
+
username: str = DEFAULT_USERNAME,
|
|
88
|
+
password: str = DEFAULT_PASSWORD,
|
|
89
|
+
port: int = HA_PORT,
|
|
90
|
+
) -> None:
|
|
91
|
+
image = f"{HA_IMAGE}:{version}"
|
|
92
|
+
super().__init__(image=image)
|
|
93
|
+
|
|
94
|
+
self._ha_port = port
|
|
95
|
+
self._username = username
|
|
96
|
+
self._password = password
|
|
97
|
+
self._token: str | None = None
|
|
98
|
+
|
|
99
|
+
self.with_exposed_ports(port)
|
|
100
|
+
self.with_env("TZ", "UTC")
|
|
101
|
+
|
|
102
|
+
if config_path is not None:
|
|
103
|
+
resolved = Path(config_path).resolve()
|
|
104
|
+
self.with_volume_mapping(str(resolved), "/config", "rw")
|
|
105
|
+
elif custom_components_path is not None:
|
|
106
|
+
resolved_cc = Path(custom_components_path).resolve()
|
|
107
|
+
self.with_volume_mapping(str(resolved_cc), "/config/custom_components", "rw")
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Context-manager helpers
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> "HATestContainer":
|
|
114
|
+
self.start()
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, *args: Any) -> None:
|
|
118
|
+
self.stop()
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Public API
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def start(self) -> "HATestContainer":
|
|
125
|
+
"""Start the container and complete onboarding."""
|
|
126
|
+
super().start()
|
|
127
|
+
self._wait_for_ha()
|
|
128
|
+
self._perform_onboarding()
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def get_url(self) -> str:
|
|
132
|
+
"""Return the base URL of the running HA instance."""
|
|
133
|
+
host = self.get_container_host_ip()
|
|
134
|
+
port = self.get_exposed_port(self._ha_port)
|
|
135
|
+
return f"http://{host}:{port}"
|
|
136
|
+
|
|
137
|
+
def get_token(self) -> str:
|
|
138
|
+
"""Return the long-lived access token for the admin user.
|
|
139
|
+
|
|
140
|
+
Raises :class:`RuntimeError` if the container has not been started yet.
|
|
141
|
+
"""
|
|
142
|
+
if self._token is None:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
"No token available – call start() first or use as a context manager."
|
|
145
|
+
)
|
|
146
|
+
return self._token
|
|
147
|
+
|
|
148
|
+
def api(
|
|
149
|
+
self,
|
|
150
|
+
method: str,
|
|
151
|
+
path: str,
|
|
152
|
+
**kwargs: Any,
|
|
153
|
+
) -> requests.Response:
|
|
154
|
+
"""Make an authenticated REST API call.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
method:
|
|
159
|
+
HTTP verb (``"GET"``, ``"POST"``, …).
|
|
160
|
+
path:
|
|
161
|
+
API path, with or without a leading ``/api/``.
|
|
162
|
+
E.g. ``"states"`` or ``"/api/states"``.
|
|
163
|
+
**kwargs:
|
|
164
|
+
Forwarded to :func:`requests.request` (``json``, ``params``, …).
|
|
165
|
+
|
|
166
|
+
Returns
|
|
167
|
+
-------
|
|
168
|
+
requests.Response
|
|
169
|
+
"""
|
|
170
|
+
if not path.startswith("/api/"):
|
|
171
|
+
path = f"/api/{path.lstrip('/')}"
|
|
172
|
+
url = f"{self.get_url()}{path}"
|
|
173
|
+
headers = kwargs.pop("headers", {})
|
|
174
|
+
headers["Authorization"] = f"Bearer {self.get_token()}"
|
|
175
|
+
headers.setdefault("Content-Type", "application/json")
|
|
176
|
+
return requests.request(method, url, headers=headers, timeout=30, **kwargs)
|
|
177
|
+
|
|
178
|
+
def setup_integration(self, domain: str) -> dict[str, Any]:
|
|
179
|
+
"""Set up a HA integration via the config-flow API.
|
|
180
|
+
|
|
181
|
+
Initiates the config flow for *domain* and, if it completes in one
|
|
182
|
+
step (e.g. UIX), returns the resulting entry data.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
domain:
|
|
187
|
+
Integration domain, e.g. ``"uix"``.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
dict
|
|
192
|
+
The response JSON from the config-flow endpoint.
|
|
193
|
+
"""
|
|
194
|
+
resp = self.api(
|
|
195
|
+
"POST",
|
|
196
|
+
"/api/config/config_entries/flow",
|
|
197
|
+
json={"handler": domain},
|
|
198
|
+
)
|
|
199
|
+
resp.raise_for_status()
|
|
200
|
+
return resp.json()
|
|
201
|
+
|
|
202
|
+
def push_lovelace_config(self, config: dict[str, Any]) -> None:
|
|
203
|
+
"""Push a Lovelace dashboard configuration via the WebSocket API.
|
|
204
|
+
|
|
205
|
+
``POST /api/lovelace/config`` was removed from recent HA releases.
|
|
206
|
+
This method uses the ``lovelace/config/save`` WebSocket command
|
|
207
|
+
instead, which is the current supported approach.
|
|
208
|
+
|
|
209
|
+
The WebSocket call is executed in a dedicated thread with its own
|
|
210
|
+
event loop so this method is safe to call from inside
|
|
211
|
+
pytest-playwright's already-running asyncio event loop (where
|
|
212
|
+
:func:`asyncio.run` would raise a ``RuntimeError``).
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
config:
|
|
217
|
+
Lovelace configuration dict to save, e.g.
|
|
218
|
+
``{"title": "Home", "views": [...]}``.
|
|
219
|
+
|
|
220
|
+
Raises
|
|
221
|
+
------
|
|
222
|
+
RuntimeError
|
|
223
|
+
If the WebSocket command fails or authentication is rejected.
|
|
224
|
+
"""
|
|
225
|
+
result: dict[str, Any] = {}
|
|
226
|
+
exc_holder: list[BaseException] = []
|
|
227
|
+
|
|
228
|
+
def _run() -> None:
|
|
229
|
+
try:
|
|
230
|
+
result.update(self._ws_call({"id": 1, "type": "lovelace/config/save", "config": config}))
|
|
231
|
+
except BaseException as e: # noqa: BLE001
|
|
232
|
+
exc_holder.append(e)
|
|
233
|
+
|
|
234
|
+
t = threading.Thread(target=_run, daemon=True)
|
|
235
|
+
t.start()
|
|
236
|
+
t.join(timeout=30)
|
|
237
|
+
if t.is_alive():
|
|
238
|
+
raise TimeoutError("push_lovelace_config timed out after 30 s")
|
|
239
|
+
if exc_holder:
|
|
240
|
+
raise exc_holder[0]
|
|
241
|
+
if not result.get("success"):
|
|
242
|
+
raise RuntimeError(f"lovelace/config/save failed: {result}")
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
# Internal helpers
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def _wait_for_ha(self) -> None:
|
|
249
|
+
"""Block until HA's web server responds (or *STARTUP_TIMEOUT* elapses)."""
|
|
250
|
+
# First wait for the "Home Assistant is running" log line so we know
|
|
251
|
+
# the internal startup sequence is complete.
|
|
252
|
+
try:
|
|
253
|
+
wait_for_logs(self, "Home Assistant is running", timeout=STARTUP_TIMEOUT)
|
|
254
|
+
except Exception: # noqa: BLE001
|
|
255
|
+
pass # fall through to the HTTP poll below
|
|
256
|
+
|
|
257
|
+
# Then confirm the HTTP endpoint is reachable.
|
|
258
|
+
url = f"{self.get_url()}/api/"
|
|
259
|
+
deadline = time.monotonic() + STARTUP_TIMEOUT
|
|
260
|
+
last_exc: Exception | None = None
|
|
261
|
+
while time.monotonic() < deadline:
|
|
262
|
+
try:
|
|
263
|
+
resp = requests.get(url, timeout=5)
|
|
264
|
+
# 200 = already set up, 401 = running but needs auth,
|
|
265
|
+
# 403 = forbidden (onboarding state)
|
|
266
|
+
if resp.status_code in (200, 401, 403):
|
|
267
|
+
return
|
|
268
|
+
except requests.exceptions.ConnectionError as exc:
|
|
269
|
+
last_exc = exc
|
|
270
|
+
time.sleep(2)
|
|
271
|
+
|
|
272
|
+
raise TimeoutError(
|
|
273
|
+
f"Home Assistant did not become ready within {STARTUP_TIMEOUT}s."
|
|
274
|
+
+ (f" Last error: {last_exc}" if last_exc else "")
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def _needs_onboarding(self) -> bool:
|
|
278
|
+
"""Return True when the HA onboarding wizard has not been completed."""
|
|
279
|
+
try:
|
|
280
|
+
resp = requests.get(
|
|
281
|
+
f"{self.get_url()}/api/onboarding",
|
|
282
|
+
timeout=10,
|
|
283
|
+
)
|
|
284
|
+
if resp.status_code == 200:
|
|
285
|
+
steps = resp.json()
|
|
286
|
+
return any(not s.get("done", False) for s in steps)
|
|
287
|
+
except requests.exceptions.RequestException:
|
|
288
|
+
pass
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
def _perform_onboarding(self) -> None:
|
|
292
|
+
"""Run through the HA onboarding API to create the admin user and token."""
|
|
293
|
+
if not self._needs_onboarding():
|
|
294
|
+
# Already onboarded (e.g. pre-populated .storage files).
|
|
295
|
+
# Try to authenticate with the supplied credentials.
|
|
296
|
+
self._token = self._password_login()
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
base_url = self.get_url()
|
|
300
|
+
client_id = f"{base_url}/"
|
|
301
|
+
|
|
302
|
+
# Step 1 – create the first admin user.
|
|
303
|
+
resp = requests.post(
|
|
304
|
+
f"{base_url}/api/onboarding/users",
|
|
305
|
+
json={
|
|
306
|
+
"client_id": client_id,
|
|
307
|
+
"name": "Test Admin",
|
|
308
|
+
"username": self._username,
|
|
309
|
+
"password": self._password,
|
|
310
|
+
"language": "en",
|
|
311
|
+
},
|
|
312
|
+
timeout=30,
|
|
313
|
+
)
|
|
314
|
+
resp.raise_for_status()
|
|
315
|
+
auth_code = resp.json()["auth_code"]
|
|
316
|
+
|
|
317
|
+
# Step 2 – exchange the auth code for an access token.
|
|
318
|
+
token_resp = requests.post(
|
|
319
|
+
f"{base_url}/auth/token",
|
|
320
|
+
data=urlencode(
|
|
321
|
+
{
|
|
322
|
+
"client_id": client_id,
|
|
323
|
+
"grant_type": "authorization_code",
|
|
324
|
+
"code": auth_code,
|
|
325
|
+
}
|
|
326
|
+
),
|
|
327
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
328
|
+
timeout=15,
|
|
329
|
+
)
|
|
330
|
+
token_resp.raise_for_status()
|
|
331
|
+
short_lived_token = token_resp.json()["access_token"]
|
|
332
|
+
|
|
333
|
+
# Step 3 – complete remaining onboarding steps (core_config, analytics,
|
|
334
|
+
# integration). These are optional from an API standpoint but HA marks
|
|
335
|
+
# them as required before the UI proceeds.
|
|
336
|
+
for step in ("core_config", "analytics"):
|
|
337
|
+
requests.post(
|
|
338
|
+
f"{base_url}/api/onboarding/{step}",
|
|
339
|
+
json={"client_id": client_id},
|
|
340
|
+
headers={"Authorization": f"Bearer {short_lived_token}"},
|
|
341
|
+
timeout=15,
|
|
342
|
+
)
|
|
343
|
+
# The integration step requires redirect_uri in addition to client_id.
|
|
344
|
+
requests.post(
|
|
345
|
+
f"{base_url}/api/onboarding/integration",
|
|
346
|
+
json={"client_id": client_id, "redirect_uri": client_id},
|
|
347
|
+
headers={"Authorization": f"Bearer {short_lived_token}"},
|
|
348
|
+
timeout=15,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Step 4 – mint a long-lived token so tests are not time-limited.
|
|
352
|
+
self._token = self._mint_long_lived_token(short_lived_token)
|
|
353
|
+
|
|
354
|
+
def _password_login(self) -> str:
|
|
355
|
+
"""Authenticate with username/password and return a long-lived token.
|
|
356
|
+
|
|
357
|
+
Used when the container is started with a pre-populated config that
|
|
358
|
+
already has a user (i.e. onboarding is skipped).
|
|
359
|
+
"""
|
|
360
|
+
base_url = self.get_url()
|
|
361
|
+
client_id = f"{base_url}/"
|
|
362
|
+
|
|
363
|
+
# Initiate login flow.
|
|
364
|
+
flow_resp = requests.post(
|
|
365
|
+
f"{base_url}/auth/login_flow",
|
|
366
|
+
json={
|
|
367
|
+
"client_id": client_id,
|
|
368
|
+
"handler": ["homeassistant", None],
|
|
369
|
+
"redirect_uri": client_id,
|
|
370
|
+
},
|
|
371
|
+
timeout=15,
|
|
372
|
+
)
|
|
373
|
+
flow_resp.raise_for_status()
|
|
374
|
+
flow_id = flow_resp.json()["flow_id"]
|
|
375
|
+
|
|
376
|
+
# Submit credentials.
|
|
377
|
+
cred_resp = requests.post(
|
|
378
|
+
f"{base_url}/auth/login_flow/{flow_id}",
|
|
379
|
+
json={"username": self._username, "password": self._password},
|
|
380
|
+
timeout=15,
|
|
381
|
+
)
|
|
382
|
+
cred_resp.raise_for_status()
|
|
383
|
+
auth_code = cred_resp.json()["result"]
|
|
384
|
+
|
|
385
|
+
# Exchange code for token.
|
|
386
|
+
token_resp = requests.post(
|
|
387
|
+
f"{base_url}/auth/token",
|
|
388
|
+
data=urlencode(
|
|
389
|
+
{
|
|
390
|
+
"client_id": client_id,
|
|
391
|
+
"grant_type": "authorization_code",
|
|
392
|
+
"code": auth_code,
|
|
393
|
+
}
|
|
394
|
+
),
|
|
395
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
396
|
+
timeout=15,
|
|
397
|
+
)
|
|
398
|
+
token_resp.raise_for_status()
|
|
399
|
+
short_lived_token = token_resp.json()["access_token"]
|
|
400
|
+
|
|
401
|
+
# Mint a long-lived token.
|
|
402
|
+
return self._mint_long_lived_token(short_lived_token)
|
|
403
|
+
|
|
404
|
+
def _ws_call(self, command: dict[str, Any]) -> dict[str, Any]:
|
|
405
|
+
"""Open an authenticated WebSocket connection, send *command*, return result.
|
|
406
|
+
|
|
407
|
+
The ``"id"`` field in *command* is used as the expected message ID in
|
|
408
|
+
the response. Caller is responsible for setting a unique ID.
|
|
409
|
+
|
|
410
|
+
Parameters
|
|
411
|
+
----------
|
|
412
|
+
command:
|
|
413
|
+
A dict representing the WebSocket command. Must include ``"id"``
|
|
414
|
+
and ``"type"`` keys.
|
|
415
|
+
|
|
416
|
+
Returns
|
|
417
|
+
-------
|
|
418
|
+
dict
|
|
419
|
+
The parsed result message from Home Assistant.
|
|
420
|
+
|
|
421
|
+
Raises
|
|
422
|
+
------
|
|
423
|
+
RuntimeError
|
|
424
|
+
If authentication fails.
|
|
425
|
+
"""
|
|
426
|
+
ws_url = self.get_url().replace("http://", "ws://") + "/api/websocket"
|
|
427
|
+
ws = websocket.create_connection(ws_url, timeout=15)
|
|
428
|
+
try:
|
|
429
|
+
ws.recv() # auth_required
|
|
430
|
+
ws.send(json.dumps({"type": "auth", "access_token": self.get_token()}))
|
|
431
|
+
auth_result = json.loads(ws.recv())
|
|
432
|
+
if auth_result.get("type") != "auth_ok":
|
|
433
|
+
raise RuntimeError(f"WebSocket auth failed: {auth_result}")
|
|
434
|
+
ws.send(json.dumps(command))
|
|
435
|
+
return json.loads(ws.recv())
|
|
436
|
+
finally:
|
|
437
|
+
ws.close()
|
|
438
|
+
|
|
439
|
+
def _mint_long_lived_token(self, short_lived_token: str) -> str:
|
|
440
|
+
"""Create a long-lived access token via the HA WebSocket API.
|
|
441
|
+
|
|
442
|
+
The legacy ``POST /api/auth/long_lived_access_token`` endpoint was
|
|
443
|
+
removed in recent HA stable releases. The WebSocket flow is:
|
|
444
|
+
|
|
445
|
+
1. Authenticate with the short-lived token.
|
|
446
|
+
2. Send an ``auth/long_lived_access_token`` command.
|
|
447
|
+
3. Return the resulting token string.
|
|
448
|
+
"""
|
|
449
|
+
ws_url = self.get_url().replace("http://", "ws://") + "/api/websocket"
|
|
450
|
+
ws = websocket.create_connection(ws_url, timeout=15)
|
|
451
|
+
try:
|
|
452
|
+
# Server sends auth_required immediately after connect.
|
|
453
|
+
ws.recv()
|
|
454
|
+
# Authenticate with the short-lived token.
|
|
455
|
+
ws.send(json.dumps({"type": "auth", "access_token": short_lived_token}))
|
|
456
|
+
auth_result = json.loads(ws.recv())
|
|
457
|
+
if auth_result.get("type") != "auth_ok":
|
|
458
|
+
raise RuntimeError(f"WebSocket auth failed: {auth_result}")
|
|
459
|
+
# Request a long-lived token.
|
|
460
|
+
ws.send(
|
|
461
|
+
json.dumps(
|
|
462
|
+
{
|
|
463
|
+
"id": 1,
|
|
464
|
+
"type": "auth/long_lived_access_token",
|
|
465
|
+
"client_name": "ha-testcontainer",
|
|
466
|
+
"lifespan": 3650,
|
|
467
|
+
}
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
result = json.loads(ws.recv())
|
|
471
|
+
if not result.get("success"):
|
|
472
|
+
raise RuntimeError(f"Failed to create long-lived token: {result}")
|
|
473
|
+
return result["result"]
|
|
474
|
+
finally:
|
|
475
|
+
ws.close()
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Visual testing helpers for ha-testcontainer.
|
|
2
|
+
|
|
3
|
+
This module provides the Playwright-based helpers that component authors import
|
|
4
|
+
in their own test files. The key exports are:
|
|
5
|
+
|
|
6
|
+
- :data:`PAGE_LOAD_TIMEOUT` — ms to wait for HA to fully render
|
|
7
|
+
- :data:`HA_SETTLE_MS` — additional settle time before snapshotting
|
|
8
|
+
- :func:`inject_ha_token` — bypass the HA login screen via localStorage
|
|
9
|
+
- :func:`assert_snapshot` — take a screenshot and compare to a baseline
|
|
10
|
+
|
|
11
|
+
Usage in a component's test file::
|
|
12
|
+
|
|
13
|
+
from ha_testcontainer.visual import PAGE_LOAD_TIMEOUT, assert_snapshot
|
|
14
|
+
|
|
15
|
+
def test_my_card(ha_page, ha_url):
|
|
16
|
+
ha_page.goto(f"{ha_url}/lovelace/0", wait_until="networkidle",
|
|
17
|
+
timeout=PAGE_LOAD_TIMEOUT)
|
|
18
|
+
assert_snapshot(ha_page, "my_card_baseline")
|
|
19
|
+
|
|
20
|
+
Where baselines are stored
|
|
21
|
+
--------------------------
|
|
22
|
+
Baseline PNGs are placed in a ``snapshots/`` sub-directory **next to the
|
|
23
|
+
calling test file** in the **consumer's own repository**. They are part of
|
|
24
|
+
the consumer project's version history — not part of ha-testcontainer.
|
|
25
|
+
|
|
26
|
+
ha-testcontainer itself does not commit any snapshot files. Any PNGs that
|
|
27
|
+
are generated locally (e.g. when running the example tests) are gitignored
|
|
28
|
+
inside this repository.
|
|
29
|
+
|
|
30
|
+
Run with ``SNAPSHOT_UPDATE=1`` (or pass ``update=True``) to create or refresh
|
|
31
|
+
baselines in the consumer's project.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import inspect
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import shutil
|
|
40
|
+
import time
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from playwright.sync_api import Page
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Constants
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
#: Milliseconds to allow for HA's frontend to fully paint before asserting
|
|
50
|
+
#: or screenshotting. Applies to ``page.goto`` wait and explicit waits.
|
|
51
|
+
PAGE_LOAD_TIMEOUT: int = 60_000
|
|
52
|
+
|
|
53
|
+
#: Additional settle time (ms) between page load and snapshot capture, to let
|
|
54
|
+
#: animations and async HA state updates complete.
|
|
55
|
+
HA_SETTLE_MS: int = 3_000
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Authentication helper
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def inject_ha_token(page: Page, ha_url: str, token: str) -> None:
|
|
64
|
+
"""Bypass the HA login screen by injecting a long-lived token into localStorage.
|
|
65
|
+
|
|
66
|
+
Home Assistant reads ``hassTokens`` from localStorage on page load.
|
|
67
|
+
Injecting it before the first navigation skips the onboarding/login flow.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
page:
|
|
72
|
+
An unnavigated Playwright :class:`~playwright.sync_api.Page`.
|
|
73
|
+
ha_url:
|
|
74
|
+
Base URL of the running HA instance, e.g. ``http://localhost:8123``.
|
|
75
|
+
token:
|
|
76
|
+
Long-lived access token obtained from :meth:`HATestContainer.get_token`.
|
|
77
|
+
"""
|
|
78
|
+
expires_ms = int(time.time() * 1000) + 365 * 24 * 3600 * 1000
|
|
79
|
+
hass_tokens_json = json.dumps({
|
|
80
|
+
"access_token": token,
|
|
81
|
+
"token_type": "Bearer",
|
|
82
|
+
"expires_in": 31536000,
|
|
83
|
+
"refresh_token": None,
|
|
84
|
+
"hassUrl": ha_url,
|
|
85
|
+
"clientId": f"{ha_url}/",
|
|
86
|
+
"expires": expires_ms,
|
|
87
|
+
})
|
|
88
|
+
# Use add_init_script so the localStorage entry is written on every
|
|
89
|
+
# document load — including HA's own SPA redirects — before any page JS
|
|
90
|
+
# runs. page.evaluate() after domcontentloaded is racy: HA's redirect
|
|
91
|
+
# destroys the execution context before evaluate() can run.
|
|
92
|
+
page.add_init_script(
|
|
93
|
+
f"localStorage.setItem('hassTokens', {json.dumps(hass_tokens_json)});"
|
|
94
|
+
)
|
|
95
|
+
page.goto(ha_url, wait_until="domcontentloaded", timeout=PAGE_LOAD_TIMEOUT)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Snapshot helper
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def assert_snapshot(
|
|
104
|
+
page: Page,
|
|
105
|
+
name: str,
|
|
106
|
+
*,
|
|
107
|
+
snapshots_dir: Path | str | None = None,
|
|
108
|
+
update: bool = False,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Take a screenshot and compare it to a stored baseline PNG.
|
|
111
|
+
|
|
112
|
+
On the **first run** (or when *update* is ``True`` / ``SNAPSHOT_UPDATE=1``),
|
|
113
|
+
the screenshot is saved as the baseline. Subsequent runs compare the new
|
|
114
|
+
screenshot against the baseline; any pixel difference fails the test.
|
|
115
|
+
|
|
116
|
+
Baseline PNGs (``<name>.png``) are placed in the ``snapshots/`` directory
|
|
117
|
+
next to the calling test file in the **consumer's own repository** and
|
|
118
|
+
should be committed there.
|
|
119
|
+
Actual screenshots (``<name>.actual.png``) are transient and should be
|
|
120
|
+
gitignored in the consumer's project.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
page:
|
|
125
|
+
Playwright page to screenshot.
|
|
126
|
+
name:
|
|
127
|
+
Filename stem for the PNG, e.g. ``"01_dashboard"``.
|
|
128
|
+
snapshots_dir:
|
|
129
|
+
Directory in which to store baseline and actual PNGs.
|
|
130
|
+
Defaults to a ``snapshots/`` sub-directory **next to the calling
|
|
131
|
+
test file** so baselines live alongside the tests that create them.
|
|
132
|
+
update:
|
|
133
|
+
When ``True``, overwrite the baseline instead of comparing.
|
|
134
|
+
Also triggered by the ``SNAPSHOT_UPDATE=1`` environment variable.
|
|
135
|
+
"""
|
|
136
|
+
resolved_dir = _resolve_snapshots_dir(snapshots_dir)
|
|
137
|
+
resolved_dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
|
|
139
|
+
baseline = resolved_dir / f"{name}.png"
|
|
140
|
+
actual = resolved_dir / f"{name}.actual.png"
|
|
141
|
+
|
|
142
|
+
page.wait_for_timeout(HA_SETTLE_MS)
|
|
143
|
+
page.screenshot(path=str(actual), full_page=False)
|
|
144
|
+
|
|
145
|
+
should_update = update or os.environ.get("SNAPSHOT_UPDATE") == "1"
|
|
146
|
+
|
|
147
|
+
baseline_existed = baseline.exists()
|
|
148
|
+
|
|
149
|
+
if not baseline_existed or should_update:
|
|
150
|
+
shutil.copy(actual, baseline)
|
|
151
|
+
print(f"\n[snapshot] baseline {'updated' if baseline_existed else 'created'}: {baseline}")
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Pixel-level comparison using Pillow when available, falling back to bytes.
|
|
155
|
+
try:
|
|
156
|
+
from PIL import Image, ImageChops # type: ignore[import]
|
|
157
|
+
|
|
158
|
+
img_base = Image.open(baseline).convert("RGB")
|
|
159
|
+
img_actual = Image.open(actual).convert("RGB")
|
|
160
|
+
diff = ImageChops.difference(img_base, img_actual)
|
|
161
|
+
bbox = diff.getbbox()
|
|
162
|
+
assert bbox is None, (
|
|
163
|
+
f"Snapshot mismatch for '{name}'. "
|
|
164
|
+
f"Differing region: {bbox}. "
|
|
165
|
+
"Run with SNAPSHOT_UPDATE=1 to accept new baseline."
|
|
166
|
+
)
|
|
167
|
+
except ImportError:
|
|
168
|
+
# Pillow not installed — byte-level fallback.
|
|
169
|
+
assert baseline.read_bytes() == actual.read_bytes(), (
|
|
170
|
+
f"Snapshot mismatch for '{name}'. "
|
|
171
|
+
"Run with SNAPSHOT_UPDATE=1 to accept new baseline."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Internal helpers
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _resolve_snapshots_dir(snapshots_dir: Path | str | None) -> Path:
|
|
181
|
+
"""Return the resolved snapshots directory path.
|
|
182
|
+
|
|
183
|
+
When *snapshots_dir* is ``None`` (the default), walk the call stack to
|
|
184
|
+
find the first frame outside this module and place the ``snapshots/``
|
|
185
|
+
sub-directory next to that file. This means snapshot baselines
|
|
186
|
+
automatically live alongside the test file that calls
|
|
187
|
+
:func:`assert_snapshot`, with no configuration required.
|
|
188
|
+
"""
|
|
189
|
+
if snapshots_dir is not None:
|
|
190
|
+
return Path(snapshots_dir)
|
|
191
|
+
|
|
192
|
+
this_file = Path(__file__).resolve()
|
|
193
|
+
for frame_info in inspect.stack()[2:]:
|
|
194
|
+
caller_file = Path(frame_info.filename).resolve()
|
|
195
|
+
if caller_file != this_file:
|
|
196
|
+
return caller_file.parent / "snapshots"
|
|
197
|
+
|
|
198
|
+
# Fallback: use the current working directory.
|
|
199
|
+
return Path.cwd() / "snapshots"
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ha-testcontainer
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Full test container for Home Assistant with demo config, custom components, and visual testing
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Lint Free Technology
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://github.com/Lint-Free-Technology/ha-testcontainer
|
|
28
|
+
Project-URL: Issues, https://github.com/Lint-Free-Technology/ha-testcontainer/issues
|
|
29
|
+
Keywords: home-assistant,testcontainers,testing,docker,playwright
|
|
30
|
+
Classifier: Development Status :: 3 - Alpha
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Topic :: Software Development :: Testing
|
|
37
|
+
Requires-Python: >=3.11
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Requires-Dist: testcontainers>=4.9
|
|
41
|
+
Requires-Dist: requests>=2.32
|
|
42
|
+
Requires-Dist: websocket-client>=1.6
|
|
43
|
+
Provides-Extra: visual
|
|
44
|
+
Requires-Dist: playwright>=1.50; extra == "visual"
|
|
45
|
+
Requires-Dist: pytest-playwright>=0.5; extra == "visual"
|
|
46
|
+
Requires-Dist: Pillow>=10.0; extra == "visual"
|
|
47
|
+
Provides-Extra: test
|
|
48
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
49
|
+
Requires-Dist: pytest-playwright>=0.5; extra == "test"
|
|
50
|
+
Requires-Dist: playwright>=1.50; extra == "test"
|
|
51
|
+
Requires-Dist: Pillow>=10.0; extra == "test"
|
|
52
|
+
Dynamic: license-file
|
|
53
|
+
|
|
54
|
+
# ha-testcontainer
|
|
55
|
+
|
|
56
|
+
> A full, reusable test container for Home Assistant.
|
|
57
|
+
|
|
58
|
+
`ha-testcontainer` is a Python library that wraps the official Home Assistant
|
|
59
|
+
Docker image in a [Testcontainers](https://testcontainers.com/)-based class.
|
|
60
|
+
It handles all the plumbing — startup, programmatic onboarding, long-lived
|
|
61
|
+
token creation, and custom component mounting — so your tests can focus on
|
|
62
|
+
what matters.
|
|
63
|
+
|
|
64
|
+
[UIX (UI eXtension)](https://github.com/Lint-Free-Technology/uix) is the
|
|
65
|
+
primary consumer and uses this library as a replacement for its legacy local
|
|
66
|
+
test stack.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
| Capability | Detail |
|
|
73
|
+
|---|---|
|
|
74
|
+
| **Version flexibility** | `stable`, `beta`, `dev`, or any pinned tag (`2024.6.0`) |
|
|
75
|
+
| **Automatic onboarding** | Creates an admin user and mints a long-lived API token with no manual interaction |
|
|
76
|
+
| **Demo entities** | Built-in HA `demo` integration gives you lights, sensors, weather objects immediately |
|
|
77
|
+
| **Custom config** | Mount any `configuration.yaml` tree via `config_path=` |
|
|
78
|
+
| **Custom components** | Mount any `custom_components/` directory via `custom_components_path=` |
|
|
79
|
+
| **Fetch any component** | `scripts/fetch_component.py owner/repo` — downloads any GitHub-hosted HA component |
|
|
80
|
+
| **Fetch any frontend plugin** | `scripts/fetch_plugin.py owner/repo` — downloads any JS dashboard plugin; registers it as a Lovelace resource |
|
|
81
|
+
| **Storage-mode dashboard** | Default Lovelace dashboard in storage mode; REST-pushable for tests |
|
|
82
|
+
| **REST API helper** | `ha.api("GET", "states")` — authenticated calls with zero boilerplate |
|
|
83
|
+
| **Integration setup** | `ha.setup_integration("my_domain")` — drives config-flow programmatically |
|
|
84
|
+
| **Playwright visual tests** | Session-scoped browser context, token injection, pixel-diff snapshot comparison |
|
|
85
|
+
| **Boilerplate example** | `examples/test_custom_component.py` — copy, fill in TODO values, done |
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Quick start
|
|
90
|
+
|
|
91
|
+
### 1 — Install
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install -e ".[test]"
|
|
95
|
+
playwright install chromium
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 2 — Fetch a custom component
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# Any GitHub-hosted HA custom component (Python, goes into custom_components/):
|
|
102
|
+
python scripts/fetch_component.py Lint-Free-Technology/uix
|
|
103
|
+
python scripts/fetch_component.py Lint-Free-Technology/uix 5.3.1 # pinned version
|
|
104
|
+
|
|
105
|
+
# Or via make (COMPONENT is required):
|
|
106
|
+
make setup COMPONENT=Lint-Free-Technology/uix
|
|
107
|
+
make setup COMPONENT=Lint-Free-Technology/uix VERSION=5.3.1
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 2b — Fetch a frontend plugin (optional)
|
|
111
|
+
|
|
112
|
+
Frontend plugins are JavaScript dashboard modules (Lovelace cards, etc.),
|
|
113
|
+
distinct from Python custom components. They are downloaded into
|
|
114
|
+
`ha-config/www/dashboard/` and automatically served by HA at `/local/…`.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Download and register a dashboard plugin:
|
|
118
|
+
python scripts/fetch_plugin.py custom-cards/button-card
|
|
119
|
+
python scripts/fetch_plugin.py thomasloven/lovelace-card-mod 3.4.4
|
|
120
|
+
|
|
121
|
+
# Or via make:
|
|
122
|
+
make fetch-plugin PLUGIN=custom-cards/button-card
|
|
123
|
+
make fetch-plugin PLUGIN=thomasloven/lovelace-card-mod VERSION=3.4.4
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 3 — Run tests
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
make test # unit + integration tests (no browser)
|
|
130
|
+
make test-visual # Playwright visual tests (requires Docker + Playwright)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
See **[Testing](#testing)** below for a full breakdown of each tier.
|
|
134
|
+
|
|
135
|
+
### 4 — Explore locally with docker compose
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
make up # starts HA at http://localhost:8123
|
|
139
|
+
make down
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Usage in your own project
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from ha_testcontainer import HATestContainer, HAVersion
|
|
148
|
+
|
|
149
|
+
with HATestContainer(
|
|
150
|
+
version=HAVersion.STABLE, # or "beta", "dev", "2024.6.0"
|
|
151
|
+
config_path="ha-config",
|
|
152
|
+
custom_components_path="custom_components",
|
|
153
|
+
) as ha:
|
|
154
|
+
# Set up any custom component via config-flow
|
|
155
|
+
ha.setup_integration("uix")
|
|
156
|
+
|
|
157
|
+
# REST API — authenticated, zero boilerplate
|
|
158
|
+
states = ha.api("GET", "states").json()
|
|
159
|
+
|
|
160
|
+
print(ha.get_url()) # http://localhost:<random-port>
|
|
161
|
+
print(ha.get_token()) # long-lived access token
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### pytest fixture example
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
# conftest.py
|
|
168
|
+
import pytest
|
|
169
|
+
from ha_testcontainer import HATestContainer
|
|
170
|
+
|
|
171
|
+
@pytest.fixture(scope="session")
|
|
172
|
+
def ha():
|
|
173
|
+
with HATestContainer(config_path="ha-config", custom_components_path="custom_components") as c:
|
|
174
|
+
c.setup_integration("uix")
|
|
175
|
+
yield c
|
|
176
|
+
|
|
177
|
+
# test_my_component.py
|
|
178
|
+
def test_api(ha):
|
|
179
|
+
resp = ha.api("GET", "states")
|
|
180
|
+
assert resp.status_code == 200
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Playwright visual test example
|
|
184
|
+
|
|
185
|
+
See [`examples/test_custom_component.py`](examples/test_custom_component.py) for a
|
|
186
|
+
fully annotated boilerplate that any component author can copy.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from ha_testcontainer.visual import PAGE_LOAD_TIMEOUT, assert_snapshot
|
|
190
|
+
|
|
191
|
+
def test_my_card(ha_page, ha_url):
|
|
192
|
+
ha_page.goto(f"{ha_url}/lovelace/0", wait_until="networkidle",
|
|
193
|
+
timeout=PAGE_LOAD_TIMEOUT)
|
|
194
|
+
assert_snapshot(ha_page, "my_card_baseline")
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Run `SNAPSHOT_UPDATE=1 pytest tests/visual/` to create or update baselines.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Fetching custom components
|
|
202
|
+
|
|
203
|
+
`scripts/fetch_component.py` works with **any** GitHub repository that follows
|
|
204
|
+
the standard HA custom-component layout (a `custom_components/<name>/` directory
|
|
205
|
+
in the repository root):
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
python scripts/fetch_component.py owner/repo # latest release
|
|
209
|
+
python scripts/fetch_component.py owner/repo 5.3.1 # specific version
|
|
210
|
+
python scripts/fetch_component.py owner/repo --list # list releases
|
|
211
|
+
python scripts/fetch_component.py owner/repo --target-dir /other/path
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The script auto-discovers all `custom_components/` sub-directories in the
|
|
215
|
+
release archive, so multi-component repositories are handled correctly.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Fetching frontend plugins (dashboard cards)
|
|
220
|
+
|
|
221
|
+
`scripts/fetch_plugin.py` downloads **JavaScript dashboard modules** — Lovelace
|
|
222
|
+
cards and similar frontend-only plugins — from GitHub releases. These are
|
|
223
|
+
distinct from Python custom components: they live in `www/` rather than
|
|
224
|
+
`custom_components/`, and are loaded by the HA frontend via Lovelace resources.
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
python scripts/fetch_plugin.py owner/repo # latest release
|
|
228
|
+
python scripts/fetch_plugin.py owner/repo 1.2.3 # specific version
|
|
229
|
+
python scripts/fetch_plugin.py owner/repo --list # list releases
|
|
230
|
+
python scripts/fetch_plugin.py owner/repo --plugin-name custom-name
|
|
231
|
+
python scripts/fetch_plugin.py owner/repo --resource-type js # legacy (default: module)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The script:
|
|
235
|
+
1. Downloads JS files from the release (assets, zip assets, or source archive fallback).
|
|
236
|
+
2. Places them at `ha-config/www/dashboard/<plugin-name>/<file>.js`.
|
|
237
|
+
3. Registers each file as a Lovelace resource in `ha-config/lovelace_resources.yaml`.
|
|
238
|
+
|
|
239
|
+
HA serves `ha-config/www/` at `/local/`, so the plugin is immediately available
|
|
240
|
+
to Lovelace dashboards as `/local/dashboard/<plugin-name>/<file>.js`.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Configuration
|
|
245
|
+
|
|
246
|
+
### HA version
|
|
247
|
+
|
|
248
|
+
| Value | Image tag pulled |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `HAVersion.STABLE` (default) | `ghcr.io/home-assistant/home-assistant:stable` |
|
|
251
|
+
| `HAVersion.BETA` | `ghcr.io/home-assistant/home-assistant:beta` |
|
|
252
|
+
| `HAVersion.DEV` | `ghcr.io/home-assistant/home-assistant:dev` |
|
|
253
|
+
| `"2024.6.0"` | `ghcr.io/home-assistant/home-assistant:2024.6.0` |
|
|
254
|
+
|
|
255
|
+
Override at runtime:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
HA_VERSION=beta pytest tests/
|
|
259
|
+
HA_VERSION=2024.6.0 make test
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Config & custom components
|
|
263
|
+
|
|
264
|
+
| Environment variable | Default | Purpose |
|
|
265
|
+
|---|---|---|
|
|
266
|
+
| `HA_VERSION` | `stable` | Image tag |
|
|
267
|
+
| `HA_CONFIG_PATH` | `ha-config/` | Host dir mounted as `/config` |
|
|
268
|
+
| `HA_CUSTOM_COMPONENTS_PATH` | `custom_components/` | Host dir mounted as `/config/custom_components` |
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Repository layout
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
ha-testcontainer/
|
|
276
|
+
├── ha_testcontainer/
|
|
277
|
+
│ ├── __init__.py # public API: HATestContainer, HAVersion, visual helpers
|
|
278
|
+
│ ├── container.py # HATestContainer implementation
|
|
279
|
+
│ └── visual.py # PAGE_LOAD_TIMEOUT, assert_snapshot, inject_ha_token
|
|
280
|
+
├── ha-config/
|
|
281
|
+
│ ├── configuration.yaml # demo HA config (default_config + demo integration)
|
|
282
|
+
│ ├── lovelace_resources.yaml # Lovelace resources list (managed by fetch_plugin.py)
|
|
283
|
+
│ ├── www/ # served at /local/ by HA; plugins downloaded here
|
|
284
|
+
│ └── themes/ # theme YAML files (auto-loaded)
|
|
285
|
+
├── custom_components/
|
|
286
|
+
│ └── README.md # populated by scripts/fetch_component.py (gitignored)
|
|
287
|
+
├── scripts/
|
|
288
|
+
│ ├── fetch_component.py # download any HA Python component from GitHub releases
|
|
289
|
+
│ └── fetch_plugin.py # download any JS frontend plugin; register as resource
|
|
290
|
+
├── examples/
|
|
291
|
+
│ └── test_custom_component.py # boilerplate visual test — copy & customise
|
|
292
|
+
├── tests/
|
|
293
|
+
│ ├── conftest.py # session-scoped ha / ha_url / ha_token fixtures
|
|
294
|
+
│ ├── test_container_unit.py # unit tests — no Docker needed (14 tests)
|
|
295
|
+
│ ├── test_container.py # integration tests — requires Docker (10 tests)
|
|
296
|
+
│ └── visual/
|
|
297
|
+
│ ├── conftest.py # Playwright fixtures (ha_page, ha_browser_context)
|
|
298
|
+
│ └── snapshots/ # gitignored — no baselines committed here (see below)
|
|
299
|
+
├── docker-compose.yml # local dev: docker compose up
|
|
300
|
+
├── Makefile # setup / test / update-snapshots targets
|
|
301
|
+
└── pyproject.toml
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## Snapshot-based visual testing
|
|
307
|
+
|
|
308
|
+
Snapshot tests follow a two-file convention:
|
|
309
|
+
|
|
310
|
+
| File | Description |
|
|
311
|
+
|---|---|
|
|
312
|
+
| `snapshots/<name>.png` | **Committed baseline** — the ground truth, lives in the **consumer's repo** |
|
|
313
|
+
| `snapshots/<name>.actual.png` | Generated on every run — gitignored |
|
|
314
|
+
|
|
315
|
+
On the **first run** (or when `SNAPSHOT_UPDATE=1` is set), the actual
|
|
316
|
+
screenshot becomes the baseline. Subsequent runs diff the two; any pixel
|
|
317
|
+
difference causes the test to fail.
|
|
318
|
+
|
|
319
|
+
Baselines are placed **next to the calling test file** automatically — no
|
|
320
|
+
configuration needed.
|
|
321
|
+
|
|
322
|
+
> **Important — baselines are stored in the consumer's repo, not here.**
|
|
323
|
+
> ha-testcontainer is a reusable library; it does not commit any snapshot PNG
|
|
324
|
+
> files. All `*.png` files under `tests/visual/snapshots/` are gitignored in
|
|
325
|
+
> this repository. When you write visual tests for your own component, baselines
|
|
326
|
+
> are committed in *your* project's `tests/visual/snapshots/` directory.
|
|
327
|
+
> They will never appear in ha-testcontainer's history.
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Testing
|
|
332
|
+
|
|
333
|
+
The test suite has three tiers:
|
|
334
|
+
|
|
335
|
+
### Tier 1 — Unit tests (no Docker required)
|
|
336
|
+
|
|
337
|
+
These run in milliseconds and cover the Python logic of `HATestContainer`
|
|
338
|
+
in isolation (URL construction, token handling, API path normalisation):
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
pip install -e ".[test]" # install once
|
|
342
|
+
pytest tests/test_container_unit.py -v
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Or via make:
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
make install
|
|
349
|
+
make test
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`make test` runs **all** non-browser tests, including both unit and
|
|
353
|
+
integration tests, skipping the visual tier.
|
|
354
|
+
|
|
355
|
+
### Tier 2 — Integration tests (Docker required)
|
|
356
|
+
|
|
357
|
+
These start a real Home Assistant container and exercise the full lifecycle
|
|
358
|
+
(onboarding, REST API, demo entities, Lovelace push):
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
# Prerequisites: Docker daemon running, HA image available
|
|
362
|
+
pytest tests/test_container.py -v
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The container is started once per pytest session (session-scoped fixture)
|
|
366
|
+
and reused across all tests in the file. Startup takes ~60 s the first time
|
|
367
|
+
while HA initialises.
|
|
368
|
+
|
|
369
|
+
Environment variables let you control which image and config directory are
|
|
370
|
+
used:
|
|
371
|
+
|
|
372
|
+
| Variable | Default | Purpose |
|
|
373
|
+
|---|---|---|
|
|
374
|
+
| `HA_VERSION` | `stable` | Image tag (`stable`, `beta`, `dev`, `2024.6.0`, …) |
|
|
375
|
+
| `HA_CONFIG_PATH` | `ha-config/` | Host dir mounted as `/config` |
|
|
376
|
+
| `HA_CUSTOM_COMPONENTS_PATH` | `custom_components/` | Host dir mounted as `/config/custom_components` |
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
HA_VERSION=beta pytest tests/test_container.py -v
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Tier 3 — Visual (Playwright) tests (Docker + Playwright required)
|
|
383
|
+
|
|
384
|
+
```bash
|
|
385
|
+
pip install -e ".[test]"
|
|
386
|
+
playwright install chromium
|
|
387
|
+
pytest tests/visual/ -v
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Or via make:
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
make install
|
|
394
|
+
make test-visual
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Visual tests open a Chromium browser, log in to the running HA instance, and
|
|
398
|
+
compare screenshots against committed baselines. Baselines are stored in the
|
|
399
|
+
**consumer's own repository** — see [Snapshot-based visual testing](#snapshot-based-visual-testing).
|
|
400
|
+
|
|
401
|
+
### Version smoke tests (slow, optional)
|
|
402
|
+
|
|
403
|
+
These pull the `stable`, `beta`, and `dev` images in sequence and verify that
|
|
404
|
+
the container starts for each:
|
|
405
|
+
|
|
406
|
+
```bash
|
|
407
|
+
pytest tests/test_container.py -v -m version_smoke
|
|
408
|
+
# or:
|
|
409
|
+
make test-smoke
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
They are skipped by default to avoid pulling large images on every run.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
UIX's `test/docker-compose.yaml` + `test/configuration.yaml` + `test/lovelace.yaml`
|
|
419
|
+
are replaced by `make up` and UIX writing its own `tests/` using `HATestContainer`.
|
|
420
|
+
|
|
421
|
+
1. Add `ha-testcontainer` as a dev dependency.
|
|
422
|
+
2. Fetch UIX: `python scripts/fetch_component.py Lint-Free-Technology/uix`.
|
|
423
|
+
3. Replace `docker compose -f test/docker-compose.yaml up` with `make up`.
|
|
424
|
+
4. Copy `examples/test_custom_component.py` into UIX's `tests/visual/`, fill in
|
|
425
|
+
UIX-specific TODO values, and add UIX-specific test cases.
|
|
426
|
+
5. Delete UIX's `test/` directory.
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## License
|
|
431
|
+
|
|
432
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
ha_testcontainer/__init__.py,sha256=rMoTL2BjJzDO5X-aSnZchImvMvpfWFmr1uJzYiFy0-Y,594
|
|
2
|
+
ha_testcontainer/container.py,sha256=MV1kbffUuhRHbh9CF1I3DPUSNeY6NbRlSyYPumYSFC4,17190
|
|
3
|
+
ha_testcontainer/visual.py,sha256=taiVMlTNOOve-_0qBdQiK8NLITWs0eHLxeX0W3RxHhA,7564
|
|
4
|
+
ha_testcontainer-1.0.1.dist-info/licenses/LICENSE,sha256=ALaaPLr8B2tnawyHtHtOWNWKYtC5EwdCkPum_paUHsM,1077
|
|
5
|
+
ha_testcontainer-1.0.1.dist-info/METADATA,sha256=NwMLsaGQb3csi3ZNIIvwPzfCHHp6zslws5rs20XKvIQ,15759
|
|
6
|
+
ha_testcontainer-1.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
ha_testcontainer-1.0.1.dist-info/top_level.txt,sha256=iOAfoZ69H1jesQ1GAzbQ6_3-7To1T6AXCSGJuNWR9Yw,17
|
|
8
|
+
ha_testcontainer-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lint Free Technology
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ha_testcontainer
|