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>
469 lines
18 KiB
Python
469 lines
18 KiB
Python
"""
|
|
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 |