Initial commit: Claude Code Project Tracker

Add comprehensive development intelligence system that tracks:
- Development sessions with automatic start/stop
- Full conversation history with semantic search
- Tool usage and file operation analytics
- Think time and engagement analysis
- Git activity correlation
- Learning pattern recognition
- Productivity insights and metrics

Features:
- FastAPI backend with SQLite database
- Modern web dashboard with interactive charts
- Claude Code hook integration for automatic tracking
- Comprehensive test suite with 100+ tests
- Complete API documentation (OpenAPI/Swagger)
- Privacy-first design with local data storage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ryan Malloy 2025-08-11 02:59:21 -06:00
commit 44ed9936b7
48 changed files with 9707 additions and 0 deletions

469
tests/test_hooks.py Normal file
View file

@ -0,0 +1,469 @@
"""
Tests for Claude Code hook simulation and integration.
"""
import pytest
import json
import subprocess
from unittest.mock import patch, MagicMock
from httpx import AsyncClient
from tests.fixtures import TestDataFactory
class TestHookSimulation:
"""Test hook payload generation and API integration."""
def test_session_start_hook_payload(self):
"""Test generating SessionStart hook payload."""
# Simulate environment variables that would be set by Claude Code
mock_env = {
"PWD": "/home/user/test-project",
"USER": "testuser"
}
with patch.dict("os.environ", mock_env):
with patch("subprocess.check_output") as mock_git:
# Mock git commands
mock_git.side_effect = [
b"main\n", # git branch --show-current
b"https://github.com/user/test-project.git\n" # git config --get remote.origin.url
]
payload = {
"session_type": "startup",
"working_directory": mock_env["PWD"],
"git_branch": "main",
"git_repo": "https://github.com/user/test-project.git",
"environment": {
"pwd": mock_env["PWD"],
"user": mock_env["USER"],
"timestamp": "2024-01-01T12:00:00Z"
}
}
# Verify payload structure matches API expectations
assert "session_type" in payload
assert "working_directory" in payload
assert payload["session_type"] in ["startup", "resume", "clear"]
def test_user_prompt_hook_payload(self):
"""Test generating UserPromptSubmit hook payload."""
mock_prompt = "How do I implement user authentication?"
mock_session_id = "123"
payload = {
"session_id": int(mock_session_id),
"timestamp": "2024-01-01T12:00:00Z",
"user_prompt": mock_prompt,
"exchange_type": "user_prompt"
}
assert payload["session_id"] == 123
assert payload["user_prompt"] == mock_prompt
assert payload["exchange_type"] == "user_prompt"
def test_post_tool_use_edit_payload(self):
"""Test generating PostToolUse Edit hook payload."""
mock_file_path = "/home/user/test-project/main.py"
mock_session_id = "123"
payload = {
"session_id": int(mock_session_id),
"tool_name": "Edit",
"action": "file_edit",
"file_path": mock_file_path,
"timestamp": "2024-01-01T12:00:00Z",
"metadata": {"success": True},
"success": True
}
assert payload["tool_name"] == "Edit"
assert payload["file_path"] == mock_file_path
assert payload["success"] is True
def test_post_tool_use_bash_payload(self):
"""Test generating PostToolUse Bash hook payload."""
mock_command = "pytest --cov=app"
mock_session_id = "123"
payload = {
"session_id": int(mock_session_id),
"tool_name": "Bash",
"action": "command_execution",
"timestamp": "2024-01-01T12:00:00Z",
"metadata": {
"command": mock_command,
"success": True
},
"success": True
}
assert payload["tool_name"] == "Bash"
assert payload["metadata"]["command"] == mock_command
def test_notification_hook_payload(self):
"""Test generating Notification hook payload."""
mock_session_id = "123"
payload = {
"session_id": int(mock_session_id),
"timestamp": "2024-01-01T12:00:00Z",
"context_before": "Claude is waiting for input"
}
assert payload["session_id"] == 123
assert "context_before" in payload
def test_stop_hook_payload(self):
"""Test generating Stop hook payload."""
mock_session_id = "123"
payload = {
"session_id": int(mock_session_id),
"timestamp": "2024-01-01T12:00:00Z",
"claude_response": "Response completed",
"exchange_type": "claude_response"
}
assert payload["exchange_type"] == "claude_response"
def test_json_escaping_in_payloads(self):
"""Test that special characters in payloads are properly escaped."""
# Test prompt with quotes and newlines
problematic_prompt = 'How do I handle "quotes" and\nnewlines in JSON?'
payload = {
"session_id": 1,
"user_prompt": problematic_prompt,
"exchange_type": "user_prompt"
}
# Should be able to serialize to JSON without errors
json_str = json.dumps(payload)
parsed = json.loads(json_str)
assert parsed["user_prompt"] == problematic_prompt
def test_file_path_escaping(self):
"""Test that file paths with spaces are handled correctly."""
file_path_with_spaces = "/home/user/My Projects/test project/main.py"
payload = {
"session_id": 1,
"tool_name": "Edit",
"file_path": file_path_with_spaces
}
json_str = json.dumps(payload)
parsed = json.loads(json_str)
assert parsed["file_path"] == file_path_with_spaces
class TestHookIntegration:
"""Test actual hook integration with the API."""
@pytest.mark.hooks
async def test_session_start_hook_integration(self, test_client: AsyncClient):
"""Test complete SessionStart hook workflow."""
# Simulate the payload that would be sent by the hook
hook_payload = TestDataFactory.create_session_data(
session_type="startup",
working_directory="/home/user/hook-test",
git_branch="main",
git_repo="https://github.com/user/hook-test.git"
)
response = await test_client.post("/api/session/start", json=hook_payload)
assert response.status_code == 201
data = response.json()
assert "session_id" in data
assert "project_id" in data
# Store session ID for subsequent hook calls
return data["session_id"]
@pytest.mark.hooks
async def test_user_prompt_hook_integration(self, test_client: AsyncClient):
"""Test UserPromptSubmit hook integration."""
# First create a session
session_id = await self.test_session_start_hook_integration(test_client)
# Simulate user prompt hook
hook_payload = {
"session_id": session_id,
"timestamp": "2024-01-01T12:00:00Z",
"user_prompt": "This prompt came from a hook",
"exchange_type": "user_prompt"
}
response = await test_client.post("/api/conversation", json=hook_payload)
assert response.status_code == 201
@pytest.mark.hooks
async def test_post_tool_use_hook_integration(self, test_client: AsyncClient):
"""Test PostToolUse hook integration."""
session_id = await self.test_session_start_hook_integration(test_client)
# Simulate Edit tool hook
hook_payload = {
"session_id": session_id,
"tool_name": "Edit",
"action": "file_edit",
"file_path": "/home/user/hook-test/main.py",
"timestamp": "2024-01-01T12:01:00Z",
"metadata": {"lines_changed": 5},
"success": True,
"lines_added": 3,
"lines_removed": 2
}
response = await test_client.post("/api/activity", json=hook_payload)
assert response.status_code == 201
@pytest.mark.hooks
async def test_waiting_period_hook_integration(self, test_client: AsyncClient):
"""Test Notification and waiting period hooks."""
session_id = await self.test_session_start_hook_integration(test_client)
# Start waiting period
start_payload = {
"session_id": session_id,
"timestamp": "2024-01-01T12:00:00Z",
"context_before": "Claude finished responding"
}
start_response = await test_client.post("/api/waiting/start", json=start_payload)
assert start_response.status_code == 201
# End waiting period
end_payload = {
"session_id": session_id,
"timestamp": "2024-01-01T12:05:00Z",
"duration_seconds": 300,
"context_after": "User submitted new prompt"
}
end_response = await test_client.post("/api/waiting/end", json=end_payload)
assert end_response.status_code == 200
@pytest.mark.hooks
async def test_stop_hook_integration(self, test_client: AsyncClient):
"""Test Stop hook integration."""
session_id = await self.test_session_start_hook_integration(test_client)
# Simulate Stop hook (Claude response)
hook_payload = {
"session_id": session_id,
"timestamp": "2024-01-01T12:10:00Z",
"claude_response": "Here's the implementation you requested...",
"tools_used": ["Edit", "Write"],
"files_affected": ["main.py", "utils.py"],
"exchange_type": "claude_response"
}
response = await test_client.post("/api/conversation", json=hook_payload)
assert response.status_code == 201
@pytest.mark.hooks
async def test_git_hook_integration(self, test_client: AsyncClient):
"""Test git operation hook integration."""
session_id = await self.test_session_start_hook_integration(test_client)
hook_payload = TestDataFactory.create_git_operation_data(
session_id=session_id,
operation="commit",
command="git commit -m 'Test commit from hook'",
files_changed=["main.py"],
lines_added=10,
lines_removed=2
)
response = await test_client.post("/api/git", json=hook_payload)
assert response.status_code == 201
class TestHookEnvironment:
"""Test hook environment and configuration."""
def test_session_id_persistence(self):
"""Test session ID storage and retrieval mechanism."""
session_file = "/tmp/claude-session-id"
test_session_id = "12345"
# Simulate writing session ID to file (as hooks would do)
with open(session_file, "w") as f:
f.write(test_session_id)
# Simulate reading session ID from file (as subsequent hooks would do)
with open(session_file, "r") as f:
retrieved_id = f.read().strip()
assert retrieved_id == test_session_id
# Clean up
import os
if os.path.exists(session_file):
os.remove(session_file)
def test_git_environment_detection(self):
"""Test git repository detection logic."""
with patch("subprocess.check_output") as mock_subprocess:
# Mock successful git commands
mock_subprocess.side_effect = [
b"main\n", # git branch --show-current
b"https://github.com/user/repo.git\n" # git config --get remote.origin.url
]
# Simulate what hooks would do
try:
branch = subprocess.check_output(
["git", "branch", "--show-current"],
stderr=subprocess.DEVNULL,
text=True
).strip()
repo = subprocess.check_output(
["git", "config", "--get", "remote.origin.url"],
stderr=subprocess.DEVNULL,
text=True
).strip()
assert branch == "main"
assert repo == "https://github.com/user/repo.git"
except subprocess.CalledProcessError:
# If git commands fail, hooks should handle gracefully
branch = "unknown"
repo = "null"
# Test non-git directory handling
with patch("subprocess.check_output", side_effect=subprocess.CalledProcessError(128, "git")):
try:
branch = subprocess.check_output(
["git", "branch", "--show-current"],
stderr=subprocess.DEVNULL,
text=True
).strip()
except subprocess.CalledProcessError:
branch = "unknown"
assert branch == "unknown"
def test_hook_error_handling(self):
"""Test hook error handling and graceful failures."""
# Test network failure (API unreachable)
import requests
from unittest.mock import patch
with patch("requests.post", side_effect=requests.exceptions.ConnectionError("Connection refused")):
# Hooks should not crash if API is unreachable
# This would be handled by curl in the actual hooks with > /dev/null 2>&1
try:
# Simulate what would happen in a hook
response = requests.post("http://localhost:8000/api/session/start", json={})
assert False, "Should have raised ConnectionError"
except requests.exceptions.ConnectionError:
# This is expected - hook should handle gracefully
pass
def test_hook_command_construction(self):
"""Test building hook commands with proper escaping."""
# Test session start hook command construction
session_data = {
"session_type": "startup",
"working_directory": "/home/user/test project", # Space in path
"git_branch": "feature/new-feature",
"environment": {"user": "testuser"}
}
# Construct JSON payload like the hook would
json_payload = json.dumps(session_data)
# Verify it can be parsed back
parsed = json.loads(json_payload)
assert parsed["working_directory"] == "/home/user/test project"
def test_hook_timing_and_ordering(self):
"""Test that hook timing and ordering work correctly."""
# Simulate rapid-fire hook calls (as would happen during active development)
timestamps = [
"2024-01-01T12:00:00Z", # Session start
"2024-01-01T12:00:01Z", # User prompt
"2024-01-01T12:00:02Z", # Waiting start
"2024-01-01T12:00:05Z", # Tool use (Edit)
"2024-01-01T12:00:06Z", # Tool use (Write)
"2024-01-01T12:00:10Z", # Waiting end
"2024-01-01T12:00:11Z", # Claude response
]
# Verify timestamps are in chronological order
for i in range(1, len(timestamps)):
assert timestamps[i] > timestamps[i-1]
class TestHookConfiguration:
"""Test hook configuration validation and setup."""
def test_hook_config_validation(self):
"""Test validation of hook configuration JSON."""
# Load and validate the provided hook configuration
with open("config/claude-hooks.json", "r") as f:
config = json.load(f)
# Verify required hook types are present
required_hooks = ["SessionStart", "UserPromptSubmit", "PostToolUse", "Notification", "Stop"]
for hook_type in required_hooks:
assert hook_type in config["hooks"]
# Verify SessionStart has all session types
session_start_hooks = config["hooks"]["SessionStart"]
matchers = [hook.get("matcher") for hook in session_start_hooks if "matcher" in hook]
assert "startup" in matchers
assert "resume" in matchers
assert "clear" in matchers
# Verify PostToolUse has main tools
post_tool_hooks = config["hooks"]["PostToolUse"]
tool_matchers = [hook.get("matcher") for hook in post_tool_hooks if "matcher" in hook]
assert "Edit" in tool_matchers
assert "Write" in tool_matchers
assert "Read" in tool_matchers
assert "Bash" in tool_matchers
def test_hook_command_structure(self):
"""Test that hook commands have proper structure."""
with open("config/claude-hooks.json", "r") as f:
config = json.load(f)
for hook_type, hooks in config["hooks"].items():
for hook in hooks:
assert "command" in hook
command = hook["command"]
# All commands should make HTTP requests to localhost:8000
assert "http://localhost:8000" in command
assert "curl" in command
# Commands should run in background and suppress output
assert "> /dev/null 2>&1 &" in command
def test_session_id_handling_in_config(self):
"""Test that hook configuration properly handles session ID."""
with open("config/claude-hooks.json", "r") as f:
config = json.load(f)
# Non-SessionStart hooks should use session ID from temp file
for hook_type, hooks in config["hooks"].items():
if hook_type != "SessionStart":
for hook in hooks:
command = hook["command"]
# Should reference the session ID temp file
assert "CLAUDE_SESSION_FILE" in command
assert "/tmp/claude-session-id" in command