Skip to content

Commit 071d458

Browse files
author
Claude
committed
test(hookify): regression tests for the hookify fixes
Run the hook scripts from an installed copy in a directory not named hookify, under a path with a space, and under a non-UTF-8 default encoding. Each test fails on the code before the fixes. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LoLAv3aLaRJocsRMcjFbpv
1 parent 5944b37 commit 071d458

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
"""Regression tests for the hookify plugin.
2+
3+
Run from anywhere:
4+
5+
python3 -m unittest discover -s plugins/hookify/tests -p 'test_*.py'
6+
7+
The subprocess tests drive the real hook scripts with a rule file in a scratch
8+
project, so they cover how Claude Code runs them: a plugin directory that is
9+
not named "hookify", a path with a space in it, and a non-UTF-8 default
10+
encoding.
11+
"""
12+
13+
import json
14+
import os
15+
import shlex
16+
import shutil
17+
import subprocess
18+
import sys
19+
import tempfile
20+
import unittest
21+
from pathlib import Path
22+
23+
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
24+
sys.path.insert(0, str(PLUGIN_ROOT.parent))
25+
26+
from hookify.core.config_loader import extract_frontmatter, load_rule_file # noqa: E402
27+
28+
29+
class ExtractFrontmatterTests(unittest.TestCase):
30+
def test_value_containing_triple_dash_is_kept_whole(self):
31+
content = "---\nname: dashes\nevent: bash\npattern: foo---bar\n---\nBody\n"
32+
frontmatter, message = extract_frontmatter(content)
33+
self.assertEqual(frontmatter["pattern"], "foo---bar")
34+
self.assertEqual(message, "Body")
35+
36+
def test_triple_dash_in_the_body_stays_in_the_message(self):
37+
content = "---\nname: n\nevent: bash\n---\nAbove\n\n---\n\nBelow\n"
38+
frontmatter, message = extract_frontmatter(content)
39+
self.assertEqual(frontmatter["name"], "n")
40+
self.assertEqual(message, "Above\n\n---\n\nBelow")
41+
42+
def test_closing_delimiter_must_be_a_line_of_its_own(self):
43+
content = "---\nname: n\npattern: a\n---b\n---\nBody\n"
44+
frontmatter, message = extract_frontmatter(content)
45+
self.assertEqual(frontmatter["name"], "n")
46+
self.assertEqual(frontmatter["pattern"], "a")
47+
self.assertEqual(message, "Body")
48+
49+
def test_crlf_line_endings(self):
50+
content = "---\r\nname: n\r\nevent: bash\r\n---\r\nBody\r\n"
51+
frontmatter, message = extract_frontmatter(content)
52+
self.assertEqual(frontmatter["name"], "n")
53+
self.assertEqual(message, "Body")
54+
55+
def test_empty_frontmatter(self):
56+
self.assertEqual(extract_frontmatter("---\n---\nBody\n"), ({}, "Body"))
57+
58+
def test_no_frontmatter_returns_the_content_untouched(self):
59+
for content in ("Just text\n", "---\nnever closed\n", ""):
60+
with self.subTest(content=content):
61+
self.assertEqual(extract_frontmatter(content), ({}, content))
62+
63+
64+
class RuleFileTests(unittest.TestCase):
65+
def setUp(self):
66+
self.tmp = Path(tempfile.mkdtemp())
67+
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
68+
69+
def write_rule(self, name, text, encoding="utf-8"):
70+
path = self.tmp / name
71+
path.write_bytes(text.encode(encoding))
72+
return path
73+
74+
def test_rule_with_triple_dash_pattern_loads_intact(self):
75+
path = self.write_rule(
76+
"hookify.dashes.local.md",
77+
"---\nname: dashes\nenabled: true\nevent: bash\npattern: a---b\n---\nMsg\n",
78+
)
79+
rule = load_rule_file(str(path))
80+
self.assertIsNotNone(rule)
81+
self.assertEqual(rule.pattern, "a---b")
82+
self.assertEqual(rule.message, "Msg")
83+
84+
def test_utf8_bom_is_accepted(self):
85+
path = self.write_rule(
86+
"hookify.bom.local.md",
87+
"---\nname: bom\nenabled: true\nevent: bash\npattern: x\n---\nMsg\n",
88+
encoding="utf-8-sig",
89+
)
90+
rule = load_rule_file(str(path))
91+
self.assertIsNotNone(rule)
92+
self.assertEqual(rule.name, "bom")
93+
94+
def test_non_ascii_rule_loads_under_a_non_utf8_default_encoding(self):
95+
# Windows defaults to cp1252; ASCII is the same failure on Linux.
96+
path = self.write_rule(
97+
"hookify.acentos.local.md",
98+
"---\nname: acentos\nenabled: true\nevent: bash\npattern: rm\n---\n¡Cuidado! ✓\n",
99+
)
100+
code = (
101+
"import sys, locale;"
102+
f"sys.path.insert(0, {str(PLUGIN_ROOT.parent)!r});"
103+
"from hookify.core.config_loader import load_rule_file;"
104+
"assert locale.getpreferredencoding(False).lower() not in ('utf-8', 'utf8'), 'encoding is UTF-8';"
105+
f"r = load_rule_file({str(path)!r});"
106+
"assert r is not None, 'rule was skipped';"
107+
"assert r.message == '\\u00a1Cuidado! \\u2713', ascii(r.message)"
108+
)
109+
env = dict(
110+
os.environ,
111+
PYTHONUTF8="0",
112+
PYTHONCOERCECLOCALE="0",
113+
PYTHONIOENCODING="utf-8",
114+
LC_ALL="C",
115+
)
116+
result = subprocess.run(
117+
[sys.executable, "-c", code], env=env, capture_output=True, text=True
118+
)
119+
self.assertEqual(result.returncode, 0, result.stderr)
120+
121+
def test_bundled_examples_are_named_so_they_load(self):
122+
examples = sorted((PLUGIN_ROOT / "examples").glob("*.local.md"))
123+
self.assertTrue(examples)
124+
for example in examples:
125+
with self.subTest(example=example.name):
126+
self.assertTrue(
127+
example.name.startswith("hookify."),
128+
"only hookify.*.local.md files are loaded",
129+
)
130+
self.assertIsNotNone(load_rule_file(str(example)))
131+
132+
133+
class HookScriptTests(unittest.TestCase):
134+
"""Run the hook the way Claude Code does, from an installed copy."""
135+
136+
RULE = (
137+
"---\nname: warn-rm\nenabled: true\nevent: bash\npattern: rm -rf\n---\n"
138+
"RM-WARNING\n"
139+
)
140+
141+
def setUp(self):
142+
self.tmp = Path(tempfile.mkdtemp())
143+
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
144+
self.project = self.tmp / "project"
145+
(self.project / ".claude").mkdir(parents=True)
146+
(self.project / ".claude" / "hookify.warn-rm.local.md").write_text(self.RULE)
147+
148+
def install(self, *parts):
149+
"""Copy the plugin to tmp/<parts> and return that directory."""
150+
dest = self.tmp.joinpath(*parts)
151+
shutil.copytree(
152+
PLUGIN_ROOT,
153+
dest,
154+
ignore=shutil.ignore_patterns("__pycache__", "tests"),
155+
)
156+
return dest
157+
158+
def run_hook(self, command, root, tool_input=None, set_root_env=True):
159+
env = {k: v for k, v in os.environ.items() if k != "CLAUDE_PLUGIN_ROOT"}
160+
if set_root_env:
161+
env["CLAUDE_PLUGIN_ROOT"] = str(root)
162+
payload = {
163+
"tool_name": "Bash",
164+
"tool_input": tool_input or {"command": "rm -rf /tmp/x"},
165+
"hook_event_name": "PreToolUse",
166+
}
167+
return subprocess.run(
168+
command,
169+
shell=isinstance(command, str),
170+
cwd=self.project,
171+
env=env,
172+
input=json.dumps(payload),
173+
capture_output=True,
174+
text=True,
175+
)
176+
177+
def assert_rule_fired(self, result):
178+
self.assertEqual(result.returncode, 0, result.stderr)
179+
self.assertNotIn("import error", result.stdout.lower(), result.stdout)
180+
self.assertIn("RM-WARNING", json.loads(result.stdout)["systemMessage"])
181+
182+
def test_works_when_the_plugin_directory_is_not_named_hookify(self):
183+
# The plugin cache names the directory by version.
184+
root = self.install("cache", "hookify", "1.0.0")
185+
self.assert_rule_fired(
186+
self.run_hook([sys.executable, str(root / "hooks" / "pretooluse.py")], root)
187+
)
188+
189+
def test_works_without_claude_plugin_root_set(self):
190+
root = self.install("somewhere", "else")
191+
self.assert_rule_fired(
192+
self.run_hook(
193+
[sys.executable, str(root / "hooks" / "pretooluse.py")],
194+
root,
195+
set_root_env=False,
196+
)
197+
)
198+
199+
def test_every_hook_script_imports_from_a_renamed_directory(self):
200+
root = self.install("cache", "hookify", "2.3.4")
201+
for name in ("pretooluse", "posttooluse", "stop", "userpromptsubmit"):
202+
with self.subTest(hook=name):
203+
result = subprocess.run(
204+
[sys.executable, str(root / "hooks" / f"{name}.py")],
205+
cwd=self.project,
206+
env=dict(os.environ, CLAUDE_PLUGIN_ROOT=str(root)),
207+
input=json.dumps({"hook_event_name": name}),
208+
capture_output=True,
209+
text=True,
210+
)
211+
self.assertEqual(result.returncode, 0, result.stderr)
212+
self.assertNotIn("import error", result.stdout.lower(), result.stdout)
213+
214+
def test_hooks_json_commands_survive_a_path_with_a_space(self):
215+
root = self.install("my plugins", "hookify")
216+
hooks = json.loads((root / "hooks" / "hooks.json").read_text())["hooks"]
217+
commands = [
218+
entry["hooks"][0]["command"]
219+
for entries in hooks.values()
220+
for entry in entries
221+
]
222+
self.assertEqual(len(commands), 4)
223+
for command in commands:
224+
with self.subTest(command=command):
225+
self.assertIn('"${CLAUDE_PLUGIN_ROOT}', command, "path must be quoted")
226+
227+
# Run the PreToolUse command through a shell, as the harness does.
228+
pre = next(c for c in commands if c.endswith('pretooluse.py"'))
229+
self.assert_rule_fired(
230+
self.run_hook(pre.replace("python3", shlex.quote(sys.executable), 1), root)
231+
)
232+
233+
234+
if __name__ == "__main__":
235+
unittest.main()

0 commit comments

Comments
 (0)