"""Run the Home-Assistant API-drift sweep (t37). Skips cleanly when homeassistant is not installed — every other test file runs against hand-written stubs, and this is the one check that must see the real package. CI's `ha-api-drift` job installs it: uvx --with homeassistant --with pytest-asyncio pytest tests/test_ha_api_drift.py -q The sweep itself lives in ``tests/ha_api_drift.py`` (documented there). """ from __future__ import annotations import ast import asyncio import importlib import json import subprocess import sys import threading from pathlib import Path from types import SimpleNamespace import ha_api_drift import pytest pytest.importorskip( "homeassistant", reason="the API-drift sweep resolves our call sites against a real install", ) #: The three t36 drifts plus the `options_updates` kwarg t35 removed — the #: sweep must flag every one of them, or it cannot protect us from the next. PRE_FIX_SOURCES = { "d1_browse_media_source.py": ( "from homeassistant.components.media_player import BrowseMediaSource\n" ), "d2_util_diagnostic.py": ( "from homeassistant.util.diagnostic import async_redact_data\n" ), "d3_unawaited_discovery.py": ( "from homeassistant import config_entries\n" "\n" "\n" "class PreFixFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n" " async def async_step_zeroconf(self, discovery_info):\n" " self._async_handle_discovery_without_unique_id()\n" ), # t57 UNIT 0a: a wrong kwarg on a FLOW-DISPLAY API. `async_show_menu` takes # no `errors` in HA 2026.10.1, and the integration passed one — so every # ordinary setup path raised TypeError at the moment a user submitted the # first form, which is invisible to a stub suite whose stubs accept it. "t57_show_menu_errors.py": ( "from homeassistant import config_entries\n" "\n" "\n" "class PreFixSetupMenu(config_entries.ConfigFlow, domain='nvidia_shield'):\n" " async def async_step_user(self, user_input=None):\n" " return self.async_show_menu(\n" " step_id='menu',\n" " menu_options=['finish'],\n" " errors={\'base\': \'nope\'},\n" " )\n" ), "t35_options_updates.py": ( "from homeassistant import config_entries\n" "\n" "\n" "class PreFixReauthFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n" " async def async_step_reauth_confirm(self, user_input=None):\n" " self.async_update_reload_and_abort(\n" " self.entry, options_updates={'protocol_enabled': True}\n" " )\n" " return self.async_abort(reason='reauth_successful')\n" ), } def _require_installed_homeassistant(): """Skip when sys.modules holds a test stub rather than an install.""" import sys module = sys.modules["homeassistant"] path = getattr(module, "__file__", None) if path is None or not Path(path).exists(): pytest.skip("homeassistant in sys.modules is a test stub, not an install") def test_no_api_drift_in_the_production_tree(): """The gate: every HA import, kwarg and await matches the real package.""" _require_installed_homeassistant() report = ha_api_drift.scan() assert report.findings == [], "\n" + str(report) def test_the_sweep_is_not_vacuous(): """The counters prove the walk really covered the production tree.""" _require_installed_homeassistant() report = ha_api_drift.scan() # Floors, not targets: the numbers live in ha_api_drift.FLOORS — one source # of truth for this wrapper and the standalone entry point (t39 F1) — so a # broken or mis-targeted walk fails here instead of looking green. violations = report.floor_violations() assert violations == [], "\n".join(violations) def test_the_sweep_flags_the_pre_fix_defects(tmp_path): """Red proof: the t36 drifts and the options_updates kwarg are all caught.""" _require_installed_homeassistant() for name, source in PRE_FIX_SOURCES.items(): (tmp_path / name).write_text(source, encoding="utf-8") report = ha_api_drift.scan(tmp_path) found = {(finding.path, finding.kind) for finding in report.findings} assert found == { ("d1_browse_media_source.py", "broken-import"), ("d2_util_diagnostic.py", "broken-import"), ("d3_unawaited_discovery.py", "missing-await"), ("t35_options_updates.py", "unknown-kwarg"), ("t57_show_menu_errors.py", "unknown-kwarg"), }, "\n" + str(report) def test_the_sweep_accepts_the_fixed_forms(tmp_path): """The same call sites written the way production writes them are clean.""" _require_installed_homeassistant() (tmp_path / "fixed.py").write_text( "from homeassistant.components.media_player import BrowseMedia\n" "from homeassistant.helpers.redact import async_redact_data\n" "from homeassistant import config_entries\n" "\n" "\n" "class FixedFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n" " async def async_step_zeroconf(self, discovery_info):\n" " await self._async_handle_discovery_without_unique_id()\n" "\n" " def finish(self):\n" " return self.async_update_reload_and_abort(\n" " self.entry, options={'protocol_enabled': True}\n" " )\n", encoding="utf-8", ) report = ha_api_drift.scan(tmp_path) assert report.findings == [], "\n" + str(report) def _run_drift_module(*paths): """Run the standalone entry point in this (HA-bearing) interpreter.""" return subprocess.run( [ sys.executable, str(Path(ha_api_drift.__file__).resolve()), *[str(path) for path in paths], ], capture_output=True, text=True, check=False, ) def test_the_standalone_entry_point_passes_on_the_production_tree(): """F1: the CLI is the sweep, with the same verdict on the real tree.""" result = _run_drift_module() assert result.returncode == 0, result.stderr assert "no Home-Assistant API drift" in result.stdout def test_the_standalone_entry_point_fails_on_an_empty_directory(tmp_path): """t39 F1: zeros must not exit 0 — the floors apply to the CLI too.""" result = _run_drift_module(tmp_path) assert result.returncode != 0 assert "0 modules" in result.stdout # the counter that used to look green assert "FLOOR:" in result.stderr assert "drift sweep FAILED" in result.stderr def test_the_standalone_entry_point_fails_on_a_missing_path(tmp_path): """A mis-specified path is a failure, not a silent zero-module sweep.""" result = _run_drift_module(tmp_path / "not-a-directory") assert result.returncode != 0 assert "bad-path" in result.stdout def test_no_floor_constants_are_duplicated_in_this_wrapper(): """F1: the floors come from ha_api_drift.FLOORS — nowhere else.""" source = Path(__file__).read_text(encoding="utf-8") tree = ast.parse(source) local_ints = [ target.id for node in tree.body if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, int) ] assert local_ints == [], f"floor-like constants belong in ha_api_drift: {local_ints}" assert "floor_violations" in source def test_the_declared_python_floor_never_lags_homeassistant(): """F2: the CI guard's floor is HA's own Requires-Python, not a guess.""" _require_installed_homeassistant() import importlib.metadata as metadata declared = metadata.metadata("homeassistant")["Requires-Python"] parsed = ha_api_drift.parse_requires_python(declared) assert parsed is not None, f"unparsable Requires-Python: {declared!r}" assert tuple(parsed) <= ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR, ( f"tests/ha_api_drift.py declares {ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR} " f"but the installed homeassistant requires {declared} — raise the constant " f"and the setup-python version in .gitea/workflows/test.yml" ) # ------------------------------------------------- entity contract (t40, real HA) # # The stub suites cannot see this class of defect: their harness replaces # ``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks # fine there. Only HA's own ``Entity.__async_calculate_state`` — which does # ``attr |= self.extra_state_attributes`` — turns it into # ``TypeError: 'method' object is not iterable`` and stops the entity from ever # rendering. These tests drive that real path. _REPO_ROOT = Path(__file__).resolve().parent.parent class _RecordingStates: """Minimal ``hass.states``: keeps what HA's entity writes.""" def __init__(self): self.registry: dict = {} self.written: list = [] def get(self, entity_id): return self.registry.get(entity_id) def async_set_internal(self, entity_id, state, attr, *args): self.written.append((entity_id, state, dict(attr))) def _real_ha_entity(module_name, class_name, *, options=None, entity_id=None): """Build a production entity under real homeassistant (no platform).""" if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) module = importlib.import_module(f"custom_components.nvidia_shield.{module_name}") states = _RecordingStates() hass = SimpleNamespace( loop_thread_id=threading.get_ident(), states=states, data={}, config=SimpleNamespace(components=set()), # t66: ``ImageEntity.__init__`` builds HA's shared httpx client, which # registers a shutdown listener on ``hass.bus``. The fake hass carries # just enough of that surface for the real base class to construct. bus=SimpleNamespace(async_listen_once=lambda *args, **kwargs: (lambda: None)), ) entry = SimpleNamespace( entry_id="e1", data={}, options=dict(options or {}), title="Shield" ) entity = getattr(module, class_name)(hass, entry) entity.entity_id = entity_id # HA's real translation-key paths (``unit_of_measurement``, device-class # names) need the platform's translation data; without it a translation-keyed # entity raises "cannot have a translation key ... before being added to the # entity platform". PlatformData is the real container, so the entity is # exercised with the same shape it sees in production. from homeassistant.helpers.entity_platform import PlatformData entity.platform_data = PlatformData( hass, domain=module_name, platform_name="nvidia_shield" ) # Not added through an EntityComponent: HA would warn once via # _suggest_report_issue (which needs hass.data["integrations"]) — set the # flag it uses to report that once instead of faking a whole platform. entity._no_platform_reported = True return entity, states def _write_ha_state(entity, states): """Run HA's real state write and return what reached the state machine.""" entity.async_write_ha_state() assert states.written, f"{type(entity).__name__} wrote no state" return states.written[-1] #: Every concrete entity class in the integration, exercised above against the #: REAL state machine. t40: the media player's defect was a state-write crash, so #: "the whole tree renders" is the contract under test, not a single entity. _REAL_HA_MATRIX = ( ("media_player", "NvidiaShieldMediaPlayer", None, "media_player.shield"), ("sensor", "NvidiaShieldCurrentAppSensor", None, "sensor.shield_current_app"), ( "sensor", "NvidiaShieldNowPlayingSensor", {"jellyfin_entity": "media_player.jellyfin"}, "sensor.shield_now_playing", ), ( "sensor", "NvidiaShieldAppsSensor", {"adb_enabled": True}, "sensor.shield_installed_apps", ), ("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"), ("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"), ( "image", "NvidiaShieldScreenImage", {"screen_capture_enabled": True}, "image.shield_screen_capture", ), ) #: Classified as an entity by the sweep, but an abstract intermediate base HA #: never instantiates — its concrete subclasses are in the matrix. _INTERMEDIATE_BASES = frozenset({"NvidiaShieldSensorBase"}) @pytest.mark.parametrize( ("module_name", "class_name", "options", "entity_id"), _REAL_HA_MATRIX ) def test_entities_calculate_state_under_real_homeassistant( module_name, class_name, options, entity_id ): """t40 blocker: HA's own state write must not raise for ANY entity. ``Entity.async_write_ha_state`` → ``__async_calculate_state`` merges the extra attributes with ``attr |= ...``; the pre-fix media player raised ``TypeError: 'method' object is not iterable`` here, so it could never render. """ _require_installed_homeassistant() entity, states = _real_ha_entity( module_name, class_name, options=options, entity_id=entity_id ) written_id, state, attrs = _write_ha_state(entity, states) assert written_id == entity_id assert isinstance(state, str) assert isinstance(attrs, dict) def test_the_media_player_extra_attributes_reach_the_state_machine(): """The property's payload must survive HA's merge, not merely not raise.""" _require_installed_homeassistant() entity, states = _real_ha_entity( "media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield" ) _entity_id, _state, attrs = _write_ha_state(entity, states) # t15: ``app_source`` names the tier that produced the app identity, so the # protocol push can never look like the ADB reading it outranked. assert {"app_id", "app_name", "app_source"} <= set(attrs), sorted(attrs) def test_the_remote_entity_publishes_the_activity_contract_under_real_ha(): """t15: the app/activity picker contract, measured against real HA. Core androidtv_remote's remote is the parity target. HA renders the picker from ``RemoteEntityFeature.ACTIVITY`` plus ``activity_list``/``current_activity``, published through the base class's FINAL ``state_attributes`` — so this asserts what the STATE MACHINE receives, not what our own attributes happen to say. The transport evidence rides the same entity, which is what makes "paired?" and "switched on?" answerable without a device test. """ _require_installed_homeassistant() from homeassistant.components.remote import RemoteEntityFeature entity, states = _real_ha_entity( "remote", "NvidiaShieldRemoteEntity", options={ "app_name_map": { "com.netflix.ninja": "Netflix", "ar.tvplayer.tv": "TiviMate", } }, entity_id="remote.shield", ) assert entity.supported_features == RemoteEntityFeature.ACTIVITY # exactly core's assert entity.activity_list == ["Netflix", "TiviMate"] assert entity.current_activity is None, "nothing is known yet — never fabricated" _entity_id, _state, attrs = _write_ha_state(entity, states) assert attrs["activity_list"] == ["Netflix", "TiviMate"] assert attrs["current_activity"] is None # ...and the transport's real state, on the same entity (no runtime_data here, # so there is no transport at all — which must read as "off", not as unpaired) assert attrs["protocol_state"] == "off" assert attrs["protocol_connected"] is False assert attrs["protocol_ever_authenticated"] is False assert attrs["power_on_route"] == "protocol" assert attrs["protocol_current_app_seen"] is False # The LIVE update paths our entity uses: HA's own metaclass turns the class-level # ``_attr_*`` declarations into properties backed by ``__attr_*`` plus cache # invalidation, so both assignments must take effect on the published state. A # private attribute name would leave ``activity_list``/``current_activity`` frozen # at their first value — silent in a unit test, wrong in a dashboard. entity._attr_current_activity = "Netflix" entity._attr_activity_list = ["Netflix", "TiviMate", "YouTube"] assert entity.current_activity == "Netflix" assert entity.activity_list == ["Netflix", "TiviMate", "YouTube"] _entity_id, _state, attrs = _write_ha_state(entity, states) assert attrs["current_activity"] == "Netflix" assert attrs["activity_list"] == ["Netflix", "TiviMate", "YouTube"] #: The same real 2x1 PNG fixture tests/test_screen_capture_image.py uses (the #: wire form ``screencap -p | base64`` produces) — a real PNG, not a stand-in. _SCREEN_PNG_B64 = ( "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/" "Af+4BDXgAAAAAElFTkSuQmCC" ) def test_the_screen_capture_entity_serves_png_bytes_under_real_ha(): """t66: HA's OWN image pieces accept what the capture path produces. The stub suite proves the logic; this proves the contract with real HA: the real ``ImageEntity`` base, HA's ``valid_image_content_type`` (what the image proxy runs before it will serve bytes), and the real state write — whose recorded state must be the capture TIMESTAMP and whose attributes must never carry the image itself (the "never a state source" boundary, measured). """ _require_installed_homeassistant() import base64 from homeassistant.components.image import valid_image_content_type class _Poller: connected = True async def shell(self, cmd): assert cmd == "screencap -p | base64" return base64.b64encode(base64.b64decode(_SCREEN_PNG_B64)).decode() entity, states = _real_ha_entity( "image", "NvidiaShieldScreenImage", options={"screen_capture_enabled": True}, entity_id="image.shield_screen_capture", ) entity._entry.runtime_data = SimpleNamespace(adb_poller=_Poller()) data = asyncio.run(entity.async_image()) assert isinstance(data, bytes) assert data.startswith(b"\x89PNG\r\n\x1a\n") assert valid_image_content_type(entity.content_type) == "image/png" written_id, state, attrs = _write_ha_state(entity, states) assert written_id == "image.shield_screen_capture" # HA's image state IS ``image_last_updated`` — the timestamp, never the bytes assert state == entity.image_last_updated.isoformat() assert attrs["capture_status"] == "ok" assert not any(isinstance(value, (bytes, bytearray)) for value in attrs.values()) def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree(): """A new entity class must join the matrix above, or this test fails. The sweep classifies entities from the AST; this keeps the real-HA coverage in step with that classification instead of silently shrinking. """ _require_installed_homeassistant() classified: set[str] = set() for path in sorted(ha_api_drift.SHIELD_DIR.glob("*.py")): classified |= ha_api_drift.entity_class_names( ast.parse(path.read_text(encoding="utf-8")) ) covered = {class_name for _module, class_name, _options, _entity_id in _REAL_HA_MATRIX} uncovered = classified - covered - _INTERMEDIATE_BASES assert uncovered == set(), ( f"entity classes never exercised against real HA: {sorted(uncovered)} — " f"add them to _REAL_HA_MATRIX" ) # ------------------------------------------------- t62: dead code and the floor # # Both defects pinned below shipped while every other gate was green: # connection.py carried a duplicated copy of the label-drain body AFTER its # `return cached` (valid Python, dead, invisible to this sweep), and the # call-argument floor sat at 20 while the counts had moved to ~46, so deleting a # flow-display API owner from HA_CALL_OWNERS did not trip it. #: t62 F2: the shape that shipped — a bare docstring plus the whole drain left #: after `return cached`, inside the same method. Line 6 is the dead statement. UNREACHABLE_PRE_FIX = ( "class Sweep:\n" " def labels(self, requests):\n" " cached = {}\n" " return cached\n" "\n" ' """Start at most one label fetch per watchdog cycle."""\n' " if not requests:\n" " return\n" " requests.discard(min(requests))\n" ) def test_the_sweep_flags_the_pre_fix_unreachable_body(tmp_path): """t62 F2 red proof: the duplicated drain body after `return cached` is dead.""" (tmp_path / "dead_sweep.py").write_text(UNREACHABLE_PRE_FIX, encoding="utf-8") findings = ha_api_drift.scan_unreachable(tmp_path) assert [(finding.path, finding.kind) for finding in findings] == [ ("dead_sweep.py", "unreachable-statement") ], findings assert findings[0].lineno == 6 # the dead docstring, not the return def test_every_terminal_statement_kind_is_flagged(tmp_path): """`return`, `raise`, `continue` and `break` all end a block (t62 F2).""" (tmp_path / "terminals.py").write_text( "def a():\n" " return 1\n" " x = 1\n" "\n" "\n" "def b():\n" " raise SystemExit(1)\n" " y = 2\n" "\n" "\n" "def c(items):\n" " for item in items:\n" " continue\n" " z = 3\n" " while True:\n" " break\n" " w = 4\n", encoding="utf-8", ) findings = ha_api_drift.scan_unreachable(tmp_path) assert [finding.kind for finding in findings] == ["unreachable-statement"] * 4, findings def test_the_production_tree_has_no_unreachable_statements(): """The gate itself: dead code is a finding, so it cannot ship silently.""" assert ha_api_drift.scan_unreachable() == [] def test_the_unreachable_pass_walks_real_statement_blocks(): """The floor proves "no findings" is not a pass that stopped parsing.""" report = ha_api_drift.scan() assert report.statement_blocks >= ha_api_drift.FLOORS["statement_blocks"] def test_removing_a_flow_display_owner_trips_the_call_argument_floor(monkeypatch): """t62: the floor must notice an API OWNER going away, not just a bad walk.""" _require_installed_homeassistant() intact = ha_api_drift.scan() assert intact.floor_violations() == [] reduced = dict(ha_api_drift.HA_CALL_OWNERS) del reduced["async_show_menu"] # the t57 UNIT 0a owner: 6 checked arguments monkeypatch.setattr(ha_api_drift, "HA_CALL_OWNERS", reduced) fewer = ha_api_drift.scan() assert fewer.call_args < intact.call_args assert fewer.call_args < ha_api_drift.FLOORS["call_args"], ( f"removing async_show_menu left {fewer.call_args} checked arguments, " f"still at or above the floor of {ha_api_drift.FLOORS['call_args']} — " "the flow-display owners are not load-bearing" ) assert any("call_args" in line for line in fewer.floor_violations()), ( fewer.floor_violations() ) # --------------------------------------- t63: the media-browser root (real HA) # # The live report ("Browse media seems to spin and never finish loading") came # from what async_browse_media HANDED BACK, not from the handler: our method # returns with no awaits, so nothing inside it can hang. HA's handler only # forwards the payload, which makes the defect visible on one path only: # # websocket_browse_media -> player.async_browse_media -> payload.as_dict() # # The frontend bundle is NOT installed here (no hass_frontend, and there is no # browser to drive), so the rendered dialog itself cannot be exercised. What # these tests prove is that backend path end to end, decorators included. The # pre-fix payload carried ``media_content_id=None`` — a node shape no core # integration ever serves, and the assertions below fail on it. class _RecordingConnection: """The pieces of websocket_api.ActiveConnection the browse handler calls.""" def __init__(self): self.results: list = [] self.messages: list = [] self.exceptions: list = [] def send_result(self, msg_id, result): self.results.append((msg_id, result)) def send_message(self, message): self.messages.append(message) def async_handle_exception(self, msg, err): self.exceptions.append(err) def _wire_the_browser(entity): """Give the entity's own hass what websocket_browse_media walks. ``hass.data[DATA_COMPONENT].get_entity`` is how the handler finds the player, and ``async_create_background_task`` is how ``@websocket_api.async_response`` schedules the real coroutine — both real shapes, so the DECORATED handler runs unmodified. """ from homeassistant.components.media_player import DATA_COMPONENT scheduled: list = [] def async_create_background_task(coro, name, *, eager_start=False): task = asyncio.create_task(coro) scheduled.append(task) return task entity.hass.data[DATA_COMPONENT] = SimpleNamespace( get_entity=lambda entity_id: entity if entity_id == entity.entity_id else None ) entity.hass.async_create_background_task = async_create_background_task return scheduled async def _browse_over_the_real_handler(entity, scheduled, *, content_id=None, content_type=None): """Send the real websocket command and await the task it schedules.""" from homeassistant.components.media_player import websocket_browse_media connection = _RecordingConnection() msg = {"id": 7, "type": "media_player/browse_media", "entity_id": entity.entity_id} if content_id is not None or content_type is not None: msg["media_content_id"] = content_id msg["media_content_type"] = content_type websocket_browse_media(entity.hass, connection, msg) # schedules the coroutine await asyncio.gather(*scheduled) assert connection.exceptions == [], connection.exceptions assert connection.messages == [], connection.messages assert len(connection.results) == 1, connection.results return connection.results[0][1] async def test_the_real_websocket_handler_serves_a_string_id_root(): """t63 regression: the browse root carries a real STRING id, not None. Every core media_player platform serves a string id (a literal, a module constant or ""), because the frontend keys the node it is loading on that id; the live dialog re-requested the same node for ever with a null one. """ _require_installed_homeassistant() entity, _states = _real_ha_entity( "media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield" ) scheduled = _wire_the_browser(entity) payload = await _browse_over_the_real_handler(entity, scheduled) assert isinstance(payload["media_content_id"], str) assert payload["media_content_id"] == "root" assert payload["media_content_type"] == "directory" # not MediaType.APPS assert payload["children"] == [] assert payload["can_expand"] is False assert payload["can_play"] is False assert payload["media_class"] == "directory" # the payload the frontend receives has to survive the websocket's own encode assert json.loads(json.dumps(payload)) == payload async def test_the_real_handler_answers_every_id_with_the_same_root(): """Idempotent: the root id, None and a stale child id give ONE payload. That is the property that stops a frontend bouncing between "loading" and "loaded" for one id — browse(root_id) must be browse(None) in shape. """ _require_installed_homeassistant() entity, _states = _real_ha_entity( "media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield" ) scheduled = _wire_the_browser(entity) first = await _browse_over_the_real_handler(entity, scheduled) root_id = first["media_content_id"] for content_type, content_id in ( (None, None), ("apps", root_id), ("apps", "apps"), ("app", "ar.tvplayer.tv"), ): repeat = await _browse_over_the_real_handler( entity, scheduled, content_id=content_id, content_type=content_type ) assert repeat == first