Skip to content

Commit 167c2f3

Browse files
committed
fix(tests,health): isolate HOME in E2E gates + detect empty version segment (#1490)
Two Critical issues surfaced in CI (e2e-plugin-hooks/3.11) that the local pre-push check missed because the developer/runner HOME leaked into pytest subprocess invocations: 1. **TestHudInstallE2ERegressionGate all 4 scenarios FAILED on CI** with `assert "CB v" in out` because ``hud_version.get_fresh_version``'s tier-1 lookup reads ``~/.claude/plugins/installed_plugins.json`` and CI has none. The render therefore produced ``CB | Ready 🟢 | ...`` — a silent half-broken state where every module imports successfully but the version segment is empty. Locally the same test passed only because the developer's real ``~/.claude/plugins/installed_plugins.json`` (v5.6.1) leaked into the subprocess. Fix: the test now writes a stub ``installed_plugins.json`` into the tmpdir fake home and invokes the subprocess with ``env={"HOME": str(home), ...}`` so all three version-resolution tiers (tier 1 installed_plugins.json, tier 2 plugin.json, tier 3 hud_state) see the isolated environment. Assertion is strengthened to ``CB v<expected_version>`` with ``expected_version`` read from ``plugin.json`` so future bump-version.sh runs auto-gate. A ``.version`` stamp assertion is also added per scenario. The simulation shell script (commit 8) was already hardened this way; this commit brings the pytest E2E suite to parity so CI and local runs agree. 2. **check_hud_installation smoke test did not detect empty version** — it only compared against the literal fallback face, so the ``CB | Ready ...`` half-broken state returned PASS even though the user would see a status line with a missing version segment. Fix: ``check_hud_installation`` now detects ``"CB "`` without ``"CB v"`` in the rendered output and returns FAIL with a clear message ("HUD rendered empty version segment — hud_version fallback chain broken"). The subprocess call also pins ``HOME=self._home_dir`` so the diagnostic honours the HealthChecker's configured home directory rather than leaking the CI runner's real home. A new regression test ``test_fail_when_version_segment_is_empty`` stubs a script that prints the half-broken line and asserts the check FAILs. 3. **test_pass_with_real_plugin_install** (health_check green-path gate) now writes the same fake ``installed_plugins.json`` so the subprocess check_hud_installation invokes can resolve the plugin version inside the isolated environment. Local verification: $ python3 -m pytest tests/test_session_start_hud.py tests/test_health_check.py -v ... 56 passed in 0.90s $ bash packages/claude-code-plugin/scripts/verify-install-simulation.sh [verify-install-simulation] PASS: full status line rendered (v5.6.2) Refs #1490
1 parent 0ab3378 commit 167c2f3

3 files changed

Lines changed: 134 additions & 4 deletions

File tree

packages/claude-code-plugin/hooks/lib/health_check.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,21 +306,47 @@ def check_hud_installation(self) -> Dict[str, str]:
306306
f"HUD lib missing modules: {', '.join(missing)}",
307307
)
308308

309-
# Subprocess render smoke — catches runtime import failures.
309+
# Subprocess render smoke — catches runtime import failures
310+
# AND partially-rendered status lines (e.g. empty version
311+
# segment when hud_version's 3-tier fallback all fail).
312+
# HOME is pinned to self._home_dir so the subprocess's
313+
# tier-1 version lookup (~/.claude/plugins/installed_plugins.json)
314+
# resolves against the same environment the diagnostic was
315+
# configured with, rather than leaking the CI runner's real
316+
# home.
317+
isolated_env = {
318+
"HOME": self._home_dir,
319+
"PATH": os.environ.get("PATH", ""),
320+
"LANG": os.environ.get("LANG", ""),
321+
"LC_ALL": os.environ.get("LC_ALL", ""),
322+
}
310323
try:
311324
r = subprocess.run(
312325
["python3", script],
313326
input='{"session_id":"healthcheck","model":{"display_name":"Test"}}',
314327
capture_output=True,
315328
text=True,
316329
timeout=5,
330+
env=isolated_env,
317331
)
318-
if r.stdout.strip() == "◕‿◕ CodingBuddy":
332+
rendered = r.stdout.strip()
333+
if rendered == "◕‿◕ CodingBuddy":
319334
return _result(
320335
"hud_installation",
321336
"FAIL",
322337
"HUD smoke test produced fallback face — lib import failing at runtime",
323338
)
339+
# Version segment must not be empty. ``CB `` without the
340+
# trailing ``v`` indicates all three version-resolution
341+
# tiers (installed_plugins.json, plugin.json, hud_state)
342+
# returned the empty string — a silent half-broken state
343+
# that would otherwise ship unnoticed.
344+
if "CB " in rendered and "CB v" not in rendered:
345+
return _result(
346+
"hud_installation",
347+
"FAIL",
348+
"HUD rendered empty version segment — hud_version fallback chain broken",
349+
)
324350
except subprocess.TimeoutExpired:
325351
return _result(
326352
"hud_installation",

packages/claude-code-plugin/tests/test_health_check.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,13 +415,41 @@ def test_fail_when_smoke_test_returns_fallback(self, env):
415415
assert result["status"] == "FAIL"
416416
assert "fallback" in result["message"].lower()
417417

418+
def test_fail_when_version_segment_is_empty(self, env):
419+
"""v5.6.2 regression gate: empty version segment must FAIL.
420+
421+
Stubs a script that renders a partial status line without a
422+
``CB v<version>`` prefix — mimics the v5.6.0/v5.6.1 runtime
423+
where hud_version's 3-tier fallback all returned empty
424+
strings and the rendered line read ``CB | Ready 🟢 | ...``.
425+
The smoke test must detect this silent half-broken state.
426+
"""
427+
from pathlib import Path
428+
h = Path(env) / ".claude" / "hud"
429+
h.mkdir(parents=True, exist_ok=True)
430+
(h / "codingbuddy-hud.py").write_text(
431+
"#!/usr/bin/env python3\n"
432+
"print('\u25d5\u203f\u25d5 CB | Ready \U0001f7e2 | Opus 4.6')\n"
433+
)
434+
os.chmod(str(h / "codingbuddy-hud.py"), 0o755)
435+
lib = h / "lib"
436+
lib.mkdir(exist_ok=True)
437+
for name in HUD_REQUIRED_LIB_MODULES:
438+
(lib / name).write_text(f"# {name} stub")
439+
440+
checker = _make_checker(env)
441+
result = checker.check_hud_installation()
442+
assert result["status"] == "FAIL"
443+
assert "empty version segment" in result["message"].lower()
444+
418445
def test_pass_with_real_plugin_install(self, env, monkeypatch):
419446
"""End-to-end: install real HUD via _install_statusline, then check.
420447
421448
This is the green-path regression gate — if check_hud_installation
422449
ever stops returning PASS for a freshly-installed real plugin,
423450
we know either the installer or the diagnostic regressed.
424451
"""
452+
import json as _json
425453
from pathlib import Path
426454
import importlib.util as importutil
427455

@@ -437,6 +465,32 @@ def test_pass_with_real_plugin_install(self, env, monkeypatch):
437465
session_start = importutil.module_from_spec(spec)
438466
spec.loader.exec_module(session_start)
439467

468+
# Mimic Claude Code's plugin manifest so check_hud_installation's
469+
# subprocess (HOME=self._home_dir=env) can resolve the plugin
470+
# version via tier-1 lookup. Without this, CI environments
471+
# produce an empty version segment and the new version-segment
472+
# validation correctly FAILs the check.
473+
plugin_root = repo_hooks.parent
474+
plugin_json = plugin_root / ".claude-plugin" / "plugin.json"
475+
expected_version = _json.loads(plugin_json.read_text())["version"]
476+
plugins_dir = env / ".claude" / "plugins"
477+
plugins_dir.mkdir(parents=True, exist_ok=True)
478+
(plugins_dir / "installed_plugins.json").write_text(
479+
_json.dumps(
480+
{
481+
"plugins": {
482+
"codingbuddy@jeremydev87": [
483+
{
484+
"scope": "user",
485+
"installPath": str(plugin_root),
486+
"version": expected_version,
487+
}
488+
]
489+
}
490+
}
491+
)
492+
)
493+
440494
# Force the installer to use the real source
441495
monkeypatch.setattr(
442496
session_start, "_find_hud_source", lambda: real_hud_source
@@ -450,3 +504,4 @@ def test_pass_with_real_plugin_install(self, env, monkeypatch):
450504
f"expected PASS, got {result}"
451505
)
452506
assert "rendering full status line" in result["message"]
507+
assert expected_version in result["message"]

packages/claude-code-plugin/tests/test_session_start_hud.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,35 @@ def test_install_then_render_full_status_line(
362362
settings_file.parent.mkdir(parents=True)
363363
settings_file.write_text("{}")
364364

365+
# Mimic Claude Code's plugin manifest so the installed HUD's
366+
# tier-1 version lookup (hud_version.get_fresh_version →
367+
# ~/.claude/plugins/installed_plugins.json) resolves to the
368+
# in-tree plugin version. Without this, CI environments (which
369+
# have no prior install) leave the version segment empty and
370+
# the `"CB v" in out` assertion below fails. This mirrors the
371+
# behavior Claude Code performs after /plugin update on real
372+
# user machines.
373+
plugin_root = real_plugin_hud_source.parents[1]
374+
plugin_json_path = plugin_root / ".claude-plugin" / "plugin.json"
375+
expected_version = json.loads(plugin_json_path.read_text())["version"]
376+
plugins_dir = home / ".claude" / "plugins"
377+
plugins_dir.mkdir(parents=True, exist_ok=True)
378+
(plugins_dir / "installed_plugins.json").write_text(
379+
json.dumps(
380+
{
381+
"plugins": {
382+
"codingbuddy@jeremydev87": [
383+
{
384+
"scope": "user",
385+
"installPath": str(plugin_root),
386+
"version": expected_version,
387+
}
388+
]
389+
}
390+
}
391+
)
392+
)
393+
365394
hud_dir = home / ".claude" / "hud"
366395

367396
if scenario == "partial":
@@ -405,7 +434,10 @@ def test_install_then_render_full_status_line(
405434
if scenario == "stale":
406435
assert not (installed_lib / "hud_obsolete_v5_5.py").exists()
407436

408-
# 🔴 The render gate
437+
# 🔴 The render gate — run the installed script as a real
438+
# subprocess with an isolated HOME so tier-1 version lookup
439+
# reads the fake installed_plugins.json we wrote above instead
440+
# of leaking the developer/CI runner's real home directory.
409441
stdin_payload = json.dumps(
410442
{
411443
"session_id": "regression-gate",
@@ -416,12 +448,19 @@ def test_install_then_render_full_status_line(
416448
},
417449
}
418450
)
451+
isolated_env = {
452+
"HOME": str(home),
453+
"PATH": os.environ.get("PATH", ""),
454+
"LANG": os.environ.get("LANG", ""),
455+
"LC_ALL": os.environ.get("LC_ALL", ""),
456+
}
419457
result = subprocess.run(
420458
["python3", str(installed_script)],
421459
input=stdin_payload,
422460
capture_output=True,
423461
text=True,
424462
timeout=10,
463+
env=isolated_env,
425464
)
426465
assert result.returncode == 0, (
427466
f"scenario={scenario} crashed: stderr={result.stderr!r}"
@@ -436,6 +475,16 @@ def test_install_then_render_full_status_line(
436475
f"{sorted(p.name for p in installed_lib.iterdir())}"
437476
)
438477

439-
assert "CB v" in out, f"version segment missing: {out!r}"
478+
# Exact version assertion — auto-tracks bump-version.sh so
479+
# every release gates on a fully-populated version segment.
480+
assert f"CB v{expected_version}" in out, (
481+
f"version segment missing/wrong: {out!r} "
482+
f"(expected 'CB v{expected_version}')"
483+
)
440484
assert "Opus 4.6" in out, f"model segment missing: {out!r}"
441485
assert "$0.42" in out, f"cost segment missing: {out!r}"
486+
487+
# Stamp file assertion
488+
stamp = hud_dir / ".version"
489+
assert stamp.exists(), f"scenario={scenario}: .version stamp missing"
490+
assert stamp.read_text(encoding="utf-8").strip() == expected_version

0 commit comments

Comments
 (0)