spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / plugins / hookify / tests / test_error_handling.py
497 lines · 15.9 KBRaw
1"""Tests for error handling and fault tolerance in hookify.
2
3Tests cover:
4- Graceful handling of missing files
5- Invalid JSON/YAML handling
6- Regex compilation errors
7- Transcript file access errors
8- Import failures
9- Edge cases and boundary conditions
10"""
11
12import pytest
13import os
14from pathlib import Path
15from typing import Dict, Any
16from unittest.mock import patch, mock_open
17
18from hookify.core.config_loader import load_rules, load_rule_file, extract_frontmatter
19from hookify.core.rule_engine import RuleEngine, compile_regex
20
21
22class TestTranscriptFileErrors:
23 """Tests for handling transcript file access errors."""
24
25 def test_missing_transcript_file(self, rule_engine: RuleEngine, temp_project_dir):
26 """Test handling when transcript file doesn't exist."""
27 stop_input = {
28 "hook_event_name": "Stop",
29 "reason": "Done",
30 "transcript_path": "/nonexistent/transcript.txt",
31 }
32
33 rules = [
34 _make_rule(
35 name="check-transcript",
36 event="stop",
37 conditions=[{"field": "transcript", "operator": "contains", "pattern": "test"}],
38 action="warn",
39 message="Test message"
40 ),
41 ]
42
43 # Should not crash, transcript returns empty string
44 result = rule_engine.evaluate_rules(rules, stop_input)
45 # Rule shouldn't match since transcript is empty
46 assert result == {}
47
48 def test_unreadable_transcript_file(self, rule_engine: RuleEngine, temp_project_dir):
49 """Test handling when transcript file is unreadable."""
50 # Create file and remove read permissions
51 transcript_file = temp_project_dir / "unreadable.txt"
52 transcript_file.write_text("content")
53 os.chmod(transcript_file, 0o000)
54
55 stop_input = {
56 "hook_event_name": "Stop",
57 "reason": "Done",
58 "transcript_path": str(transcript_file),
59 }
60
61 rules = [
62 _make_rule(
63 name="check-transcript",
64 event="stop",
65 conditions=[{"field": "transcript", "operator": "contains", "pattern": "test"}],
66 action="warn",
67 message="Test"
68 ),
69 ]
70
71 try:
72 # Should not crash
73 result = rule_engine.evaluate_rules(rules, stop_input)
74 assert result == {} # No match since transcript couldn't be read
75 finally:
76 # Restore permissions for cleanup
77 os.chmod(transcript_file, 0o644)
78
79
80class TestRegexErrors:
81 """Tests for regex compilation and matching errors."""
82
83 def test_invalid_regex_pattern(self, rule_engine: RuleEngine):
84 """Test handling of invalid regex patterns."""
85 input_data = {
86 "hook_event_name": "PreToolUse",
87 "tool_name": "Bash",
88 "tool_input": {"command": "ls -la"}
89 }
90
91 rules = [
92 _make_rule(
93 name="invalid-regex",
94 event="bash",
95 conditions=[{"field": "command", "operator": "regex_match", "pattern": "[unclosed"}],
96 action="block",
97 message="Should not match"
98 ),
99 ]
100
101 # Should not crash, invalid regex returns False (no match)
102 result = rule_engine.evaluate_rules(rules, input_data)
103 assert result == {}
104
105 def test_catastrophic_backtracking_regex(self, rule_engine: RuleEngine):
106 """Test handling of potentially slow regex patterns."""
107 input_data = {
108 "hook_event_name": "PreToolUse",
109 "tool_name": "Bash",
110 "tool_input": {"command": "a" * 100}
111 }
112
113 # This pattern could cause catastrophic backtracking in some engines
114 # Python's re module handles this reasonably well
115 rules = [
116 _make_rule(
117 name="complex-regex",
118 event="bash",
119 conditions=[{"field": "command", "operator": "regex_match", "pattern": "(a+)+$"}],
120 action="warn",
121 message="Matched"
122 ),
123 ]
124
125 # Should complete without hanging
126 result = rule_engine.evaluate_rules(rules, input_data)
127 assert "Matched" in result.get("systemMessage", "")
128
129 def test_regex_cache(self):
130 """Test that regex patterns are cached."""
131 pattern = r"test\s+pattern"
132
133 # Compile same pattern twice
134 regex1 = compile_regex(pattern)
135 regex2 = compile_regex(pattern)
136
137 # Should be the same object due to caching
138 assert regex1 is regex2
139
140
141class TestMalformedInput:
142 """Tests for handling malformed input data."""
143
144 def test_missing_tool_name(self, rule_engine: RuleEngine):
145 """Test handling input without tool_name."""
146 input_data = {
147 "hook_event_name": "PreToolUse",
148 # Missing tool_name
149 "tool_input": {"command": "test"}
150 }
151
152 rules = [
153 _make_rule(
154 name="test-rule",
155 event="bash",
156 conditions=[{"field": "command", "operator": "contains", "pattern": "test"}],
157 action="warn",
158 message="Test"
159 ),
160 ]
161
162 # Should not crash
163 result = rule_engine.evaluate_rules(rules, input_data)
164 # May or may not match depending on implementation
165
166 def test_missing_tool_input(self, rule_engine: RuleEngine):
167 """Test handling input without tool_input."""
168 input_data = {
169 "hook_event_name": "PreToolUse",
170 "tool_name": "Bash",
171 # Missing tool_input
172 }
173
174 rules = [
175 _make_rule(
176 name="test-rule",
177 event="bash",
178 conditions=[{"field": "command", "operator": "contains", "pattern": "test"}],
179 action="warn",
180 message="Test"
181 ),
182 ]
183
184 # Should not crash
185 result = rule_engine.evaluate_rules(rules, input_data)
186 assert result == {} # No match with missing input
187
188 def test_null_values_in_input(self, rule_engine: RuleEngine):
189 """Test handling None values in tool_input."""
190 input_data = {
191 "hook_event_name": "PreToolUse",
192 "tool_name": "Bash",
193 "tool_input": {
194 "command": None
195 }
196 }
197
198 rules = [
199 _make_rule(
200 name="test-rule",
201 event="bash",
202 conditions=[{"field": "command", "operator": "contains", "pattern": "test"}],
203 action="warn",
204 message="Test"
205 ),
206 ]
207
208 # Should not crash
209 result = rule_engine.evaluate_rules(rules, input_data)
210
211 def test_non_string_field_values(self, rule_engine: RuleEngine):
212 """Test handling non-string values that get converted."""
213 input_data = {
214 "hook_event_name": "PreToolUse",
215 "tool_name": "Bash",
216 "tool_input": {
217 "command": 123 # Number instead of string
218 }
219 }
220
221 rules = [
222 _make_rule(
223 name="test-rule",
224 event="bash",
225 conditions=[{"field": "command", "operator": "contains", "pattern": "123"}],
226 action="warn",
227 message="Found number"
228 ),
229 ]
230
231 result = rule_engine.evaluate_rules(rules, input_data)
232 # Should convert to string and match
233 assert "Found number" in result.get("systemMessage", "")
234
235
236class TestRuleFileErrors:
237 """Tests for rule file loading errors."""
238
239 def test_malformed_yaml(self, create_rule_file):
240 """Test handling of malformed YAML in frontmatter."""
241 content = """---
242name: test
243enabled: [unclosed bracket
244---
245message
246"""
247 rule_file = create_rule_file("malformed", content)
248 rule = load_rule_file(str(rule_file))
249
250 # Should handle gracefully (may return None or partial data)
251 # The custom YAML parser is lenient
252
253 def test_unicode_errors(self, temp_project_dir):
254 """Test handling of files with invalid unicode."""
255 rule_file = temp_project_dir / ".claude" / "hookify.unicode.local.md"
256
257 # Write binary content that's not valid UTF-8
258 with open(rule_file, 'wb') as f:
259 f.write(b"---\nname: test\n---\n\xff\xfe invalid unicode")
260
261 rule = load_rule_file(str(rule_file))
262 assert rule is None # Should return None for encoding errors
263
264 def test_empty_file(self, create_rule_file):
265 """Test handling of empty rule file."""
266 rule_file = create_rule_file("empty", "")
267 rule = load_rule_file(str(rule_file))
268
269 assert rule is None
270
271
272class TestFieldExtractionErrors:
273 """Tests for field extraction edge cases."""
274
275 def test_unknown_field_name(self, rule_engine: RuleEngine):
276 """Test handling of unknown field names."""
277 input_data = {
278 "hook_event_name": "PreToolUse",
279 "tool_name": "Bash",
280 "tool_input": {"command": "test"}
281 }
282
283 rules = [
284 _make_rule(
285 name="test-rule",
286 event="bash",
287 conditions=[{"field": "nonexistent_field", "operator": "contains", "pattern": "test"}],
288 action="warn",
289 message="Test"
290 ),
291 ]
292
293 # Should not crash, unknown field returns None -> no match
294 result = rule_engine.evaluate_rules(rules, input_data)
295 assert result == {}
296
297 def test_multiedit_with_empty_edits(self, rule_engine: RuleEngine):
298 """Test MultiEdit tool with empty edits array."""
299 input_data = {
300 "hook_event_name": "PreToolUse",
301 "tool_name": "MultiEdit",
302 "tool_input": {
303 "file_path": "/test/file.py",
304 "edits": [] # Empty edits
305 }
306 }
307
308 rules = [
309 _make_rule(
310 name="test-rule",
311 event="file",
312 conditions=[{"field": "new_text", "operator": "contains", "pattern": "test"}],
313 action="warn",
314 message="Test"
315 ),
316 ]
317
318 # Should not crash
319 result = rule_engine.evaluate_rules(rules, input_data)
320 assert result == {}
321
322 def test_multiedit_with_malformed_edits(self, rule_engine: RuleEngine):
323 """Test MultiEdit tool with malformed edit entries."""
324 input_data = {
325 "hook_event_name": "PreToolUse",
326 "tool_name": "MultiEdit",
327 "tool_input": {
328 "file_path": "/test/file.py",
329 "edits": [
330 {"invalid": "entry"}, # Missing new_string
331 None, # Null entry
332 "not a dict" # Wrong type
333 ]
334 }
335 }
336
337 rules = [
338 _make_rule(
339 name="test-rule",
340 event="file",
341 conditions=[{"field": "new_text", "operator": "contains", "pattern": "test"}],
342 action="warn",
343 message="Test"
344 ),
345 ]
346
347 # Should handle gracefully
348 result = rule_engine.evaluate_rules(rules, input_data)
349
350
351class TestOperatorEdgeCases:
352 """Tests for operator edge cases."""
353
354 def test_unknown_operator(self, rule_engine: RuleEngine):
355 """Test handling of unknown operator."""
356 input_data = {
357 "hook_event_name": "PreToolUse",
358 "tool_name": "Bash",
359 "tool_input": {"command": "test"}
360 }
361
362 rules = [
363 _make_rule(
364 name="test-rule",
365 event="bash",
366 conditions=[{"field": "command", "operator": "unknown_op", "pattern": "test"}],
367 action="warn",
368 message="Test"
369 ),
370 ]
371
372 # Unknown operator returns False -> no match
373 result = rule_engine.evaluate_rules(rules, input_data)
374 assert result == {}
375
376 def test_empty_pattern(self, rule_engine: RuleEngine):
377 """Test handling of empty pattern."""
378 input_data = {
379 "hook_event_name": "PreToolUse",
380 "tool_name": "Bash",
381 "tool_input": {"command": "test"}
382 }
383
384 rules = [
385 _make_rule(
386 name="test-rule",
387 event="bash",
388 conditions=[{"field": "command", "operator": "contains", "pattern": ""}],
389 action="warn",
390 message="Empty pattern"
391 ),
392 ]
393
394 result = rule_engine.evaluate_rules(rules, input_data)
395 # Empty string is contained in any string
396 assert "Empty pattern" in result.get("systemMessage", "")
397
398 def test_special_characters_in_pattern(self, rule_engine: RuleEngine):
399 """Test patterns with special regex characters when using 'contains'."""
400 input_data = {
401 "hook_event_name": "PreToolUse",
402 "tool_name": "Bash",
403 "tool_input": {"command": "echo $HOME"}
404 }
405
406 rules = [
407 _make_rule(
408 name="test-rule",
409 event="bash",
410 conditions=[{"field": "command", "operator": "contains", "pattern": "$HOME"}],
411 action="warn",
412 message="Found $HOME"
413 ),
414 ]
415
416 result = rule_engine.evaluate_rules(rules, input_data)
417 # 'contains' does literal string matching, not regex
418 assert "Found $HOME" in result.get("systemMessage", "")
419
420
421class TestConcurrentRuleEvaluation:
422 """Tests for multiple rules with various states."""
423
424 def test_mixed_match_states(self, rule_engine: RuleEngine):
425 """Test evaluation with mix of matching and non-matching rules."""
426 input_data = {
427 "hook_event_name": "PreToolUse",
428 "tool_name": "Bash",
429 "tool_input": {"command": "ls -la"}
430 }
431
432 rules = [
433 _make_rule(
434 name="match-ls",
435 event="bash",
436 conditions=[{"field": "command", "operator": "contains", "pattern": "ls"}],
437 action="warn",
438 message="Found ls"
439 ),
440 _make_rule(
441 name="no-match-rm",
442 event="bash",
443 conditions=[{"field": "command", "operator": "contains", "pattern": "rm"}],
444 action="block",
445 message="Found rm"
446 ),
447 _make_rule(
448 name="match-dash",
449 event="bash",
450 conditions=[{"field": "command", "operator": "contains", "pattern": "-"}],
451 action="warn",
452 message="Found dash"
453 ),
454 ]
455
456 result = rule_engine.evaluate_rules(rules, input_data)
457
458 # Should have warnings from matching rules
459 assert "Found ls" in result.get("systemMessage", "")
460 assert "Found dash" in result.get("systemMessage", "")
461 # Should not have blocking (rm rule didn't match)
462 assert "hookSpecificOutput" not in result
463
464 def test_empty_rules_list(self, rule_engine: RuleEngine):
465 """Test evaluation with empty rules list."""
466 input_data = {
467 "hook_event_name": "PreToolUse",
468 "tool_name": "Bash",
469 "tool_input": {"command": "ls"}
470 }
471
472 result = rule_engine.evaluate_rules([], input_data)
473 assert result == {}
474
475
476# Helper function to create rules for tests
477def _make_rule(name, event, conditions, action="warn", message="Test", enabled=True, tool_matcher=None):
478 """Helper to create Rule objects."""
479 from hookify.core.config_loader import Rule, Condition
480
481 cond_objects = [
482 Condition(
483 field=c.get("field", ""),
484 operator=c.get("operator", "regex_match"),
485 pattern=c.get("pattern", "")
486 )
487 for c in conditions
488 ]
489 return Rule(
490 name=name,
491 enabled=enabled,
492 event=event,
493 conditions=cond_objects,
494 action=action,
495 message=message,
496 tool_matcher=tool_matcher
497 )