Files
2026-07-13 13:00:08 +08:00

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
}