Skip to content

Commit 5398cf3

Browse files
committed
test(plugins): cover Claude hook settings precedence
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 910a65a commit 5398cf3

1 file changed

Lines changed: 201 additions & 0 deletions

File tree

tests/test_claude_plugin_hooks.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import json
2+
import os
3+
import shutil
4+
import subprocess
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
11+
HOOK_RUNTIME_AVAILABLE = shutil.which("bash") is not None and shutil.which("python3") is not None
12+
pytestmark = pytest.mark.skipif(
13+
not HOOK_RUNTIME_AVAILABLE,
14+
reason="Claude Code hook tests require bash and python3",
15+
)
16+
17+
18+
@dataclass(frozen=True, slots=True)
19+
class HookHarness:
20+
repo_root: Path
21+
home: Path
22+
bin_dir: Path
23+
command_log: Path
24+
25+
def write_settings(self, directory: Path, name: str, basic_memory: dict[str, object]) -> None:
26+
settings_dir = directory / ".claude"
27+
settings_dir.mkdir(parents=True, exist_ok=True)
28+
(settings_dir / name).write_text(
29+
json.dumps({"basicMemory": basic_memory}),
30+
encoding="utf-8",
31+
)
32+
33+
def run_hook(self, hook_name: str, payload: dict[str, str]) -> subprocess.CompletedProcess[str]:
34+
env = os.environ.copy()
35+
env.update(
36+
{
37+
"BM_TEST_COMMAND_LOG": str(self.command_log),
38+
"HOME": str(self.home),
39+
"PATH": f"{self.bin_dir}{os.pathsep}{env['PATH']}",
40+
}
41+
)
42+
return subprocess.run(
43+
["bash", str(self.repo_root / "plugins/claude-code/hooks" / hook_name)],
44+
input=json.dumps(payload),
45+
capture_output=True,
46+
check=False,
47+
env=env,
48+
text=True,
49+
)
50+
51+
def logged_commands(self) -> list[list[str]]:
52+
if not self.command_log.exists():
53+
return []
54+
return [json.loads(line) for line in self.command_log.read_text().splitlines()]
55+
56+
57+
@pytest.fixture
58+
def hook_harness(tmp_path: Path) -> HookHarness:
59+
repo_root = Path(__file__).resolve().parents[1]
60+
home = tmp_path / "home"
61+
bin_dir = tmp_path / "bin"
62+
home.mkdir()
63+
bin_dir.mkdir()
64+
command_log = tmp_path / "basic-memory-commands.jsonl"
65+
66+
fake_basic_memory = bin_dir / "basic-memory"
67+
fake_basic_memory.write_text(
68+
"""#!/usr/bin/env python3
69+
import json
70+
import os
71+
import sys
72+
73+
with open(os.environ["BM_TEST_COMMAND_LOG"], "a", encoding="utf-8") as command_log:
74+
command_log.write(json.dumps(sys.argv[1:]) + "\\n")
75+
76+
if sys.argv[1:3] == ["tool", "search-notes"]:
77+
print(json.dumps({"results": []}))
78+
""",
79+
encoding="utf-8",
80+
)
81+
fake_basic_memory.chmod(0o755)
82+
83+
return HookHarness(
84+
repo_root=repo_root,
85+
home=home,
86+
bin_dir=bin_dir,
87+
command_log=command_log,
88+
)
89+
90+
91+
def test_session_start_uses_user_settings_without_project_config(
92+
hook_harness: HookHarness,
93+
) -> None:
94+
hook_harness.write_settings(
95+
hook_harness.home,
96+
"settings.json",
97+
{"primaryProject": "global-project", "captureFolder": "global-sessions"},
98+
)
99+
# Claude Code does not treat this as a user-level settings source. A stale
100+
# file must not silently reroute hooks away from the visible global config.
101+
hook_harness.write_settings(
102+
hook_harness.home,
103+
"settings.local.json",
104+
{"primaryProject": "stale-project"},
105+
)
106+
cwd = hook_harness.home / "work/repo/src"
107+
cwd.mkdir(parents=True)
108+
109+
result = hook_harness.run_hook("session-start.sh", {"cwd": str(cwd)})
110+
111+
assert result.returncode == 0, result.stderr
112+
assert "**Project:** global-project" in result.stdout
113+
assert "`global-sessions/`" in result.stdout
114+
assert "stale-project" not in result.stdout
115+
116+
117+
def test_session_start_merges_nearest_ancestor_project_settings_over_user_settings(
118+
hook_harness: HookHarness,
119+
) -> None:
120+
hook_harness.write_settings(
121+
hook_harness.home,
122+
"settings.json",
123+
{"primaryProject": "global-project", "captureFolder": "global-sessions"},
124+
)
125+
project_root = hook_harness.home / "work/repo"
126+
hook_harness.write_settings(
127+
project_root,
128+
"settings.json",
129+
{"primaryProject": "project-override"},
130+
)
131+
hook_harness.write_settings(
132+
project_root,
133+
"settings.local.json",
134+
{"captureFolder": "local-sessions"},
135+
)
136+
cwd = project_root / "packages/client/src"
137+
cwd.mkdir(parents=True)
138+
139+
result = hook_harness.run_hook("session-start.sh", {"cwd": str(cwd)})
140+
141+
assert result.returncode == 0, result.stderr
142+
assert "**Project:** project-override" in result.stdout
143+
assert "`local-sessions/`" in result.stdout
144+
search_commands = [
145+
command
146+
for command in hook_harness.logged_commands()
147+
if command[:2] == ["tool", "search-notes"]
148+
]
149+
assert len(search_commands) == 3
150+
assert all(
151+
command[command.index("--project") + 1] == "project-override" for command in search_commands
152+
)
153+
154+
155+
def test_pre_compact_uses_merged_project_and_capture_folder(
156+
hook_harness: HookHarness,
157+
tmp_path: Path,
158+
) -> None:
159+
hook_harness.write_settings(
160+
hook_harness.home,
161+
"settings.json",
162+
{"primaryProject": "global-project", "captureFolder": "global-sessions"},
163+
)
164+
project_root = hook_harness.home / "work/repo"
165+
hook_harness.write_settings(
166+
project_root,
167+
"settings.json",
168+
{"primaryProject": "project-override"},
169+
)
170+
hook_harness.write_settings(
171+
project_root,
172+
"settings.local.json",
173+
{"captureFolder": "local-sessions"},
174+
)
175+
cwd = project_root / "src"
176+
cwd.mkdir(parents=True)
177+
transcript = tmp_path / "transcript.jsonl"
178+
transcript.write_text(
179+
json.dumps({"message": {"role": "user", "content": "Ship the settings fallback"}}) + "\n",
180+
encoding="utf-8",
181+
)
182+
183+
result = hook_harness.run_hook(
184+
"pre-compact.sh",
185+
{
186+
"cwd": str(cwd),
187+
"session_id": "session-123",
188+
"transcript_path": str(transcript),
189+
},
190+
)
191+
192+
assert result.returncode == 0, result.stderr
193+
write_commands = [
194+
command
195+
for command in hook_harness.logged_commands()
196+
if command[:2] == ["tool", "write-note"]
197+
]
198+
assert len(write_commands) == 1
199+
write_command = write_commands[0]
200+
assert write_command[write_command.index("--project") + 1] == "project-override"
201+
assert write_command[write_command.index("--folder") + 1] == "local-sessions"

0 commit comments

Comments
 (0)