Skip to content

Commit 3cef5a8

Browse files
Your Nameclaude
andcommitted
Add integration tests for hook bridge causal chain integrity
Simulates the actual PreToolUse/PostToolUse stdin/stdout flow via subprocess and verifies that parent_event_id chains are correctly formed, trees are buildable, edge cases are handled (orphan Post, missing Post, model env propagation). 9 tests covering: - Basic record (Pre+Post → 1 event) - Two/three/four-event causal chains - Tree builder output from hook-produced data - PostToolUse without PreToolUse (graceful) - PreToolUse without PostToolUse (no leak) - Model/provider env var propagation - Mixed tool types in chain Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8dc4ace commit 3cef5a8

1 file changed

Lines changed: 301 additions & 0 deletions

File tree

tests/test_hooks_integration.py

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
"""Integration tests for the Claude Code hook bridge (stdin/stdout flow).
2+
3+
These test the actual hook entry point by simulating PreToolUse/PostToolUse
4+
JSON messages via subprocess, then verifying the stored trace has correct
5+
causal chains.
6+
7+
Run: python3 -m pytest tests/test_hooks_integration.py -v
8+
"""
9+
import json
10+
import os
11+
import subprocess
12+
import sys
13+
import tempfile
14+
from pathlib import Path
15+
16+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
17+
18+
from causetrace.core import JSONStore, ToolEvent, build_tree
19+
20+
HOOK_SCRIPT = (
21+
Path(__file__).resolve().parent.parent
22+
/ "causetrace"
23+
/ "hooks"
24+
/ "claude_code.py"
25+
)
26+
27+
28+
def _hook_run(input_json: dict, home_dir: str) -> dict:
29+
"""Run the hook script as a subprocess with isolated home directory."""
30+
env = {**os.environ, "HOME": home_dir}
31+
result = subprocess.run(
32+
[sys.executable, str(HOOK_SCRIPT)],
33+
input=json.dumps(input_json),
34+
capture_output=True,
35+
text=True,
36+
timeout=10,
37+
env=env,
38+
)
39+
assert result.returncode == 0, f"hook returned {result.returncode}: {result.stderr}"
40+
if result.stdout.strip():
41+
return json.loads(result.stdout)
42+
return {}
43+
44+
45+
def _make_hook_input(
46+
event_name: str,
47+
tool_name: str = "Bash",
48+
tool_input: dict | None = None,
49+
tool_result: dict | None = None,
50+
session_id: str = "test_integration",
51+
) -> dict:
52+
d: dict = {
53+
"hook_event_name": event_name,
54+
"session_id": session_id,
55+
"tool_name": tool_name,
56+
"tool_input": tool_input or {"command": "echo hello"},
57+
}
58+
if tool_result is not None:
59+
d["tool_result"] = tool_result
60+
return d
61+
62+
63+
def test_hook_records_both_pre_and_post():
64+
"""PreToolUse + PostToolUse should produce exactly one stored event."""
65+
with tempfile.TemporaryDirectory() as tmp:
66+
store = JSONStore(store_dir=os.path.join(tmp, ".causetrace", "data"))
67+
sid = "test_simple"
68+
_hook_run(_make_hook_input("PreToolUse", session_id=sid), home_dir=tmp)
69+
_hook_run(_make_hook_input("PostToolUse", session_id=sid), home_dir=tmp)
70+
71+
events = store.load(sid)
72+
assert len(events) == 1
73+
assert events[0].tool_name == "Bash"
74+
75+
76+
def test_hook_causal_chain_two_calls():
77+
"""Two consecutive tool calls should form a parent→child chain."""
78+
with tempfile.TemporaryDirectory() as tmp:
79+
store = JSONStore(store_dir=os.path.join(tmp, ".causetrace", "data"))
80+
sid = "test_chain"
81+
82+
_hook_run(
83+
_make_hook_input("PreToolUse", "Grep", {"pattern": "TODO"}, session_id=sid),
84+
home_dir=tmp,
85+
)
86+
_hook_run(
87+
_make_hook_input("PostToolUse", "Grep", session_id=sid,
88+
tool_result={"output": "line1"}),
89+
home_dir=tmp,
90+
)
91+
92+
_hook_run(
93+
_make_hook_input("PreToolUse", "Read", {"file_path": "src/main.py"}, session_id=sid),
94+
home_dir=tmp,
95+
)
96+
_hook_run(
97+
_make_hook_input("PostToolUse", "Read", session_id=sid,
98+
tool_result={"output": "content"}),
99+
home_dir=tmp,
100+
)
101+
102+
events = store.load(sid)
103+
assert len(events) == 2
104+
_assert_ordered_chain(events, ["Grep", "Read"])
105+
106+
107+
def test_hook_causal_chain_three_calls():
108+
"""Three chained calls: ev0 → ev1 → ev2."""
109+
with tempfile.TemporaryDirectory() as tmp:
110+
store = JSONStore(store_dir=os.path.join(tmp, ".causetrace", "data"))
111+
sid = "test_chain_3"
112+
113+
for tool_name, tool_input in [
114+
("Bash", {"command": "ls"}),
115+
("Read", {"file_path": "a.py"}),
116+
("Edit", {"file_path": "a.py", "old_string": "x", "new_string": "y"}),
117+
]:
118+
_hook_run(
119+
_make_hook_input("PreToolUse", tool_name, tool_input, session_id=sid),
120+
home_dir=tmp,
121+
)
122+
_hook_run(
123+
_make_hook_input("PostToolUse", tool_name, session_id=sid),
124+
home_dir=tmp,
125+
)
126+
127+
events = store.load(sid)
128+
assert len(events) == 3
129+
130+
# Find root (the event with no parent)
131+
roots = [e for e in events if e.parent_event_id is None]
132+
assert len(roots) == 1, f"expected 1 root, got {len(roots)}"
133+
assert roots[0].tool_name == "Bash"
134+
135+
# Build chain by following parent refs
136+
by_id = {e.event_id: e for e in events}
137+
chain = []
138+
cur = roots[0]
139+
while cur:
140+
chain.append(cur)
141+
children = [e for e in events if e.parent_event_id == cur.event_id]
142+
cur = children[0] if children else None
143+
assert len(chain) == 3
144+
assert [e.tool_name for e in chain] == ["Bash", "Read", "Edit"]
145+
146+
147+
def test_hook_builds_tree():
148+
"""Hook-produced events should form a valid causal tree."""
149+
with tempfile.TemporaryDirectory() as tmp:
150+
store = JSONStore(store_dir=os.path.join(tmp, ".causetrace", "data"))
151+
sid = "test_tree"
152+
153+
for tool_name, inp in [
154+
("Read", {"file_path": "main.py"}),
155+
("Grep", {"pattern": "FIXME"}),
156+
("Edit", {"file_path": "main.py"}),
157+
("Bash", {"command": "pytest"}),
158+
]:
159+
_hook_run(
160+
_make_hook_input("PreToolUse", tool_name, inp, session_id=sid),
161+
home_dir=tmp,
162+
)
163+
_hook_run(
164+
_make_hook_input("PostToolUse", tool_name, session_id=sid),
165+
home_dir=tmp,
166+
)
167+
168+
events = store.load(sid)
169+
trees = build_tree(events)
170+
assert len(trees) == 1, "one chain = one root"
171+
assert trees[0]["event"].tool_name == "Read"
172+
assert len(trees[0]["children"]) == 1
173+
174+
175+
def test_hook_post_without_pre_graceful():
176+
"""PostToolUse without a preceding PreToolUse should not crash."""
177+
with tempfile.TemporaryDirectory() as tmp:
178+
store = JSONStore(store_dir=os.path.join(tmp, ".causetrace", "data"))
179+
sid = "test_orphan_post"
180+
181+
result = _hook_run(
182+
_make_hook_input("PostToolUse", session_id=sid),
183+
home_dir=tmp,
184+
)
185+
assert result is not None
186+
events = store.load(sid)
187+
assert len(events) == 0
188+
189+
190+
def test_hook_missing_post_leaves_no_event():
191+
"""PreToolUse without matching PostToolUse should leave no stored event."""
192+
with tempfile.TemporaryDirectory() as tmp:
193+
store = JSONStore(store_dir=os.path.join(tmp, ".causetrace", "data"))
194+
sid = "test_missing_post"
195+
196+
_hook_run(
197+
_make_hook_input("PreToolUse", session_id=sid),
198+
home_dir=tmp,
199+
)
200+
events = store.load(sid)
201+
assert len(events) == 0
202+
203+
204+
def test_hook_model_provider_propagated():
205+
"""Model and provider env vars should propagate to the recorded event."""
206+
with tempfile.TemporaryDirectory() as tmp:
207+
store = JSONStore(store_dir=os.path.join(tmp, ".causetrace", "data"))
208+
sid = "test_attrs"
209+
210+
env = {**os.environ, "ANTHROPIC_MODEL": "claude-4-sonnet", "HOME": tmp}
211+
result = subprocess.run(
212+
[sys.executable, str(HOOK_SCRIPT)],
213+
input=json.dumps(_make_hook_input("PreToolUse", session_id=sid)),
214+
capture_output=True, text=True, timeout=10, env=env,
215+
)
216+
assert result.returncode == 0
217+
result = subprocess.run(
218+
[sys.executable, str(HOOK_SCRIPT)],
219+
input=json.dumps(_make_hook_input("PostToolUse", session_id=sid)),
220+
capture_output=True, text=True, timeout=10, env=env,
221+
)
222+
assert result.returncode == 0
223+
224+
events = store.load(sid)
225+
assert len(events) == 1
226+
assert events[0].model == "claude-4-sonnet"
227+
assert events[0].provider == "anthropic"
228+
229+
230+
def _assert_ordered_chain(events, expected_tools: list[str]):
231+
"""Verify events form a single chain in the given tool order.
232+
233+
Uses parent refs (not index ordering) so it's robust to timestamp ties.
234+
"""
235+
by_id = {e.event_id: e for e in events}
236+
# Find root
237+
roots = [e for e in events if e.parent_event_id is None]
238+
assert len(roots) == 1, f"expected 1 root, got {len(roots)}"
239+
assert roots[0].tool_name == expected_tools[0]
240+
241+
# Walk chain
242+
chain_tools = []
243+
cur = roots[0]
244+
while cur and len(chain_tools) < len(events):
245+
chain_tools.append(cur.tool_name)
246+
children = [e for e in events if e.parent_event_id == cur.event_id]
247+
cur = children[0] if children else None
248+
assert chain_tools == expected_tools, f"expected {expected_tools}, got {chain_tools}"
249+
250+
251+
def test_hook_different_tools_chain():
252+
"""Mixed tool types should still form proper causal chains."""
253+
with tempfile.TemporaryDirectory() as tmp:
254+
store = JSONStore(store_dir=os.path.join(tmp, ".causetrace", "data"))
255+
sid = "test_mixed"
256+
257+
for tool_name, inp in [
258+
("WebSearch", {"query": "python api"}),
259+
("Read", {"file_path": "example.py"}),
260+
("Bash", {"command": "python example.py"}),
261+
("Write", {"file_path": "output.txt", "content": "result"}),
262+
]:
263+
_hook_run(
264+
_make_hook_input("PreToolUse", tool_name, inp, session_id=sid),
265+
home_dir=tmp,
266+
)
267+
_hook_run(
268+
_make_hook_input("PostToolUse", tool_name, session_id=sid),
269+
home_dir=tmp,
270+
)
271+
272+
events = store.load(sid)
273+
_assert_ordered_chain(events, ["WebSearch", "Read", "Bash", "Write"])
274+
275+
276+
def test_hook_chain_validate_via_tree():
277+
"""Hook chain should produce a valid single-root tree."""
278+
with tempfile.TemporaryDirectory() as tmp:
279+
store = JSONStore(store_dir=os.path.join(tmp, ".causetrace", "data"))
280+
sid = "test_valid_tree"
281+
282+
_hook_run(
283+
_make_hook_input("PreToolUse", "Read", {"file_path": "x.py"}, session_id=sid),
284+
home_dir=tmp,
285+
)
286+
_hook_run(
287+
_make_hook_input("PostToolUse", "Read", session_id=sid),
288+
home_dir=tmp,
289+
)
290+
_hook_run(
291+
_make_hook_input("PreToolUse", "Edit", {"file_path": "x.py"}, session_id=sid),
292+
home_dir=tmp,
293+
)
294+
_hook_run(
295+
_make_hook_input("PostToolUse", "Edit", session_id=sid),
296+
home_dir=tmp,
297+
)
298+
299+
events = store.load(sid)
300+
assert len(events) == 2
301+
_assert_ordered_chain(events, ["Read", "Edit"])

0 commit comments

Comments
 (0)