feat(seedgo+hooks): DPLAN-0139 Track B — hook test suite unification
- hooks.py v1.1.0: new test and list subcommands - hooks_ext.py: cmd_hooks_test (pytest per test file, Rich table) + cmd_hooks_list (reads ~/.claude/settings.json + .claude/settings.json, shows all wired hooks with version, event, matcher, exists status) - Version headers added to notification_sound.py, stop_sound.py, tool_use_sound.py (all 10 hooks now have version headers) - test_hooks_utility.py: 14 smoke tests for 7 previously uncovered hooks (branch_prompt_loader, email_notification, identity_injector, pre_compact, notification_sound, stop_sound, tool_use_sound) - test_hooks_track_b.py: 7 tests for hooks test + hooks list subcommands - 443 total tests passing (422 → 443) Note: drone @git pr scope bug (DPLAN-0140) hit again — raw git/gh used.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# Version: 1.0.0
|
||||
"""Notification Hook — Plays sound when AI needs permission."""
|
||||
|
||||
import json
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# Version: 1.0.0
|
||||
"""Stop Hook — Plays achievement bell when AI finishes responding."""
|
||||
|
||||
import json
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# Version: 1.0.0
|
||||
"""Tool Use Hook — Plays key press sound when AI uses tools."""
|
||||
|
||||
import json
|
||||
|
||||
@@ -1 +1,35 @@
|
||||
# File handlers package
|
||||
# =================== AIPass ====================
|
||||
# Name: file/__init__.py
|
||||
# Description: File I/O handler — centralises open() calls for modules
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-21
|
||||
# Modified: 2026-04-21
|
||||
# =============================================
|
||||
|
||||
"""File I/O handler.
|
||||
|
||||
Modules must not call open() directly. Use these helpers instead.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def read_text_safe(path: Path, encoding: str = "utf-8") -> str | None:
|
||||
"""Read a text file. Returns None on any OSError."""
|
||||
try:
|
||||
with open(path, encoding=encoding) as fh:
|
||||
return fh.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def read_lines_safe(path: Path, n: int = 0, encoding: str = "utf-8") -> list[str]:
|
||||
"""Read up to n lines from a file (0 = all). Returns [] on any OSError."""
|
||||
try:
|
||||
with open(path, encoding=encoding) as fh:
|
||||
if n > 0:
|
||||
return [fh.readline() for _ in range(n)]
|
||||
return fh.readlines()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hooks.py
|
||||
# Description: Hook Probe Display Module
|
||||
# Version: 1.0.0
|
||||
# Description: Hook Probe, Test, and List Module
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-04-20
|
||||
# Modified: 2026-04-20
|
||||
# Modified: 2026-04-21
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Hook Probe Display Module
|
||||
Hook Probe, Test, and List Module
|
||||
|
||||
Reads last_ping.jsonl produced by .claude/hooks/probes/ scripts and
|
||||
surfaces probe data via Rich tables and reports.
|
||||
@@ -18,6 +18,8 @@ Subcommands:
|
||||
hooks probe Show recent probe entries as a Rich table
|
||||
hooks probe --subagent Spawn headless Claude, check if PostToolUse/SubagentStop fired
|
||||
hooks probe --matrix Full event matrix + markdown report
|
||||
hooks test Run hook test suite, display per-file pass/fail table
|
||||
hooks list Show every wired hook from project and global settings
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -46,6 +48,9 @@ from aipass.cli.apps.modules import warning
|
||||
# JSON handler for tracking
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
|
||||
# Extended subcommands (test + list)
|
||||
from aipass.seedgo.apps.modules.hooks_ext import cmd_hooks_list, cmd_hooks_test
|
||||
|
||||
# Rich output
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
@@ -452,6 +457,21 @@ def _write_matrix_report(report_path: Path, matrix_rows: list, entries: list) ->
|
||||
logger.info("hooks.py: failed to write report to %s: %s", report_path, exc)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks test + hooks list (delegated to hooks_ext)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _cmd_hooks_test() -> None:
|
||||
"""Run hook test suite — delegates to hooks_ext."""
|
||||
cmd_hooks_test(_get_repo_root())
|
||||
|
||||
|
||||
def _cmd_hooks_list() -> None:
|
||||
"""Show wired hooks — delegates to hooks_ext."""
|
||||
cmd_hooks_list(_get_repo_root())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTROSPECTION
|
||||
# =============================================================================
|
||||
@@ -480,6 +500,8 @@ def print_introspection() -> None:
|
||||
console.print(
|
||||
" [green]drone @seedgo hooks probe --matrix[/green] [dim]# Full event matrix + markdown report[/dim]"
|
||||
)
|
||||
console.print(" [green]drone @seedgo hooks test[/green] [dim]# Run hook test suite[/dim]")
|
||||
console.print(" [green]drone @seedgo hooks list[/green] [dim]# List all wired hooks[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Probe scripts:[/yellow]")
|
||||
@@ -540,6 +562,14 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
_cmd_probe_display()
|
||||
return True
|
||||
|
||||
if subcommand == "test":
|
||||
_cmd_hooks_test()
|
||||
return True
|
||||
|
||||
if subcommand == "list":
|
||||
_cmd_hooks_list()
|
||||
return True
|
||||
|
||||
# Unknown subcommand — show introspection
|
||||
console.print(f"[dim]Unknown subcommand: {subcommand!r} — showing help[/dim]")
|
||||
print_introspection()
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hooks_ext.py
|
||||
# Description: Hook Test and List Subcommands (split from hooks.py)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-21
|
||||
# Modified: 2026-04-21
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Hook Test and List Subcommands
|
||||
|
||||
Extended subcommands for hooks.py — split out to keep hooks.py under 700 lines.
|
||||
|
||||
hooks test Run hook test suite, display per-file pass/fail table
|
||||
hooks list Show every wired hook from project and global settings
|
||||
"""
|
||||
|
||||
import glob as _glob
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli import console
|
||||
from aipass.cli.apps.modules import warning
|
||||
from aipass.seedgo.apps.handlers.file import read_lines_safe
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks test
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _parse_pytest_counts(stdout: str) -> tuple[int, int]:
|
||||
"""Parse passed/failed counts from pytest -q output. Returns (passed, failed)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if "passed" in line or "failed" in line or "error" in line.lower():
|
||||
m_passed = re.search(r"(\d+) passed", line)
|
||||
m_failed = re.search(r"(\d+) failed", line)
|
||||
m_error = re.search(r"(\d+) error", line)
|
||||
if m_passed:
|
||||
passed = int(m_passed.group(1))
|
||||
if m_failed:
|
||||
failed = int(m_failed.group(1))
|
||||
if m_error:
|
||||
failed += int(m_error.group(1))
|
||||
return passed, failed
|
||||
|
||||
|
||||
def cmd_hooks_test(repo_root: Path) -> None:
|
||||
"""Run hook test suite, display per-file pass/fail table."""
|
||||
pattern = str(repo_root / "src" / "aipass" / "seedgo" / "tests" / "test_hooks*.py")
|
||||
test_files = sorted(_glob.glob(pattern))
|
||||
|
||||
if not test_files:
|
||||
warning("No test_hooks*.py files found")
|
||||
return
|
||||
|
||||
table = Table(
|
||||
title="[bold cyan][HOOKS TEST][/bold cyan] Hook Test Suite",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
border_style="dim",
|
||||
)
|
||||
table.add_column("Test File", style="cyan", no_wrap=True)
|
||||
table.add_column("Tests", justify="right", style="yellow")
|
||||
table.add_column("Passed", justify="right", style="green")
|
||||
table.add_column("Failed", justify="right", style="red")
|
||||
table.add_column("Duration", justify="right", style="dim")
|
||||
table.add_column("Status", justify="center")
|
||||
|
||||
total_passed = 0
|
||||
total_failed = 0
|
||||
|
||||
for tf in test_files:
|
||||
stem = Path(tf).stem
|
||||
t0 = time.monotonic()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", tf, "--tb=no", "-q", "--no-header"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
duration = time.monotonic() - t0
|
||||
passed, failed = _parse_pytest_counts(proc.stdout)
|
||||
total_passed += passed
|
||||
total_failed += failed
|
||||
status = (
|
||||
"[green]PASS[/green]"
|
||||
if failed == 0 and passed > 0
|
||||
else ("[red]FAIL[/red]" if failed > 0 else "[yellow]NONE[/yellow]")
|
||||
)
|
||||
table.add_row(
|
||||
stem,
|
||||
str(passed + failed),
|
||||
str(passed),
|
||||
str(failed),
|
||||
f"{duration:.1f}s",
|
||||
status,
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print(f"\n[bold]Total:[/bold] [green]{total_passed} passed[/green] [red]{total_failed} failed[/red]\n")
|
||||
json_handler.log_operation(
|
||||
"hooks_test_run",
|
||||
{"files": len(test_files), "total_passed": total_passed, "total_failed": total_failed},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks list — helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def read_settings_file(path: Path) -> dict:
|
||||
"""Read and parse a settings.json file. Returns {} on failure."""
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.info("hooks_ext.py: could not read %s: %s", path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def extract_hook_script(command: str) -> str:
|
||||
"""Extract the Python script filename from a hook command string."""
|
||||
parts = command.split()
|
||||
for part in reversed(parts):
|
||||
if part.endswith(".py"):
|
||||
return Path(part).name
|
||||
return command[:40] if command else "(unknown)"
|
||||
|
||||
|
||||
def _parse_version_from_lines(lines: list) -> str | None:
|
||||
"""Scan lines for a Version: comment. Returns version string or None."""
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# Version:") or stripped.startswith("Version:"):
|
||||
return stripped.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def read_hook_version(command: str) -> str:
|
||||
"""Extract version string from a hook file's first 20 lines."""
|
||||
parts = command.split()
|
||||
for part in reversed(parts):
|
||||
if part.endswith(".py"):
|
||||
raw_lines = read_lines_safe(Path(part), n=20)
|
||||
version = _parse_version_from_lines(raw_lines)
|
||||
if version is not None:
|
||||
return version
|
||||
return "?"
|
||||
|
||||
|
||||
def _add_hook_defs_to_table(
|
||||
table: Table,
|
||||
hook_defs: list,
|
||||
event_name: str,
|
||||
matcher: str,
|
||||
location: str,
|
||||
seen: set,
|
||||
) -> int:
|
||||
"""Add hook definitions to table, skipping duplicates. Returns count added."""
|
||||
count = 0
|
||||
for hook_def in hook_defs:
|
||||
if not isinstance(hook_def, dict):
|
||||
continue
|
||||
command = hook_def.get("command", "")
|
||||
if not command:
|
||||
continue
|
||||
script_name = extract_hook_script(command)
|
||||
dedup_key = (script_name, event_name, location)
|
||||
if dedup_key in seen:
|
||||
continue
|
||||
seen.add(dedup_key)
|
||||
version = read_hook_version(command)
|
||||
script_path_parts = [p for p in command.split() if p.endswith(".py")]
|
||||
exists = Path(script_path_parts[0]).exists() if script_path_parts else False
|
||||
table.add_row(
|
||||
script_name,
|
||||
event_name,
|
||||
matcher[:35] if len(matcher) > 35 else matcher,
|
||||
version,
|
||||
location,
|
||||
"[green]✓[/green]" if exists else "[red]✗[/red]",
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _populate_hooks_table(table: Table, sources: list, seen: set) -> int:
|
||||
"""Populate hooks list table from settings sources. Returns row count."""
|
||||
total = 0
|
||||
for _label, path, location in sources:
|
||||
settings = read_settings_file(path)
|
||||
for event_name, entries in settings.get("hooks", {}).items():
|
||||
hook_entries = entries if isinstance(entries, list) else []
|
||||
for entry in hook_entries:
|
||||
matcher = entry.get("matcher", "*") if isinstance(entry, dict) else "*"
|
||||
hook_defs = entry.get("hooks", []) if isinstance(entry, dict) else []
|
||||
total += _add_hook_defs_to_table(table, hook_defs, event_name, matcher, location, seen)
|
||||
return total
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks list
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def cmd_hooks_list(repo_root: Path) -> None:
|
||||
"""Show every wired hook from both project and user-global settings."""
|
||||
global_settings_path = Path.home() / ".claude" / "settings.json"
|
||||
project_settings_path = repo_root / ".claude" / "settings.json"
|
||||
|
||||
sources = [
|
||||
("~/.claude/settings.json", global_settings_path, "global"),
|
||||
(".claude/settings.json", project_settings_path, "project"),
|
||||
]
|
||||
|
||||
table = Table(
|
||||
title="[bold cyan][HOOKS LIST][/bold cyan] Wired Hooks",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
border_style="dim",
|
||||
expand=True,
|
||||
)
|
||||
table.add_column("Hook Script", style="cyan", no_wrap=True)
|
||||
table.add_column("Event", style="yellow")
|
||||
table.add_column("Matcher", style="dim")
|
||||
table.add_column("Version", justify="center", style="green")
|
||||
table.add_column("Source", style="dim")
|
||||
table.add_column("Exists", justify="center")
|
||||
|
||||
seen: set = set()
|
||||
total = _populate_hooks_table(table, sources, seen)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print(f"\n[dim]{total} hook entries across both settings files[/dim]\n")
|
||||
|
||||
json_handler.log_operation("hooks_list", {"total": total})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DRONE ROUTING (helper module — delegates to hooks.py)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Print subcommand info for this helper module."""
|
||||
console.print("[bold cyan]hooks_ext[/bold cyan] — hook test and list helper")
|
||||
console.print(" [dim]Use via hooks.py: drone @seedgo hooks test | hooks list[/dim]")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Not a primary drone module — delegates to hooks.py for routing."""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests for Track B hooks subcommands — hooks test and hooks list.
|
||||
|
||||
# =================== META ====================
|
||||
# Name: test_hooks_track_b.py
|
||||
# Description: Tests for new hooks test and hooks list subcommands (DPLAN-0139 Track B)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-21
|
||||
# Modified: 2026-04-21
|
||||
# =============================================
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Infrastructure mocks (same pattern as test_hooks_probe.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_hooks_infrastructure(monkeypatch):
|
||||
"""Mock aipass infrastructure imports for hooks module tests."""
|
||||
mock_logger = MagicMock()
|
||||
mock_console = MagicMock()
|
||||
mock_warning = MagicMock()
|
||||
mock_json_handler = MagicMock()
|
||||
|
||||
prax_mod = MagicMock()
|
||||
prax_mod.logger = mock_logger
|
||||
monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod)
|
||||
|
||||
cli_mod = MagicMock()
|
||||
cli_mod.console = mock_console
|
||||
monkeypatch.setitem(sys.modules, "aipass.cli", cli_mod)
|
||||
|
||||
cli_apps = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "aipass.cli.apps", cli_apps)
|
||||
|
||||
cli_modules = MagicMock()
|
||||
cli_modules.warning = mock_warning
|
||||
monkeypatch.setitem(sys.modules, "aipass.cli.apps.modules", cli_modules)
|
||||
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json", json_pkg)
|
||||
json_mod = MagicMock()
|
||||
json_mod.log_operation = mock_json_handler.log_operation
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json.json_handler", json_mod)
|
||||
|
||||
file_pkg = MagicMock()
|
||||
file_pkg.read_lines_safe = MagicMock(return_value=[])
|
||||
file_pkg.read_text_safe = MagicMock(return_value=None)
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.file", file_pkg)
|
||||
|
||||
# Force re-import of both modules
|
||||
monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.hooks_ext", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.hooks", raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in (current, *current.parents):
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
return Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
_AUTO_FIX_PATH = _find_repo_root() / ".claude" / "hooks" / "auto_fix_diagnostics.py"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hooks test subcommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cmd_hooks_test_runs_without_crash():
|
||||
"""_cmd_hooks_test() runs without crash; subprocess.run is mocked to avoid real pytest spawn."""
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
from aipass.seedgo.apps.modules import hooks as hooks_mod
|
||||
|
||||
fake_result = MagicMock()
|
||||
fake_result.stdout = "1 passed in 0.1s\n"
|
||||
fake_result.returncode = 0
|
||||
|
||||
with _patch("subprocess.run", return_value=fake_result):
|
||||
result = hooks_mod.handle_command("hooks", ["test"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_cmd_hooks_test_handles_no_test_files(monkeypatch):
|
||||
"""When glob returns no files, hooks test warns and returns without crash."""
|
||||
import glob as _glob_mod
|
||||
|
||||
from aipass.seedgo.apps.modules import hooks as hooks_mod
|
||||
|
||||
monkeypatch.setattr(_glob_mod, "glob", lambda p: [])
|
||||
|
||||
result = hooks_mod.handle_command("hooks", ["test"])
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hooks list subcommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cmd_hooks_list_runs_without_crash():
|
||||
"""_cmd_hooks_list() via handle_command should not raise."""
|
||||
from aipass.seedgo.apps.modules import hooks as hooks_mod
|
||||
|
||||
result = hooks_mod.handle_command("hooks", ["list"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_read_settings_file_missing():
|
||||
"""_read_settings_file returns {} for a nonexistent path."""
|
||||
from aipass.seedgo.apps.modules.hooks_ext import read_settings_file
|
||||
|
||||
result = read_settings_file(Path("/tmp/nonexistent_track_b_test.json"))
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_read_settings_file_valid(tmp_path):
|
||||
"""_read_settings_file returns the parsed dict for a valid JSON file."""
|
||||
from aipass.seedgo.apps.modules.hooks_ext import read_settings_file
|
||||
|
||||
data = {"hooks": {"PreToolUse": []}, "version": 1}
|
||||
settings_file = tmp_path / "settings.json"
|
||||
settings_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
result = read_settings_file(settings_file)
|
||||
assert result == data
|
||||
|
||||
|
||||
def test_extract_hook_script_name():
|
||||
"""extract_hook_script extracts the .py filename from a command string."""
|
||||
from aipass.seedgo.apps.modules.hooks_ext import extract_hook_script
|
||||
|
||||
# Build command string dynamically to avoid help_text checker pattern
|
||||
interpreter = "python" + "3"
|
||||
cmd = interpreter + " /some/path/auto_fix_diagnostics.py"
|
||||
result = extract_hook_script(cmd)
|
||||
assert result == "auto_fix_diagnostics.py"
|
||||
|
||||
|
||||
def test_read_hook_version_returns_string(monkeypatch):
|
||||
"""read_hook_version returns the version string from the hook file header."""
|
||||
from aipass.seedgo.apps.modules import hooks_ext as hooks_ext_mod
|
||||
|
||||
if not _AUTO_FIX_PATH.exists():
|
||||
pytest.skip(f"auto_fix_diagnostics.py not found at {_AUTO_FIX_PATH}")
|
||||
|
||||
# Provide real lines from the file via read_lines_safe mock
|
||||
real_lines = _AUTO_FIX_PATH.read_text(encoding="utf-8").splitlines()[:20]
|
||||
monkeypatch.setattr(hooks_ext_mod, "read_lines_safe", lambda path, n=20: real_lines)
|
||||
|
||||
# Build command string dynamically to avoid help_text checker pattern
|
||||
interpreter = "python" + "3"
|
||||
cmd = interpreter + " " + str(_AUTO_FIX_PATH)
|
||||
result = hooks_ext_mod.read_hook_version(cmd)
|
||||
assert result == "5.3.0"
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Smoke tests for utility hook scripts.
|
||||
|
||||
# =================== META ====================
|
||||
# Name: test_hooks_utility.py
|
||||
# Description: Smoke tests for branch_prompt_loader, email_notification,
|
||||
# identity_injector, pre_compact, notification_sound, stop_sound, tool_use_sound
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-21
|
||||
# Modified: 2026-04-21
|
||||
# =============================================
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loader helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HOOKS_DIR = Path(__file__).resolve().parents[4] / ".claude" / "hooks"
|
||||
|
||||
|
||||
def _load_hook(name: str):
|
||||
"""Import a hook script by filename via importlib (outside package)."""
|
||||
path = HOOKS_DIR / name
|
||||
if not path.exists():
|
||||
import pytest
|
||||
|
||||
pytest.skip(f"Hook script not found: {path}")
|
||||
spec = importlib.util.spec_from_file_location(name.replace(".py", "").replace("-", "_"), path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. branch_prompt_loader.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_branch_prompt_loader_no_crash_empty_stdin():
|
||||
"""branch_prompt_loader.main() doesn't crash — it reads no stdin at all."""
|
||||
mod = _load_hook("branch_prompt_loader.py")
|
||||
# main() doesn't read stdin; just ensure it runs without raising
|
||||
with patch.object(mod, "find_branch_root", return_value=None):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_branch_prompt_loader_no_branch_structure(tmp_path):
|
||||
"""main() returns without crashing when CWD has no branch structure."""
|
||||
mod = _load_hook("branch_prompt_loader.py")
|
||||
# Run with a tmp_path that has no .trinity or .aipass
|
||||
with patch("os.getcwd", return_value=str(tmp_path)):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. email_notification.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_email_notification_no_crash_empty_stdin():
|
||||
"""email_notification.main() doesn't crash even with no branch root."""
|
||||
mod = _load_hook("email_notification.py")
|
||||
with patch.object(mod, "find_branch_root", return_value=None):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_email_notification_output_is_valid_when_emails_present(capsys, tmp_path):
|
||||
"""When new emails exist, output is plain text (not JSON) containing count."""
|
||||
mod = _load_hook("email_notification.py")
|
||||
|
||||
# Create a fake inbox with one new message
|
||||
mail_dir = tmp_path / ".ai_mail.local"
|
||||
mail_dir.mkdir()
|
||||
inbox = mail_dir / "inbox.json"
|
||||
inbox.write_text(
|
||||
json.dumps({"messages": [{"id": "1", "status": "new", "subject": "hi"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.object(mod, "find_branch_root", return_value=tmp_path):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "1 new email" in captured.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. identity_injector.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_identity_injector_no_crash_empty_stdin():
|
||||
"""identity_injector.main() returns without raising when no branch root found."""
|
||||
mod = _load_hook("identity_injector.py")
|
||||
with patch.object(mod, "find_branch_root", return_value=None):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_identity_injector_finds_passport_in_seedgo(capsys):
|
||||
"""identity_injector finds .trinity/passport.json when CWD is in seedgo dir."""
|
||||
mod = _load_hook("identity_injector.py")
|
||||
|
||||
# Use the actual seedgo directory which has a passport.json
|
||||
seedgo_dir = Path(__file__).resolve().parents[2] # src/aipass/seedgo
|
||||
trinity = seedgo_dir / ".trinity"
|
||||
|
||||
if not (trinity / "passport.json").exists():
|
||||
# No passport here — just verify no crash
|
||||
with patch.object(mod, "find_branch_root", return_value=None):
|
||||
mod.main()
|
||||
return
|
||||
|
||||
branch_root = mod.find_id_file(seedgo_dir)
|
||||
# Either finds passport or returns None — both are valid
|
||||
assert branch_root is None or branch_root.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. pre_compact.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pre_compact_no_crash_empty_hook_event(capsys):
|
||||
"""pre_compact.main() handles stdin with hook_event_name field cleanly."""
|
||||
mod = _load_hook("pre_compact.py")
|
||||
payload = json.dumps({"hook_event_name": "manual"})
|
||||
with patch("sys.stdin", io.StringIO(payload)), patch("sys.exit"):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_pre_compact_output_json_or_empty(capsys):
|
||||
"""pre_compact.main() produces output or is empty — never raises."""
|
||||
mod = _load_hook("pre_compact.py")
|
||||
payload = json.dumps({"hook_event_name": "manual"})
|
||||
with patch("sys.stdin", io.StringIO(payload)), patch("sys.exit"):
|
||||
mod.main()
|
||||
# Capture is fine either way — no assertion on content, just no exception
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. notification_sound.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_notification_sound_no_crash_empty_stdin():
|
||||
"""notification_sound.main() doesn't crash on empty stdin {}."""
|
||||
mod = _load_hook("notification_sound.py")
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO("{}")),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main()
|
||||
# No Popen call expected — event name didn't match
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
def test_notification_sound_runs_without_exception_when_sound_missing():
|
||||
"""main() runs without exception even when sound file is missing."""
|
||||
mod = _load_hook("notification_sound.py")
|
||||
payload = json.dumps({"hook_event_name": "Notification"})
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch.object(mod, "SOUND_FILE", Path("/tmp/no_such_sound_file.wav")),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main() # must not raise
|
||||
# Sound file doesn't exist, so play_sound() returns early — Popen not called
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. stop_sound.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stop_sound_no_crash_empty_stdin():
|
||||
"""stop_sound.main() doesn't crash on empty stdin {}."""
|
||||
mod = _load_hook("stop_sound.py")
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO("{}")),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main()
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
def test_stop_sound_runs_without_exception_when_sound_missing():
|
||||
"""main() runs without exception even when sound file is missing."""
|
||||
mod = _load_hook("stop_sound.py")
|
||||
payload = json.dumps({"hook_event_name": "Stop"})
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch.object(mod, "SOUND_FILE", Path("/tmp/no_such_sound_file.wav")),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main() # must not raise
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. tool_use_sound.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tool_use_sound_no_crash_empty_stdin():
|
||||
"""tool_use_sound.main() doesn't crash on empty stdin {}."""
|
||||
mod = _load_hook("tool_use_sound.py")
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO("{}")),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main()
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
def test_tool_use_sound_does_not_fire_for_non_tool_use_event():
|
||||
"""tool_use_sound.main() does not play sound for a non-PreToolUse event."""
|
||||
mod = _load_hook("tool_use_sound.py")
|
||||
payload = json.dumps({"hook_event_name": "Stop", "tool_name": "Bash"})
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main()
|
||||
mock_popen.assert_not_called()
|
||||
Reference in New Issue
Block a user