sandbox: load registries in the private hass + make the compat lane engage the sandbox

Two critical review findings (plan-review-overhead E1+E2), each the test
that would have caught the other:

E1 — FlowRunner.create built a bare HomeAssistant and never ran the
registry loads bootstrap does, so er.async_get returned an unloaded
EntityRegistry with no .entities and every real EntityPlatform add died
with AttributeError while entry_setup still ACKed ok — the sandbox
bridged zero entities for real integrations. Load the
area/category/device/entity/floor/issue/label registries in create(),
before the channel opens, so their Stores bind to the local tempdir.
Regression-tested by driving the real sun integration through
EntryRunner end-to-end.

E2 — both compat pytest plugins only installed the entry autotag; the
sandbox_inprocess/sandbox_subprocess fixtures were opt-in and no vanilla
test requests them, so hass.config_entries.router stayed None and every
tagged entry set up locally: COMPAT.md's 99.97% measured vanilla tests,
not sandbox compatibility. The plugins now inject their sandbox fixture
into every hass-using test at collection, count router-driven
entry_setups via a test-side wrapper, and report the count in the
terminal summary; run_compat.py records the count per integration,
marks green-but-never-engaged rows no_op, and fails the run outright if
nothing engaged. Probe: sun now runs 10 routed entry setups, 98/101
passing (3 real compat gaps for the lane to report).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
This commit is contained in:
Paulus Schoutsen
2026-07-07 15:28:03 -04:00
co-authored by Claude Fable 5
parent 1cad82bb67
commit 36ed94eb2e
9 changed files with 278 additions and 49 deletions
@@ -16,6 +16,7 @@ that can't be serialised. The docstring in ``_marshal_result`` is the
load-bearing note for how the schema is later marshalled.
"""
import asyncio
from collections.abc import Callable, Mapping
import contextlib
import ipaddress
@@ -30,6 +31,18 @@ from homeassistant.config_entries import (
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType, UnknownFlow
from homeassistant.helpers import (
area_registry as ar,
category_registry as cr,
device_registry as dr,
entity,
entity_registry as er,
floor_registry as fr,
frame,
issue_registry as ir,
label_registry as lr,
translation,
)
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
@@ -111,6 +124,25 @@ class FlowRunner:
# has built its default one, so we inherit all the wiring.
hass.config_entries.flow = _SandboxFlowManager(hass, hass.config_entries, {})
loader.async_setup(hass)
# The registry subset of bootstrap's async_load_base_functionality —
# a bare hass never runs bootstrap, and an unloaded EntityRegistry
# has no ``.entities``, so a real EntityPlatform cannot add a single
# entity without these. Runs before the runtime opens the channel and
# sets ``current_sandbox``, so registry Stores bind to the sandbox's
# local tempdir (fresh per process), never route to main.
entity.async_setup(hass)
frame.async_setup(hass)
translation.async_setup(hass)
dr.async_setup(hass)
await asyncio.gather(
ar.async_load(hass),
cr.async_load(hass),
dr.async_load(hass),
er.async_load(hass),
fr.async_load(hass),
ir.async_load(hass),
lr.async_load(hass),
)
return cls(hass)
def register(self, channel: Channel) -> None:
@@ -162,14 +162,14 @@ class SandboxRuntime:
# every coroutine the runtime spawns inherits it (asyncio copies
# the context at `create_task` time).
#
# Ordering caveat (see the plan's touch-points audit): registries
# whose `Store` is constructed AND first loaded inside
# `FlowRunner.create` already ran their `async_load` against the
# sandbox tempdir before this point, so they keep their local
# file backing. `restore_state`'s `async_load` runs *after* this
# set, so it routes to main — which is what we want. If a future
# refactor moves a registry's first `async_load` to straddle this
# line, that registry would silently start routing to main.
# Ordering caveat: `FlowRunner.create` explicitly loads the
# area/category/device/entity/floor/issue/label registries before
# this point, so their Stores bind to the sandbox tempdir and keep
# their local file backing. `restore_state`'s `async_load` runs
# *after* this set, so it routes to main — which is what we want.
# If a future refactor moves a registry's first `async_load` to
# straddle this line, that registry would silently start routing
# to main.
assert current_sandbox.get() is None, (
"current_sandbox already set — two sandbox runtimes sharing "
"one event loop? (see plan Risk #3)"
@@ -65,6 +65,66 @@ def classify_domain_sync(domain: str) -> str | None:
return GROUP_BUILT_IN
_ENGAGEMENT = {"entry_setups": 0}
def engagement_count() -> int:
"""Router-driven sandbox ``entry_setup`` count for this pytest session."""
return _ENGAGEMENT["entry_setups"]
def install_router_engagement_counter() -> Callable[[], None]:
"""Count every router-driven sandbox entry setup.
The compat lane's honesty guard: a run whose counter stays at zero
never routed anything through a sandbox, so ``run_compat.py`` can
flag it as a no-op instead of reporting vanilla-test results as
sandbox compatibility. Idempotent; returns an unpatch callable.
"""
# Lazy import: the HA integration tree must not load at plugin
# import time (same rule as the classifier import below).
from homeassistant.components.sandbox.router import ( # noqa: PLC0415
SandboxFlowRouter,
)
if getattr(SandboxFlowRouter, "_sandbox_counter_patched", False):
return lambda: None
original = SandboxFlowRouter.async_setup_entry
async def counted(self: Any, entry: Any) -> bool | None:
result = await original(self, entry)
if result is not None:
_ENGAGEMENT["entry_setups"] += 1
return result
SandboxFlowRouter.async_setup_entry = counted
SandboxFlowRouter._sandbox_counter_patched = True # noqa: SLF001
def restore() -> None:
SandboxFlowRouter.async_setup_entry = original
with contextlib.suppress(AttributeError):
delattr(SandboxFlowRouter, "_sandbox_counter_patched")
return restore
def configure_compat_plugin() -> Callable[[], None]:
"""Install the autotag patch + engagement counter; returns the undo.
Shared by both compat plugins' ``pytest_configure`` so the install /
restore pairing lives in one place.
"""
unpatch_autotag = install_mock_config_entry_autotag()
unpatch_counter = install_router_engagement_counter()
def restore() -> None:
unpatch_counter()
unpatch_autotag()
return restore
def install_mock_config_entry_autotag() -> Callable[[], None]:
"""Patch :meth:`MockConfigEntry.add_to_hass` to inject the sandbox group.
@@ -103,4 +163,10 @@ def install_mock_config_entry_autotag() -> Callable[[], None]:
return restore
__all__ = ["classify_domain_sync", "install_mock_config_entry_autotag"]
__all__ = [
"classify_domain_sync",
"configure_compat_plugin",
"engagement_count",
"install_mock_config_entry_autotag",
"install_router_engagement_counter",
]
@@ -32,12 +32,12 @@ a :class:`SubprocessSandbox` handle once the subprocess is running.
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import pytest
import pytest_asyncio
from hass_client.testing._autotag import install_mock_config_entry_autotag
from hass_client.testing._autotag import configure_compat_plugin, engagement_count
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
@@ -48,27 +48,36 @@ DEFAULT_GROUP = "built-in"
_MARK_NO_FREEZER = "no_sandbox_freezer"
_unpatch_autotag: Callable[[], None] | None = None
_unconfigure: Callable[[], None] | None = None
def pytest_configure(config: pytest.Config) -> None:
"""Register the ``no_sandbox_freezer`` marker and install the autotag patch."""
"""Register the freezer marker; install autotag + engagement counter."""
config.addinivalue_line(
"markers",
f"{_MARK_NO_FREEZER}: skip the test when the real-subprocess sandbox"
" plugin is active (freezer + subprocess clock skew hangs the channel)",
)
global _unpatch_autotag # noqa: PLW0603
if _unpatch_autotag is None:
_unpatch_autotag = install_mock_config_entry_autotag()
global _unconfigure # noqa: PLW0603
if _unconfigure is None:
_unconfigure = configure_compat_plugin()
def pytest_unconfigure(config: pytest.Config) -> None:
"""Restore the original ``MockConfigEntry.add_to_hass`` on session exit."""
global _unpatch_autotag # noqa: PLW0603
if _unpatch_autotag is not None:
_unpatch_autotag()
_unpatch_autotag = None
"""Restore the patched hooks on session exit."""
global _unconfigure # noqa: PLW0603
if _unconfigure is not None:
_unconfigure()
_unconfigure = None
def pytest_terminal_summary(
terminalreporter: Any, exitstatus: int, config: pytest.Config
) -> None:
"""Report how often the sandbox router actually engaged (see run_compat)."""
terminalreporter.write_line(
f"sandbox-compat: router entry_setup engaged {engagement_count()} time(s)"
)
def pytest_collection_modifyitems(
@@ -95,9 +104,17 @@ def pytest_collection_modifyitems(
if item.get_closest_marker(_MARK_NO_FREEZER) is not None:
item.add_marker(skip_freezer)
continue
fixtures = getattr(item, "fixturenames", ())
fixtures = getattr(item, "fixturenames", None)
if fixtures is None:
continue
if "freezer" in fixtures:
item.add_marker(skip_freezer)
continue
# Inject the subprocess sandbox into every hass-using test — the
# autotag alone leaves ``hass.config_entries.router`` None, and a
# tagged entry then quietly sets up locally (lane no-op).
if "hass" in fixtures and "sandbox_subprocess" not in fixtures:
fixtures.append("sandbox_subprocess")
@dataclass
@@ -33,7 +33,7 @@ import pytest
import pytest_asyncio
from hass_client.sandbox import SandboxRuntime
from hass_client.testing._autotag import install_mock_config_entry_autotag
from hass_client.testing._autotag import configure_compat_plugin, engagement_count
from hass_client.testing._inproc import make_inproc_channel_pair
if TYPE_CHECKING:
@@ -43,22 +43,53 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_GROUP = "built-in"
_unpatch_autotag: Callable[[], None] | None = None
_unconfigure: Callable[[], None] | None = None
def pytest_configure(config: pytest.Config) -> None:
"""Patch ``MockConfigEntry.add_to_hass`` so the classifier path fires."""
global _unpatch_autotag # noqa: PLW0603
if _unpatch_autotag is None:
_unpatch_autotag = install_mock_config_entry_autotag()
"""Install the autotag patch + router engagement counter."""
global _unconfigure # noqa: PLW0603
if _unconfigure is None:
_unconfigure = configure_compat_plugin()
def pytest_unconfigure(config: pytest.Config) -> None:
"""Restore the original ``MockConfigEntry.add_to_hass`` on session exit."""
global _unpatch_autotag # noqa: PLW0603
if _unpatch_autotag is not None:
_unpatch_autotag()
_unpatch_autotag = None
"""Restore the patched hooks on session exit."""
global _unconfigure # noqa: PLW0603
if _unconfigure is not None:
_unconfigure()
_unconfigure = None
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
"""Inject the in-process sandbox into every ``hass``-using test.
The autotag alone only marks entries with a group; with no sandbox
set up, ``hass.config_entries.router`` stays ``None`` and every
tagged entry quietly sets up locally — the lane measures nothing.
Requesting the fixture here makes the router path real for each test.
"""
for item in items:
fixtures = getattr(item, "fixturenames", None)
if fixtures is None or "hass" not in fixtures:
continue
if "sandbox_inprocess" not in fixtures:
fixtures.append("sandbox_inprocess")
def pytest_terminal_summary(
terminalreporter: Any, exitstatus: int, config: pytest.Config
) -> None:
"""Report how often the sandbox router actually engaged.
``run_compat.py`` parses this line; zero engagements on a suite that
sets up config entries means the lane regressed to a no-op.
"""
terminalreporter.write_line(
f"sandbox-compat: router entry_setup engaged {engagement_count()} time(s)"
)
@dataclass
@@ -567,12 +567,8 @@ async def test_device_registry_update_resends_linked_entities(
sandbox.start()
# Link the entity to a device in the registry so the device-update
# handler can find it via its device_id. The sandbox-private hass does
# not bootstrap the registries, so set them up explicitly and register a
# config entry the device can hang off.
dr.async_setup(hass)
await dr.async_load(hass, load_empty=True)
await er.async_load(hass, load_empty=True)
# handler can find it via its device_id (FlowRunner.create loads the
# registries), and register a config entry the device can hang off.
config_entry = ConfigEntry(
version=1,
minor_version=1,
@@ -20,6 +20,7 @@ import pytest
from homeassistant import config_entries as ha_config_entries, loader as ha_loader
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_component import DATA_INSTANCES
@@ -390,3 +391,37 @@ async def test_entity_query_method_raises(
with pytest.raises(ChannelRemoteError) as err:
await main.call("sandbox/entity_query", msg)
assert err.value.error_type == "ServiceValidationError"
async def test_entry_setup_real_platform_adds_entities(
channels: tuple[Channel, Channel], runner: EntryRunner
) -> None:
"""A real integration's entity platform adds entities on the private hass.
Regression test for the missing registry loads: an unloaded
``EntityRegistry`` has no ``.entities``, so every
``EntityPlatform._async_add_entity`` died with ``AttributeError`` while
``entry_setup`` still reported ok — the sandbox bridged zero entities.
Driving the real ``sun`` integration end-to-end pins the whole path.
"""
main, sandbox = channels
runner.register(sandbox)
main.start()
sandbox.start()
payload = pb.EntrySetup(
entry_id="sun_entry",
domain="sun",
title="Sun",
source="user",
version=1,
minor_version=1,
)
result = await main.call("sandbox/entry_setup", payload)
assert result.ok, result.reason
hass = runner.hass
await hass.async_block_till_done()
assert hass.states.get("sun.sun") is not None
ent_reg = er.async_get(hass)
assert ent_reg.async_get_entity_id("sensor", "sun", "sun_entry-next_dawn")
+35 -7
View File
@@ -58,6 +58,8 @@ _SUMMARY_RE = {
"skipped": re.compile(r"(\d+) skipped"),
}
_ENGAGED_RE = re.compile(r"sandbox-compat: router entry_setup engaged (\d+) time")
@dataclass
class Result:
@@ -68,6 +70,7 @@ class Result:
failed: int = 0
errors: int = 0
skipped: int = 0
engaged: int = 0
status: str = "no_tests"
@property
@@ -129,13 +132,19 @@ def run_one(integration: str, plugin: str, *, timeout: float = 300.0) -> Result:
for field, pattern in _SUMMARY_RE.items():
if (match := pattern.search(line)) is not None:
setattr(result, field, int(match.group(1)))
if (match := _ENGAGED_RE.search(output)) is not None:
result.engaged = int(match.group(1))
if result.total == 0:
result.status = "no_tests"
elif result.failed == 0 and result.errors == 0:
result.status = "pass"
else:
elif result.failed != 0 or result.errors != 0:
result.status = "issues"
elif result.engaged == 0 and result.passed > 0:
# Tests passed but nothing ever routed through a sandbox — the
# plugin regressed to a no-op; do NOT report this as compatibility.
result.status = "no_op"
else:
result.status = "pass"
if result.status in ("issues", "timeout"):
ERRORS_DIR.mkdir(parents=True, exist_ok=True)
@@ -147,7 +156,9 @@ def write_csv(results: list[Result], path: Path) -> None:
"""Persist per-integration results as CSV."""
with path.open("w", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["integration", "status", "passed", "failed", "errors", "skipped"])
writer.writerow(
["integration", "status", "passed", "failed", "errors", "skipped", "engaged"]
)
for result in results:
writer.writerow(
[
@@ -157,13 +168,20 @@ def write_csv(results: list[Result], path: Path) -> None:
result.failed,
result.errors,
result.skipped,
result.engaged,
]
)
def write_report(results: list[Result], plugin: str, path: Path) -> None:
"""Write a short Markdown summary suitable for review."""
counts: dict[str, int] = {"pass": 0, "issues": 0, "timeout": 0, "no_tests": 0}
counts: dict[str, int] = {
"pass": 0,
"issues": 0,
"timeout": 0,
"no_tests": 0,
"no_op": 0,
}
totals = {"passed": 0, "failed": 0, "errors": 0, "skipped": 0}
for result in results:
counts[result.status] = counts.get(result.status, 0) + 1
@@ -181,6 +199,7 @@ def write_report(results: list[Result], plugin: str, path: Path) -> None:
"",
f"- Integrations passing: **{counts.get('pass', 0)}**",
f"- Integrations with issues: **{counts.get('issues', 0)}**",
f"- No-op runs (sandbox never engaged): **{counts.get('no_op', 0)}**",
f"- Timeouts: **{counts.get('timeout', 0)}**",
f"- No tests collected: **{counts.get('no_tests', 0)}**",
"",
@@ -191,13 +210,14 @@ def write_report(results: list[Result], plugin: str, path: Path) -> None:
"",
"## Per-integration results",
"",
"| integration | status | passed | failed | errors | skipped |",
"| --- | --- | ---: | ---: | ---: | ---: |",
"| integration | status | passed | failed | errors | skipped | engaged |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
]
for result in results:
lines.append(
f"| {result.integration} | {result.status} | {result.passed} |"
f" {result.failed} | {result.errors} | {result.skipped} |"
f" {result.engaged} |"
)
path.write_text("\n".join(lines) + "\n")
@@ -262,6 +282,14 @@ def main(argv: list[str] | None = None) -> int:
write_report(results, plugin, args.report)
print(f"\nWrote {args.csv}")
print(f"Wrote {args.report}")
if results and all(result.engaged == 0 for result in results):
print(
"ERROR: no test in the entire run routed an entry through a"
" sandbox — the compat lane is a no-op.",
file=sys.stderr,
)
return 1
return 0
@@ -100,7 +100,7 @@ def _make_item(
) -> MagicMock:
"""Construct a fake pytest item with the given fixtures/markers."""
item = MagicMock(spec=pytest.Item)
item.fixturenames = fixturenames
item.fixturenames = list(fixturenames)
item.get_closest_marker.side_effect = lambda name: (
MagicMock() if name in markers else None
)
@@ -128,11 +128,35 @@ def test_conftest_sandbox_skips_marker_tagged_tests() -> None:
assert args[0].name == "skip"
def test_conftest_sandbox_leaves_unrelated_tests_alone() -> None:
"""Tests without the freezer fixture or marker are left untouched."""
def test_conftest_sandbox_injects_sandbox_into_hass_tests() -> None:
"""A hass-using test gets the ``sandbox_subprocess`` fixture injected.
Without the injection the plugin only tags entries — the router stays
``None`` and the compat lane is a no-op.
"""
item = _make_item(fixturenames=("hass",))
cs_plugin.pytest_collection_modifyitems(MagicMock(), [item])
assert not item.add_marker.called
assert "sandbox_subprocess" in item.fixturenames
def test_conftest_sandbox_leaves_non_hass_tests_alone() -> None:
"""Tests without hass, the freezer fixture, or the marker are untouched."""
item = _make_item(fixturenames=("tmp_path",))
cs_plugin.pytest_collection_modifyitems(MagicMock(), [item])
assert not item.add_marker.called
assert item.fixturenames == ["tmp_path"]
def test_inprocess_plugin_injects_sandbox_into_hass_tests() -> None:
"""The in-process plugin injects ``sandbox_inprocess`` the same way."""
from hass_client.testing import pytest_plugin as inproc_plugin # noqa: PLC0415
item = _make_item(fixturenames=("hass",))
other = _make_item(fixturenames=("tmp_path",))
inproc_plugin.pytest_collection_modifyitems(MagicMock(), [item, other])
assert "sandbox_inprocess" in item.fixturenames
assert other.fixturenames == ["tmp_path"]
def test_autotag_sets_mock_config_entry_sandbox() -> None: