diff --git a/.github/workflows/ha-contract.yml b/.github/workflows/ha-test-harness.yml similarity index 77% rename from .github/workflows/ha-contract.yml rename to .github/workflows/ha-test-harness.yml index e8ecec9..8cfd78a 100644 --- a/.github/workflows/ha-contract.yml +++ b/.github/workflows/ha-test-harness.yml @@ -1,4 +1,4 @@ -name: Home Assistant MQTT contract +name: Home Assistant MQTT test harness on: workflow_dispatch: @@ -9,7 +9,7 @@ permissions: contents: read jobs: - contract: + test_harness: runs-on: ubuntu-latest strategy: fail-fast: false @@ -23,8 +23,8 @@ jobs: expect_disabled_cleanup: "1" env: HA_VERSION: ${{ matrix.home_assistant }} - CONTRACT_EXPECT_DISABLED_CLEANUP: ${{ matrix.expect_disabled_cleanup }} - COMPOSE_FILE: tests/ha-contract/compose.yaml + TEST_HARNESS_EXPECT_DISABLED_CLEANUP: ${{ matrix.expect_disabled_cleanup }} + COMPOSE_FILE: tests/ha-test-harness/compose.yaml steps: - uses: actions/checkout@v4 - name: Start broker and Home Assistant @@ -34,10 +34,10 @@ jobs: - name: Restart Home Assistant from retained data run: docker compose restart homeassistant - name: Check retained discovery after restart - run: docker compose run --rm -e CONTRACT_MODE=retained-restart tests + run: docker compose run --rm -e TEST_HARNESS_MODE=retained-restart tests - name: Collect Home Assistant logs on failure if: failure() run: docker compose logs --no-color homeassistant mqtt - - name: Remove contract volumes + - name: Remove test harness volumes if: always() run: docker compose down -v diff --git a/docs/compatibility.md b/docs/compatibility.md index 13ae896..9daf64c 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,7 +1,7 @@ # Compatibility baseline This maintenance line begins from fork commit `84cc0037b1c0` (release `v3.0.2`). -It intentionally tracks Home Assistant's MQTT discovery contract without merging +It intentionally tracks Home Assistant's MQTT discovery test harness without merging upstream development wholesale. | Reference | Audited revision / target | @@ -10,7 +10,7 @@ upstream development wholesale. | Upstream main | `1d333ab229b2` (`v2.1.0`) | | Upstream develop | `a7039fad810b` (unreleased WIP 2.2.0) | | Device discovery minimum | Home Assistant `2024.11.0` | -| Contract matrix | `2024.11.3`, current `stable`, current `dev` | +| Test harness matrix | `2024.11.3`, current `stable`, current `dev` | The only imported upstream code fix is the four missing-device-ID guards from upstream commit `9c9d074`. Device discovery migration, JSON validation, @@ -37,4 +37,4 @@ git diff --stat main...upstream/develop Port only independently reviewed changes with regression tests; treat open upstream pull requests as proposals, not release inputs. Run the native, -board-compile, and HA contract gates before publishing a new release. +board-compile, and HA test harness gates before publishing a new release. diff --git a/test-support/ha-mqtt-contract/README.md b/test-support/ha-mqtt-test-harness/README.md similarity index 81% rename from test-support/ha-mqtt-contract/README.md rename to test-support/ha-mqtt-test-harness/README.md index 7b25b9e..1a1d220 100644 --- a/test-support/ha-mqtt-contract/README.md +++ b/test-support/ha-mqtt-test-harness/README.md @@ -1,7 +1,7 @@ -# HA/MQTT contract testkit +# HA/MQTT test harness testkit This small Python package supplies generic test transport primitives for a -disposable Home Assistant + MQTT contract environment: +disposable Home Assistant + MQTT test harness environment: - Home Assistant onboarding, MQTT config-entry setup, WebSocket registry access, REST state/service helpers, and readiness waiting; @@ -10,7 +10,7 @@ disposable Home Assistant + MQTT contract environment: It deliberately contains no ArduinoHA discovery fixture, DeviceFramework import, entity expectation, or release policy. ArduinoHA owns its migration -fixture in `tests/ha-contract`; DeviceFramework owns its hardware fixture and +fixture in `tests/ha-test-harness`; DeviceFramework owns its hardware fixture and Docker adapter in its own repository. Other projects can reuse this package by providing their own retained MQTT messages and assertions. diff --git a/test-support/ha-mqtt-contract/pyproject.toml b/test-support/ha-mqtt-test-harness/pyproject.toml similarity index 73% rename from test-support/ha-mqtt-contract/pyproject.toml rename to test-support/ha-mqtt-test-harness/pyproject.toml index f2144bd..08162b7 100644 --- a/test-support/ha-mqtt-contract/pyproject.toml +++ b/test-support/ha-mqtt-test-harness/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] -name = "ha-mqtt-contract-testkit" +name = "ha-mqtt-test-harness-testkit" version = "0.1.0" -description = "Generic Home Assistant MQTT contract-test support" +description = "Generic Home Assistant MQTT test-harness support" requires-python = ">=3.11" dependencies = [ "paho-mqtt==2.1.0", diff --git a/test-support/ha-mqtt-contract/src/ha_mqtt_contract/__init__.py b/test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/__init__.py similarity index 81% rename from test-support/ha-mqtt-contract/src/ha_mqtt_contract/__init__.py rename to test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/__init__.py index 3bf4744..edcd7c0 100644 --- a/test-support/ha-mqtt-contract/src/ha_mqtt_contract/__init__.py +++ b/test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/__init__.py @@ -1,4 +1,4 @@ -"""Generic Home Assistant MQTT contract-test support. +"""Generic Home Assistant MQTT test harness-test support. This package deliberately knows nothing about ArduinoHA, DeviceFramework, or any particular discovery schema. Consumers own their fixtures and assertions; @@ -8,10 +8,10 @@ the package owns only HA/MQTT transport, readiness, and artifact primitives. from .artifacts import write_json_artifact, write_json_lines from .home_assistant import HomeAssistantClient from .mqtt import MqttObserver, RetainedPublisher -from .wait import ContractError, wait_until +from .wait import TestHarnessError, wait_until __all__ = [ - "ContractError", + "TestHarnessError", "HomeAssistantClient", "MqttObserver", "RetainedPublisher", diff --git a/test-support/ha-mqtt-contract/src/ha_mqtt_contract/artifacts.py b/test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/artifacts.py similarity index 100% rename from test-support/ha-mqtt-contract/src/ha_mqtt_contract/artifacts.py rename to test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/artifacts.py diff --git a/test-support/ha-mqtt-contract/src/ha_mqtt_contract/home_assistant.py b/test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/home_assistant.py similarity index 79% rename from test-support/ha-mqtt-contract/src/ha_mqtt_contract/home_assistant.py rename to test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/home_assistant.py index 2def2c1..86d3655 100644 --- a/test-support/ha-mqtt-contract/src/ha_mqtt_contract/home_assistant.py +++ b/test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/home_assistant.py @@ -10,11 +10,11 @@ from typing import Any import requests import websocket -from .wait import ContractError, wait_until +from .wait import TestHarnessError, wait_until class HomeAssistantClient: - """Authenticated HA API client with a deliberately small contract surface.""" + """Authenticated HA API client with a deliberately small test harness surface.""" def __init__(self, base_url: str, token: str): self.base_url = base_url.rstrip("/") @@ -32,9 +32,9 @@ class HomeAssistantClient: base_url: str, state_dir: str | pathlib.Path, *, - owner_name: str = "HA MQTT Contract Owner", - username: str = "ha-mqtt-contract", - password: str = "ha-mqtt-contract-password", + owner_name: str = "HA MQTT Test Harness Owner", + username: str = "ha-mqtt-test-harness", + password: str = "ha-mqtt-test-harness-password", ) -> "HomeAssistantClient": """Create or load an isolated owner token for a disposable HA volume.""" base_url = base_url.rstrip("/") @@ -44,7 +44,7 @@ class HomeAssistantClient: if token_file.exists(): return cls(base_url, token_file.read_text(encoding="utf-8").strip()) - client_id = "http://ha-mqtt-contract.local/" + client_id = "http://ha-mqtt-test-harness.local/" user = { "client_id": client_id, "name": owner_name, @@ -58,7 +58,7 @@ class HomeAssistantClient: ) auth_code = created.get("auth_code") if not auth_code: - raise ContractError("Home Assistant onboarding did not return an auth_code") + raise TestHarnessError("Home Assistant onboarding did not return an auth_code") token_response = cls._response_json( requests.post( f"{base_url}/auth/token", @@ -69,7 +69,7 @@ class HomeAssistantClient: ) token = token_response.get("access_token") if not token: - raise ContractError("Home Assistant token exchange did not return an access_token") + raise TestHarnessError("Home Assistant token exchange did not return an access_token") client = cls(base_url, token) for path, payload in ( @@ -78,7 +78,7 @@ class HomeAssistantClient: ): response = requests.post(f"{base_url}{path}", headers=client._headers, json=payload, timeout=10) if response.status_code not in (200, 201, 400, 404): - raise ContractError( + raise TestHarnessError( f"Home Assistant onboarding step {path} failed: {response.status_code} {response.text}" ) state.mkdir(parents=True, exist_ok=True) @@ -89,19 +89,19 @@ class HomeAssistantClient: def from_state(cls, base_url: str, state_dir: str | pathlib.Path) -> "HomeAssistantClient": token_file = pathlib.Path(state_dir) / "ha-token" if not token_file.exists(): - raise ContractError("Home Assistant token has not been bootstrapped") + raise TestHarnessError("Home Assistant token has not been bootstrapped") return cls(base_url, token_file.read_text(encoding="utf-8").strip()) @staticmethod def _response_json(response: requests.Response, context: str) -> dict[str, Any]: if not response.ok: - raise ContractError(f"{context} failed ({response.status_code}): {response.text}") + raise TestHarnessError(f"{context} failed ({response.status_code}): {response.text}") try: value = response.json() except ValueError as error: - raise ContractError(f"{context} returned invalid JSON: {error}") from error + raise TestHarnessError(f"{context} returned invalid JSON: {error}") from error if not isinstance(value, dict): - raise ContractError(f"{context} returned unexpected JSON: {value}") + raise TestHarnessError(f"{context} returned unexpected JSON: {value}") return value @classmethod @@ -126,7 +126,7 @@ class HomeAssistantClient: if flow.get("type") == "create_entry": return if flow.get("type") != "form" or not flow.get("flow_id"): - raise ContractError(f"unexpected MQTT config-entry flow result: {flow}") + raise TestHarnessError(f"unexpected MQTT config-entry flow result: {flow}") user_input: dict[str, Any] = {"broker": host, "port": port} schema_names = { field.get("name") for field in flow.get("data_schema", []) if isinstance(field, dict) @@ -147,7 +147,7 @@ class HomeAssistantClient: "MQTT config-entry flow configuration", ) if configured.get("type") != "create_entry": - raise ContractError(f"MQTT config-entry flow did not create an entry: {configured}") + raise TestHarnessError(f"MQTT config-entry flow did not create an entry: {configured}") time.sleep(3) def _ensure_socket(self) -> websocket.WebSocket: @@ -159,12 +159,12 @@ class HomeAssistantClient: required = json.loads(socket.recv()) if required.get("type") != "auth_required": socket.close() - raise ContractError(f"unexpected Home Assistant WebSocket greeting: {required}") + raise TestHarnessError(f"unexpected Home Assistant WebSocket greeting: {required}") socket.send(json.dumps({"type": "auth", "access_token": self.token})) authenticated = json.loads(socket.recv()) if authenticated.get("type") != "auth_ok": socket.close() - raise ContractError(f"Home Assistant WebSocket authentication failed: {authenticated}") + raise TestHarnessError(f"Home Assistant WebSocket authentication failed: {authenticated}") self._socket = socket return socket @@ -183,23 +183,23 @@ class HomeAssistantClient: if result.get("id") != message_id: continue if not result.get("success"): - raise ContractError(f"WebSocket {message_type} failed: {result}") + raise TestHarnessError(f"WebSocket {message_type} failed: {result}") return result.get("result") def entity_registry(self) -> list[dict[str, Any]]: result = self.call("config/entity_registry/list") if not isinstance(result, list): - raise ContractError(f"entity registry returned unexpected value: {result}") + raise TestHarnessError(f"entity registry returned unexpected value: {result}") return result - # A concise compatibility spelling for contract suites; this remains schema-neutral. + # A concise compatibility spelling for test harness suites; this remains schema-neutral. def registry_entries(self) -> list[dict[str, Any]]: return self.entity_registry() def device_registry(self) -> list[dict[str, Any]]: result = self.call("config/device_registry/list") if not isinstance(result, list): - raise ContractError(f"device registry returned unexpected value: {result}") + raise TestHarnessError(f"device registry returned unexpected value: {result}") return result def entities_for_device(self, device_id: str) -> list[dict[str, Any]]: @@ -209,7 +209,7 @@ class HomeAssistantClient: def find() -> dict[str, Any] | None: matches = [entry for entry in self.entity_registry() if entry.get("unique_id") == unique_id] if len(matches) > 1: - raise ContractError(f"duplicate entity-registry entries for {unique_id}: {matches}") + raise TestHarnessError(f"duplicate entity-registry entries for {unique_id}: {matches}") return matches[0] if matches else None return wait_until(f"entity registry entry {unique_id}", find, timeout=timeout) @@ -235,11 +235,11 @@ class HomeAssistantClient: timeout=15, ) if not response.ok: - raise ContractError(f"service {domain}.{service} failed ({response.status_code}): {response.text}") + raise TestHarnessError(f"service {domain}.{service} failed ({response.status_code}): {response.text}") try: result = response.json() except ValueError as error: - raise ContractError(f"service {domain}.{service} returned invalid JSON: {error}") from error + raise TestHarnessError(f"service {domain}.{service} returned invalid JSON: {error}") from error if not isinstance(result, list): - raise ContractError(f"service {domain}.{service} returned unexpected JSON: {result}") + raise TestHarnessError(f"service {domain}.{service} returned unexpected JSON: {result}") return result diff --git a/test-support/ha-mqtt-contract/src/ha_mqtt_contract/mqtt.py b/test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/mqtt.py similarity index 87% rename from test-support/ha-mqtt-contract/src/ha_mqtt_contract/mqtt.py rename to test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/mqtt.py index cbeb520..fcc3d4c 100644 --- a/test-support/ha-mqtt-contract/src/ha_mqtt_contract/mqtt.py +++ b/test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/mqtt.py @@ -1,4 +1,4 @@ -"""MQTT publishing and observation helpers for retained-discovery contracts.""" +"""MQTT publishing and observation helpers for retained-discovery test harnesses.""" from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Any import paho.mqtt.client as mqtt -from .wait import ContractError, wait_until +from .wait import TestHarnessError, wait_until class _MqttClient: @@ -25,7 +25,7 @@ class _MqttClient: self._client.loop_start() if not self._connected.wait(timeout=15): self.close() - raise ContractError(f"MQTT client {client_prefix} did not connect within 15 seconds") + raise TestHarnessError(f"MQTT client {client_prefix} did not connect within 15 seconds") def _on_connect(self, _client: mqtt.Client, _userdata: Any, _flags: Any, reason_code: Any, _properties: Any) -> None: if reason_code == 0: @@ -40,13 +40,13 @@ class RetainedPublisher(_MqttClient): """A QoS-1 retained publisher used by schema/migration fixtures.""" def __init__(self, host: str, port: int): - super().__init__(host, port, client_prefix="contract-publisher") + super().__init__(host, port, client_prefix="test-harness-publisher") def publish(self, topic: str, payload: str, *, retain: bool = True) -> None: info = self._client.publish(topic, payload, qos=1, retain=retain) info.wait_for_publish(timeout=10) if not info.is_published(): - raise ContractError(f"MQTT publish timed out for {topic}") + raise TestHarnessError(f"MQTT publish timed out for {topic}") def retained_payload(self, topic: str, *, timeout: float = 30) -> str: received: list[str] = [] @@ -75,17 +75,17 @@ class MqttObserver(_MqttClient): self._messages: list[dict[str, Any]] = [] self._lock = threading.Lock() self._subscription_ack = threading.Event() - super().__init__(host, port, client_prefix="contract-observer") + super().__init__(host, port, client_prefix="test-harness-observer") self._client.on_message = self._on_message self._client.on_subscribe = self._on_subscribe for topic in topics: result, _mid = self._client.subscribe(topic, qos=1) if result != mqtt.MQTT_ERR_SUCCESS: self.close() - raise ContractError(f"unable to subscribe to MQTT topic {topic}") + raise TestHarnessError(f"unable to subscribe to MQTT topic {topic}") if not self._subscription_ack.wait(timeout=15): self.close() - raise ContractError(f"MQTT subscription to {topic} was not acknowledged within 15 seconds") + raise TestHarnessError(f"MQTT subscription to {topic} was not acknowledged within 15 seconds") self._subscription_ack.clear() def _on_subscribe( diff --git a/test-support/ha-mqtt-contract/src/ha_mqtt_contract/wait.py b/test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/wait.py similarity index 75% rename from test-support/ha-mqtt-contract/src/ha_mqtt_contract/wait.py rename to test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/wait.py index aabe439..0d22c84 100644 --- a/test-support/ha-mqtt-contract/src/ha_mqtt_contract/wait.py +++ b/test-support/ha-mqtt-test-harness/src/ha_mqtt_test_harness/wait.py @@ -1,4 +1,4 @@ -"""Bounded wait helpers shared by integration contracts.""" +"""Bounded wait helpers shared by integration test harnesses.""" from __future__ import annotations @@ -7,8 +7,8 @@ from collections.abc import Callable from typing import TypeVar -class ContractError(RuntimeError): - """An external Home Assistant or MQTT contract could not be satisfied.""" +class TestHarnessError(RuntimeError): + """An external Home Assistant or MQTT test harness could not be satisfied.""" T = TypeVar("T") @@ -33,4 +33,4 @@ def wait_until( last_error = error time.sleep(interval) suffix = f" (last error: {last_error})" if last_error else "" - raise ContractError(f"timed out waiting for {description}{suffix}") + raise TestHarnessError(f"timed out waiting for {description}{suffix}") diff --git a/tests/ha-contract/Dockerfile b/tests/ha-contract/Dockerfile deleted file mode 100644 index d895c47..0000000 --- a/tests/ha-contract/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /tests -COPY test-support/ha-mqtt-contract /opt/ha-mqtt-contract -COPY tests/ha-contract/requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -RUN pip install --no-cache-dir /opt/ha-mqtt-contract -COPY tests/ha-contract/test_contract.py . - -CMD ["python", "test_contract.py"] diff --git a/tests/ha-test-harness/Dockerfile b/tests/ha-test-harness/Dockerfile new file mode 100644 index 0000000..29a6acb --- /dev/null +++ b/tests/ha-test-harness/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /tests +COPY test-support/ha-mqtt-test-harness /opt/ha-mqtt-test-harness +COPY tests/ha-test-harness/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir /opt/ha-mqtt-test-harness +COPY tests/ha-test-harness/test_harness.py . + +CMD ["python", "test_harness.py"] diff --git a/tests/ha-contract/README.md b/tests/ha-test-harness/README.md similarity index 65% rename from tests/ha-contract/README.md rename to tests/ha-test-harness/README.md index 1585fd9..36561f8 100644 --- a/tests/ha-contract/README.md +++ b/tests/ha-test-harness/README.md @@ -1,4 +1,4 @@ -# Home Assistant MQTT contract tests +# Home Assistant MQTT test harness tests This harness runs retained MQTT discovery messages through Mosquitto and a real Home Assistant container. It verifies the behavior that firmware unit tests @@ -23,11 +23,11 @@ device-component tombstone/omission sequence, and makes the current From the repository root: ```bash -HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml up -d mqtt homeassistant -HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml run --rm tests -HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml restart homeassistant -CONTRACT_MODE=retained-restart HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml run --rm tests -HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml down -v +HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml up -d mqtt homeassistant +HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml run --rm tests +HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml restart homeassistant +TEST_HARNESS_MODE=retained-restart HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml run --rm tests +HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml down -v ``` Use `HA_VERSION=stable` and `HA_VERSION=dev` for the current supported and @@ -38,7 +38,7 @@ as a compatibility audit. When it is on the repository's default branch, GitHub exposes it for manual dispatch and the weekly schedule. It is not a tag-release gate. -For a local development-image test, add `CONTRACT_EXPECT_DISABLED_CLEANUP=1` to +For a local development-image test, add `TEST_HARNESS_EXPECT_DISABLED_CLEANUP=1` to both `docker compose run ... tests` commands. That lane checks that initially disabled device components are cleaned. The test uses only ephemeral named volumes; `down -v` removes its broker data, Home Assistant config, owner token, and @@ -46,12 +46,12 @@ registry state. ## Shared support -The schema-neutral [HA/MQTT contract testkit](../../test-support/ha-mqtt-contract/README.md) owns only Docker-side Home Assistant onboarding, MQTT transport, registry/service access, retries, and artifact helpers. It has no ArduinoHA discovery assertions. This suite owns ArduinoHA migration fixtures; DeviceFramework carries its own fixtures and hardware adapter while reusing the testkit. +The schema-neutral [HA/MQTT test harness testkit](../../test-support/ha-mqtt-test-harness/README.md) owns only Docker-side Home Assistant onboarding, MQTT transport, registry/service access, retries, and artifact helpers. It has no ArduinoHA discovery assertions. This suite owns ArduinoHA migration fixtures; DeviceFramework carries its own fixtures and hardware adapter while reusing the testkit. ## Scope The firmware's native Unity suite covers JSON escaping, invalid topic tokens, serializer preflight, migration ordering, component removal, and lifecycle -behavior. This container suite covers Home Assistant's persistence contract. It +behavior. This container suite covers Home Assistant's persistence test harness. It does not need a physical board: fixture discovery documents mirror retained payloads emitted by ArduinoHA and isolate Home Assistant/MQTT compatibility. diff --git a/tests/ha-contract/compose.yaml b/tests/ha-test-harness/compose.yaml similarity index 79% rename from tests/ha-contract/compose.yaml rename to tests/ha-test-harness/compose.yaml index 9871925..515098a 100644 --- a/tests/ha-contract/compose.yaml +++ b/tests/ha-test-harness/compose.yaml @@ -24,15 +24,15 @@ services: tests: build: context: ../.. - dockerfile: tests/ha-contract/Dockerfile + dockerfile: tests/ha-test-harness/Dockerfile environment: HA_URL: http://homeassistant:8123 MQTT_HOST: mqtt MQTT_PORT: "1883" - CONTRACT_MODE: ${CONTRACT_MODE:-migration} - CONTRACT_EXPECT_DISABLED_CLEANUP: ${CONTRACT_EXPECT_DISABLED_CLEANUP:-0} + TEST_HARNESS_MODE: ${TEST_HARNESS_MODE:-migration} + TEST_HARNESS_EXPECT_DISABLED_CLEANUP: ${TEST_HARNESS_EXPECT_DISABLED_CLEANUP:-0} volumes: - - contract_state:/state + - test_harness_state:/state depends_on: mqtt: condition: service_healthy @@ -41,4 +41,4 @@ services: volumes: ha_config: - contract_state: + test_harness_state: diff --git a/tests/ha-contract/configuration.yaml b/tests/ha-test-harness/configuration.yaml similarity index 100% rename from tests/ha-contract/configuration.yaml rename to tests/ha-test-harness/configuration.yaml diff --git a/tests/ha-contract/mosquitto.conf b/tests/ha-test-harness/mosquitto.conf similarity index 100% rename from tests/ha-contract/mosquitto.conf rename to tests/ha-test-harness/mosquitto.conf diff --git a/tests/ha-contract/requirements.txt b/tests/ha-test-harness/requirements.txt similarity index 100% rename from tests/ha-contract/requirements.txt rename to tests/ha-test-harness/requirements.txt diff --git a/tests/ha-contract/test_contract.py b/tests/ha-test-harness/test_harness.py similarity index 86% rename from tests/ha-contract/test_contract.py rename to tests/ha-test-harness/test_harness.py index ef960b1..f3438e8 100644 --- a/tests/ha-contract/test_contract.py +++ b/tests/ha-test-harness/test_harness.py @@ -1,4 +1,4 @@ -"""Home Assistant MQTT discovery contract checks. +"""Home Assistant MQTT discovery test harness checks. The test intentionally uses retained MQTT messages, like a deployed firmware node. It inspects HA's entity registry over the authenticated WebSocket API so @@ -11,8 +11,8 @@ import os import pathlib import time -from ha_mqtt_contract import ( - ContractError, +from ha_mqtt_test_harness import ( + TestHarnessError, HomeAssistantClient, RetainedPublisher as SharedRetainedPublisher, wait_until, @@ -22,16 +22,16 @@ from ha_mqtt_contract import ( HA_URL = os.environ.get("HA_URL", "http://homeassistant:8123").rstrip("/") MQTT_HOST = os.environ.get("MQTT_HOST", "mqtt") MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) -MODE = os.environ.get("CONTRACT_MODE", "migration") +MODE = os.environ.get("TEST_HARNESS_MODE", "migration") STATE = pathlib.Path("/state") TOKEN_FILE = STATE / "ha-token" -DEVICE_ID = "contract_device" -EDGE_DEVICE_ID = "contract_edge_device" -EXPECT_DISABLED_CLEANUP = os.environ.get("CONTRACT_EXPECT_DISABLED_CLEANUP") == "1" +DEVICE_ID = "test_harness_device" +EDGE_DEVICE_ID = "test_harness_edge_device" +EXPECT_DISABLED_CLEANUP = os.environ.get("TEST_HARNESS_EXPECT_DISABLED_CLEANUP") == "1" def fail(message): - raise ContractError(message) + raise TestHarnessError(message) def legacy_topic(object_id, device_id=DEVICE_ID): @@ -47,7 +47,7 @@ def component(object_id, unique_id=None, device_id=DEVICE_ID, **extra): "p": "sensor", "name": object_id.replace("_", " ").title(), "uniq_id": unique_id or f"{device_id}_{object_id}", - "stat_t": f"contract/{object_id}/state", + "stat_t": f"test-harness/{object_id}/state", } payload.update(extra) return payload @@ -55,15 +55,15 @@ def component(object_id, unique_id=None, device_id=DEVICE_ID, **extra): def device_payload(components, device_id=DEVICE_ID): return { - "dev": {"ids": [device_id], "name": "ArduinoHA contract device"}, - "o": {"name": "ArduinoHA", "sw": "contract"}, + "dev": {"ids": [device_id], "name": "ArduinoHA test harness device"}, + "o": {"name": "ArduinoHA", "sw": "test harness"}, "cmps": components, } def legacy_payload(object_id, unique_id=None, device_id=DEVICE_ID, **extra): payload = component(object_id, unique_id, **extra) - payload["dev"] = {"ids": [device_id], "name": "ArduinoHA contract device"} + payload["dev"] = {"ids": [device_id], "name": "ArduinoHA test harness device"} payload.pop("p") return payload @@ -83,24 +83,24 @@ def wait_for_entry(ws, unique_id): ) -def migration_contract(): +def migration_test_harness(): publisher = SharedRetainedPublisher(MQTT_HOST, MQTT_PORT) ws = HomeAssistantClient.bootstrap( HA_URL, STATE, - owner_name="ArduinoHA Contract Owner", - username="arduinoha-contract", - password="arduinoha-contract-password", + owner_name="ArduinoHA Test Harness Owner", + username="arduinoha-test-harness", + password="arduinoha-test-harness-password", ) ws.configure_mqtt(MQTT_HOST, MQTT_PORT) try: # Existing single-component entity and a user-owned registry customization. unique = f"{DEVICE_ID}_temperature" publisher.publish(legacy_topic("temperature"), json.dumps(legacy_payload("temperature", unique))) - publisher.publish("contract/temperature/state", "21.5") + publisher.publish("test-harness/temperature/state", "21.5") original = wait_for_entry(ws, unique) original_id = original["id"] - renamed_entity_id = "sensor.contract_temperature_user_name" + renamed_entity_id = "sensor.test_harness_temperature_user_name" ws.call( "config/entity_registry/update", entity_id=original["entity_id"], @@ -149,7 +149,7 @@ def migration_contract(): # Direct publication is deliberately not a migration protocol. It must not # create a second registry entry for the same stable unique ID. Keep it # on a separate device topic so it cannot invalidate the primary - # retained-payload/restart contract. + # retained-payload/restart test harness. direct_unique = f"{EDGE_DEVICE_ID}_direct" publisher.publish( legacy_topic("direct", EDGE_DEVICE_ID), @@ -233,11 +233,11 @@ def migration_contract(): if find_unique(ws.registry_entries(), f"{DEVICE_ID}_malformed"): fail("malformed discovery payload created an entity") - # This fixture documents the current HA field contract: def_ent_id is + # This fixture documents the current HA field behaviour: def_ent_id is # allowed on first creation; obsolete obj_id is intentionally absent. - default_payload = legacy_payload("default_name", def_ent_id="contract_default_name") + default_payload = legacy_payload("default_name", def_ent_id="test_harness_default_name") if "obj_id" in default_payload: - fail("contract fixture accidentally contains obsolete obj_id") + fail("test harness fixture accidentally contains obsolete obj_id") publisher.publish(legacy_topic("default_name"), json.dumps(default_payload)) wait_for_entry(ws, f"{DEVICE_ID}_default_name") @@ -248,14 +248,14 @@ def migration_contract(): publisher.close() -def retained_restart_contract(): +def retained_restart_test_harness(): if not (STATE / "migration-complete").exists(): - fail("retained-restart mode requires the migration contract to run first") + fail("retained-restart mode requires the migration test harness to run first") publisher = SharedRetainedPublisher(MQTT_HOST, MQTT_PORT) ws = HomeAssistantClient.from_state(HA_URL, STATE) try: migrated = wait_for_entry(ws, f"{DEVICE_ID}_temperature") - if migrated["entity_id"] != "sensor.contract_temperature_user_name": + if migrated["entity_id"] != "sensor.test_harness_temperature_user_name": fail("HA restart lost the user-owned entity rename") retained = json.loads(publisher.retained_payload(device_topic())) if "cmps" not in retained: @@ -268,12 +268,12 @@ def retained_restart_contract(): def main(): HomeAssistantClient.wait_until_ready(HA_URL) if MODE == "migration": - migration_contract() + migration_test_harness() elif MODE == "retained-restart": - retained_restart_contract() + retained_restart_test_harness() else: - fail(f"unknown CONTRACT_MODE: {MODE}") - print(f"Home Assistant MQTT contract mode {MODE} passed") + fail(f"unknown TEST_HARNESS_MODE: {MODE}") + print(f"Home Assistant MQTT test harness mode {MODE} passed") if __name__ == "__main__":