# Quality gate: lint + unit tests + the HA API-drift sweep. # # The test suite is stdlib-only (parsers, intents, running-apps) and runs the # vendored ha_adb_common helpers without installing Home Assistant — see # tests/conftest.py. androidtvremote2 is installed so the real-library # volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real # firmware captures live in tests/fixtures # (how to capture more: docs/RESEARCH_SHIELD.md §8). # # Home Assistant is installed in exactly two jobs: `ha-api-drift` (below) and # `real-ha-config-flow`. Both exist because every other job validates our code # against hand-written stubs, which stay green even when a call site no longer # matches the real API. That is how three drifts reached master (t36 D1/D2/D3) — # see tests/ha_api_drift.py. name: Test on: push: branches: [master] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install test tooling run: | set -euo pipefail # Try the plain install first: it succeeds on images whose Python is # not externally managed (and on pip < 23, which has no PEP 668 # enforcement at all). When the runner image DOES mark its Python as # externally managed — Debian/Ubuntu based Gitea act images do — pip # refuses with externally-managed-environment and we retry with the # sanctioned override. That override is safe here specifically # because the job runs in a disposable container, and because the # flag only exists on pip >= 23.0.1, the same version that raises # the refusal in the first place. # # Tools are pinned to the versions the suite was validated against so # a newer ruff rule set cannot redden the gate on unrelated commits; # androidtvremote2 tracks the manifest floor so CI exercises the same # dependency users install. PKGS="pytest~=9.1 pytest-asyncio~=1.4 ruff~=0.17.0 'androidtvremote2>=0.2.0'" if ! eval python3 -m pip install --quiet $PKGS; then echo "system Python is externally managed; retrying with --break-system-packages" eval python3 -m pip install --quiet --break-system-packages $PKGS fi - name: Lint run: ruff check custom_components tests - name: Unit tests run: python3 -m pytest tests -q # Resolve every Home-Assistant API use in our production modules against a # real install: imports, keyword arguments and await/sync mismatches. The # sweep is repo-owned (tests/ha_api_drift.py) and skips cleanly when # Home Assistant is absent, so it is inert in the job above and live here. ha-api-drift: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Provision the interpreter Home Assistant needs uses: actions/setup-python@v5 with: # Home Assistant 2026.10.1 declares Requires-Python >= 3.14.2 (PyPI # metadata; tests/ha_api_drift.py holds our copy of that number and the # drift suite pins it against the installed distribution), so the # runner's own python3 cannot install the version this gate must run # against. This resolves the newest 3.14.x patch; the guard below # refuses anything still short of the floor rather than letting the # install fail halfway through. python-version: "3.14" - name: Check the interpreter satisfies Home Assistant's floor run: | set -euo pipefail # The floor comes from tests/ha_api_drift.py — one source of truth for # this guard and the metadata pin in tests/test_ha_api_drift.py. python3 - <<'PY' import sys from pathlib import Path sys.path.insert(0, str(Path("tests").resolve())) from ha_api_drift import HOMEASSISTANT_PYTHON_FLOOR as FLOOR # noqa: E402 needed = ".".join(str(part) for part in FLOOR) if sys.version_info < FLOOR: print( f"Home Assistant needs Python >= {needed}, but this job runs " f"{sys.version.split()[0]}." ) print(f"Fix: raise actions/setup-python in this job to a >= {needed} build.") raise SystemExit(1) print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}") PY - name: Install Home Assistant + test tooling run: | set -euo pipefail # Same PEP 668 dance as the test job (see the note there). The # version floor tracks what we validate against; a resolver failure # here is a real signal (Python too old), not something to swallow. PKGS="pytest~=9.1 pytest-asyncio~=1.4 'homeassistant>=2026.10'" if ! eval python3 -m pip install --quiet $PKGS; then echo "system Python is externally managed; retrying with --break-system-packages" eval python3 -m pip install --quiet --break-system-packages $PKGS fi - name: HA API-drift sweep run: python3 -m pytest tests/test_ha_api_drift.py -q # The real-HA tests are the ONLY place the integration meets the installed Home # Assistant — and EVERY one of them SKIPS ITSELF at import time when # homeassistant is absent (pytest.importorskip at module scope). A skipped file # and a passing one are indistinguishable in the jobs above: `python3 -m pytest # tests -q` ends in "N passed, 3 skipped" and still exits 0, and the drift sweep # never touches them. That is exactly how three real-HA-only facts stayed hidden # while five gates were green: # # * a menu row id must equal its async_step_ method name (otherwise the # real FlowManager raises when the row is dispatched), # * the rendered step_id must equal the row id, and # * async_show_menu takes no errors kwarg. # # t14 added a second file, and with it the reason this job GLOBS instead of # naming one: tests/test_adb_command_real_ha.py proves the adb_command action is # SELECTABLE (the runtime service description carries the entity target the # frontend builds its picker from — `async_register` has no target parameter) and # that a real async_call returns the command's output, and it also caught an # `async_entries_for_device` that is a MODULE-LEVEL helper, not a registry # method. A named file list would have left it skipping silently here. The # pattern `tests/test_*real_ha.py` is the rule: a new real-HA file is picked up # by virtue of existing, and the files that ran are printed. # # So this job does two things the others cannot: it installs the real stack on # Home Assistant's interpreter floor, and it FAILS LOUDLY if the tests are # skipped instead of run (or if the pattern matches nothing). Do not "simplify" # the guard at the bottom away — a test file that skips silently is # indistinguishable from one that passes. real-ha-config-flow: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Provision the interpreter Home Assistant needs uses: actions/setup-python@v5 with: # Same floor as the ha-api-drift job: Home Assistant 2026.10.1 # declares Requires-Python >= 3.14.2 (tests/ha_api_drift.py holds our # copy of that number). This resolves the newest 3.14.x patch. python-version: "3.14" - name: Ensure uv is available run: | set -euo pipefail # uvx runs the exact command this gate was validated with locally; # runner images are not guaranteed to ship it. if ! command -v uvx >/dev/null 2>&1; then python3 -m pip install --quiet uv \ || python3 -m pip install --quiet --break-system-packages uv fi uvx --version - name: Check the interpreter satisfies Home Assistant's floor run: | set -euo pipefail # The floor comes from tests/ha_api_drift.py — one source of truth for # this guard and the metadata pin in tests/test_ha_api_drift.py. uvx # resolves the same interpreter the test step will use, so a too-old # interpreter fails here rather than halfway through the install. uvx --python 3.14 python - <<'PY' import sys from pathlib import Path sys.path.insert(0, str(Path("tests").resolve())) from ha_api_drift import HOMEASSISTANT_PYTHON_FLOOR as FLOOR # noqa: E402 needed = ".".join(str(part) for part in FLOOR) if sys.version_info < FLOOR: print( f"Home Assistant needs Python >= {needed}, but this job runs " f"{sys.version.split()[0]}." ) raise SystemExit(1) print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}") PY - name: Real-HA tests run: | set -euo pipefail LOG=/tmp/real-ha.log # THE PATTERN, not a filename: every tests/test_*real_ha.py runs, so a new # real-HA file cannot skip silently merely because nobody listed it here. # An empty match is a failure too — but it is checked first so the log says # why instead of blaming pytest's argument parsing. shopt -s nullglob FILES=(tests/test_*real_ha.py) shopt -u nullglob if [ "${#FILES[@]}" -eq 0 ]; then echo "::error::no tests/test_*real_ha.py file exists — this gate would pass vacuously" exit 1 fi echo "real-HA files collected (${#FILES[@]}): ${FILES[*]}" # Verbatim the command this gate is validated with locally; -rs is what # makes a silent skip visible in the log. set +e uvx --python 3.14 --with 'homeassistant>=2026.10' --with adb-shell \ --with pytest-asyncio \ pytest "${FILES[@]}" -q -rs 2>&1 | tee "$LOG" rc=${PIPESTATUS[0]} set -e if [ "$rc" -ne 0 ]; then echo "::error::the real-HA tests exited $rc" exit "$rc" fi # THE GUARD. Some pytest versions exit 0 for a file that skipped # itself, so the exit code above is not enough. The last line of a -q # run is the counts line ("50 passed in 1.6s" / "1 skipped in # 0.01s" / "no tests ran in 0.01s"); it must report PASSED tests and # nothing else — a skip, a failure and a zero-collection run all fail # here, which is what makes the glob safe to widen. summary=$(grep -v '^[[:space:]]*$' "$LOG" | tail -n 1) echo "pytest summary: $summary" case "$summary" in [0-9]*" passed"*) ;; *) echo "::error::the real-HA tests did not run (summary: $summary)" echo "These files skip themselves when homeassistant is missing — a skip here is a FAILURE, not a pass." exit 1 ;; esac case "$summary" in *skipped*|*failed*|*error*|*"no tests ran"*) echo "::error::the real-HA run was not a clean pass (summary: $summary)" exit 1 ;; esac