Files
claude-mem/tests/test_trigger.py
John Reilly Pospos 58d9cbc25a Consolidate hook handlers into trigger.py and bump to v0.8.1 (#21)
Merge stop.py into trigger.py to handle PreToolUse, Stop, and
SubagentStop hooks via a single entry point routed by hook_event_name.
Add PreToolUse hook for gitmode commit gating, SubagentStop hook for
automatic dirty-files cleanup, and update tests and docs accordingly.
2026-02-10 21:06:45 +10:00

434 lines
17 KiB
Python

"""Unit tests for trigger.py - consolidated PreToolUse, Stop, and SubagentStop handler.
Tests internal functions directly (no subprocess), complementing the
subprocess-based integration tests in test_hooks.py.
"""
import json
import sys
from pathlib import Path
from unittest.mock import patch
# Import trigger.py module
SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import importlib
trigger = importlib.import_module("trigger")
sys.path.pop(0)
class TestLoadConfig:
"""Tests for load_config - reads plugin configuration."""
def test_returns_default_when_no_config(self, tmp_path):
"""Returns default triggerMode when config file is missing."""
config = trigger.load_config(str(tmp_path))
assert config == {"triggerMode": "default"}
def test_reads_valid_config(self, tmp_path):
"""Reads and returns existing config file."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "gitmode"}))
config = trigger.load_config(str(tmp_path))
assert config["triggerMode"] == "gitmode"
def test_returns_default_on_invalid_json(self, tmp_path):
"""Returns default when config file has invalid JSON."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text("not json{{{")
config = trigger.load_config(str(tmp_path))
assert config == {"triggerMode": "default"}
def test_preserves_extra_fields(self, tmp_path):
"""Preserves extra fields in config beyond triggerMode."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(
json.dumps({"triggerMode": "gitmode", "customField": "value"})
)
config = trigger.load_config(str(tmp_path))
assert config["customField"] == "value"
class TestReadDirtyFiles:
"""Tests for read_dirty_files - reads and deduplicates dirty file list."""
def test_returns_empty_when_no_file(self, tmp_path):
"""Returns empty list when dirty-files doesn't exist."""
files = trigger.read_dirty_files(str(tmp_path))
assert files == []
def test_returns_empty_when_file_empty(self, tmp_path):
"""Returns empty list when dirty-files is empty."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("")
files = trigger.read_dirty_files(str(tmp_path))
assert files == []
def test_reads_file_paths(self, tmp_path):
"""Reads file paths from dirty-files."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/src/main.py\n/src/util.py\n")
files = trigger.read_dirty_files(str(tmp_path))
assert files == ["/src/main.py", "/src/util.py"]
def test_deduplicates_paths(self, tmp_path):
"""Removes duplicate file paths."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/file.py\n/file.py\n/file.py\n")
files = trigger.read_dirty_files(str(tmp_path))
assert files == ["/file.py"]
def test_strips_commit_context(self, tmp_path):
"""Strips inline commit context [hash: message] from paths."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/src/main.py [abc1234: Add feature]\n")
files = trigger.read_dirty_files(str(tmp_path))
assert files == ["/src/main.py"]
def test_limits_to_20_files(self, tmp_path):
"""Caps file list at 20 entries."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
lines = [f"/file{i:03d}.py" for i in range(30)]
dirty.write_text("\n".join(lines) + "\n")
files = trigger.read_dirty_files(str(tmp_path))
assert len(files) == 20
def test_sorted_output(self, tmp_path):
"""Returns files in sorted order."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/z.py\n/a.py\n/m.py\n")
files = trigger.read_dirty_files(str(tmp_path))
assert files == ["/a.py", "/m.py", "/z.py"]
def test_skips_blank_lines(self, tmp_path):
"""Ignores blank lines in dirty-files."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/a.py\n\n\n/b.py\n\n")
files = trigger.read_dirty_files(str(tmp_path))
assert files == ["/a.py", "/b.py"]
class TestBuildSpawnReason:
"""Tests for build_spawn_reason - constructs agent spawn instruction."""
def test_includes_file_list(self):
"""Spawn reason includes the file paths."""
reason = trigger.build_spawn_reason(["/src/main.py", "/src/util.py"])
assert "/src/main.py" in reason
assert "/src/util.py" in reason
def test_includes_task_tool_params(self):
"""Spawn reason includes required Task tool parameters."""
reason = trigger.build_spawn_reason(["/file.py"])
assert "run_in_background" in reason
assert "bypassPermissions" in reason
assert "memory-updater" in reason
def test_includes_read_instruction(self):
"""Spawn reason tells Claude to re-read CLAUDE.md after agent completes."""
reason = trigger.build_spawn_reason(["/file.py"])
assert "Read tool" in reason
assert "CLAUDE.md" in reason
class TestHandleStop:
"""Tests for handle_stop - Stop hook event handler."""
def test_no_output_when_no_dirty_files(self, tmp_path, capsys):
"""No output when dirty-files is empty or missing."""
trigger.handle_stop({}, str(tmp_path))
assert capsys.readouterr().out == ""
def test_no_output_when_stop_hook_active(self, tmp_path, capsys):
"""No output when stop_hook_active prevents infinite loop."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/file.py\n")
trigger.handle_stop({"stop_hook_active": True}, str(tmp_path))
assert capsys.readouterr().out == ""
def test_blocks_with_dirty_files(self, tmp_path, capsys):
"""Outputs block decision when dirty files exist."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/src/main.py\n")
trigger.handle_stop({}, str(tmp_path))
output = json.loads(capsys.readouterr().out)
assert output["decision"] == "block"
assert "/src/main.py" in output["reason"]
def test_works_in_gitmode(self, tmp_path, capsys):
"""Stop handler still fires in gitmode (safety net for last commit)."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "gitmode"}))
(config_dir / "dirty-files").write_text("/file.py\n")
trigger.handle_stop({}, str(tmp_path))
output = json.loads(capsys.readouterr().out)
assert output["decision"] == "block"
class TestHandlePreToolUse:
"""Tests for handle_pre_tool_use - PreToolUse hook event handler."""
def _setup_gitmode(self, tmp_path):
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "gitmode"}))
def test_no_output_in_default_mode(self, tmp_path, capsys):
"""No output in default trigger mode (PreToolUse only active in gitmode)."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/file.py\n")
input_data = {
"hook_event_name": "PreToolUse",
"tool_input": {"command": "git commit -m 'test'"},
}
trigger.handle_pre_tool_use(input_data, str(tmp_path))
assert capsys.readouterr().out == ""
def test_no_output_for_non_git_commit(self, tmp_path, capsys):
"""No output for non-git-commit commands in gitmode."""
self._setup_gitmode(tmp_path)
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.write_text("/file.py\n")
input_data = {
"hook_event_name": "PreToolUse",
"tool_input": {"command": "git status"},
}
trigger.handle_pre_tool_use(input_data, str(tmp_path))
assert capsys.readouterr().out == ""
def test_no_output_when_no_dirty_files(self, tmp_path, capsys):
"""No output when no dirty files even with git commit in gitmode."""
self._setup_gitmode(tmp_path)
input_data = {
"hook_event_name": "PreToolUse",
"tool_input": {"command": "git commit -m 'test'"},
}
trigger.handle_pre_tool_use(input_data, str(tmp_path))
assert capsys.readouterr().out == ""
def test_denies_git_commit_with_dirty_files(self, tmp_path, capsys):
"""Denies git commit in gitmode when dirty files exist."""
self._setup_gitmode(tmp_path)
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.write_text("/src/feature.py\n")
input_data = {
"hook_event_name": "PreToolUse",
"tool_input": {"command": "git commit -m 'Add feature'"},
}
trigger.handle_pre_tool_use(input_data, str(tmp_path))
output = json.loads(capsys.readouterr().out)
hook_output = output["hookSpecificOutput"]
assert hook_output["hookEventName"] == "PreToolUse"
assert hook_output["permissionDecision"] == "deny"
assert "/src/feature.py" in hook_output["permissionDecisionReason"]
class TestEventRouting:
"""Tests for main() event routing - the core consolidation logic.
Verifies that trigger.py correctly routes to handle_stop or
handle_pre_tool_use based on hook_event_name in stdin JSON.
"""
def test_routes_stop_event(self, tmp_path):
"""Routes to handle_stop when hook_event_name is Stop."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/file.py\n")
stdin_data = json.dumps({"hook_event_name": "Stop"})
with (
patch.dict("os.environ", {"CLAUDE_PROJECT_DIR": str(tmp_path)}),
patch("sys.stdin") as mock_stdin,
patch("builtins.print") as mock_print,
):
mock_stdin.read.return_value = stdin_data
trigger.main()
mock_print.assert_called_once()
output = json.loads(mock_print.call_args[0][0])
assert output["decision"] == "block"
def test_routes_pre_tool_use_event(self, tmp_path):
"""Routes to handle_pre_tool_use when hook_event_name is PreToolUse."""
# Set up gitmode so PreToolUse actually does something
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "gitmode"}))
(config_dir / "dirty-files").write_text("/file.py\n")
stdin_data = json.dumps({
"hook_event_name": "PreToolUse",
"tool_input": {"command": "git commit -m 'test'"},
})
with (
patch.dict("os.environ", {"CLAUDE_PROJECT_DIR": str(tmp_path)}),
patch("sys.stdin") as mock_stdin,
patch("builtins.print") as mock_print,
):
mock_stdin.read.return_value = stdin_data
trigger.main()
mock_print.assert_called_once()
output = json.loads(mock_print.call_args[0][0])
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
def test_defaults_to_stop_when_no_event_name(self, tmp_path):
"""Defaults to Stop handler when hook_event_name is missing."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/file.py\n")
with (
patch.dict("os.environ", {"CLAUDE_PROJECT_DIR": str(tmp_path)}),
patch("sys.stdin") as mock_stdin,
patch("builtins.print") as mock_print,
):
mock_stdin.read.return_value = "{}"
trigger.main()
output = json.loads(mock_print.call_args[0][0])
assert output["decision"] == "block"
def test_exits_silently_without_project_dir(self):
"""Exits without output when CLAUDE_PROJECT_DIR is not set."""
with (
patch.dict("os.environ", {}, clear=True),
patch("sys.stdin") as mock_stdin,
patch("builtins.print") as mock_print,
):
mock_stdin.read.return_value = "{}"
trigger.main()
mock_print.assert_not_called()
def test_routes_subagent_stop_event(self, tmp_path):
"""Routes to handle_subagent_stop when hook_event_name is SubagentStop."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "default"}))
(config_dir / "dirty-files").write_text("/file.py\n")
stdin_data = json.dumps({"hook_event_name": "SubagentStop"})
with (
patch.dict("os.environ", {"CLAUDE_PROJECT_DIR": str(tmp_path)}),
patch("sys.stdin") as mock_stdin,
patch("builtins.print") as mock_print,
):
mock_stdin.read.return_value = stdin_data
trigger.main()
# SubagentStop produces no output, just clears dirty-files
mock_print.assert_not_called()
dirty = config_dir / "dirty-files"
assert dirty.read_text() == ""
class TestClearDirtyFiles:
"""Tests for clear_dirty_files - truncates dirty-files."""
def test_clears_existing_file(self, tmp_path):
"""Truncates dirty-files when it exists with content."""
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty.parent.mkdir(parents=True)
dirty.write_text("/src/main.py\n/src/util.py\n")
trigger.clear_dirty_files(str(tmp_path))
assert dirty.read_text() == ""
def test_noop_when_file_missing(self, tmp_path):
"""Does nothing when dirty-files doesn't exist."""
trigger.clear_dirty_files(str(tmp_path))
dirty = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert not dirty.exists()
class TestHandleSubagentStop:
"""Tests for handle_subagent_stop - SubagentStop hook event handler."""
def test_clears_when_config_and_dirty_files_present(self, tmp_path):
"""Clears dirty-files when config.json and dirty-files both exist."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "default"}))
dirty = config_dir / "dirty-files"
dirty.write_text("/src/main.py\n/src/util.py\n")
trigger.handle_subagent_stop(str(tmp_path))
assert dirty.read_text() == ""
def test_noop_when_no_config(self, tmp_path):
"""Does nothing when config.json is missing (plugin not active)."""
dirty_dir = tmp_path / ".claude" / "auto-memory"
dirty_dir.mkdir(parents=True)
dirty = dirty_dir / "dirty-files"
dirty.write_text("/file.py\n")
trigger.handle_subagent_stop(str(tmp_path))
# dirty-files should remain unchanged
assert dirty.read_text() == "/file.py\n"
def test_noop_when_dirty_files_empty(self, tmp_path):
"""Does nothing when dirty-files is empty (nothing to clean up)."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "default"}))
dirty = config_dir / "dirty-files"
dirty.write_text("")
trigger.handle_subagent_stop(str(tmp_path))
assert dirty.read_text() == ""
def test_noop_when_dirty_files_missing(self, tmp_path):
"""Does nothing when dirty-files doesn't exist."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "default"}))
trigger.handle_subagent_stop(str(tmp_path))
dirty = config_dir / "dirty-files"
assert not dirty.exists()
def test_no_output(self, tmp_path, capsys):
"""SubagentStop handler produces no stdout output."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "default"}))
(config_dir / "dirty-files").write_text("/file.py\n")
trigger.handle_subagent_stop(str(tmp_path))
assert capsys.readouterr().out == ""