450 lines
13 KiB
Go
450 lines
13 KiB
Go
package hook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- Runner construction ---
|
|
|
|
func TestNewRunnerNil(t *testing.T) {
|
|
var r *Runner
|
|
if r.Enabled() {
|
|
t.Error("nil Runner should not be enabled")
|
|
}
|
|
if r.Hooks() != nil {
|
|
t.Error("nil Runner.Hooks() should be nil")
|
|
}
|
|
}
|
|
|
|
func TestNewRunnerEmpty(t *testing.T) {
|
|
r := NewRunner(nil, "/tmp", nil, nil)
|
|
if r.Enabled() {
|
|
t.Error("empty hooks Runner should not be enabled")
|
|
}
|
|
}
|
|
|
|
func TestNewRunnerWithHooks(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "echo hi"}, Event: PreToolUse, Scope: ScopeGlobal},
|
|
}
|
|
r := NewRunner(hooks, "/tmp", nil, nil)
|
|
if !r.Enabled() {
|
|
t.Error("Runner with hooks should be enabled")
|
|
}
|
|
if len(r.Hooks()) != 1 {
|
|
t.Errorf("Hooks() count = %d, want 1", len(r.Hooks()))
|
|
}
|
|
}
|
|
|
|
// --- Runner.PreToolUse ---
|
|
|
|
func TestRunnerPreToolUseNoHooks(t *testing.T) {
|
|
r := NewRunner(nil, "/tmp", nil, nil)
|
|
block, msg := r.PreToolUse(context.Background(), "bash", nil)
|
|
if block || msg != "" {
|
|
t.Errorf("no hooks should pass: block=%v msg=%q", block, msg)
|
|
}
|
|
}
|
|
|
|
func TestRunnerPreToolUsePass(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "allow"}, Event: PreToolUse},
|
|
}
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
return SpawnResult{ExitCode: 0}
|
|
}
|
|
r := NewRunner(hooks, "/tmp", spawner, nil)
|
|
block, msg := r.PreToolUse(context.Background(), "bash", nil)
|
|
if block {
|
|
t.Errorf("exit 0 should not block: msg=%q", msg)
|
|
}
|
|
}
|
|
|
|
func TestRunnerPreToolUseBlock(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "deny"}, Event: PreToolUse},
|
|
}
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
return SpawnResult{ExitCode: 2, Stderr: "blocked by policy"}
|
|
}
|
|
var notified string
|
|
notify := func(msg string) { notified = msg }
|
|
r := NewRunner(hooks, "/tmp", spawner, notify)
|
|
block, msg := r.PreToolUse(context.Background(), "bash", nil)
|
|
if !block {
|
|
t.Error("exit 2 on PreToolUse should block")
|
|
}
|
|
if msg == "" {
|
|
t.Error("block message should not be empty")
|
|
}
|
|
if notified == "" {
|
|
t.Error("notify should have been called")
|
|
}
|
|
}
|
|
|
|
// --- Runner.PostToolUse ---
|
|
|
|
func TestRunnerPostToolUseNoHooks(t *testing.T) {
|
|
r := NewRunner(nil, "/tmp", nil, nil)
|
|
// Should not panic.
|
|
r.PostToolUse(context.Background(), "bash", nil, "ok")
|
|
}
|
|
|
|
func TestRunnerPostToolUseWarn(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "warn"}, Event: PostToolUse},
|
|
}
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
return SpawnResult{ExitCode: 1, Stdout: "warning message"}
|
|
}
|
|
var notified string
|
|
notify := func(msg string) { notified = msg }
|
|
r := NewRunner(hooks, "/tmp", spawner, notify)
|
|
r.PostToolUse(context.Background(), "bash", nil, "result")
|
|
if notified == "" {
|
|
t.Error("PostToolUse warn should notify")
|
|
}
|
|
}
|
|
|
|
// --- Runner.PermissionRequest ---
|
|
|
|
func TestRunnerPermissionRequestPayload(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "notify", Match: "bash"}, Event: PermissionRequest},
|
|
}
|
|
var got Payload
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
if err := json.Unmarshal([]byte(in.Stdin), &got); err != nil {
|
|
t.Fatalf("payload json: %v", err)
|
|
}
|
|
return SpawnResult{ExitCode: 0}
|
|
}
|
|
args := json.RawMessage(`{"command":"go test ./..."}`)
|
|
r := NewRunner(hooks, "/tmp", spawner, nil)
|
|
r.PermissionRequest(context.Background(), "bash", "go test ./...", args)
|
|
|
|
if got.Event != PermissionRequest {
|
|
t.Errorf("Event = %q, want PermissionRequest", got.Event)
|
|
}
|
|
if got.ToolName != "bash" {
|
|
t.Errorf("ToolName = %q, want bash", got.ToolName)
|
|
}
|
|
if got.Subject != "go test ./..." {
|
|
t.Errorf("Subject = %q, want command subject", got.Subject)
|
|
}
|
|
if string(got.ToolArgs) != string(args) {
|
|
t.Errorf("ToolArgs = %s, want %s", got.ToolArgs, args)
|
|
}
|
|
}
|
|
|
|
func TestRunnerPermissionRequestWarnOnly(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "warn"}, Event: PermissionRequest},
|
|
}
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
return SpawnResult{ExitCode: 2, Stderr: "notification failed"}
|
|
}
|
|
var notified string
|
|
r := NewRunner(hooks, "/tmp", spawner, func(msg string) { notified = msg })
|
|
r.PermissionRequest(context.Background(), "bash", "go test", nil)
|
|
if notified == "" {
|
|
t.Error("PermissionRequest warn should notify")
|
|
}
|
|
}
|
|
|
|
// --- Runner.PromptSubmit ---
|
|
|
|
func TestRunnerPromptSubmitBlock(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "gate"}, Event: UserPromptSubmit},
|
|
}
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
return SpawnResult{ExitCode: 2, Stderr: "not allowed"}
|
|
}
|
|
r := NewRunner(hooks, "/tmp", spawner, nil)
|
|
block, _ := r.PromptSubmit(context.Background(), "bad input", 1)
|
|
if !block {
|
|
t.Error("exit 2 on UserPromptSubmit should block")
|
|
}
|
|
}
|
|
|
|
// --- Runner.Stop ---
|
|
|
|
func TestRunnerStopNoHooks(t *testing.T) {
|
|
r := NewRunner(nil, "/tmp", nil, nil)
|
|
// Should not panic.
|
|
r.Stop(context.Background(), "last answer", 1)
|
|
}
|
|
|
|
func TestRunnerStopWithHooks(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "log"}, Event: Stop},
|
|
}
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
return SpawnResult{ExitCode: 0}
|
|
}
|
|
r := NewRunner(hooks, "/tmp", spawner, nil)
|
|
r.Stop(context.Background(), "done", 1)
|
|
}
|
|
|
|
func TestRunnerSessionStartReturnsAdditionalContexts(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "plain"}, Event: SessionStart},
|
|
{HookConfig: HookConfig{Command: "json"}, Event: SessionStart},
|
|
}
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
switch in.Command {
|
|
case "plain":
|
|
return SpawnResult{ExitCode: 0, Stdout: "Load notes."}
|
|
case "json":
|
|
return SpawnResult{ExitCode: 0, Stdout: `{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Use Superpowers."}}`}
|
|
default:
|
|
return SpawnResult{ExitCode: 1, Stderr: "unexpected"}
|
|
}
|
|
}
|
|
r := NewRunner(hooks, "/tmp", spawner, nil)
|
|
got := r.SessionStart(context.Background())
|
|
if len(got) != 2 || got[0] != "Load notes." || got[1] != "Use Superpowers." {
|
|
t.Fatalf("SessionStart contexts = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestRunnerSessionStartReadsContextFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
contextPath := filepath.Join(dir, "CLAUDE.md")
|
|
if err := os.WriteFile(contextPath, []byte("Use the packaged workflow."), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calledSpawner := false
|
|
r := NewRunner([]ResolvedHook{{
|
|
HookConfig: HookConfig{ContextFile: contextPath, Description: "Plugin CLAUDE.md"},
|
|
Event: SessionStart,
|
|
Scope: ScopePlugin,
|
|
}}, dir, func(context.Context, SpawnInput) SpawnResult {
|
|
calledSpawner = true
|
|
return SpawnResult{ExitCode: 1}
|
|
}, nil)
|
|
|
|
got := r.SessionStart(context.Background())
|
|
if calledSpawner {
|
|
t.Fatal("context file hook should not invoke shell spawner")
|
|
}
|
|
if len(got) != 1 || got[0] != "Use the packaged workflow." {
|
|
t.Fatalf("SessionStart contexts = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestRunnerSessionStartWarnsOnInvalidJSON(t *testing.T) {
|
|
hooks := []ResolvedHook{{HookConfig: HookConfig{Command: "bad-json"}, Event: SessionStart}}
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
return SpawnResult{ExitCode: 0, Stdout: `{"hookSpecificOutput":`}
|
|
}
|
|
var notified string
|
|
r := NewRunner(hooks, "/tmp", spawner, func(msg string) { notified = msg })
|
|
if got := r.SessionStart(context.Background()); len(got) != 0 {
|
|
t.Fatalf("SessionStart contexts = %#v, want none", got)
|
|
}
|
|
if !contains(notified, "invalid JSON") {
|
|
t.Fatalf("notify = %q, want invalid JSON warning", notified)
|
|
}
|
|
}
|
|
|
|
// --- Runner.PostLLMCall ---
|
|
|
|
func TestRunnerHasPostLLMCall(t *testing.T) {
|
|
with := NewRunner([]ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: PostLLMCall}}, "/tmp", nil, nil)
|
|
if !with.HasPostLLMCall() {
|
|
t.Error("a configured PostLLMCall hook should report HasPostLLMCall")
|
|
}
|
|
without := NewRunner([]ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: Stop}}, "/tmp", nil, nil)
|
|
if without.HasPostLLMCall() {
|
|
t.Error("only a Stop hook should not report HasPostLLMCall")
|
|
}
|
|
if (*Runner)(nil).HasPostLLMCall() {
|
|
t.Error("nil runner should report no PostLLMCall hook")
|
|
}
|
|
}
|
|
|
|
func TestRunnerPostLLMCallReplacesReasoning(t *testing.T) {
|
|
hooks := []ResolvedHook{{HookConfig: HookConfig{Command: "translate"}, Event: PostLLMCall}}
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
return SpawnResult{ExitCode: 0, Stdout: " 译文 "}
|
|
}
|
|
r := NewRunner(hooks, "/tmp", spawner, nil)
|
|
if got := r.PostLLMCall(context.Background(), "raw reasoning", 2); got != "译文" {
|
|
t.Fatalf("PostLLMCall = %q, want trimmed hook stdout", got)
|
|
}
|
|
}
|
|
|
|
func TestRunnerPostLLMCallKeepsOriginal(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
hooks []ResolvedHook
|
|
spawn SpawnResult
|
|
}{
|
|
{"no PostLLMCall hook", []ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: Stop}}, SpawnResult{ExitCode: 0, Stdout: "ignored"}},
|
|
{"empty stdout", []ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: PostLLMCall}}, SpawnResult{ExitCode: 0, Stdout: " "}},
|
|
{"non-zero exit", []ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: PostLLMCall}}, SpawnResult{ExitCode: 1, Stdout: "should be ignored"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := NewRunner(tc.hooks, "/tmp", func(context.Context, SpawnInput) SpawnResult { return tc.spawn }, nil)
|
|
if got := r.PostLLMCall(context.Background(), "raw", 1); got != "raw" {
|
|
t.Fatalf("PostLLMCall = %q, want original reasoning preserved", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- FormatOutcome ---
|
|
|
|
func TestFormatOutcomePass(t *testing.T) {
|
|
o := Outcome{
|
|
Hook: ResolvedHook{HookConfig: HookConfig{Command: "echo hi"}, Event: PreToolUse, Scope: ScopeProject},
|
|
Decision: DecisionPass,
|
|
}
|
|
msg := FormatOutcome(o)
|
|
if msg == "" {
|
|
t.Error("FormatOutcome should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestFormatOutcomeWithDetail(t *testing.T) {
|
|
o := Outcome{
|
|
Hook: ResolvedHook{HookConfig: HookConfig{Command: "check"}, Event: PreToolUse, Scope: ScopeGlobal},
|
|
Decision: DecisionBlock,
|
|
Stderr: "forbidden",
|
|
Truncated: true,
|
|
}
|
|
msg := FormatOutcome(o)
|
|
if !contains(msg, "forbidden") {
|
|
t.Errorf("should include stderr: %s", msg)
|
|
}
|
|
if !contains(msg, "truncated") {
|
|
t.Errorf("should mention truncation: %s", msg)
|
|
}
|
|
}
|
|
|
|
// --- clipRunes ---
|
|
|
|
func TestClipRunes(t *testing.T) {
|
|
if got := clipRunes("short", 10); got != "short" {
|
|
t.Errorf("clipRunes short = %q", got)
|
|
}
|
|
if got := clipRunes("hello world", 5); got != "hello…" {
|
|
t.Errorf("clipRunes = %q", got)
|
|
}
|
|
if got := clipRunes("", 5); got != "" {
|
|
t.Errorf("clipRunes empty = %q", got)
|
|
}
|
|
if got := clipRunes("abc", 0); got != "" {
|
|
t.Errorf("clipRunes max=0 = %q", got)
|
|
}
|
|
}
|
|
|
|
// --- payload JSON ---
|
|
|
|
func TestPayloadJSON(t *testing.T) {
|
|
args := json.RawMessage(`{"command":"echo hi"}`)
|
|
p := Payload{
|
|
Event: PreToolUse,
|
|
Cwd: "/tmp",
|
|
ToolName: "bash",
|
|
ToolArgs: args,
|
|
Turn: 1,
|
|
}
|
|
b, err := json.Marshal(p)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
var decoded Payload
|
|
if err := json.Unmarshal(b, &decoded); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if decoded.Event != PreToolUse {
|
|
t.Errorf("Event = %q", decoded.Event)
|
|
}
|
|
if decoded.ToolName != "bash" {
|
|
t.Errorf("ToolName = %q", decoded.ToolName)
|
|
}
|
|
if decoded.Turn != 1 {
|
|
t.Errorf("Turn = %d", decoded.Turn)
|
|
}
|
|
}
|
|
|
|
// --- capping behavior ---
|
|
|
|
func TestCappedBuffer(t *testing.T) {
|
|
var cb cappedBuffer
|
|
// Write within cap.
|
|
n, err := cb.Write([]byte("hello"))
|
|
if err != nil || n != 5 {
|
|
t.Errorf("small write: n=%d err=%v", n, err)
|
|
}
|
|
if cb.truncated {
|
|
t.Error("should not be truncated yet")
|
|
}
|
|
if cb.String() != "hello" {
|
|
t.Errorf("String() = %q", cb.String())
|
|
}
|
|
|
|
// Write beyond cap.
|
|
big := make([]byte, outputCapBytes+1000)
|
|
for i := range big {
|
|
big[i] = 'x'
|
|
}
|
|
n, err = cb.Write(big)
|
|
if err != nil || n != len(big) {
|
|
t.Errorf("big write: n=%d err=%v", n, err)
|
|
}
|
|
if !cb.truncated {
|
|
t.Error("should be truncated after exceeding cap")
|
|
}
|
|
}
|
|
|
|
// --- IsBlocking ---
|
|
|
|
func TestIsBlocking(t *testing.T) {
|
|
if !IsBlocking(PreToolUse) {
|
|
t.Error("PreToolUse should be blocking")
|
|
}
|
|
if !IsBlocking(UserPromptSubmit) {
|
|
t.Error("UserPromptSubmit should be blocking")
|
|
}
|
|
if IsBlocking(PostToolUse) {
|
|
t.Error("PostToolUse should not be blocking")
|
|
}
|
|
if IsBlocking(Stop) {
|
|
t.Error("Stop should not be blocking")
|
|
}
|
|
}
|
|
|
|
// --- defaultTimeout ---
|
|
|
|
func TestDefaultTimeout(t *testing.T) {
|
|
if defaultTimeout(PreToolUse) != 5*time.Second {
|
|
t.Errorf("PreToolUse timeout = %v", defaultTimeout(PreToolUse))
|
|
}
|
|
if defaultTimeout(PermissionRequest) != 5*time.Second {
|
|
t.Errorf("PermissionRequest timeout = %v", defaultTimeout(PermissionRequest))
|
|
}
|
|
if defaultTimeout(PostToolUse) != 30*time.Second {
|
|
t.Errorf("PostToolUse timeout = %v", defaultTimeout(PostToolUse))
|
|
}
|
|
}
|
|
|
|
// helper
|
|
func contains(s, sub string) bool {
|
|
for i := 0; i <= len(s)-len(sub); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|