Files
mempalace--mempalace/tests/test_claude_plugin_hook_config.py
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

114 lines
4.9 KiB
Python

"""Schema tests for Claude plugin hook config: timeout must be bounded."""
import json
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
HOOK_CONFIG = REPO_ROOT / ".claude-plugin" / "hooks" / "hooks.json"
# Per-event hook-level timeout bounds (seconds): (floor, ceiling).
#
# Stop is fire-and-forget for the mine subprocess (_spawn_mine returns
# after a detached Popen), but the handler also calls _save_diary_direct
# synchronously, which touches chromadb. 10..30s is generous for that
# work without leaving room for runaway hangs to freeze the session.
#
# PreCompact runs _mine_sync synchronously with a per-target subprocess
# timeout of 60s in mempalace/hooks_cli.py. The hook-level floor of 60
# keeps the inner bound from being truncated, and the ceiling of 90
# bounds the worst case at ~30s above that.
# SessionEnd backgrounds all of its work in the shell wrapper — the foreground
# only forks the detached child and returns in milliseconds — so its timeout is
# a generous backstop on a near-instant operation, not a synchronous-work bound
# like Stop/PreCompact. A bound is still required (#1465) so a wedged fork can
# never fall back to the 600s command default.
EVENT_TIMEOUT_BOUNDS: dict[str, tuple[int, int]] = {
"Stop": (10, 30),
"SessionEnd": (5, 30),
"PreCompact": (60, 90),
}
@pytest.fixture(scope="module")
def hook_config() -> dict:
return json.loads(HOOK_CONFIG.read_text(encoding="utf-8"))
@pytest.mark.parametrize("event", sorted(EVENT_TIMEOUT_BOUNDS))
def test_plugin_hook_timeout_within_bounds(hook_config: dict, event: str) -> None:
"""Each declared plugin hook must declare a positive bounded timeout (#1465).
Without ``timeout``, Claude Code falls back to the 600s command default
and a hung ``mempalace hook run`` freezes the interactive session for
up to ten minutes before being canceled.
"""
floor, ceiling = EVENT_TIMEOUT_BOUNDS[event]
assert event in hook_config.get("hooks", {}), f"missing event {event!r} in hook config"
entries = hook_config["hooks"][event]
assert isinstance(entries, list) and entries, f"no entries declared for {event}"
# Pin cardinality: the plugin config intentionally declares exactly one
# command per event. A duplicate entry would silently double-fire the
# hook and pass per-hook bounds, so cardinality drift must fail loudly.
assert len(entries) == 1, (
f"{event} expected exactly one entry, found {len(entries)}; "
"duplicate entries would double-fire the hook"
)
for entry in entries:
sub_hooks = entry.get("hooks")
assert isinstance(sub_hooks, list) and sub_hooks, (
f"{event} entry missing non-empty 'hooks' array"
)
assert len(sub_hooks) == 1, (
f"{event} entry expected exactly one hook command, found {len(sub_hooks)}"
)
for hook in sub_hooks:
assert hook.get("type") == "command", (
f"unexpected hook type for {event}: {hook.get('type')!r}"
)
assert "timeout" in hook, f"{event} hook missing 'timeout' key"
timeout = hook["timeout"]
# bool subclasses int, so reject it explicitly: True == 1 must fail.
is_real_int = isinstance(timeout, int) and not isinstance(timeout, bool)
assert is_real_int and floor <= timeout <= ceiling, (
f"{event} hook timeout must be an int in [{floor}, {ceiling}]s; got {timeout!r}"
)
def test_no_unbounded_events_in_plugin_config(hook_config: dict) -> None:
"""No plugin hook event may ship without an explicit bounds entry.
Adding a new event (SessionStart, PreToolUse, etc.) to
``.claude-plugin/hooks/hooks.json`` without registering bounds in
``EVENT_TIMEOUT_BOUNDS`` would silently fall back to the 600s
Claude Code command default and re-introduce the regression.
"""
declared_events = set(hook_config.get("hooks", {}).keys())
bounded_events = set(EVENT_TIMEOUT_BOUNDS)
unbounded = declared_events - bounded_events
assert not unbounded, (
f"plugin hook events without timeout bounds: {sorted(unbounded)}. "
"Add a (floor, ceiling) entry to EVENT_TIMEOUT_BOUNDS in this test "
"after deciding the worst-case freeze the event can tolerate."
)
def test_session_end_hook_uses_background_wrapper(hook_config: dict) -> None:
"""Claude SessionEnd should use the backgrounding wrapper, not PreCompact."""
events = hook_config.get("hooks", {})
assert "SessionEnd" in events
assert "PreCompact" in events
assert events["SessionEnd"] != events["PreCompact"]
commands = [
hook["command"]
for entry in events["SessionEnd"]
for hook in entry.get("hooks", [])
if hook.get("type") == "command"
]
assert any("mempal-session-end-hook.sh" in command for command in commands)
assert not any("mempal-precompact-hook.sh" in command for command in commands)