The user's live run could not select his Shield for
nvidia_shield.adb_command, so he ran his diagnostics through the deprecated
androidtv integration instead. Measured against the installed HA (2026.10.1),
the cause is a contract, not a bug in the handler:
* ServiceRegistry.async_register has NO target parameter, so a target cannot be
declared at registration;
* the frontend renders the target picker from the service DESCRIPTION, which
HA builds from services.yaml or from the runtime async_set_service_schema —
and the runtime description WINS.
So async_setup now registers with supports_response=OPTIONAL and publishes
ADB_COMMAND_SERVICE_DESCRIPTION (target: {entity: [{domain: media_player}]},
fields, prose mirrored in strings.json/translations/en.json and pinned against
both by a test). No services.yaml is needed, and none is added.
The target resolution is strict, because the failure mode of guessing is a raw
shell command on the wrong device: exactly one PARTICIPANT, told apart as
no_target (nothing selected), unsupported_target (not a participant / option
off / another integration's entity) and ambiguous_target (two selected — sent
to neither). Accepts what HA actually sends: entity_id as text or as the LIST a
picked target produces, and device_id (an automation targeting the DEVICE),
expanded only through this integration's own registry entries and only to media
players. The output now comes back in the response under adb_response — the key
the user's captures read — while an unreachable Shield stays adb_unavailable,
an error rather than an empty response, and a call that does not ask for a
response is unchanged.
Evidence: 12 real-HA tests (new tests/test_adb_command_real_ha.py) drive the
real service registry, the real get_services websocket payload (the exact JSON
bytes the browser parses: target present, response optional, both fields) and
real async_call for every shape above. t14's CI gate globs tests/test_*real_ha.py
so a new real-HA file cannot skip silently; the skip/empty-collection guards stay
and were executed. The real-HA probe also caught async_entries_for_device being
a MODULE-LEVEL helper (not a registry method) that the stub had hidden, so the
drift gate's owner map now covers async_register, async_set_service_schema and
async_entries_for_device, with the call-argument floor raised 44 -> 52.
Verify: suites 681/4 and 682/3; real-HA 38 + the new file 12 (50 with the glob);
ruff clean; drift exit 0 (54 call arguments).
244 lines
11 KiB
YAML
244 lines
11 KiB
YAML
# 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_<id> 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
|