mirror of
https://github.com/anthropics/claude-code.git
synced 2026-02-19 04:27:33 -08:00
Create comprehensive pytest integration test suite for the hookify plugin: - test_integration.py: Multi-hook evaluation, rule priority (blocking over warnings), condition AND logic, tool type field extraction, Stop/UserPromptSubmit events - test_rule_loading.py: YAML frontmatter parsing, rule file loading, event filtering - test_error_handling.py: Fault tolerance for missing files, invalid regex, malformed input Also fix a bug discovered through testing: MultiEdit field extraction now gracefully handles malformed edit entries (non-dict values in edits array). 68 tests covering: - Multiple rules combining messages - Blocking rules taking priority over warnings - Multiple conditions with AND logic - Different tool types (Bash, Write, Edit, MultiEdit) - Stop event transcript checking - UserPromptSubmit validation - Tool matcher filtering - Regex pattern matching and caching - Error handling and edge cases https://claude.ai/code/session_014B79JcfZHUaTfnThn3o3g2
411 lines
10 KiB
Python
411 lines
10 KiB
Python
"""Tests for rule loading and filtering from .local.md files.
|
|
|
|
Tests cover:
|
|
- Loading multiple rule files
|
|
- Event-based filtering
|
|
- YAML frontmatter parsing
|
|
- Legacy pattern to conditions conversion
|
|
"""
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
from hookify.core.config_loader import (
|
|
Rule, Condition, load_rules, load_rule_file, extract_frontmatter
|
|
)
|
|
|
|
|
|
class TestExtractFrontmatter:
|
|
"""Tests for YAML frontmatter extraction."""
|
|
|
|
def test_simple_frontmatter(self):
|
|
"""Test parsing simple key-value pairs."""
|
|
content = """---
|
|
name: test-rule
|
|
enabled: true
|
|
event: bash
|
|
action: warn
|
|
---
|
|
|
|
Rule message here.
|
|
"""
|
|
frontmatter, message = extract_frontmatter(content)
|
|
|
|
assert frontmatter["name"] == "test-rule"
|
|
assert frontmatter["enabled"] is True
|
|
assert frontmatter["event"] == "bash"
|
|
assert frontmatter["action"] == "warn"
|
|
assert message == "Rule message here."
|
|
|
|
def test_boolean_values(self):
|
|
"""Test boolean value parsing (true/false)."""
|
|
content = """---
|
|
enabled: true
|
|
disabled: false
|
|
---
|
|
msg
|
|
"""
|
|
frontmatter, _ = extract_frontmatter(content)
|
|
|
|
assert frontmatter["enabled"] is True
|
|
assert frontmatter["disabled"] is False
|
|
|
|
def test_quoted_strings(self):
|
|
"""Test quoted string parsing."""
|
|
content = """---
|
|
pattern: "rm -rf"
|
|
name: 'test-name'
|
|
---
|
|
msg
|
|
"""
|
|
frontmatter, _ = extract_frontmatter(content)
|
|
|
|
assert frontmatter["pattern"] == "rm -rf"
|
|
assert frontmatter["name"] == "test-name"
|
|
|
|
def test_conditions_list(self):
|
|
"""Test parsing conditions as list of dicts."""
|
|
content = """---
|
|
name: test
|
|
conditions:
|
|
- field: command
|
|
operator: contains
|
|
pattern: test
|
|
- field: file_path
|
|
operator: ends_with
|
|
pattern: .py
|
|
---
|
|
msg
|
|
"""
|
|
frontmatter, _ = extract_frontmatter(content)
|
|
|
|
assert "conditions" in frontmatter
|
|
assert len(frontmatter["conditions"]) == 2
|
|
assert frontmatter["conditions"][0]["field"] == "command"
|
|
assert frontmatter["conditions"][0]["operator"] == "contains"
|
|
assert frontmatter["conditions"][1]["pattern"] == ".py"
|
|
|
|
def test_inline_dict_conditions(self):
|
|
"""Test parsing inline comma-separated dict items."""
|
|
content = """---
|
|
name: test
|
|
conditions:
|
|
- field: command, operator: regex_match, pattern: test
|
|
---
|
|
msg
|
|
"""
|
|
frontmatter, _ = extract_frontmatter(content)
|
|
|
|
assert len(frontmatter["conditions"]) == 1
|
|
assert frontmatter["conditions"][0]["field"] == "command"
|
|
assert frontmatter["conditions"][0]["operator"] == "regex_match"
|
|
|
|
def test_no_frontmatter(self):
|
|
"""Test handling content without frontmatter."""
|
|
content = "Just plain text without frontmatter"
|
|
frontmatter, message = extract_frontmatter(content)
|
|
|
|
assert frontmatter == {}
|
|
assert message == content
|
|
|
|
def test_incomplete_frontmatter(self):
|
|
"""Test handling incomplete frontmatter markers."""
|
|
content = """---
|
|
name: test
|
|
No closing marker
|
|
"""
|
|
frontmatter, _ = extract_frontmatter(content)
|
|
assert frontmatter == {}
|
|
|
|
|
|
class TestLoadRuleFile:
|
|
"""Tests for loading individual rule files."""
|
|
|
|
def test_load_valid_rule(self, create_rule_file):
|
|
"""Test loading a valid rule file."""
|
|
content = """---
|
|
name: valid-rule
|
|
enabled: true
|
|
event: bash
|
|
action: block
|
|
conditions:
|
|
- field: command
|
|
operator: contains
|
|
pattern: danger
|
|
---
|
|
|
|
This is a dangerous command!
|
|
"""
|
|
rule_file = create_rule_file("valid-rule", content)
|
|
rule = load_rule_file(str(rule_file))
|
|
|
|
assert rule is not None
|
|
assert rule.name == "valid-rule"
|
|
assert rule.enabled is True
|
|
assert rule.event == "bash"
|
|
assert rule.action == "block"
|
|
assert len(rule.conditions) == 1
|
|
assert rule.conditions[0].field == "command"
|
|
assert "dangerous command" in rule.message
|
|
|
|
def test_load_legacy_pattern_rule(self, create_rule_file):
|
|
"""Test loading rule with legacy pattern (converts to condition)."""
|
|
content = """---
|
|
name: legacy-rule
|
|
enabled: true
|
|
event: bash
|
|
pattern: "rm -rf"
|
|
---
|
|
|
|
Old style rule.
|
|
"""
|
|
rule_file = create_rule_file("legacy-rule", content)
|
|
rule = load_rule_file(str(rule_file))
|
|
|
|
assert rule is not None
|
|
assert len(rule.conditions) == 1
|
|
assert rule.conditions[0].field == "command" # Inferred from bash event
|
|
assert rule.conditions[0].operator == "regex_match"
|
|
assert rule.conditions[0].pattern == "rm -rf"
|
|
|
|
def test_load_file_event_legacy_pattern(self, create_rule_file):
|
|
"""Test legacy pattern with file event infers correct field."""
|
|
content = """---
|
|
name: file-legacy
|
|
enabled: true
|
|
event: file
|
|
pattern: "TODO"
|
|
---
|
|
|
|
Found TODO.
|
|
"""
|
|
rule_file = create_rule_file("file-legacy", content)
|
|
rule = load_rule_file(str(rule_file))
|
|
|
|
assert rule.conditions[0].field == "new_text"
|
|
|
|
def test_load_missing_frontmatter(self, create_rule_file):
|
|
"""Test loading file without frontmatter returns None."""
|
|
content = "No frontmatter here"
|
|
rule_file = create_rule_file("no-frontmatter", content)
|
|
rule = load_rule_file(str(rule_file))
|
|
|
|
assert rule is None
|
|
|
|
def test_load_nonexistent_file(self):
|
|
"""Test loading nonexistent file returns None."""
|
|
rule = load_rule_file("/nonexistent/path/hookify.test.local.md")
|
|
assert rule is None
|
|
|
|
|
|
class TestLoadRules:
|
|
"""Tests for loading multiple rules with filtering."""
|
|
|
|
def test_load_multiple_rules(self, temp_project_dir, create_rule_file):
|
|
"""Test loading multiple rule files."""
|
|
create_rule_file("rule1", """---
|
|
name: rule-one
|
|
enabled: true
|
|
event: bash
|
|
conditions:
|
|
- field: command
|
|
operator: contains
|
|
pattern: test1
|
|
---
|
|
Rule 1
|
|
""")
|
|
create_rule_file("rule2", """---
|
|
name: rule-two
|
|
enabled: true
|
|
event: bash
|
|
conditions:
|
|
- field: command
|
|
operator: contains
|
|
pattern: test2
|
|
---
|
|
Rule 2
|
|
""")
|
|
|
|
rules = load_rules()
|
|
|
|
assert len(rules) == 2
|
|
names = {r.name for r in rules}
|
|
assert "rule-one" in names
|
|
assert "rule-two" in names
|
|
|
|
def test_filter_by_event(self, temp_project_dir, create_rule_file):
|
|
"""Test filtering rules by event type."""
|
|
create_rule_file("bash-rule", """---
|
|
name: bash-rule
|
|
enabled: true
|
|
event: bash
|
|
conditions:
|
|
- field: command
|
|
operator: contains
|
|
pattern: test
|
|
---
|
|
Bash rule
|
|
""")
|
|
create_rule_file("file-rule", """---
|
|
name: file-rule
|
|
enabled: true
|
|
event: file
|
|
conditions:
|
|
- field: content
|
|
operator: contains
|
|
pattern: test
|
|
---
|
|
File rule
|
|
""")
|
|
create_rule_file("all-rule", """---
|
|
name: all-rule
|
|
enabled: true
|
|
event: all
|
|
conditions:
|
|
- field: content
|
|
operator: contains
|
|
pattern: test
|
|
---
|
|
All events rule
|
|
""")
|
|
|
|
# Filter for bash events
|
|
bash_rules = load_rules(event="bash")
|
|
bash_names = {r.name for r in bash_rules}
|
|
assert "bash-rule" in bash_names
|
|
assert "all-rule" in bash_names # 'all' matches any event
|
|
assert "file-rule" not in bash_names
|
|
|
|
# Filter for file events
|
|
file_rules = load_rules(event="file")
|
|
file_names = {r.name for r in file_rules}
|
|
assert "file-rule" in file_names
|
|
assert "all-rule" in file_names
|
|
assert "bash-rule" not in file_names
|
|
|
|
def test_filter_excludes_disabled(self, temp_project_dir, create_rule_file):
|
|
"""Test that disabled rules are excluded."""
|
|
create_rule_file("enabled-rule", """---
|
|
name: enabled-rule
|
|
enabled: true
|
|
event: bash
|
|
conditions:
|
|
- field: command
|
|
operator: contains
|
|
pattern: test
|
|
---
|
|
Enabled
|
|
""")
|
|
create_rule_file("disabled-rule", """---
|
|
name: disabled-rule
|
|
enabled: false
|
|
event: bash
|
|
conditions:
|
|
- field: command
|
|
operator: contains
|
|
pattern: test
|
|
---
|
|
Disabled
|
|
""")
|
|
|
|
rules = load_rules()
|
|
|
|
assert len(rules) == 1
|
|
assert rules[0].name == "enabled-rule"
|
|
|
|
def test_load_rules_handles_invalid_file(self, temp_project_dir, create_rule_file):
|
|
"""Test that invalid files are skipped without crashing."""
|
|
# Valid rule
|
|
create_rule_file("valid", """---
|
|
name: valid
|
|
enabled: true
|
|
event: bash
|
|
conditions:
|
|
- field: command
|
|
operator: contains
|
|
pattern: test
|
|
---
|
|
Valid rule
|
|
""")
|
|
# Invalid rule (no frontmatter)
|
|
create_rule_file("invalid", "No frontmatter")
|
|
|
|
rules = load_rules()
|
|
|
|
# Should only load the valid rule
|
|
assert len(rules) == 1
|
|
assert rules[0].name == "valid"
|
|
|
|
def test_load_with_no_rules(self, temp_project_dir):
|
|
"""Test loading when no rule files exist."""
|
|
rules = load_rules()
|
|
assert rules == []
|
|
|
|
|
|
class TestRuleFromDict:
|
|
"""Tests for Rule.from_dict construction."""
|
|
|
|
def test_defaults(self):
|
|
"""Test default values for optional fields."""
|
|
frontmatter = {
|
|
"name": "test",
|
|
"event": "bash",
|
|
}
|
|
rule = Rule.from_dict(frontmatter, "message")
|
|
|
|
assert rule.name == "test"
|
|
assert rule.enabled is True # Default
|
|
assert rule.action == "warn" # Default
|
|
assert rule.message == "message"
|
|
|
|
def test_explicit_values(self):
|
|
"""Test explicit values override defaults."""
|
|
frontmatter = {
|
|
"name": "test",
|
|
"enabled": False,
|
|
"event": "file",
|
|
"action": "block",
|
|
"tool_matcher": "Write|Edit",
|
|
}
|
|
rule = Rule.from_dict(frontmatter, "message")
|
|
|
|
assert rule.enabled is False
|
|
assert rule.event == "file"
|
|
assert rule.action == "block"
|
|
assert rule.tool_matcher == "Write|Edit"
|
|
|
|
|
|
class TestConditionFromDict:
|
|
"""Tests for Condition.from_dict construction."""
|
|
|
|
def test_all_fields(self):
|
|
"""Test creating condition with all fields."""
|
|
data = {
|
|
"field": "command",
|
|
"operator": "regex_match",
|
|
"pattern": r"rm\s+-rf"
|
|
}
|
|
condition = Condition.from_dict(data)
|
|
|
|
assert condition.field == "command"
|
|
assert condition.operator == "regex_match"
|
|
assert condition.pattern == r"rm\s+-rf"
|
|
|
|
def test_default_operator(self):
|
|
"""Test default operator is regex_match."""
|
|
data = {
|
|
"field": "command",
|
|
"pattern": "test"
|
|
}
|
|
condition = Condition.from_dict(data)
|
|
|
|
assert condition.operator == "regex_match"
|
|
|
|
def test_missing_fields(self):
|
|
"""Test missing fields default to empty strings."""
|
|
data = {}
|
|
condition = Condition.from_dict(data)
|
|
|
|
assert condition.field == ""
|
|
assert condition.pattern == ""
|