Skip to content

Commit 6f4fbcd

Browse files
kingpanther13claude
andcommitted
test(themes): deterministic engine-identity e2e, and own the disconnect task
Adds the deterministic no-engine coverage where it is actually reachable. dashboard_screenshot_engine_url is a runtime-settable AdvancedField resolved live per capture, so ha_dev_manage_settings can point it at an explicit URL mid-run -- which forces resolve_engine() to return no addon_credential, exactly the case where ha-mcp's own credential must not be treated as the engine account. Both engine-theme actions are asserted to refuse, and the setting is reset in a finally. This lives in test_dev_mode_tools.py because the tool is registered only when HAMCP_ENABLE_DEV_MODE is on, which the themes suite does not set; the themes-suite test stays tolerant and asserts the structured-error contract. The shielded close left its inner task pending after a timeout or cancellation, still holding the socket and able to raise late with no owner. The task is now created explicitly, cancelled when the bounded wait gives up, and awaited with return_exceptions=True before the finally exits. The regression test blocks disconnect() and asserts the task is settled rather than left pending. The themes e2e now asserts success is False explicitly before reading the error fields: the previous shape returned early only on success is True, so a response missing the field entirely could have passed on truthy error values alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Nm7tyA1nfxNCWFXaR3AxV
1 parent e4cf519 commit 6f4fbcd

4 files changed

Lines changed: 108 additions & 2 deletions

File tree

src/ha_mcp/dashboard_screenshot/theme_guard.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,19 +238,28 @@ async def _session(self) -> AsyncIterator[HomeAssistantWebSocketClient]:
238238
finally:
239239
# Best-effort: a real failure to close must not mask the original
240240
# exception (including the cancellation that triggered cleanup).
241+
# Owned explicitly: a bare shield leaves the inner task pending
242+
# after a timeout or cancellation, still holding the socket and
243+
# able to raise late with nobody to receive it.
244+
close = asyncio.ensure_future(ws.disconnect())
241245
try:
242246
# shield: we are frequently here *because* of a cancellation
243247
# (SESSION_TIMEOUT_SECONDS). A bare await would be cancelled
244248
# at once and the socket would never actually close.
245249
await asyncio.wait_for(
246-
asyncio.shield(ws.disconnect()),
247-
timeout=CLOSE_TIMEOUT_SECONDS,
250+
asyncio.shield(close), timeout=CLOSE_TIMEOUT_SECONDS
248251
)
249252
except (Exception, TimeoutError) as close_error:
250253
logger.debug(
251254
"Ignoring error while closing the theme-guard session: %s",
252255
close_error,
253256
)
257+
finally:
258+
if not close.done():
259+
close.cancel()
260+
# Retrieve the outcome so a late failure never surfaces as an
261+
# orphaned "exception was never retrieved" warning.
262+
await asyncio.gather(close, return_exceptions=True)
254263

255264
@staticmethod
256265
async def _fetch_theme(ws: HomeAssistantWebSocketClient) -> Any:

tests/src/e2e/tools/test_dev_mode_tools.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,63 @@ async def test_restart_unavailable_standalone(self, mcp_client_with_dev_mode):
276276
)
277277
assert result.get("success") is not True
278278
assert "standalone" in extract_error_message(result).lower()
279+
280+
281+
class TestEngineThemeIdentityGuard:
282+
"""Deterministic engine-account coverage, only reachable with dev mode.
283+
284+
ha_dev_manage_settings can set dashboard_screenshot_engine_url at runtime
285+
(it is resolved live per capture, so no restart is needed). Pointing it at
286+
an explicit URL forces resolve_engine() to return no addon_credential,
287+
which is exactly the case where ha-mcp's own credential must NOT be
288+
treated as the engine account -- so the refusal is deterministic here in a
289+
way it cannot be in the themes suite, where dev mode is off.
290+
"""
291+
292+
async def test_explicit_engine_url_refuses_engine_theme_actions(
293+
self, mcp_client_with_dev_mode
294+
):
295+
set_result = await safe_call_tool(
296+
mcp_client_with_dev_mode,
297+
"ha_dev_manage_settings",
298+
{
299+
"action": "set",
300+
"setting": "dashboard_screenshot_engine_url",
301+
"value": "http://sidecar.example:10000",
302+
},
303+
)
304+
assert set_result.get("success") is True, f"Could not set URL: {set_result}"
305+
306+
try:
307+
read = await safe_call_tool(
308+
mcp_client_with_dev_mode,
309+
"ha_manage_theme",
310+
{"action": "get_engine_theme"},
311+
)
312+
assert read.get("success") is False, (
313+
f"Reading an unidentifiable engine account must fail: {read}"
314+
)
315+
assert "identified" in extract_error_message(read).lower(), (
316+
f"Error should name the identity problem: {read}"
317+
)
318+
319+
# The write refuses for the same reason, so a restore can never
320+
# land on ha-mcp's own profile.
321+
write = await safe_call_tool(
322+
mcp_client_with_dev_mode,
323+
"ha_manage_theme",
324+
{
325+
"action": "set_engine_theme",
326+
"value": {"theme": "", "dark": False},
327+
"expected_current": None,
328+
},
329+
)
330+
assert write.get("success") is False, (
331+
f"Writing an unidentifiable engine account must fail: {write}"
332+
)
333+
finally:
334+
await safe_call_tool(
335+
mcp_client_with_dev_mode,
336+
"ha_dev_manage_settings",
337+
{"action": "reset", "setting": "dashboard_screenshot_engine_url"},
338+
)

tests/src/e2e/workflows/themes/test_manage_theme.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,9 @@ async def test_get_engine_theme_fails_actionably_without_an_engine(
236236
)
237237
return
238238

239+
assert data.get("success") is False, (
240+
f"Expected an explicit failure, got: {data}"
241+
)
239242
# Assert the structured-error contract rather than matching wording:
240243
# the message varies with WHY the engine is unavailable (not
241244
# configured, not installed, not started, unidentifiable account).

tests/src/unit/test_dashboard_screenshot_theme_guard.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,40 @@ async def hang_connect(self: Any) -> bool:
541541
assert len(_FakeWsClient.instances) == 1
542542
assert _FakeWsClient.instances[0].disconnected is True
543543

544+
async def test_blocked_disconnect_is_cancelled_not_orphaned(
545+
self, monkeypatch: Any
546+
) -> None:
547+
"""A disconnect that never completes must not outlive the cleanup."""
548+
import ha_mcp.dashboard_screenshot.theme_guard as guard_module
549+
550+
entered = asyncio.Event()
551+
tasks: list[Any] = []
552+
553+
async def hang_disconnect(self: Any) -> None:
554+
entered.set()
555+
await asyncio.sleep(3600)
556+
557+
real_ensure = asyncio.ensure_future
558+
559+
def tracking_ensure(coro: Any, **kw: Any) -> Any:
560+
task = real_ensure(coro, **kw)
561+
tasks.append(task)
562+
return task
563+
564+
with monkeypatch.context() as patched:
565+
patched.setattr(_FakeWsClient, "disconnect", hang_disconnect)
566+
patched.setattr(guard_module, "CLOSE_TIMEOUT_SECONDS", 0.01)
567+
patched.setattr(guard_module.asyncio, "ensure_future", tracking_ensure)
568+
_FakeWsClient.user_data[THEME_USER_DATA_KEY] = dict(_DARK_THEME)
569+
guard = ThemeGuard.for_capture(_PUPPET_CREDENTIAL, None)
570+
await guard.take_snapshot()
571+
await asyncio.wait_for(entered.wait(), timeout=1)
572+
573+
# The cleanup owned the blocked close and finished with it settled,
574+
# rather than leaving it pending against a live socket.
575+
assert tasks, "cleanup should have created a disconnect task"
576+
assert all(t.done() for t in tasks), "disconnect task left pending"
577+
544578

545579
class TestNullExpectedCurrentGuard:
546580
"""An explicit null expected_current must guard, not mean 'unguarded'."""

0 commit comments

Comments
 (0)