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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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