diff --git a/.github/workflows/e2e-plugin.yml b/.github/workflows/e2e-plugin.yml new file mode 100644 index 00000000..5a1b4740 --- /dev/null +++ b/.github/workflows/e2e-plugin.yml @@ -0,0 +1,66 @@ +name: e2e-plugin-hooks + +on: + push: + branches-ignore: + - master + - stag-** + paths: + - 'packages/claude-code-plugin/hooks/**' + - 'tests/e2e/plugin-hooks/**' + - .github/workflows/e2e-plugin.yml + +permissions: + statuses: write + contents: read + +jobs: + e2e-plugin-hooks: + if: github.repository == 'JeremyDev87/codingbuddy' + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + matrix: + python-version: ['3.11', '3.12'] + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Setup node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24' + + - name: Install Python test dependencies + run: pip install -r tests/e2e/plugin-hooks/requirements.txt + + - name: Configure git for tests + run: | + git config --global user.name "CI Bot" + git config --global user.email "ci@test.local" + git config --global init.defaultBranch main + + - name: Run E2E plugin hook tests + run: python3 -m pytest tests/e2e/plugin-hooks/ -v --timeout=30 --tb=short + + e2e-plugin-docker: + if: github.repository == 'JeremyDev87/codingbuddy' + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build Docker test image + run: docker build -t codingbuddy-e2e-plugin -f tests/e2e/plugin-hooks/Dockerfile . + + - name: Run E2E tests in Docker + run: docker run --rm codingbuddy-e2e-plugin diff --git a/tests/e2e/plugin-hooks/Dockerfile b/tests/e2e/plugin-hooks/Dockerfile new file mode 100644 index 00000000..f8fc0b26 --- /dev/null +++ b/tests/e2e/plugin-hooks/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +# Install Node.js (needed for project context scanning in hooks) +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +# Copy project files (context provided by CI) +COPY . . + +# Install Python test dependencies +RUN pip install --no-cache-dir -r tests/e2e/plugin-hooks/requirements.txt + +# Configure git for tests (conftest.py uses git init) +RUN git config --global user.name "CI Bot" \ + && git config --global user.email "ci@test.local" \ + && git config --global init.defaultBranch main + +# Run E2E tests +CMD ["python3", "-m", "pytest", "tests/e2e/plugin-hooks/", "-v", "--timeout=30", "--tb=short"] diff --git a/tests/e2e/plugin-hooks/cli_mock.py b/tests/e2e/plugin-hooks/cli_mock.py new file mode 100644 index 00000000..f37f4f1d --- /dev/null +++ b/tests/e2e/plugin-hooks/cli_mock.py @@ -0,0 +1,219 @@ +"""Mock Claude Code CLI for E2E hook testing. + +Simulates the Claude Code hook protocol: +- Feeds JSON input via stdin to hook scripts +- Captures stdout/stderr output +- Validates JSON responses match expected hook contract +""" +import json +import os +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + + +# Resolve hooks directory relative to this project +_PROJECT_ROOT = Path(__file__).resolve().parents[3] +HOOKS_DIR = _PROJECT_ROOT / "packages" / "claude-code-plugin" / "hooks" + + +@dataclass +class HookResult: + """Result of executing a hook script.""" + + exit_code: int + stdout: str + stderr: str + json_output: Optional[Dict[str, Any]] = None + + @property + def succeeded(self) -> bool: + """Hook must always exit 0 (never block Claude Code).""" + return self.exit_code == 0 + + @property + def has_json(self) -> bool: + return self.json_output is not None + + @property + def additional_context(self) -> Optional[str]: + """Extract additionalContext from hookSpecificOutput.""" + if not self.json_output: + return None + hso = self.json_output.get("hookSpecificOutput", {}) + return hso.get("additionalContext") + + @property + def status_message(self) -> Optional[str]: + """Extract statusMessage from hookSpecificOutput.""" + if not self.json_output: + return None + hso = self.json_output.get("hookSpecificOutput", {}) + return hso.get("statusMessage") + + @property + def system_message(self) -> Optional[str]: + """Extract systemMessage from top-level output.""" + if not self.json_output: + return None + return self.json_output.get("systemMessage") + + +@dataclass +class MockEnvironment: + """Isolated environment for hook execution.""" + + home_dir: str + project_dir: str + env_vars: Dict[str, str] = field(default_factory=dict) + + def build_env(self) -> Dict[str, str]: + """Build environment variables for hook subprocess.""" + env = os.environ.copy() + env["HOME"] = self.home_dir + env["CLAUDE_PROJECT_DIR"] = self.project_dir + env["CLAUDE_CWD"] = self.project_dir + env["CLAUDE_PLUGIN_DIR"] = str(HOOKS_DIR.parent) + env["CLAUDE_PLUGIN_ROOT"] = str(HOOKS_DIR.parent) + # Isolate plugin data to temp dir + env["CLAUDE_PLUGIN_DATA"] = os.path.join(self.home_dir, ".codingbuddy") + # Prevent actual system language detection from interfering + env["LANG"] = "en_US.UTF-8" + env.update(self.env_vars) + return env + + +def run_hook( + hook_script: str, + input_data: Optional[Dict[str, Any]] = None, + env: Optional[MockEnvironment] = None, + timeout: int = 15, +) -> HookResult: + """Execute a hook script with simulated Claude Code protocol. + + Args: + hook_script: Filename of the hook in the hooks directory (e.g. "session-start.py"). + input_data: JSON-serializable dict to feed via stdin. + env: Mock environment for isolation. Uses real env if None. + timeout: Max seconds before killing the process. + + Returns: + HookResult with captured output. + """ + script_path = HOOKS_DIR / hook_script + if not script_path.exists(): + raise FileNotFoundError(f"Hook script not found: {script_path}") + + stdin_bytes = json.dumps(input_data).encode() if input_data else b"" + + proc_env = env.build_env() if env else os.environ.copy() + + try: + result = subprocess.run( + [sys.executable, str(script_path)], + input=stdin_bytes, + capture_output=True, + timeout=timeout, + env=proc_env, + cwd=env.project_dir if env else None, + ) + except subprocess.TimeoutExpired: + return HookResult(exit_code=1, stdout="", stderr="TIMEOUT") + + stdout_text = result.stdout.decode("utf-8", errors="replace") + stderr_text = result.stderr.decode("utf-8", errors="replace") + + # Try to parse stdout as JSON (hooks that use safe_main output JSON) + json_output = None + stdout_stripped = stdout_text.strip() + if stdout_stripped: + # Some hooks output plain text before JSON; try to find JSON at the end + for candidate in [stdout_stripped, stdout_stripped.split("\n")[-1]]: + try: + json_output = json.loads(candidate) + break + except (json.JSONDecodeError, IndexError): + continue + + return HookResult( + exit_code=result.returncode, + stdout=stdout_text, + stderr=stderr_text, + json_output=json_output, + ) + + +@dataclass +class LifecycleRunner: + """Simulates a full Claude Code session hook lifecycle. + + Runs hooks in order: SessionStart → PreToolUse → PostToolUse → Stop + """ + + env: MockEnvironment + results: List[HookResult] = field(default_factory=list) + + def session_start(self) -> HookResult: + """Execute SessionStart hook.""" + result = run_hook("session-start.py", env=self.env) + self.results.append(result) + return result + + def user_prompt_submit(self, prompt: str) -> HookResult: + """Execute UserPromptSubmit hook with a user prompt.""" + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": prompt}, + env=self.env, + ) + self.results.append(result) + return result + + def pre_tool_use( + self, + tool_name: str, + tool_input: Optional[Dict[str, Any]] = None, + ) -> HookResult: + """Execute PreToolUse hook.""" + result = run_hook( + "pre-tool-use.py", + input_data={ + "tool_name": tool_name, + "tool_input": tool_input or {}, + }, + env=self.env, + ) + self.results.append(result) + return result + + def post_tool_use( + self, + tool_name: str, + tool_input: Optional[Dict[str, Any]] = None, + tool_output: str = "", + ) -> HookResult: + """Execute PostToolUse hook.""" + result = run_hook( + "post-tool-use.py", + input_data={ + "tool_name": tool_name, + "tool_input": tool_input or {}, + "tool_output": tool_output, + }, + env=self.env, + ) + self.results.append(result) + return result + + def stop(self) -> HookResult: + """Execute Stop hook.""" + result = run_hook("stop.py", input_data={}, env=self.env) + self.results.append(result) + return result + + @property + def all_succeeded(self) -> bool: + """Check all hooks exited with code 0.""" + return all(r.succeeded for r in self.results) diff --git a/tests/e2e/plugin-hooks/conftest.py b/tests/e2e/plugin-hooks/conftest.py new file mode 100644 index 00000000..f605f230 --- /dev/null +++ b/tests/e2e/plugin-hooks/conftest.py @@ -0,0 +1,45 @@ +"""Shared fixtures for plugin hook E2E tests.""" +import os +import tempfile +from pathlib import Path + +import pytest + +from cli_mock import MockEnvironment, LifecycleRunner + + +@pytest.fixture() +def isolated_home(tmp_path): + """Create an isolated HOME directory with minimal .claude structure.""" + home = tmp_path / "home" + home.mkdir() + claude_dir = home / ".claude" + claude_dir.mkdir() + hooks_dir = claude_dir / "hooks" + hooks_dir.mkdir() + return str(home) + + +@pytest.fixture() +def project_dir(tmp_path): + """Create a temporary project directory with git init.""" + project = tmp_path / "project" + project.mkdir() + # Initialize git repo (needed for git-related hooks) + os.system(f"git init {project} --quiet") + return str(project) + + +@pytest.fixture() +def mock_env(isolated_home, project_dir): + """Create a MockEnvironment with isolated home and project dirs.""" + return MockEnvironment( + home_dir=isolated_home, + project_dir=project_dir, + ) + + +@pytest.fixture() +def lifecycle(mock_env): + """Create a LifecycleRunner with isolated environment.""" + return LifecycleRunner(env=mock_env) diff --git a/tests/e2e/plugin-hooks/requirements.txt b/tests/e2e/plugin-hooks/requirements.txt new file mode 100644 index 00000000..64853f62 --- /dev/null +++ b/tests/e2e/plugin-hooks/requirements.txt @@ -0,0 +1,2 @@ +pytest>=8.0.0 +pytest-timeout>=2.2.0 diff --git a/tests/e2e/plugin-hooks/test_full_lifecycle_e2e.py b/tests/e2e/plugin-hooks/test_full_lifecycle_e2e.py new file mode 100644 index 00000000..8897c34b --- /dev/null +++ b/tests/e2e/plugin-hooks/test_full_lifecycle_e2e.py @@ -0,0 +1,112 @@ +"""E2E tests for the full hook lifecycle. + +Simulates a complete Claude Code session: +SessionStart → UserPromptSubmit → PreToolUse → PostToolUse → Stop + +Verifies the hooks work together without interference. +""" +import pytest + +from cli_mock import LifecycleRunner, MockEnvironment + + +class TestFullLifecycle: + """Complete session lifecycle tests.""" + + def test_basic_session_lifecycle(self, lifecycle): + """All hooks execute successfully in sequence.""" + lifecycle.session_start() + lifecycle.user_prompt_submit("PLAN design a feature") + lifecycle.pre_tool_use("Bash", {"command": "ls -la"}) + lifecycle.post_tool_use("Bash", {"command": "ls -la"}, "file1\nfile2") + lifecycle.stop() + + assert lifecycle.all_succeeded, ( + f"Failed hooks: {[(i, r.exit_code, r.stderr) for i, r in enumerate(lifecycle.results) if not r.succeeded]}" + ) + + def test_auto_mode_lifecycle(self, lifecycle): + """AUTO mode lifecycle with multiple tool uses.""" + lifecycle.session_start() + lifecycle.user_prompt_submit("AUTO implement user dashboard") + + # Simulate multiple tool calls in AUTO mode + for _ in range(3): + lifecycle.pre_tool_use("Bash", {"command": "yarn test"}) + lifecycle.post_tool_use("Bash", {"command": "yarn test"}, "Tests passed") + + lifecycle.pre_tool_use("Edit", {"file_path": "src/app.ts"}) + lifecycle.post_tool_use("Edit", {"file_path": "src/app.ts"}) + lifecycle.stop() + + assert lifecycle.all_succeeded + + def test_git_commit_in_lifecycle(self, lifecycle): + """Git commit during lifecycle triggers quality gate check.""" + lifecycle.session_start() + lifecycle.user_prompt_submit("ACT implement changes") + + # Normal tool use + lifecycle.pre_tool_use("Read", {"file_path": "src/main.ts"}) + lifecycle.post_tool_use("Read", {"file_path": "src/main.ts"}) + + # Git commit — may trigger quality gate + commit_result = lifecycle.pre_tool_use( + "Bash", {"command": "git commit -m 'feat: add feature'"} + ) + assert commit_result.succeeded + + lifecycle.stop() + assert lifecycle.all_succeeded + + def test_session_with_no_tool_calls(self, lifecycle): + """Session that starts and stops without tool calls.""" + lifecycle.session_start() + lifecycle.stop() + + assert lifecycle.all_succeeded + assert len(lifecycle.results) == 2 + + def test_multiple_prompts_in_session(self, lifecycle): + """Multiple user prompts within a single session.""" + lifecycle.session_start() + + lifecycle.user_prompt_submit("PLAN design auth") + lifecycle.pre_tool_use("Bash", {"command": "ls src/"}) + lifecycle.post_tool_use("Bash", {"command": "ls src/"}) + + lifecycle.user_prompt_submit("ACT implement auth") + lifecycle.pre_tool_use("Write", {"file_path": "src/auth.ts"}) + lifecycle.post_tool_use("Write", {"file_path": "src/auth.ts"}) + + lifecycle.user_prompt_submit("EVAL review auth") + lifecycle.pre_tool_use("Read", {"file_path": "src/auth.ts"}) + lifecycle.post_tool_use("Read", {"file_path": "src/auth.ts"}) + + lifecycle.stop() + assert lifecycle.all_succeeded + + +class TestLifecycleIsolation: + """Verify hooks don't interfere with each other.""" + + def test_session_start_does_not_affect_pre_tool_use(self, lifecycle): + """SessionStart output doesn't leak into PreToolUse.""" + start_result = lifecycle.session_start() + pre_result = lifecycle.pre_tool_use("Bash", {"command": "echo hello"}) + + assert start_result.succeeded + assert pre_result.succeeded + + def test_stop_after_error_in_pre_tool_use(self, mock_env): + """Stop works even after PreToolUse processes unusual input.""" + lifecycle = LifecycleRunner(env=mock_env) + lifecycle.session_start() + + # Send unusual input to PreToolUse + lifecycle.pre_tool_use("UnknownTool", {"weird": "data"}) + + # Stop should still work + stop_result = lifecycle.stop() + assert stop_result.succeeded + assert lifecycle.all_succeeded diff --git a/tests/e2e/plugin-hooks/test_pre_tool_use_e2e.py b/tests/e2e/plugin-hooks/test_pre_tool_use_e2e.py new file mode 100644 index 00000000..5aa0b3a0 --- /dev/null +++ b/tests/e2e/plugin-hooks/test_pre_tool_use_e2e.py @@ -0,0 +1,171 @@ +"""E2E tests for the PreToolUse hook lifecycle. + +Verifies: +- Hook always exits 0 (never blocks Claude Code) +- Git commit quality gate triggers additionalContext +- Agent status message is returned via statusMessage +- Non-Bash tools pass through without intervention +""" +import json +import os + +import pytest + +from cli_mock import run_hook, MockEnvironment + + +class TestPreToolUseNeverBlocks: + """PreToolUse must NEVER block Claude Code.""" + + def test_exits_zero_for_bash_tool(self, mock_env): + """Hook exits 0 for normal Bash commands.""" + result = run_hook( + "pre-tool-use.py", + input_data={"tool_name": "Bash", "tool_input": {"command": "ls -la"}}, + env=mock_env, + ) + assert result.succeeded + + def test_exits_zero_for_read_tool(self, mock_env): + """Hook exits 0 for non-Bash tools.""" + result = run_hook( + "pre-tool-use.py", + input_data={"tool_name": "Read", "tool_input": {"file_path": "/tmp/test"}}, + env=mock_env, + ) + assert result.succeeded + + def test_exits_zero_with_empty_input(self, mock_env): + """Hook exits 0 even with empty JSON input.""" + result = run_hook( + "pre-tool-use.py", + input_data={}, + env=mock_env, + ) + assert result.succeeded + + def test_exits_zero_with_malformed_input(self, mock_env): + """Hook exits 0 even with unexpected input structure.""" + result = run_hook( + "pre-tool-use.py", + input_data={"unexpected": "data"}, + env=mock_env, + ) + assert result.succeeded + + +class TestGitCommitQualityGate: + """PreToolUse enforces quality gates on git commit commands.""" + + def test_no_gate_when_disabled(self, mock_env): + """No quality gate context when qualityGates.enabled is false (default).""" + result = run_hook( + "pre-tool-use.py", + input_data={ + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'test'"}, + }, + env=mock_env, + ) + assert result.succeeded + # Quality gates are disabled by default, so no context about quality + if result.additional_context: + assert "Quality Gate" not in result.additional_context + + def test_gate_triggers_when_enabled(self, mock_env): + """Quality gate context appears when enabled in config.""" + # Create a config that enables quality gates + config = {"qualityGates": {"enabled": True}} + config_path = os.path.join(mock_env.project_dir, "codingbuddy.config.json") + with open(config_path, "w") as f: + json.dump(config, f) + + result = run_hook( + "pre-tool-use.py", + input_data={ + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'feat: add feature'"}, + }, + env=mock_env, + ) + assert result.succeeded + if result.additional_context: + assert "Quality Gate" in result.additional_context + + def test_no_gate_for_non_commit_git(self, mock_env): + """No quality gate for non-commit git commands.""" + config = {"qualityGates": {"enabled": True}} + config_path = os.path.join(mock_env.project_dir, "codingbuddy.config.json") + with open(config_path, "w") as f: + json.dump(config, f) + + result = run_hook( + "pre-tool-use.py", + input_data={ + "tool_name": "Bash", + "tool_input": {"command": "git status"}, + }, + env=mock_env, + ) + assert result.succeeded + if result.additional_context: + assert "Quality Gate" not in result.additional_context + + +class TestAgentStatus: + """PreToolUse returns agent status in statusMessage.""" + + def test_status_message_with_active_agent(self, mock_env): + """statusMessage appears when CODINGBUDDY_ACTIVE_AGENT is set.""" + mock_env.env_vars["CODINGBUDDY_ACTIVE_AGENT"] = "frontend-developer" + result = run_hook( + "pre-tool-use.py", + input_data={"tool_name": "Bash", "tool_input": {"command": "ls"}}, + env=mock_env, + ) + assert result.succeeded + # Agent status is built by agent_status module + # If the module is available, statusMessage should contain agent info + if result.has_json and result.status_message: + assert len(result.status_message) > 0 + + def test_no_status_without_active_agent(self, mock_env): + """No statusMessage when no agent is active.""" + mock_env.env_vars.pop("CODINGBUDDY_ACTIVE_AGENT", None) + result = run_hook( + "pre-tool-use.py", + input_data={"tool_name": "Read", "tool_input": {}}, + env=mock_env, + ) + assert result.succeeded + + +class TestNonBashPassthrough: + """Non-Bash tools should pass through without Bash-specific checks.""" + + def test_edit_tool_passthrough(self, mock_env): + """Edit tool triggers no Bash-specific context.""" + result = run_hook( + "pre-tool-use.py", + input_data={ + "tool_name": "Edit", + "tool_input": {"file_path": "/tmp/test.py", "old_string": "a", "new_string": "b"}, + }, + env=mock_env, + ) + assert result.succeeded + if result.additional_context: + assert "Quality Gate" not in result.additional_context + assert "Rules/config changed" not in result.additional_context + + def test_write_tool_passthrough(self, mock_env): + """Write tool triggers no Bash-specific context.""" + result = run_hook( + "pre-tool-use.py", + input_data={ + "tool_name": "Write", + "tool_input": {"file_path": "/tmp/new.py", "content": "hello"}, + }, + env=mock_env, + ) + assert result.succeeded diff --git a/tests/e2e/plugin-hooks/test_session_start_e2e.py b/tests/e2e/plugin-hooks/test_session_start_e2e.py new file mode 100644 index 00000000..db9c9fe1 --- /dev/null +++ b/tests/e2e/plugin-hooks/test_session_start_e2e.py @@ -0,0 +1,107 @@ +"""E2E tests for the SessionStart hook lifecycle. + +Verifies: +- Hook always exits 0 (never blocks Claude Code) +- UserPromptSubmit hook file is installed to ~/.claude/hooks/ +- Hook is registered in ~/.claude/settings.json +- Buddy greeting is rendered on session start +""" +import json +import os +from pathlib import Path + +import pytest + +from cli_mock import run_hook, MockEnvironment, HOOKS_DIR + + +class TestSessionStartNeverBlocks: + """SessionStart must NEVER block Claude Code, even on errors.""" + + def test_exits_zero_in_clean_environment(self, mock_env): + """Hook exits 0 in a fresh, empty environment.""" + result = run_hook("session-start.py", env=mock_env) + assert result.succeeded, f"stderr: {result.stderr}" + + def test_exits_zero_with_missing_plugin_dir(self, mock_env): + """Hook exits 0 even when CLAUDE_PLUGIN_DIR points nowhere.""" + mock_env.env_vars["CLAUDE_PLUGIN_DIR"] = "/nonexistent/path" + result = run_hook("session-start.py", env=mock_env) + assert result.succeeded + + def test_exits_zero_with_corrupted_settings(self, mock_env): + """Hook exits 0 even when settings.json is corrupted.""" + settings = Path(mock_env.home_dir) / ".claude" / "settings.json" + settings.write_text("NOT VALID JSON {{{") + result = run_hook("session-start.py", env=mock_env) + assert result.succeeded + + +class TestHookInstallation: + """SessionStart installs the UserPromptSubmit hook file.""" + + def test_installs_mode_detect_hook(self, mock_env): + """Hook copies mode detection script to ~/.claude/hooks/.""" + result = run_hook("session-start.py", env=mock_env) + assert result.succeeded + + target = Path(mock_env.home_dir) / ".claude" / "hooks" / "codingbuddy-mode-detect.py" + assert target.exists(), "Mode detection hook was not installed" + + def test_hook_file_is_executable(self, mock_env): + """Installed hook file has executable permission.""" + run_hook("session-start.py", env=mock_env) + target = Path(mock_env.home_dir) / ".claude" / "hooks" / "codingbuddy-mode-detect.py" + if target.exists(): + assert os.access(str(target), os.X_OK), "Hook file is not executable" + + def test_registers_hook_in_settings(self, mock_env): + """Hook registers UserPromptSubmit in settings.json.""" + run_hook("session-start.py", env=mock_env) + + settings_path = Path(mock_env.home_dir) / ".claude" / "settings.json" + if settings_path.exists(): + settings = json.loads(settings_path.read_text()) + hooks = settings.get("hooks", {}).get("UserPromptSubmit", []) + commands = [ + h.get("command", "") + for group in hooks + for h in group.get("hooks", []) + ] + assert any("codingbuddy-mode-detect.py" in cmd for cmd in commands), ( + f"Hook not registered. Commands found: {commands}" + ) + + def test_idempotent_installation(self, mock_env): + """Running SessionStart twice does not duplicate the hook.""" + run_hook("session-start.py", env=mock_env) + run_hook("session-start.py", env=mock_env) + + settings_path = Path(mock_env.home_dir) / ".claude" / "settings.json" + if settings_path.exists(): + settings = json.loads(settings_path.read_text()) + hooks = settings.get("hooks", {}).get("UserPromptSubmit", []) + commands = [ + h.get("command", "") + for group in hooks + for h in group.get("hooks", []) + ] + mode_detect_count = sum( + 1 for cmd in commands if "codingbuddy-mode-detect.py" in cmd + ) + assert mode_detect_count == 1, ( + f"Hook registered {mode_detect_count} times (expected 1)" + ) + + +class TestBuddyGreeting: + """SessionStart renders buddy greeting output.""" + + def test_produces_stdout_output(self, mock_env): + """Hook produces some output on stdout (greeting or install message).""" + result = run_hook("session-start.py", env=mock_env) + # SessionStart should produce some output (hook install msg or greeting) + # The exact content depends on lib modules availability + assert result.succeeded + # stdout may contain install message, greeting, or system prompt injection + # We just verify it doesn't crash diff --git a/tests/e2e/plugin-hooks/test_stop_e2e.py b/tests/e2e/plugin-hooks/test_stop_e2e.py new file mode 100644 index 00000000..13120c7a --- /dev/null +++ b/tests/e2e/plugin-hooks/test_stop_e2e.py @@ -0,0 +1,82 @@ +"""E2E tests for the Stop hook lifecycle. + +Verifies: +- Hook always exits 0 (never blocks Claude Code) +- Session summary is generated as systemMessage +- Buddy session summary renders to stderr +""" +import json +import os + +import pytest + +from cli_mock import run_hook, MockEnvironment + + +class TestStopNeverBlocks: + """Stop hook must NEVER block Claude Code.""" + + def test_exits_zero_with_empty_input(self, mock_env): + """Hook exits 0 with empty input data.""" + result = run_hook("stop.py", input_data={}, env=mock_env) + assert result.succeeded + + def test_exits_zero_with_no_stats(self, mock_env): + """Hook exits 0 when no session stats exist.""" + result = run_hook("stop.py", input_data={}, env=mock_env) + assert result.succeeded + + def test_exits_zero_with_corrupted_stats(self, mock_env): + """Hook exits 0 even when stats data is corrupted.""" + # Create corrupted stats file + stats_dir = os.path.join(mock_env.home_dir, ".codingbuddy", "stats") + os.makedirs(stats_dir, exist_ok=True) + corrupted = os.path.join(stats_dir, "session.json") + with open(corrupted, "w") as f: + f.write("NOT JSON {{{") + + result = run_hook("stop.py", input_data={}, env=mock_env) + assert result.succeeded + + +class TestSessionSummary: + """Stop hook generates session summary.""" + + def test_returns_system_message_when_stats_exist(self, mock_env): + """systemMessage is returned when session stats are available.""" + # Initialize a session via SessionStart first (creates stats) + run_hook("session-start.py", env=mock_env) + + # Simulate some tool calls via PostToolUse + for tool in ["Bash", "Read", "Edit"]: + run_hook( + "post-tool-use.py", + input_data={"tool_name": tool, "tool_input": {}}, + env=mock_env, + ) + + # Now stop + result = run_hook("stop.py", input_data={}, env=mock_env) + assert result.succeeded + # If stats module successfully tracked calls, we get a systemMessage + # This depends on stats module being importable in isolated env + + def test_stop_without_session_start(self, mock_env): + """Stop gracefully handles case where SessionStart was not called.""" + result = run_hook("stop.py", input_data={}, env=mock_env) + assert result.succeeded + + +class TestBuddySessionSummary: + """Stop hook renders buddy summary to stderr.""" + + def test_stderr_output_on_stop(self, mock_env): + """Buddy summary may appear on stderr during stop.""" + # Start a session + run_hook("session-start.py", env=mock_env) + + # Stop + result = run_hook("stop.py", input_data={}, env=mock_env) + assert result.succeeded + # stderr may contain buddy session summary if renderer is available + # We just verify it doesn't crash diff --git a/tests/e2e/plugin-hooks/test_user_prompt_submit_e2e.py b/tests/e2e/plugin-hooks/test_user_prompt_submit_e2e.py new file mode 100644 index 00000000..a0cd5b76 --- /dev/null +++ b/tests/e2e/plugin-hooks/test_user_prompt_submit_e2e.py @@ -0,0 +1,177 @@ +"""E2E tests for the UserPromptSubmit (mode detection) hook. + +Verifies: +- Mode keyword detection for all supported languages +- Context injection with correct mode name +- Non-mode prompts produce no output +- Hook always exits 0 +""" +import json + +import pytest + +from cli_mock import run_hook, MockEnvironment + + +class TestModeDetectionNeverBlocks: + """UserPromptSubmit must NEVER block Claude Code.""" + + def test_exits_zero_with_normal_prompt(self, mock_env): + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": "Help me fix a bug"}, + env=mock_env, + ) + assert result.succeeded + + def test_exits_zero_with_empty_prompt(self, mock_env): + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": ""}, + env=mock_env, + ) + assert result.succeeded + + def test_exits_zero_with_missing_prompt(self, mock_env): + result = run_hook( + "user-prompt-submit.py", + input_data={}, + env=mock_env, + ) + assert result.succeeded + + +class TestEnglishModeKeywords: + """Detect English mode keywords: PLAN, ACT, EVAL, AUTO.""" + + @pytest.mark.parametrize("keyword,mode", [ + ("PLAN design auth feature", "PLAN"), + ("ACT implement the changes", "ACT"), + ("EVAL review the code", "EVAL"), + ("AUTO implement user dashboard", "AUTO"), + ]) + def test_detects_english_keyword(self, mock_env, keyword, mode): + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": keyword}, + env=mock_env, + ) + assert result.succeeded + assert f"MODE_KEYWORD_DETECTED: {mode}" in result.stdout + + def test_no_detection_for_normal_prompt(self, mock_env): + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": "Fix the login button"}, + env=mock_env, + ) + assert result.succeeded + assert "MODE_KEYWORD_DETECTED" not in result.stdout + + +class TestKoreanModeKeywords: + """Detect Korean mode keywords.""" + + @pytest.mark.parametrize("keyword,mode", [ + ("계획 인증 기능 설계", "PLAN"), + ("실행 변경 사항 구현", "ACT"), + ("평가 코드 리뷰", "EVAL"), + ("자동 대시보드 구현", "AUTO"), + ]) + def test_detects_korean_keyword(self, mock_env, keyword, mode): + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": keyword}, + env=mock_env, + ) + assert result.succeeded + assert f"MODE_KEYWORD_DETECTED: {mode}" in result.stdout + + +class TestJapaneseModeKeywords: + """Detect Japanese mode keywords.""" + + @pytest.mark.parametrize("keyword,mode", [ + ("計画 認証機能の設計", "PLAN"), + ("実行 変更の実装", "ACT"), + ("評価 コードレビュー", "EVAL"), + ("自動 ダッシュボード実装", "AUTO"), + ]) + def test_detects_japanese_keyword(self, mock_env, keyword, mode): + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": keyword}, + env=mock_env, + ) + assert result.succeeded + assert f"MODE_KEYWORD_DETECTED: {mode}" in result.stdout + + +class TestChineseModeKeywords: + """Detect Chinese mode keywords.""" + + @pytest.mark.parametrize("keyword,mode", [ + ("计划 设计认证功能", "PLAN"), + ("执行 实施变更", "ACT"), + ("评估 代码审查", "EVAL"), + ("自动 实现仪表板", "AUTO"), + ]) + def test_detects_chinese_keyword(self, mock_env, keyword, mode): + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": keyword}, + env=mock_env, + ) + assert result.succeeded + assert f"MODE_KEYWORD_DETECTED: {mode}" in result.stdout + + +class TestSpanishModeKeywords: + """Detect Spanish mode keywords.""" + + @pytest.mark.parametrize("keyword,mode", [ + ("PLANIFICAR diseñar autenticación", "PLAN"), + ("ACTUAR implementar cambios", "ACT"), + ("EVALUAR revisar código", "EVAL"), + ("AUTOMÁTICO implementar dashboard", "AUTO"), + ]) + def test_detects_spanish_keyword(self, mock_env, keyword, mode): + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": keyword}, + env=mock_env, + ) + assert result.succeeded + assert f"MODE_KEYWORD_DETECTED: {mode}" in result.stdout + + +class TestContextInjection: + """Verify correct context format is injected.""" + + def test_context_contains_mandatory_action(self, mock_env): + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": "PLAN design a new feature"}, + env=mock_env, + ) + assert "MANDATORY_ACTION" in result.stdout + assert "parse_mode" in result.stdout + + def test_context_wrapped_in_tags(self, mock_env): + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": "AUTO implement feature"}, + env=mock_env, + ) + assert "" in result.stdout + assert "" in result.stdout + + def test_case_insensitive_detection(self, mock_env): + """Keywords should be detected case-insensitively.""" + result = run_hook( + "user-prompt-submit.py", + input_data={"prompt": "plan design something"}, + env=mock_env, + ) + assert result.succeeded + assert "MODE_KEYWORD_DETECTED: PLAN" in result.stdout