#677 + #635 hooks: -p headless short-circuit + Codex bridge + portable hook test runner. #677: session_boot.py detects -p in extra_args and execvp's claude directly — headless one-shots never register in ~/.claude/sessions, so tmux/session-lookup/live-attach logic was wrong for them (DPLAN-0226 fine-tune debt; +5 tests, 39 pass). #635: new bridges/codex.py mirrors claude.py with Codex protocol normalization (stdin input->tool_input, stdout hookSpecificOutput envelope, permissionDecision+permissionDecisionReason — fixes DPLAN-0205 bugs) + new hook_test module: drone @hooks test fires every hook from .aipass/hooks.json with per-event mock data, reports fired/blocked/disabled/crashed with timing (23 tests). codex.py bypass entries exactly mirror shipped claude.py set. Built by @hooks; devpulse verified: 62/62 tests re-run against real repo, seedgo 31/31 all three files.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hook_test.py
|
||||
# Version: 1.0.0
|
||||
# Description: Portable hook test runner — fires every hook with mock data
|
||||
# Branch: hooks
|
||||
# Layer: apps/modules
|
||||
# Created: 2026-07-10
|
||||
# Modified: 2026-07-10
|
||||
# =============================================
|
||||
|
||||
"""Portable hook test runner.
|
||||
|
||||
Fires every hook from a project's .aipass/hooks.json with mock data
|
||||
and reports what fired, what blocked, and what crashed. Runnable from
|
||||
any project directory.
|
||||
|
||||
Usage:
|
||||
drone @hooks test [--verbose]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from aipass.hooks.apps.modules.engine import dispatch
|
||||
from aipass.hooks.apps.handlers.config.loader import find_project_config
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import err_console
|
||||
|
||||
CONSOLE = err_console
|
||||
|
||||
HELP_COMMANDS = [
|
||||
("test [--verbose]", "Fire every hook with mock data and report results"),
|
||||
]
|
||||
|
||||
_SYNTHETIC_PATH = os.path.join(tempfile.gettempdir(), "hook_test_synthetic.txt")
|
||||
|
||||
MOCK_EVENTS = {
|
||||
"UserPromptSubmit": {
|
||||
"type": "UserPromptSubmit",
|
||||
"prompt": "[hook test] synthetic prompt for test runner",
|
||||
},
|
||||
"PreToolUse": {
|
||||
"tool_name": "Read",
|
||||
"tool_input": {"file_path": _SYNTHETIC_PATH},
|
||||
},
|
||||
"PostToolUse": {
|
||||
"tool_name": "Read",
|
||||
"tool_input": {"file_path": _SYNTHETIC_PATH},
|
||||
"tool_output": "synthetic output",
|
||||
},
|
||||
"SubagentStop": {
|
||||
"agent_type": "general-purpose",
|
||||
"type": "SubagentStop",
|
||||
},
|
||||
"Stop": {
|
||||
"type": "Stop",
|
||||
},
|
||||
"Notification": {
|
||||
"type": "Notification",
|
||||
"message": "[hook test] synthetic notification",
|
||||
},
|
||||
"PreCompact": {
|
||||
"compact_type": "PreCompact",
|
||||
},
|
||||
"SessionStart": {
|
||||
"type": "SessionStart",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _test_single_hook(event_type: str, hook_name: str, hook_def: dict, stdin_data: str, verbose: bool) -> dict:
|
||||
"""Dispatch one hook and return its result dict."""
|
||||
single_config = {"hooks_enabled": True, event_type: {hook_name: hook_def}}
|
||||
enabled = hook_def.get("enabled", True)
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
output, exit_code = dispatch(event_type, stdin_data, single_config)
|
||||
elapsed_ms = round((time.monotonic() - start) * 1000, 1)
|
||||
|
||||
if not enabled:
|
||||
status = "disabled"
|
||||
elif exit_code == 2:
|
||||
status = "blocked"
|
||||
elif output:
|
||||
status = "fired"
|
||||
else:
|
||||
status = "fired (empty output)"
|
||||
|
||||
return {
|
||||
"hook": hook_name,
|
||||
"status": status,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"exit_code": exit_code,
|
||||
"output_len": len(output),
|
||||
"output_preview": output[:200] if verbose else "",
|
||||
}
|
||||
except Exception as exc:
|
||||
elapsed_ms = round((time.monotonic() - start) * 1000, 1)
|
||||
logger.error("[HOOKS:test] %s.%s crashed: %s", event_type, hook_name, exc)
|
||||
return {
|
||||
"hook": hook_name,
|
||||
"status": "crashed",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"error": str(exc)[:200],
|
||||
}
|
||||
|
||||
|
||||
def run_test(verbose: bool = False) -> dict:
|
||||
"""Fire every hook with mock data, return results summary."""
|
||||
config = find_project_config()
|
||||
if config is None:
|
||||
return {"error": "No .aipass/hooks.json found — run from an AIPass project directory."}
|
||||
|
||||
if not config.get("hooks_enabled", True):
|
||||
return {"error": "hooks_enabled is false in project config."}
|
||||
|
||||
results = {}
|
||||
|
||||
for event_type, event_hooks in config.items():
|
||||
if event_type in ("hooks_enabled", "_comment"):
|
||||
continue
|
||||
if not isinstance(event_hooks, dict):
|
||||
continue
|
||||
|
||||
mock_data = MOCK_EVENTS.get(event_type, {"type": event_type})
|
||||
stdin_data = json.dumps(mock_data)
|
||||
|
||||
event_results = []
|
||||
for hook_name, hook_def in event_hooks.items():
|
||||
if not isinstance(hook_def, dict):
|
||||
continue
|
||||
if not hook_def.get("handler", "") and not hook_def.get("command", ""):
|
||||
continue
|
||||
result = _test_single_hook(event_type, hook_name, hook_def, stdin_data, verbose)
|
||||
event_results.append(result)
|
||||
|
||||
if event_results:
|
||||
results[event_type] = event_results
|
||||
|
||||
return results
|
||||
|
||||
|
||||
_STATUS_ICONS = {
|
||||
"fired": "[green]✓[/green]",
|
||||
"fired (empty output)": "[green]✓[/green]",
|
||||
"blocked": "[yellow]⊘[/yellow]",
|
||||
"disabled": "[dim]○[/dim]",
|
||||
"crashed": "[red]✗[/red]",
|
||||
}
|
||||
|
||||
_STATUS_COUNTS = {"fired", "fired (empty output)", "blocked", "disabled", "crashed"}
|
||||
|
||||
|
||||
def print_results(results: dict, verbose: bool = False) -> None:
|
||||
"""Render test results to console."""
|
||||
if "error" in results:
|
||||
CONSOLE.print(f"[red]{results['error']}[/red]")
|
||||
return
|
||||
|
||||
counts = {"fired": 0, "blocked": 0, "disabled": 0, "crashed": 0}
|
||||
|
||||
for event_type, hooks in results.items():
|
||||
CONSOLE.print(f"\n[bold cyan]{event_type}[/bold cyan]")
|
||||
for h in hooks:
|
||||
status = h["status"]
|
||||
name = h["hook"]
|
||||
ms = h.get("elapsed_ms", 0)
|
||||
icon = _STATUS_ICONS.get(status, "[dim]?[/dim]")
|
||||
|
||||
if status in ("fired", "fired (empty output)"):
|
||||
counts["fired"] += 1
|
||||
elif status in counts:
|
||||
counts[status] += 1
|
||||
|
||||
CONSOLE.print(f" {icon} {name:30} {status:20} {ms:>6.0f}ms")
|
||||
if verbose and h.get("output_preview"):
|
||||
CONSOLE.print(f" [dim]{h['output_preview']}[/dim]")
|
||||
if h.get("error"):
|
||||
CONSOLE.print(f" [red]{h['error']}[/red]")
|
||||
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(
|
||||
f"[bold]Summary:[/bold] {counts['fired']} fired, "
|
||||
f"{counts['blocked']} blocked, {counts['disabled']} disabled, "
|
||||
f"{counts['crashed']} crashed"
|
||||
)
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Print module introspection for drone discovery."""
|
||||
CONSOLE.print("[cyan]hook_test[/cyan] — Portable hook test runner")
|
||||
CONSOLE.print(" Fire every hook with mock data and report what fired.")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Route 'test' command."""
|
||||
if command != "test":
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h"):
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
verbose = "--verbose" in args or "-v" in args
|
||||
|
||||
CONSOLE.print("[bold cyan]HOOKS Test Runner[/bold cyan]")
|
||||
CONSOLE.print("[dim]Firing every hook with mock data...[/dim]")
|
||||
|
||||
results = run_test(verbose=verbose)
|
||||
print_results(results, verbose=verbose)
|
||||
return True
|
||||
Reference in New Issue
Block a user